chore: restructure the project, make it not use the astal application stuff

now it's more organized and I have more control over the shell behaviour
This commit is contained in:
retrozinndev
2025-08-06 15:25:21 -03:00
parent 5a6d5b47c6
commit d549ad9596
191 changed files with 529 additions and 1000 deletions
+104
View File
@@ -0,0 +1,104 @@
import { Gdk, Gtk } from "ags/gtk4";
import { execAsync } from "ags/process";
import AstalApps from "gi://AstalApps";
import AstalHyprland from "gi://AstalHyprland";
export const uwsmIsActive: boolean = await execAsync(
"uwsm check is-active"
).then(() => true).catch(() => false);
const astalApps: AstalApps.Apps = new AstalApps.Apps();
let appsList: Array<AstalApps.Application> = astalApps.get_list();
export function getApps(): Array<AstalApps.Application> {
return appsList;
}
export function updateApps(): void {
astalApps.reload();
appsList = astalApps.get_list();
}
export function getAstalApps(): AstalApps.Apps {
return astalApps;
}
/** handles running with uwsm if it's installed */
export function execApp(app: AstalApps.Application|string, dispatchExecArgs?: string) {
const executable = (typeof app === "string") ? app
: app.executable.replace(/(%f|%F|%u|%U|%i|%c|%k)/g, "");
AstalHyprland.get_default().dispatch("exec",
`${dispatchExecArgs ? `${dispatchExecArgs} ` : ""}${uwsmIsActive ? "uwsm app -- " : ""}${executable}`
);
}
export function lookupIcon(name: string): boolean {
return Gtk.IconTheme.get_for_display(Gdk.Display.get_default()!)?.has_icon(name);
}
export function getAppsByName(appName: string): (Array<AstalApps.Application>|undefined) {
let found: Array<AstalApps.Application> = [];
getApps().map((app: AstalApps.Application) => {
if(app.get_name().trim().toLowerCase() === appName.trim().toLowerCase()
|| (app?.wmClass && app.wmClass.trim().toLowerCase() === appName.trim().toLowerCase()))
found.push(app);
});
return (found.length > 0 ? found : undefined);
}
export function getIconByAppName(appName: string): (string|undefined) {
if(!appName) return undefined;
if(lookupIcon(appName))
return appName;
if(lookupIcon(appName.toLowerCase()))
return appName.toLowerCase();
const nameReverseDNS = appName.split('.');
const lastItem = nameReverseDNS[nameReverseDNS.length - 1];
const lastPretty = `${lastItem.charAt(0).toUpperCase()}${lastItem.substring(1, lastItem.length)}`;
const uppercaseRDNS = nameReverseDNS.slice(0, nameReverseDNS.length - 1)
.concat(lastPretty).join('.');
if(lookupIcon(uppercaseRDNS))
return uppercaseRDNS;
if(lookupIcon(nameReverseDNS[nameReverseDNS.length - 1]))
return nameReverseDNS[nameReverseDNS.length - 1];
const found: (AstalApps.Application|undefined) = getAppsByName(appName)?.[0];
if(Boolean(found))
return found?.iconName;
return undefined;
}
export function getAppIcon(app: (string|AstalApps.Application)): (string|undefined) {
if(!app) return undefined;
if(typeof app === "string")
return getIconByAppName(app);
if(app.iconName && lookupIcon(app.iconName))
return app.iconName;
if(app.wmClass)
return getIconByAppName(app.wmClass);
return getIconByAppName(app.name);
}
export function getSymbolicIcon(app: (string|AstalApps.Application)): (string|undefined) {
const icon = getAppIcon(app);
return (icon && lookupIcon(`${icon}-symbolic`)) ?
`${icon}-symbolic`
: undefined;
}
+291
View File
@@ -0,0 +1,291 @@
import { Wireplumber } from "./volume";
import { Windows } from "../windows";
import { restartInstance } from "./reload-handler";
import { timeout } from "ags/time";
import { Runner } from "../runner/Runner";
import { showWorkspaceNumber } from "../widget/bar/Workspaces";
import { playSystemBell } from "./utils";
import { player, setPlayer } from "../widget/bar/Media";
import { generalConfig } from "../app";
import AstalIO from "gi://AstalIO";
import AstalMpris from "gi://AstalMpris";
let wsTimeout: (AstalIO.Time|undefined);
export function handleArguments(args: Array<string>): any {
switch(args[0]) {
case "help":
case "h":
return getHelp();
case "open":
case "close":
case "toggle":
case "windows":
case "reopen":
return handleWindowArgs(args);
case "volume":
return handleVolumeArgs(args);
case "media":
return handleMediaArgs(args);
case "reload":
restartInstance();
return "Restarting instance...";
case "runner":
!Runner.instance ?
Runner.openDefault(args[1] || undefined)
: Runner.close();
return `Opening runner${args[1] ? ` with predefined text: "${args[1]}"` : ""}`;
case "peek-workspace-num":
if(wsTimeout)
return "Workspace numbers are already showing";
showWorkspaceNumber(true);
wsTimeout = timeout(Number.parseInt(args[1]) || 2200, () => {
showWorkspaceNumber(false);
wsTimeout = undefined;
});
return "Toggled workspace numbers";
default:
return "Error: command not found! try checking help";
}
}
function handleMediaArgs(args: Array<string>): string {
if(/h|help/.test(args[1]))
return `
Manage colorshell's active player
Options:
play: resume/start active player's media.
pause: pause the active player.
play-pause: toggle play/pause on active player.
stop: stop the active player's media.
previous: go back to previous media if player supports it.
next: jump to next media if player supports it.
bus-name: get active player's mpris bus name.
list: show available players with their bus name.
select bus_name: change the active player, where bus_name is
the desired player's mpris bus name(with the mediaplayer2 prefix).
`.trim();
const activePlayer: AstalMpris.Player|undefined = player.get().available ?
player.get()
: undefined;
const players = AstalMpris.get_default().players.filter(pl => pl.available);
if(!activePlayer)
return `Error: no active player found! try playing some media first`
switch(args[1]) {
case "play":
activePlayer.play();
return "Playing";
case "list":
return `Available players:\n${players.map(pl => {
let playbackStatusStr: string;
switch(pl.playbackStatus) {
case AstalMpris.PlaybackStatus.PAUSED:
playbackStatusStr = "paused";
break;
case AstalMpris.PlaybackStatus.PLAYING:
playbackStatusStr = "playing";
break;
default:
playbackStatusStr = "stopped";
break;
}
return ` ${pl.busName}: ${playbackStatusStr}`;
}).join('\n')}`;
case "pause":
activePlayer.pause();
return "Paused";
case "play-pause":
activePlayer.play_pause();
return activePlayer?.playbackStatus === AstalMpris.PlaybackStatus.PAUSED ?
"Toggled play"
: "Toggled pause";
case "stop":
activePlayer.stop();
return "Stopped!";
case "previous":
activePlayer.canGoPrevious && activePlayer.previous();
return activePlayer.canGoPrevious ?
"Back to previous"
: "Player does not support this command";
case "next":
activePlayer.canGoNext && activePlayer.next();
return activePlayer.canGoNext ?
"Jump to next"
: "Player does not support this command";
case "bus-name":
return activePlayer.busName;
case "select":
if(!args[2] || !players.filter(pl => pl.busName == args[2])?.[0])
return `Error: either no player was specified or the player with specified bus name does not exist/is not available!`;
setPlayer(players.filter(pl => pl.busName === args[2])[0]);
return `Done setting player to \`${args[2]}\`!`
}
return "Error: couldn't handle media arguments, try checking `media help`";
}
function handleWindowArgs(args: Array<string>): string {
switch(args[0]) {
case "reopen":
Windows.getDefault().reopen();
return "Reopening all open windows";
case "windows":
return Object.keys(Windows.getDefault().windows).map(name =>
`${name}: ${Windows.getDefault().isOpen(name) ? "open" : "closed" }`).join('\n');
}
const specifiedWindow: string = args[1];
if(!specifiedWindow)
return "Error: window argument not specified!";
if(!Windows.getDefault().hasWindow(specifiedWindow))
return `Error: "${specifiedWindow}" not found on window list! Make sure to add new windows to the system before using them`;
switch(args[0]) {
case "open":
if(!Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().open(specifiedWindow);
return `Opening window with name "${args[1]}"`;
}
return `Window is already open, ignored`;
case "close":
if(Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().close(specifiedWindow);
return `Closing window with name "${args[1]}"`;
}
return `Window is already closed, ignored`;
case "toggle":
if(!Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().open(specifiedWindow);
return `Toggle opening window "${args[1]}"`;
}
Windows.getDefault().close(specifiedWindow);
return `Toggle closing window "${args[1]}"`;
}
return "Couldn't handle window management arguments";
}
function handleVolumeArgs(args: Array<string>) {
if(!args[1])
return `Please specify what you want to do!\n\n${volumeHelp()}`;
if(/^(sink|source)(\-increase|\-decrease|\-set)$/.test(args[1]) && !args[2])
return `You forgot to add a value to be set!`;
if(Number.isNaN(Number.parseFloat(args[2])) && Number.isSafeInteger(Number.parseFloat(args[2])))
return `Argument "${args[2]} is not a valid number! Please use integers"`;
const command: Array<string> = args[1].split('-');
if(args[1] === "help")
return volumeHelp();
switch(command[1]) {
case "set":
command[0] === "sink" ?
Wireplumber.getDefault().setSinkVolume(Number.parseInt(args[2]))
: Wireplumber.getDefault().setSourceVolume(Number.parseInt(args[2]))
return `Done! Set ${command[0]} volume to ${args[2]}`;
case "mute":
command[0] === "sink" ?
Wireplumber.getDefault().toggleMuteSink()
: Wireplumber.getDefault().toggleMuteSource()
return `Done toggling mute!`;
case "increase":
command[0] === "sink" ?
Wireplumber.getDefault().increaseSinkVolume(Number.parseInt(args[2]))
: Wireplumber.getDefault().increaseSourceVolume(Number.parseInt(args[2]))
generalConfig.getProperty("misc.play_bell_on_volume_change", "boolean") === true &&
playSystemBell();
return `Done increasing volume by ${args[2]}`;
case "decrease":
command[0] === "sink" ?
Wireplumber.getDefault().decreaseSinkVolume(Number.parseInt(args[2]))
: Wireplumber.getDefault().decreaseSourceVolume(Number.parseInt(args[2]))
generalConfig.getProperty("misc.play_bell_on_volume_change", "boolean") === true &&
playSystemBell();
return `Done decreasing volume to ${args[2]}`;
}
return `Couldn't resolve arguments! "${args.join(' ').replace(new RegExp(`^${args[0]}`), "")}"`;
function volumeHelp(): string {
return `
Control speaker and microphone volumes
Options:
(sink|source)-set [number]: set speaker/microphone volume.
(sink|source)-mute: toggle mute for the speaker/microphone device.
(sink|source)-increase [number]: increases speaker/microphone volume.
(sink|source)-decrease [number]: decreases speaker/microphone volume.
`.trim();
}
}
function getHelp(): string {
return `Manage Astal Windows and do more stuff. From retrozinndev's colorshell,
made using Astal Libraries, AGS and Gnim by Aylur.
Window Management:
open [window]: opens the specified window.
close [window]: closes all instances of specified window.
toggle [window]: toggle-open/close the specified window.
windows: list shell windows and their respective status.
reload: quit this instance and start a new one.
reopen: restart all open-windows.
Audio Controls:
volume: speaker and microphone volume controller, see "volume help".
Media Controls:
media: manage colorshell's active player, see "media help".
Other options:
runner [initial_text]: open the application runner, optionally add an initial search.
peek-workspace-num [millis]: peek the workspace numbers on bar window.
h, help: shows this help message.
2025 (c) retrozinndev's colorshell, licensed under the MIT License.
https://github.com/retrozinndev/colorshell
`.split('\n').map(l => l.replace(/^ {8}/, "")).join('\n');
}
+73
View File
@@ -0,0 +1,73 @@
import { execAsync, Gio, GLib, register } from "astal";
import Polkit from "gi://Polkit";
import PolkitAgent from "gi://PolkitAgent";
import { EntryPopup, EntryPopupProps } from "../widget/EntryPopup";
import AstalAuth from "gi://AstalAuth";
import { AskPopup, AskPopupProps } from "../widget/AskPopup";
export { Auth };
@register({ GTypeName: "AuthAgent" })
class Auth extends PolkitAgent.Listener {
private static instance: Auth;
#subject: Polkit.Subject;
constructor() {
super();
this.#subject = Polkit.UnixSession.new(GLib.get_user_name());
this.register(PolkitAgent.RegisterFlags.NONE,
this.#subject,
"/io/github/retrozinndev/Colorshell/PolicyKit/AuthAgent",
null
);
}
vfunc_dispose() {
PolkitAgent.Listener.unregister();
}
static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array<Polkit.Identity>, cancellable?: Gio.Cancellable, callback?: Gio.AsyncReadyCallback): void | Promise<boolean> {
const authPopup = EntryPopup({
title: "Authentication",
text: message,
isPassword: true,
onFinish: callback,
onCancel: () => cancellable?.cancel(),
closeOnAccept: false,
onAccept: (input: string) => {
if(this.validatePasswd(input)) {
authPopup.close();
}
AskPopup({
} as AskPopupProps)
}
} as EntryPopupProps);
}
public static initAgent(): Auth {
if(!this.instance)
this.instance = new Auth();
return this.instance;
}
private static validatePasswd(passwd: string): boolean {
return AstalAuth.Pam.authenticate(passwd, null);
}
/** @returns if successful, true, or else, false */
public async polkitExecute(cmd: string | Array<string>): Promise<boolean> {
let success: boolean = true;
await execAsync([ "pkexec", "--", ...(Array.isArray(cmd) ?
cmd as Array<string> : [ cmd as string ]) ]
).catch((r) => {
success = false;
console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`);
});
return success;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { exec, execAsync, GObject, monitorFile, readFileAsync, register, signal } from "astal";
import { Connectable } from "astal/binding";
/** !!TODO!! Needs more work and testing
* I(retrozinndev) don't have a monitor that has software-controlled brightness :(
*/
@register({ GTypeName: "Brightness" })
class Brightness extends GObject.Object implements Connectable {
private readonly backlight: string|undefined;
private max: number;
private brightness: number;
@signal(Number)
declare brightnessChanged: (value: number) => void;
constructor(backlightDevice?: string) {
super();
this.backlight = backlightDevice || "intel_backlight";
this.max = Number.parseInt(exec(`brightnessctl -d ${backlightDevice} max`))
this.brightness = Number.parseInt(exec(`brightnessctl -d ${backlightDevice} get`))
readFileAsync(`/sys/class/backlight/${backlightDevice}/brightness`).catch(() => {
throw new Error(`Couldn't find backlight ${backlightDevice}`);
});
monitorFile(`/sys/class/backlight/${backlightDevice}/brightness`, async () => {
this.brightness = Number.parseInt(await execAsync(`brightnessctl -d ${backlightDevice} get`));
this.max = Number.parseInt(await execAsync(`brightnessctl -d ${backlightDevice} max`));
this.emit("brightness-changed", this.brightness);
});
}
public setBrightness(newBrightness: number): void {
execAsync(`brightnessctl -d ${this.backlight} set ${newBrightness || this.brightness}`).catch(() => {
throw new Error(`Couldn't set brightness of backlight ${this.backlight}`);
});
this.emit("brightness-changed", newBrightness);
}
}
+249
View File
@@ -0,0 +1,249 @@
import AstalIO from "gi://AstalIO";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
import GObject, { getter, register, signal } from "ags/gobject";
import { timeout } from "ags/time";
import { monitorFile, readFile } from "ags/file";
import { execAsync } from "ags/process";
interface ClipboardSignals extends GObject.Object.SignalSignatures {
copied: Clipboard["copied"];
wiped: Clipboard["wiped"];
};
export enum ClipboardItemType {
TEXT = 0,
IMAGE = 1
}
export class ClipboardItem {
id: number;
type: ClipboardItemType;
preview: string;
constructor(id: number, type: ClipboardItemType, preview: string) {
this.id = id;
this.type = type;
this.preview = preview;
}
}
export { Clipboard };
/** Cliphist Manager and event listener
* This only supports wipe and store events from cliphist */
@register({ GTypeName: "Clipboard" })
class Clipboard extends GObject.Object {
private static instance: Clipboard;
declare $signals: ClipboardSignals;
#dbFile: Gio.File;
#dbMonitor: Gio.FileMonitor;
#updateDone: boolean = false;
#history = new Array<ClipboardItem>;
#changesTimeout: (AstalIO.Time|undefined);
#ignoreChanges: boolean = false;
@signal(GObject.TYPE_JSOBJECT) copied(_item: object) {}
@signal() wiped() {};
@getter(Array)
public get history() { return this.#history; }
constructor() {
super();
this.#dbFile = this.getCliphistDatabase();
this.#dbMonitor = monitorFile(this.#dbFile.get_path()!, () => {
if(this.#ignoreChanges || this.#changesTimeout)
return;
this.#changesTimeout = timeout(300, () => this.#changesTimeout = undefined);
if(this.#updateDone) {
this.updateDatabase();
return;
}
this.init();
});
if(this.#dbFile.query_exists(null)) {
this.init();
return;
}
console.log("Clipboard: cliphist database not found. Try copying something first!");
}
vfunc_dispose(): void {
this.#dbMonitor.cancel();
this.#dbMonitor.unref();
}
private init() {
console.log("Clipboard: Starting to read cliphist history...");
this.updateDatabase().then(() => {
console.log("Clipboard: Done reading cliphist history!");
}).catch((err) =>
console.error(`Clipboard: An error occurred while reading cliphist history. Stderr: ${err}`)
);
}
public async copyAsync(content: string): Promise<void> {
await execAsync(`wl-copy "${content}"`).catch((err: Gio.IOErrorEnum) => {
console.error(`Clipboard: Couldn't copy text using wl-copy. Stderr:\n\t${err.message
} | Stack:\n\t\t${err.stack}`);
});
}
public async selectItem(itemToSelect: number|ClipboardItem): Promise<boolean> {
const item = await this.getItemContent(itemToSelect);
let res: boolean = true;
if(item)
await this.copyAsync(item).catch(() => res = false);
return res;
}
/** Gets history item's content by its ID.
* @returns the clipboard item's content */
public async getItemContent(item: number|ClipboardItem): Promise<string|undefined> {
const id = (typeof item === "number") ?
item : item.id;
const cmd = Gio.Subprocess.new([ "cliphist", "decode", id.toString() ],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
const [ , stdout, stderr ] = cmd.communicate_utf8(null, null);
if(stderr) {
console.error(`Clipboard: An error occurred while getting item content. Stderr:\n${stderr}`);
return;
}
return stdout;
}
/** Searches for the cliphist database file
* Will not work if cliphist config file is not on default path */
private getCliphistDatabase(): Gio.File {
// Check if env variable is set
const path = GLib.getenv("CLIPHIST_DB_PATH");
if(path != null)
return Gio.File.new_for_path(path);
// Check config file
const confFile = Gio.File.new_for_path(`${GLib.get_user_config_dir()}/cliphist/config`);
if(confFile.query_exists(null)) {
const cliphistConf = readFile(confFile.get_path()!);
for(const line of cliphistConf.split('\n').map(l => l.trim())) {
if(line.startsWith('#'))
continue;
const [ key, value ] = line.split('\s', 1);
if(key === "db-path") {
return Gio.File.new_for_path(value.trimStart());
}
}
}
// return default path if none of the above matches
return Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/cliphist/db`);
}
private getContentType(preview: string): ClipboardItemType {
return /^\[\[.*binary data.*x.*\]\]$/u.test(preview) ?
ClipboardItemType.IMAGE
: ClipboardItemType.TEXT;
}
public async wipeHistory(noExec?: boolean): Promise<void> {
if(noExec) {
this.#history = [];
this.emit("wiped");
return;
}
this.#ignoreChanges = true;
await execAsync("cliphist wipe").then(() => {
this.#history = [];
this.emit("wiped");
}).catch((err: Gio.IOErrorEnum) =>
console.error(`Clipboard: An error occurred on cliphist database wipe. Stderr: ${
err.message ? `${err.message}\n` : ""}${err.stack}`)
).finally(() => this.#ignoreChanges = false);
}
public async updateDatabase(): Promise<void> {
const proc = Gio.Subprocess.new([ "cliphist", "list" ],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
proc.communicate_utf8_async(null, null, (_, asyncRes) => {
const [ success, stdout, stderr ] = proc.communicate_utf8_finish(asyncRes);
if(!success || stderr) {
console.error("Clipboard: Couldn't communicate with cliphist! Is it installed?");
return;
}
if(!stdout.trim()) {
this.wipeHistory(true);
this.notify("history");
return;
}
const items = stdout.split('\n');
if(this.#updateDone) {
const [ id, preview ] = items[0].split('\t');
const clipItem = {
id: Number.parseInt(id),
preview,
type: this.getContentType(preview)
} as ClipboardItem;
this.#history.unshift(clipItem);
this.emit("copied", clipItem);
this.notify("history");
return;
}
for(const item of items) {
if(!item) continue;
const [ id, preview ] = item.split('\t');
const clipItem = {
id: Number.parseInt(id),
preview,
type: this.getContentType(preview)
} as ClipboardItem;
this.#history.push(clipItem);
this.emit("copied", clipItem);
this.notify("history");
}
this.#updateDone = true;
});
}
public static getDefault(): Clipboard {
if(!this.instance)
this.instance = new Clipboard();
return this.instance;
}
}
+71
View File
@@ -0,0 +1,71 @@
import GLib from "gi://GLib?version=2.0";
import GObject, { getter, property, register } from "ags/gobject";
/** WIP Global implementation of a system that supports
* a variety of Wayland Compositors */
export namespace Compositor {
let instance: _Compositor;
@register({ GTypeName: "CompositorMonitor" })
class _CompositorMonitor extends GObject.Object {
public readonly width: number;
public readonly height: number;
@property(Boolean)
public readonly mirror: boolean;
constructor(width: number, height: number, mirror: boolean = false) {
super();
this.width = width;
this.height = height;
this.mirror = mirror;
}
}
@register({ GTypeName: "CompositorWorkspace" })
class _CompositorWorkspace extends GObject.Object {
public readonly id: number;
@getter(_CompositorMonitor)
public readonly monitor: _CompositorMonitor;
constructor(monitor: _CompositorMonitor, id: number) {
super();
this.monitor = monitor;
this.id = id;
}
}
@register({ GTypeName: "Compositor" })
class _Compositor extends GObject.Object {
#workspaces: Array<_CompositorWorkspace> = [];
@property()
public get workspaces() { return this.#workspaces; }
};
export function getDefault(): _Compositor {
if(!instance)
instance = new _Compositor();
return instance;
}
export const Compositor = _Compositor,
CompositorWorkspace = _CompositorWorkspace,
CompositorMonitor = _CompositorMonitor;
/** Uses the XDG_CURRENT_DESKTOP variable to detect running compositor's name.
* ---
* @returns running wayland compositor's name (lowercase) or `undefined` if variable's not set */
export function getName(): string|undefined {
return GLib.getenv("XDG_CURRENT_DESKTOP") ?? undefined;
}
}
+183
View File
@@ -0,0 +1,183 @@
import { timeout } from "ags/time";
import { monitorFile, readFileAsync } from "ags/file";
import { Notifications } from "./notifications";
import { encoder } from "./utils";
import { Accessor } from "ags";
import GObject, { getter, register } from "ags/gobject";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
import AstalIO from "gi://AstalIO";
import AstalNotifd from "gi://AstalNotifd";
export { Config };
type ValueTypes = "string" | "boolean" | "object" | "number" | "undefined" | "any";
@register({ GTypeName: "Config" })
class Config<K extends NonNullable<string|number|symbol>, V extends string|object|any> extends GObject.Object {
declare $signals: GObject.Object.SignalSignatures & {
"notify::entries": (entries: Record<K, V>) => void;
};
/** unmodified object with default entries. User-values are stored
* in the `entries` field */
public readonly defaults: Record<K, V>;
@getter(Object)
public get entries(): object { return this.#entries; }
#file: Gio.File;
#entries: Record<K, V>;
private timeout: (AstalIO.Time|boolean|undefined);
public get file() { return this.#file; };
constructor(filePath: Gio.File|string, defaults?: Record<K, V>) {
super();
this.defaults = (defaults ?? {}) as Record<K, V>;
this.#entries = { ...defaults } as Record<K, V>;
this.#file = (typeof filePath === "string") ?
Gio.File.new_for_path(filePath)
: filePath;
if(!this.#file.query_exists(null)) {
this.#file.make_directory_with_parents(null);
this.#file.delete(null);
this.#file.create_readwrite_async(
Gio.FileCreateFlags.NONE, GLib.PRIORITY_DEFAULT,
null, (_, asyncRes) => {
const ioStream = this.#file.create_readwrite_finish(asyncRes);
ioStream.outputStream.write_bytes_async(
GLib.Bytes.new(encoder.encode(JSON.stringify(this.entries, undefined, 4))),
GLib.PRIORITY_DEFAULT, null,
(_, asyncRes) => {
const writtenBytes = ioStream.outputStream.write_bytes_finish(asyncRes);
if(!writtenBytes)
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Write error",
body: `Couldn't write default configuration file to "${this.#file.get_path()!}"`
});
}
);
});
}
monitorFile(this.#file.get_path()!,
() => {
if(this.timeout) return;
this.timeout = timeout(500, () => this.timeout = undefined);
if(this.#file.query_exists(null)) {
this.timeout?.cancel();
this.timeout = true;
this.readFile().finally(() =>
this.timeout = undefined);
return;
}
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Config error",
body: `Could not hot-reload configuration: config file not found in \`${this.#file.get_path()!}\`, last valid configuration is being used. Maybe it got deleted?`
});
}
);
}
private async readFile(): Promise<void> {
await readFileAsync(this.#file.get_path()!).then((content) => {
let config: (Record<K, V>|undefined);
try {
config = JSON.parse(content) as Record<K, V>;
} catch(e) {
Notifications.getDefault().sendNotification({
urgency: AstalNotifd.Urgency.NORMAL,
appName: "colorshell",
summary: "Config parsing error",
body: `An error occurred while parsing colorshell's config file: \nFile: ${
this.#file.get_path()!}\n${
(e as SyntaxError).message}\n${(e as SyntaxError).stack}`
});
}
if(!config) return;
// only change valid entries that are available in the defaults (with 1 of depth)
for(const k of Object.keys(this.entries)) {
if(config[k as keyof typeof config] === undefined)
return;
// TODO needs more work, like object-recursive(infinite depth) entry attributions
this.#entries[k as keyof Record<K, V>] = config[k as keyof typeof config];
}
this.notify("entries");
}).catch((e: Gio.IOErrorEnum) => {
Notifications.getDefault().sendNotification({
urgency: AstalNotifd.Urgency.NORMAL,
appName: "colorshell",
summary: "Config read error",
body: `An error occurred while reading colorshell's config file: \nFile: ${`${
this.#file.get_path()!}\n${e.message ? `${e.message}\n` : ""}${e.stack}`.replace(/[<>]/g, "\\&")}`
});
});
}
public bindProperty(propertyPath: string, expectType?: ValueTypes): Accessor<any|undefined> {
return new Accessor<Record<K, V>>(() => this.getProperty(propertyPath, expectType), (callback: () => void) => {
const id = this.connect("notify::entries", () => callback());
return () => this.disconnect(id);
});
}
public getProperty(path: string, expectType?: ValueTypes): (any|undefined) {
return this._getProperty(path, this.#entries, expectType);
}
public getPropertyDefault(path: string, expectType?: ValueTypes): (any|undefined) {
return this._getProperty(path, this.defaults, expectType);
}
private _getProperty(path: string, entries: Record<K, V>, expectType?: ValueTypes): (any|undefined) {
let property: any = entries;
const pathArray = path.split('.').filter(str => str);
for(let i = 0; i < pathArray.length; i++) {
const currentPath = pathArray[i];
property = property[currentPath as keyof typeof property];
}
if(expectType !== "any" && typeof property !== expectType) {
console.error(`Config: property with path \`${path
}\` is either \`undefined\` or not in the expected value type \`${expectType
}\`, returning default value`);
property = this.defaults;
for(let i = 0; i < pathArray.length; i++) {
const currentPath = pathArray[i];
property = property[currentPath as keyof typeof property];
}
}
if(expectType !== "any" && typeof property !== expectType) {
console.error(`Config: property with path \`${path}\` not found in defaults/user-entries, returning \`undefined\``);
property = undefined;
}
return property;
}
}
+150
View File
@@ -0,0 +1,150 @@
import AstalIO from "gi://AstalIO";
import GLib from "gi://GLib?version=2.0";
import GObject, { getter, register } from "ags/gobject";
import { execAsync, exec } from "ags/process";
import { interval } from "ags/time";
export { NightLight };
@register({ GTypeName: "NightLight" })
class NightLight extends GObject.Object {
private static instance: NightLight;
#watchInterval: (AstalIO.Time|null) = null;
#temperature: number = 4500;
#gamma: number = 100;
#identity: boolean = false;
#prevTemperature: (number|null) = null;
#prevGamma: (number|null) = null;
@getter(Number)
public get temperature() { return this.#temperature; }
public set temperature(newValue: number) { this.setTemperature(newValue); }
@getter(Number)
public get gamma() { return this.#gamma; }
public set gamma(newValue: number) { this.setGamma(newValue); }
public readonly maxTemperature = 20000;
public readonly minTemperature = 1000;
public readonly identityTemperature = 6000;
public readonly maxGamma = 100;
@getter(Boolean)
public get identity() { return this.#identity; }
public set identity(newValue: boolean) {
newValue ? this.applyIdentity() : this.filter();
}
constructor() {
super();
this.#watchInterval = interval(1000, () => {
execAsync("hyprctl hyprsunset temperature").then(t => {
if(t.trim() !== "" && t.trim().length <= 5) {
const val = Number.parseInt(t.trim());
if(this.#temperature !== val) {
this.#temperature = val;
this.notify("temperature");
}
}
}).catch((r) => console.error(r));
execAsync("hyprctl hyprsunset gamma").then(g => {
if(g.trim() !== "" && g.trim().length <= 5) {
const val = Number.parseInt(g.trim());
if(this.#gamma !== val) {
this.#gamma = val;
this.notify("gamma");
}
}
}).catch((r) => console.error(r));
});
this.vfunc_dispose = () => this.#watchInterval &&
this.#watchInterval.cancel();
}
public static getDefault(): NightLight {
if(!this.instance)
this.instance = new NightLight();
return this.instance;
}
private setTemperature(value: number): void {
if(value === this.temperature) return;
if(value > this.maxTemperature || value < 1000) {
console.error(`Night Light(hyprsunset): provided temperatue ${value
} is out of bounds (min: 1000; max: ${this.maxTemperature})`);
return;
}
execAsync(`hyprctl hyprsunset temperature ${value}`).then(() => {
this.#temperature = value;
this.notify("temperature");
this.#identity = false;
this.#prevTemperature = null;
this.#prevGamma = null;
}).catch((r) => console.error(
`Night Light(hyprsunset): Couldn't set temperature. Stderr: ${r}`
));
}
private setGamma(value: number): void {
if(value === this.gamma) return;
if(value > this.maxGamma || value < 0) {
console.error(`Night Light(hyprsunset): provided gamma ${value
} is out of bounds (min: 0; max: ${this.maxTemperature})`);
return;
}
execAsync(`hyprctl hyprsunset gamma ${value}`).then(() => {
this.#gamma = value;
this.notify("gamma");
this.#identity = false;
this.#prevTemperature = null;
this.#prevGamma = null;
}).catch((r) => console.error(
`Night Light(hyprsunset): Couldn't set gamma. Stderr: ${r}`
));
}
public applyIdentity(): void {
if(this.#identity) return;
this.#prevGamma = this.#gamma;
this.#prevTemperature = this.#temperature;
this.#identity = true;
this.temperature = this.identityTemperature;
this.gamma = this.maxGamma;
}
public filter(): void {
if(!this.#identity) return;
this.#identity = false;
this.setTemperature(this.#prevTemperature ?? this.identityTemperature);
this.setGamma(this.#prevGamma ?? this.maxGamma);
this.#prevTemperature = null;
this.#prevGamma = null;
}
public saveData(): void {
exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/save-hyprsunset.sh`);
}
public loadData(): void {
exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/load-hyprsunset.sh`);
}
}
+313
View File
@@ -0,0 +1,313 @@
import { timeout } from "ags/time";
import { execAsync } from "ags/process";
import { readFile } from "ags/file";
import { generalConfig } from "../app";
import { onCleanup } from "ags";
import GObject, { getter, property, register, signal } from "ags/gobject";
import AstalNotifd from "gi://AstalNotifd";
import AstalIO from "gi://AstalIO";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
export interface HistoryNotification {
id: number;
appName: string;
body: string;
summary: string;
urgency: AstalNotifd.Urgency;
appIcon?: string;
time: number;
image?: string;
}
@register({ GTypeName: "Notifications" })
class Notifications extends GObject.Object {
private static instance: (Notifications|null) = null;
#notifications: Array<AstalNotifd.Notification> = [];
#history: Array<HistoryNotification> = [];
#notificationsOnHold: Set<number> = new Set<number>();
#connections: Array<number> = [];
@getter(Array<AstalNotifd.Notification>)
public get notifications() { return this.#notifications };
@getter(Array<HistoryNotification>)
public get history() { return this.#history };
@property(Number)
public historyLimit: number = 10;
@signal(AstalNotifd.Notification) notificationAdded(_notification: AstalNotifd.Notification) {};
@signal(Number) notificationRemoved(_id: number) {};
@signal(Object) historyAdded(_notification: Object) {};
@signal(Number) historyRemoved(_id: number) {};
@signal(Number) notificationReplaced(_id: number) {};
constructor() {
super();
this.#connections.push(
AstalNotifd.get_default().connect("notified", (notifd, id) => {
const notification = notifd.get_notification(id);
const notifTimeout = generalConfig.getProperty(
`notifications.timeout_${this.getUrgencyString(notification.urgency).toLowerCase()}`,
"number") as number;
if(this.getNotifd().dontDisturb) {
this.addHistory(notification, () => notification.dismiss());
return;
}
this.addNotification(notification, () => {
if(notification.urgency !== AstalNotifd.Urgency.CRITICAL ||
(notification.urgency === AstalNotifd.Urgency.CRITICAL &&
notifTimeout > 0)) {
let notifTimer: (AstalIO.Time|undefined) = undefined;
let replacedConnectionId: number;
const removeFun = () => { // Funny name haha lmao remove fun :skull:
notifTimer = undefined;
if(this.#notificationsOnHold.has(notification.id)) return;
this.addHistory(notification, () => {
replacedConnectionId && this.disconnect(replacedConnectionId);
this.removeNotification(id);
});
}
notifTimer = timeout(notifTimeout, removeFun);
replacedConnectionId = this.connect("notification-replaced", (_, id: number) => {
if(notification.id !== id) return;
notifTimer?.cancel();
notifTimer = timeout(notifTimeout, removeFun);
});
}
});
}),
AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => {
this.removeNotification(id);
this.addHistory(notifd.get_notification(id));
})
);
this.retrieveHistoryFromFile();
onCleanup(() => {
this.#connections.map(id =>
AstalNotifd.get_default().disconnect(id));
});
}
public static getDefault(): Notifications {
if(!this.instance)
this.instance = new Notifications();
return this.instance;
}
private retrieveHistoryFromFile(): void {
const historyFile = Gio.File.new_for_path(`${GLib.get_user_state_dir()}/astal/notifd/notifications.json`);
if(!historyFile.query_exists(null)) return;
let content: string;
console.log("Notifications: History file found! Trying to retrieve history from JSON");
try {
content = readFile(historyFile.get_path()!);
} catch(e: any) {
console.error(`Notifications: An error occurred while trying to read the history file. Stderr:\n${
(e as Error).message}\n${(e as Error).stack}`);
return;
}
try {
const historyJSON = JSON.parse(content);
(historyJSON["notifications"] as Array<AstalNotifd.Notification>).reverse()
.forEach(n => this.addHistory(n));
} catch(e: any) {
if(e instanceof SyntaxError) {
console.error(`Notifications: Couldn't parse history JSON because of a SyntaxError:\n${e.message
}\n${e.stack}`);
return;
}
console.error(`Notifications: An error occurred while parsing the history JSON file. Stderr:\n${
e.message}\n${e.stack}`);
return;
}
}
public async sendNotification(props: {
urgency?: AstalNotifd.Urgency;
appName?: string;
image?: string;
summary: string;
body?: string;
replaceId?: number;
actions?: Array<{
id?: (string|number);
text: string;
onAction?: () => void
}>
}): Promise<{
id?: (string|number);
text: string;
onAction?: () => void
}|null|void> {
return await execAsync([
"notify-send",
...(props.urgency ? [
"-u", this.getUrgencyString(props.urgency)
] : []), ...(props.appName ? [
"-a", props.appName
] : []), ...(props.image ? [
"-i", props.image
] : []), ...(props.actions ? props.actions.map((action) =>
[ "-A", action.text ]
).flat(2) : []), ...(props.replaceId ? [
"-r", props.replaceId.toString()
] : []), props.summary, props.body ? props.body : ""
]).then((stdout) => {
stdout = stdout.trim();
if(!stdout) {
if(props.actions && props.actions.length > 0)
return null;
return;
}
if(props.actions && props.actions.length > 0) {
const action = props.actions[Number.parseInt(stdout)];
action?.onAction?.();
return action ?? undefined;
}
}).catch((err: Error) => {
console.error(`Notifications: Couldn't send notification! Is the daemon running? Stderr:\n${
err.message ? `${err.message}\n` : ""}Stack: ${err.stack}`);
});
}
public getUrgencyString(urgency: AstalNotifd.Notification|AstalNotifd.Urgency) {
switch((urgency instanceof AstalNotifd.Notification) ?
urgency.urgency : urgency) {
case AstalNotifd.Urgency.LOW:
return "low";
case AstalNotifd.Urgency.CRITICAL:
return "critical";
}
return "normal";
}
private addHistory(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void {
if(!notif) return;
this.#history.length === this.historyLimit &&
this.removeHistory(this.#history[this.#history.length - 1]);
this.#history.map((notifb, i) =>
notifb.id === notif.id && this.#history.splice(i, 1));
this.#history.unshift({
id: notif.id,
appName: notif.app_name,
body: notif.body,
summary: notif.summary,
urgency: notif.urgency,
appIcon: notif.app_icon,
time: notif.time,
image: notif.image ? notif.image : undefined
} as HistoryNotification);
this.notify("history");
this.emit("history-added", this.#history[0]);
onAdded && onAdded(notif);
}
public async clearHistory(): Promise<void> {
this.#history.reverse().map((notif) => {
this.#history = this.history.filter((n) => n.id !== notif.id);
this.emit("history-removed", notif.id);
});
this.notify("history");
}
public removeHistory(notif: (HistoryNotification|number)): void {
const notifId = (typeof notif === "number") ? notif : notif.id;
this.#history = this.#history.filter((item: HistoryNotification) =>
item.id !== notifId);
this.notify("history");
this.emit("history-removed", notifId);
}
private addNotification(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void {
for(let i = 0; i < this.#notifications.length; i++) {
const item = this.#notifications[i];
if(item.id !== notif.id) continue;
this.#notifications.splice(i, 1);
this.emit("notification-replaced", item.id);
break;
}
this.#notifications.unshift(notif);
this.notify("notifications");
this.emit("notification-added", notif);
onAdded?.(notif);
}
public removeNotification(notif: (AstalNotifd.Notification|number)): void {
const notificationId = (notif instanceof AstalNotifd.Notification) ? notif.id : notif;
this.#notificationsOnHold.delete(notificationId);
this.#notifications = this.#notifications.filter((item) =>
item.id !== notificationId);
AstalNotifd.get_default().get_notification(notificationId)?.dismiss();
this.notify("notifications");
this.emit("notification-removed", notificationId);
}
private getNotificationById(id: number): AstalNotifd.Notification|undefined {
return this.#notifications.filter(notif => notif.id === id)?.[0];
}
public holdNotification(notif: (AstalNotifd.Notification|number)): void {
notif = (typeof notif === "number") ?
this.getNotificationById(notif)!
: notif;
if(!notif) return;
this.#notificationsOnHold.add(notif.id);
}
public toggleDoNotDisturb(value?: boolean): boolean {
value = value ?? !AstalNotifd.get_default().dontDisturb;
AstalNotifd.get_default().dontDisturb = value;
return value;
}
public getNotifd(): AstalNotifd.Notifd { return AstalNotifd.get_default(); }
}
export { Notifications };
+158
View File
@@ -0,0 +1,158 @@
import { execAsync } from "ags/process";
import { getter, register, signal } from "ags/gobject";
import { Gdk } from "ags/gtk4";
import { makeDirectory } from "./utils";
import { Notifications } from "./notifications";
import { time } from "./utils";
import GObject from "ags/gobject";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
export { Recording };
@register({ GTypeName: "Recording" })
class Recording extends GObject.Object {
private static instance: Recording;
@signal() started() {};
@signal() stopped() {};
#recording: boolean = false;
#path: string = "~/Recordings";
/** Default extension: mp4(h264) */
#extension: string = "mp4";
#recordAudio: boolean = false;
#area: (Gdk.Rectangle|null) = null;
#startedAt: number = -1;
#process: (Gio.Subprocess|null) = null;
#output: (string|null) = null;
/** GLib.DateTime of when recording started
* its value can be `-1` if undefined(no recording is happening) */
@getter(Number)
public get startedAt() { return this.#startedAt; }
@getter(Boolean)
public get recording() { return this.#recording; }
private set recording(newValue: boolean) {
(!newValue && this.#recording) ?
this.stopRecording()
: this.startRecording(this.#area || undefined);
this.#recording = newValue;
this.notify("recording");
}
@getter(String)
public get path() { return this.#path; }
public set path(newPath: string) {
if(this.recording) return;
this.#path = newPath;
this.notify("path");
}
@getter(String)
public get extension() { return this.#extension; }
public set extension(newExt: string) {
if(this.recording) return;
this.#extension = newExt;
this.notify("extension");
}
/** Recording output file name. %NULL if screen is not being recorded */
public get output() { return this.#output; }
/** Currently unsupported property */
public get recordAudio() { return this.#recordAudio; }
public set recordAudio(newValue: boolean) {
if(this.recording) return;
this.#recordAudio = newValue;
this.notify("record-audio");
}
constructor() {
super();
const videosDir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_VIDEOS);
if(videosDir) this.#path = `${videosDir}/Recordings`;
}
public static getDefault() {
if(!this.instance)
this.instance = new Recording();
return this.instance;
}
public startRecording(area?: Gdk.Rectangle) {
if(this.recording)
throw new Error("Screen Recording is already running!");
this.#output = `${time.get().format("%Y-%m-%d-%H%M%S")}_rec.${this.extension || "mp4"}`;
this.#recording = true;
this.notify("recording");
this.emit("started");
makeDirectory(this.path);
const cancellable = Gio.Cancellable.new();
cancellable.cancel = () => {};
const areaString = `${area?.x ?? 0},${area?.y ?? 0} ${area?.width ?? 1}x${area?.height ?? 1}`;
this.#process = Gio.Subprocess.new([
"wf-recorder",
...(area ? [ `-g`, areaString ] : []),
"-f",
`${this.path}/${this.output!}`
], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
this.#process.wait_async(cancellable, () => {
this.stopRecording();
});
this.#startedAt = time.get().to_unix();
}
public stopRecording() {
if(!this.#process) return;
!this.#process.get_if_exited() && execAsync([
"kill", "-s", "SIGTERM", this.#process.get_identifier()!
]);
const path = this.#path;
const output = this.#output;
this.#process = null;
this.#recording = false;
this.#startedAt = -1;
this.#output = null;
this.notify("recording");
this.emit("stopped");
Notifications.getDefault().sendNotification({
actions: [
{
text: "View",
onAction: () => {
execAsync(["nautilus", "-s", output!, path]);
}
},
{
text: "Open",
onAction: () => {
execAsync(["xdg-open", `${path}/${output}`]);
}
}
],
appName: "Screen Recording",
summary: "Screen Recording saved",
body: `Saved as ${path}/${output}`
});
}
};
+27
View File
@@ -0,0 +1,27 @@
import { monitorFile } from "ags/file";
import { execAsync } from "ags/process";
import { uwsmIsActive } from "./apps";
import Gio from "gi://Gio?version=2.0";
const monitoringPaths = [ "./scripts", "./window", "./app.ts", "env.d.ts" ];
export function restartInstance(): void {
execAsync(`astal -q "colorshell"`);
Gio.Subprocess.new(
( uwsmIsActive ?
[ "uwsm", "app", "--", "ags", "run" ]
: [ "ags", "run" ]),
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
}
export function monitorPaths(): void {
monitoringPaths.map((path: string) => {
monitorFile(
path,
() => restartInstance()
)
});
}
+87
View File
@@ -0,0 +1,87 @@
import { monitorFile, readFile } from "ags/file";
import { timeout } from "ags/time";
import { exec, execAsync } from "ags/process";
import { Shell } from "../app";
import AstalIO from "gi://AstalIO";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
/** handles stylesheet compiling and reloading */
export class Stylesheet {
private static instance: Stylesheet;
#watchDelay: (AstalIO.Time|undefined);
#outputPath = Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/colorshell/style`);
#styles = [ "./style", "./style.scss" ];
public get stylePath() { return this.#outputPath.get_path()!; }
public async compileSass(): Promise<void> {
console.log("Stylesheet: Compiling Sass");
exec(`bash -c "sass ${this.#styles.map(style => `-I ${style}`).join('\s')} ${
this.#outputPath.get_path()!}/style.css"`);
}
public async reapply(cssFilePath: string): Promise<void> {
console.log("Stylesheet: Applying stylesheet");
const content = readFile(cssFilePath);
if(content?.trim()) {
Shell.getDefault().resetStyle();
Shell.getDefault().applyStyle(content);
console.log("Stylesheet: done applying stylesheet to shell");
return;
}
console.error(`Stylesheet: An error occurred while trying to read the css file: ${
cssFilePath}`);
}
public async compileApply(): Promise<void> {
await this.compileSass().then(() =>
this.reapply(this.#outputPath.get_path()! + "/style.css")
).catch((err: Error) =>
console.error(`Stylesheet: An error occurred and Sass couldn't be compiled. Stderr:\n${
err.message}\n${err.stack}`)
);
}
public static getDefault(): Stylesheet {
if(!this.instance)
this.instance = new Stylesheet();
return this.instance;
}
constructor() {
(async () => !this.#outputPath.query_exists(null) &&
this.#outputPath.make_directory_with_parents(null))();
this.#styles.map((path: string) =>
monitorFile(
`${path}`,
(file: string) => {
if(this.#watchDelay || file.endsWith('~') || Number.isNaN(file))
return;
this.#watchDelay = timeout(250, () => this.#watchDelay = undefined);
console.log(`Stylesheet: \`${file.startsWith(GLib.get_home_dir()) ?
file.replace(GLib.get_home_dir(), '~')
: file}\` changed`)
this.compileApply();
}
)
)
monitorFile(`${GLib.get_user_cache_dir()}/wal/colors.scss`, (file: string) => {
execAsync(`bash -c "cp -f ${file} ./style/_wal.scss"`).catch(r => {
console.error(`Stylesheet: Failed to copy pywal stylesheet to style dir. Stderr: ${r}`);
});
});
}
}
+214
View File
@@ -0,0 +1,214 @@
import { createPoll } from "ags/time";
import { exec, execAsync } from "ags/process";
import { Accessor, For, With } from "ags";
import { Astal, Gtk } from "ags/gtk4";
import { getSymbolicIcon } from "./apps";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
import Xdp from "gi://Xdp?version=1.0";
/** gnim doesn't export this, so we need to do it again */
export type WidgetNodeType = Array<JSX.Element> | JSX.Element | number | string | boolean | null | undefined;
export const decoder = new TextDecoder("utf-8"),
encoder = new TextEncoder();
export const time = createPoll(GLib.DateTime.new_now_local(), 500, () =>
GLib.DateTime.new_now_local());
export const XdgPortal = Xdp.Portal.new();
export function getHyprlandInstanceSig(): (string|null) {
return GLib.getenv("HYPRLAND_INSTANCE_SIGNATURE");
}
export function getHyprlandVersion(): string {
return exec(`${GLib.getenv("HYPRLAND_CMD") ?? "Hyprland"} --version | head -n1`).split(" ")[1];
}
export function getPlayerIconFromBusName(busName: string): string {
const splitName = busName.split('.').filter(str => str !== "" &&
!str.toLowerCase().includes('instance'));
return getSymbolicIcon(splitName[splitName.length - 1]) ?
getSymbolicIcon(splitName[splitName.length - 1])!
: "folder-music-symbolic";
}
export function escapeUnintendedMarkup(input: string): string {
return input.replace(/<[^>]*>|[<>&"]/g, (s) => {
if(s.startsWith('<') && s.endsWith('>'))
return s;
switch(s) {
case "<": return "&lt;";
case ">": return "&gt;";
case "&": return "&amp;";
case "\"": return "&quot;";
}
return s;
});
}
export function getChildren(widget: Gtk.Widget): Array<Gtk.Widget> {
const firstChild = widget.get_first_child(),
children: Array<Gtk.Widget> = [];
if(!firstChild) return [];
let currentChild = firstChild.get_next_sibling();
while(currentChild != null) {
children.push(currentChild);
currentChild = currentChild.get_next_sibling();
}
return children;
}
export function omitObjectKeys<ObjT = object>(obj: ObjT, keys: keyof ObjT|Array<keyof ObjT>): object {
const finalObject = { ...obj };
for(const objKey of Object.keys(finalObject as object)) {
if(!Array.isArray(keys)) {
if(objKey === keys) {
delete finalObject[keys as keyof typeof finalObject];
break;
}
continue;
}
for(const omitKey of keys) {
if(objKey === omitKey) {
delete finalObject[objKey as keyof typeof finalObject];
break;
}
}
}
return finalObject as object;
}
export function pickObjectKeys<ObjT = object>(obj: ObjT, keys: Array<keyof ObjT>): object {
const finalObject = {} as Record<keyof ObjT, any>;
for(const key of keys) {
for(const objKey of Object.keys(obj as object)) {
if(key === objKey) {
finalObject[key as keyof ObjT] = obj[objKey as keyof ObjT];
break;
}
}
}
return finalObject;
}
export function variableToBoolean(variable: any|Array<any>|Accessor<Array<any>|any>): boolean|Accessor<boolean> {
return (variable instanceof Accessor) ?
variable.as(v => Array.isArray(v) ?
(v as Array<any>).length > 0
: Boolean(v))
: Array.isArray(variable) ?
variable.length > 0
: Boolean(variable);
}
export function pathToURI(path: string): string {
switch(true) {
case (/^[/]/).test(path):
return `file://${path}`;
case (/^[~]/).test(path):
case (/^file:\/\/[~]/i).test(path):
return `file://${GLib.get_home_dir()}/${path.replace(/^(file\:\/\/|[~]|file\:\/\[~])/i, "")}`;
}
return path;
}
export function transform<ValueType = any|Array<any>, RType = any>(
v: Accessor<ValueType>|ValueType, fn: (v: ValueType) => RType
): RType|Accessor<RType> {
return (v instanceof Accessor) ?
v.as(fn)
: fn(v);
}
export function transformWidget<ValueType = unknown>(
v: Accessor<ValueType|Array<ValueType>>|ValueType|Array<ValueType>,
fn: (v: ValueType, i?: Accessor<number>|number) => JSX.Element
): WidgetNodeType {
return (v instanceof Accessor) ?
Array.isArray(v.get()) ?
For({
each: v as Accessor<Array<ValueType>>,
children: (cval, i) => fn(cval, i)
})
: With({
value: v as Accessor<ValueType>,
children: fn
})
: (Array.isArray(v) ?
v.map(val => fn(val))
: fn(v));
}
export function filter<ValueType = unknown, FilterReturnType = unknown>(
v: Accessor<Array<ValueType>>|Array<ValueType>,
fn: (v: ValueType, i: number, array: Array<ValueType>) => FilterReturnType
): Array<ValueType>|Accessor<Array<ValueType>> {
return ((v instanceof Accessor) ?
v(v => v.filter((it, i, arr) => fn(it, i, arr)))
: v.filter((it, i, arr) => fn(it, i, arr)));
}
export function makeDirectory(dir: string): void {
execAsync([ "mkdir", "-p", dir ]);
}
export function deleteFile(path: string): void {
execAsync([ "rm", "-r", path ]);
}
export function playSystemBell(): void {
execAsync("canberra-gtk-play -i bell").catch((e: Error) => {
console.error(`Couldn't play system bell. Stderr: ${e.message}\n${e.stack}`);
});
}
export function isInstalled(commandName: string): boolean {
const proc = Gio.Subprocess.new(["bash", "-c", `command -v ${commandName}`],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
const [ , stdout, stderr ] = proc.communicate_utf8(null, null);
if(stdout && !stderr)
return true;
return false;
}
export function addSliderMarksFromMinMax(slider: Astal.Slider, amountOfMarks: number = 2, markup?: (string | null)) {
if(markup && !markup.includes("{}"))
markup = `${markup}{}`
slider.add_mark(slider.min, Gtk.PositionType.BOTTOM, markup ?
markup.replaceAll("{}", `${slider.min}`) : null);
const num = (amountOfMarks - 1);
for(let i = 1; i <= num; i++) {
const part = (slider.max / num) | 0;
if(i > num) {
slider.add_mark(slider.max, Gtk.PositionType.BOTTOM, `${slider.max}K`);
break;
}
slider.add_mark(part*i, Gtk.PositionType.BOTTOM, markup ?
markup.replaceAll("{}", `${part*i}`) : null);
}
return slider;
}
+148
View File
@@ -0,0 +1,148 @@
import GObject, { register } from "ags/gobject";
import AstalWp from "gi://AstalWp";
export { Wireplumber };
@register({ GTypeName: "Wireplumber" })
class Wireplumber extends GObject.Object {
private static astalWireplumber: (AstalWp.Wp|null) = AstalWp.get_default();
private static inst: Wireplumber;
private defaultSink: AstalWp.Endpoint = Wireplumber.astalWireplumber!.get_default_speaker()!;
private defaultSource: AstalWp.Endpoint = Wireplumber.astalWireplumber!.get_default_microphone()!;
private maxSinkVolume: number = 100;
private maxSourceVolume: number = 100;
constructor() {
super();
if(!Wireplumber.astalWireplumber)
throw new Error("Audio features will not work correctly! Please install wireplumber first", {
cause: "Wireplumber library not found"
});
}
public static getDefault(): Wireplumber {
if(!Wireplumber.inst)
Wireplumber.inst = new Wireplumber();
return Wireplumber.inst;
}
public static getWireplumber(): AstalWp.Wp {
return Wireplumber.astalWireplumber!;
}
public getMaxSinkVolume(): number {
return this.maxSinkVolume;
}
public getMaxSourceVolume(): number {
return this.maxSourceVolume;
}
public getDefaultSink(): AstalWp.Endpoint {
return this.defaultSink;
}
public getDefaultSource(): AstalWp.Endpoint {
return this.defaultSource;
}
public getSinkVolume(): number {
return Math.floor(this.getDefaultSink().get_volume() * 100);
}
public getSourceVolume(): number {
return Math.floor(this.getDefaultSource().get_volume() * 100);
}
public setSinkVolume(newSinkVolume: number): void {
this.defaultSink.set_volume(
(newSinkVolume > this.maxSinkVolume ? this.maxSinkVolume : newSinkVolume) / 100
);
}
public setSourceVolume(newSourceVolume: number): void {
this.defaultSource.set_volume(
newSourceVolume > this.maxSourceVolume ? this.maxSourceVolume : newSourceVolume / 100
);
}
public increaseEndpointVolume(endpoint: AstalWp.Endpoint, volumeIncrease: number): void {
volumeIncrease = Math.abs(volumeIncrease) / 100;
if((endpoint.get_volume() + volumeIncrease) > (this.maxSinkVolume / 100)) {
endpoint.set_volume(1.0);
return;
}
endpoint.set_volume(endpoint.get_volume() + volumeIncrease);
}
public increaseSinkVolume(volumeIncrease: number): void {
this.increaseEndpointVolume(this.getDefaultSink(), volumeIncrease);
}
public increaseSourceVolume(volumeIncrease: number): void {
this.increaseEndpointVolume(this.getDefaultSource(), volumeIncrease);
}
public decreaseEndpointVolume(endpoint: AstalWp.Endpoint, volumeDecrease: number): void {
volumeDecrease = Math.abs(volumeDecrease) / 100;
if((endpoint.get_volume() - volumeDecrease) < 0) {
endpoint.set_volume(0);
return;
}
endpoint.set_volume(endpoint.get_volume() - volumeDecrease);
}
public decreaseSinkVolume(volumeDecrease: number): void {
this.decreaseEndpointVolume(this.getDefaultSink(), volumeDecrease);
}
public decreaseSourceVolume(volumeDecrease: number): void {
this.decreaseEndpointVolume(this.getDefaultSource(), volumeDecrease);
}
public muteSink(): void {
this.getDefaultSink().set_mute(true);
}
public muteSource(): void {
this.getDefaultSource().set_mute(true);
}
public unmuteSink(): void {
this.getDefaultSink().set_mute(false);
}
public unmuteSource(): void {
this.getDefaultSource().set_mute(false);
}
public isMutedSink(): boolean {
return this.getDefaultSink().get_mute();
}
public isMutedSource(): boolean {
return this.getDefaultSource().get_mute();
}
public toggleMuteSink(): void {
if(this.isMutedSink())
return this.unmuteSink();
return this.muteSink();
}
public toggleMuteSource(): void {
if(this.isMutedSource())
return this.unmuteSource();
return this.muteSource();
}
}
+191
View File
@@ -0,0 +1,191 @@
import { execAsync } from "ags/process";
import { timeout } from "ags/time";
import GObject, { register, getter } from "ags/gobject";
import { monitorFile } from "ags/file";
import AstalIO from "gi://AstalIO";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
export { Wallpaper };
@register({ GTypeName: "Wallpaper" })
class Wallpaper extends GObject.Object {
private static instance: Wallpaper;
#wallpaper: (string|undefined);
#splash: boolean = true;
#monitor: Gio.FileMonitor;
#hyprpaperFile: Gio.File;
#wallpapersPath: string;
#ignoreWatch: boolean = false;
@getter(Boolean)
public get splash() { return this.#splash; }
public set splash(showSplash: boolean) {
this.#splash = showSplash;
this.notify("splash");
}
/** current wallpaper's complete path
* can be an empty string if undefined */
@getter(String)
public get wallpaper() { return this.#wallpaper ?? ""; }
public set wallpaper(newValue: string) { this.setWallpaper(newValue); }
public get wallpapersPath() { return this.#wallpapersPath; }
constructor() {
super();
this.#wallpapersPath = GLib.getenv("WALLPAPERS") ??
`${GLib.get_home_dir()}/wallpapers`;
this.#hyprpaperFile = Gio.File.new_for_path(`${
GLib.get_user_config_dir()}/hypr/hyprpaper.conf`);
this.getWallpaper().then((wall) => {
if(wall?.trim()) this.#wallpaper = wall.trim();
});
let tmeout: (AstalIO.Time|undefined) = undefined;
this.#monitor = monitorFile(this.#hyprpaperFile.get_path()!, (_, event) => {
if(event !== Gio.FileMonitorEvent.CHANGED && event !== Gio.FileMonitorEvent.CREATED &&
event !== Gio.FileMonitorEvent.MOVED_IN)
return;
if(tmeout) return;
else tmeout = timeout(1500, () => tmeout = undefined);
if(this.#ignoreWatch) {
this.#ignoreWatch = false;
return;
}
const [ loaded, text ] = this.#hyprpaperFile.load_contents(null);
if(!loaded)
console.error("Wallpaper: Couldn't read changes inside the hyprpaper file!");
const content = new TextDecoder().decode(text);
if(content) {
let setWall: boolean = true;
for(const line of content.split('\n')) {
if(line.trim().startsWith('#'))
continue;
const lineSplit = line.split('=');
const key = lineSplit[0].trim(),
value = lineSplit.filter((_, i) => i !== 0).join('=').trim();
switch(key) {
case "splash":
this.splash = (/(yes|true|on|enable|enabled|1).*/.test(value)) ? true : false;
break;
case "wallpaper":
if(this.#wallpaper !== value && setWall) {
this.setWallpaper(value, false);
setWall = false; // wallpaper already set
}
break;
}
}
}
});
}
vfunc_dispose(): void {
this.#monitor.cancel();
}
public static getDefault(): Wallpaper {
if(!this.instance)
this.instance = new Wallpaper();
return this.instance;
}
private writeChanges(): void {
this.#ignoreWatch = true; // tell monitor to ignore file replace
this.#hyprpaperFile.replace_async(null, false,
Gio.FileCreateFlags.REPLACE_DESTINATION,
GLib.PRIORITY_DEFAULT, null, (_, result) => {
const res = this.#hyprpaperFile.replace_finish(result);
if(res) {
// success
this.#ignoreWatch = true; // tell monitor to ignore this change
res.write_bytes_async(new TextEncoder().encode(`# This file was automatically generated by color-shell
preload = ${this.#wallpaper}
splash = ${this.#splash}
wallpaper = , ${this.#wallpaper}`.split('\n').map(str => str.trimStart()).join('\n')),
GLib.PRIORITY_DEFAULT, null, (_, asyncRes) => {
if(_!.write_finish(asyncRes)) res.flush(null);
res.close(null);
}
);
return;
}
console.error(`Wallpaper: an error occurred when trying to replace the hyprpaper file`);
}
);
}
public async getWallpaper(): Promise<string|undefined> {
return await execAsync("sh -c \"hyprctl hyprpaper listactive | tail -n 1\"").then(stdout => {
const loaded: (string|undefined) = stdout.split('=')[1]?.trim();
if(!loaded)
console.warn(`Wallpaper: Couldn't get wallpaper. There is(are) no loaded wallpaper(s)`);
return loaded;
}).catch((err: Gio.IOErrorEnum) => {
console.error(`Wallpaper: Couldn't get wallpaper. Stderr: \n${err.message ? `${err.message} /` : ""} Stack: \n ${err.stack}`);
return undefined;
});
}
public reloadColors(): void {
execAsync(`wal -t --cols16 darken -i "${this.#wallpaper}"`).then(() => {
console.log("Wallpaper: reloaded shell colors");
}).catch(r => {
console.error(`Wallpaper: Couldn't update shell colors. Stderr: ${r}`);
});
}
public setWallpaper(path: string|Gio.File, write: boolean = true): void {
execAsync("hyprctl hyprpaper unload all").then(() =>
execAsync(`hyprctl hyprpaper preload ${path}`).then(() =>
execAsync(`hyprctl hyprpaper wallpaper ${path}`).then(() => {
this.#wallpaper = (typeof path === "string") ? path : path.get_path()!;
this.reloadColors();
write && this.writeChanges();
}).catch(r => {
console.error(`Wallpaper: Couldn't set wallpaper. Stderr: ${r}`);
})
).catch(r => {
console.error(`Wallpaper: Couldn't preload image. Stderr: ${r}`);
})
).catch(r => {
console.error(`Wallpaper: Couldn't unload images from memory. Stderr: ${r}`);
});
}
public async pickWallpaper(): Promise<string|undefined> {
return (await execAsync(`zenity --file-selection`).then(wall => {
if(!wall.trim()) return undefined;
this.setWallpaper(wall);
return wall;
}).catch(r => {
console.error(`Wallpaper: Couldn't pick wallpaper, is \`zenity\` installed? Stderr: ${r}`);
return undefined;
}));
}
}