feat: make AGS colorshell configuration fully declarative

- Add complete colorshell v2.0.3 configuration to home/ags-config/
- Disable runner plugin and NightLight tile (incompatible with NixOS)
- Customize SCSS with full opacity (no transparency)
- Add dark pale blue color scheme in home/pywal-colors/
- Configure Papirus-Dark icon theme via home-manager
- Make ~/.config/ags/ immutable and managed by Nix store
- Auto-deploy pywal colors to ~/.cache/wal/colors.json

All AGS configuration is now reproducible and version controlled.
This commit is contained in:
2025-11-04 21:36:38 +00:00
parent 593735370a
commit b2ae32a078
240 changed files with 1024921 additions and 3 deletions

View File

@@ -0,0 +1,105 @@
import { Gdk, Gtk } from "ags/gtk4";
import { execAsync } from "ags/process";
import AstalApps from "gi://AstalApps";
import AstalHyprland from "gi://AstalHyprland";
// Disabled uwsm - not installed in NixOS
export const uwsmIsActive: boolean = 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(/%[fFcuUik]/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;
}

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 = false; // Disabled - uwsm not installed
"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(/%[fFcuUik]/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;
}

View File

@@ -0,0 +1,406 @@
import { Gtk } from "ags/gtk4";
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 "../window/bar/widgets/Workspaces";
import { playSystemBell } from "./utils";
import { Shell } from "../app";
import { generalConfig } from "../config";
import Media from "./media";
import AstalIO from "gi://AstalIO";
import AstalMpris from "gi://AstalMpris";
export type RemoteCaller = {
printerr_literal: (message: string) => void,
print_literal: (message: string) => void
};
let wsTimeout: AstalIO.Time|undefined;
const help = `Manage Astal Windows and do more stuff. From retrozinndev's colorshell, \
made using GTK4, AGS, Gnim and Astal libraries 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.
quit: exit the main instance of the shell.
Audio Controls:
volume: speaker and microphone volume controller, see "volume help".
Media Controls:
media: manage colorshell's active player, see "media help".
${false ? `
Development Tools:
dev: tools to help debugging colorshell
` : ""}
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.
v, version: display current colorshell version.
h, help: shows this help message.
2025 (c) retrozinndev's colorshell, licensed under the BSD 3-Clause License.
https://github.com/retrozinndev/colorshell
`.trim();
export function handleArguments(cmd: RemoteCaller, args: Array<string>): number {
switch(args[0]) {
case "help":
case "h":
cmd.print_literal(help);
return 0;
case "version":
case "v":
cmd.print_literal(`colorshell by retrozinndev, version ${COLORSHELL_VERSION
}${false ? " (devel)" : ""}\nhttps://github.com/retrozinndev/colorshell`);
return 0;
case "dev":
return handleDevArgs(cmd, args);
case "open":
case "close":
case "toggle":
case "windows":
case "reopen":
return handleWindowArgs(cmd, args);
case "volume":
return handleVolumeArgs(cmd, args);
case "media":
return handleMediaArgs(cmd, args);
case "reload":
restartInstance();
cmd.print_literal("Restarting instance...");
return 0;
case "quit":
try {
Shell.getDefault().quit();
cmd.print_literal("Quitting main instance...");
} catch(_e) {
const e = _e as Error;
cmd.printerr_literal(`Error: couldn't quit instance. Stderr: ${e.message}\n${e.stack}`);
return 1;
}
return 0;
case "runner":
!Runner.instance ?
Runner.openDefault(args[1] || undefined)
: Runner.close();
cmd.print_literal(`Opening runner${args[1] ? ` with predefined text: "${args[1]}"` : ""}`);
return 0;
case "peek-workspace-num":
if(wsTimeout) {
cmd.print_literal("Workspace numbers are already showing");
return 0;
}
showWorkspaceNumber(true);
wsTimeout = timeout(Number.parseInt(args[1]) || 2200, () => {
showWorkspaceNumber(false);
wsTimeout = undefined;
});
cmd.print_literal("Toggled workspace numbers");
return 0;
}
cmd.printerr_literal("Error: command not found! try checking help");
return 1;
}
function handleDevArgs(cmd: RemoteCaller, args: Array<string>): number {
if(/h|help/.test(args[1])) {
cmd.print_literal(`
Debugging tools for colorshell.
Options:
inspector: open GTK's visual debugger
`.trim());
return 0;
}
switch(args[1]) {
case "inspector":
cmd.print_literal("Opening inspector...");
Gtk.Window.set_interactive_debugging(true);
return 0;
}
cmd.printerr_literal("Error: command not found! try checking `dev help`");
return 1;
}
function handleMediaArgs(cmd: RemoteCaller, args: Array<string>): number {
if(/h|help/.test(args[1])) {
const mediaHelp = `
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();
cmd.print_literal(mediaHelp);
return 0;
}
const activePlayer: AstalMpris.Player|undefined = Media.getDefault().player.available ?
Media.getDefault().player
: undefined;
const players = AstalMpris.get_default().players.filter(pl => pl.available);
if(!activePlayer) {
cmd.printerr_literal(`Error: no active player found! try playing some media first`);
return 1;
}
switch(args[1]) {
case "play":
activePlayer.play();
cmd.print_literal("Playing");
return 0;
case "list":
cmd.print_literal(`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')}`);
return 0;
case "pause":
activePlayer.pause();
cmd.print_literal("Paused");
return 0;
case "play-pause":
activePlayer.play_pause();
cmd.print_literal(
activePlayer?.playbackStatus === AstalMpris.PlaybackStatus.PAUSED ?
"Toggled play"
: "Toggled pause"
);
return 0;
case "stop":
activePlayer.stop();
cmd.print_literal("Stopped!");
return 0;
case "previous":
activePlayer.canGoPrevious && activePlayer.previous();
cmd.print_literal(
activePlayer.canGoPrevious ?
"Back to previous"
: "Player does not support this command"
);
return 0;
case "next":
activePlayer.canGoNext && activePlayer.next();
cmd.print_literal(
activePlayer.canGoNext ?
"Jump to next"
: "Player does not support this command"
);
return 0;
case "bus-name":
cmd.print_literal(activePlayer.busName);
return 0;
case "select":
if(!args[2] || !players.filter(pl => pl.busName == args[2])?.[0]) {
cmd.printerr_literal(`Error: either no player was specified or the player with \
specified bus name does not exist/is not available!`);
return 1;
}
Media.getDefault().player = players.filter(pl => pl.busName === args[2])[0];
cmd.print_literal(`Done setting player to \`${args[2]}\`!`);
return 0;
}
cmd.printerr_literal("Error: couldn't handle media arguments, try checking `media help`");
return 1;
}
function handleWindowArgs(cmd: RemoteCaller, args: Array<string>): number {
switch(args[0]) {
case "reopen":
Windows.getDefault().reopen();
cmd.print_literal("Reopening all open windows");
return 0;
case "windows":
cmd.print_literal(
Object.keys(Windows.getDefault().windows).map(name =>
`${name}: ${Windows.getDefault().isOpen(name) ?
"open"
: "closed"}`
).join('\n')
);
return 0;
}
const specifiedWindow: string = args[1];
if(!specifiedWindow) {
cmd.printerr_literal("Error: window argument not specified!");
return 1;
}
if(!Windows.getDefault().hasWindow(specifiedWindow)) {
cmd.printerr_literal(
`Error: "${specifiedWindow}" not found on window list! Make sure to add new windows to the system before using them`
);
return 1;
}
switch(args[0]) {
case "open":
if(!Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().open(specifiedWindow);
cmd.print_literal(`Opening window with name "${args[1]}"`);
return 0;
}
cmd.print_literal(`Window is already open, ignored`);
return 0;
case "close":
if(Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().close(specifiedWindow);
cmd.print_literal(`Closing window with name "${args[1]}"`);
return 0;
}
cmd.print_literal(`Window is already closed, ignored`);
return 0;
case "toggle":
if(!Windows.getDefault().isOpen(specifiedWindow)) {
Windows.getDefault().open(specifiedWindow);
cmd.print_literal(`Toggle opening window "${args[1]}"`);
return 0;
}
Windows.getDefault().close(specifiedWindow);
cmd.print_literal(`Toggle closing window "${args[1]}"`);
return 0;
}
cmd.printerr_literal("Couldn't handle window management arguments");
return 1;
}
function handleVolumeArgs(cmd: RemoteCaller, args: Array<string>): number {
if(!args[1]) {
cmd.printerr_literal(`Error: please specify what to do! see \`volume help\``);
return 1;
}
if(/^(sink|source)[-](increase|decrease|set)$/.test(args[1]) && !args[2]) {
cmd.printerr_literal(`Error: you forgot to set a value`);
return 1;
}
const command: Array<string> = args[1].split('-');
if(/h|help/.test(args[1])) {
cmd.print_literal(`
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());
return 0;
}
if(command[1] === "mute") {
command[0] === "sink" ?
Wireplumber.getDefault().toggleMuteSink()
: Wireplumber.getDefault().toggleMuteSource()
cmd.print_literal(`Done toggling mute!`);
return 0;
}
if(Number.isNaN(Number.parseFloat(args[2]))) {
cmd.printerr_literal(`Error: argument "${args[2]} is not a valid number! Please use integers"`);
return 1;
}
switch(command[1]) {
case "set":
command[0] === "sink" ?
Wireplumber.getDefault().setSinkVolume(Number.parseInt(args[2]))
: Wireplumber.getDefault().setSourceVolume(Number.parseInt(args[2]))
cmd.print_literal(`Done! Set ${command[0]} volume to ${args[2]}`);
return 0;
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();
cmd.print_literal(`Done increasing volume by ${args[2]}`);
return 0;
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();
cmd.print_literal(`Done decreasing volume to ${args[2]}`);
return 0;
}
cmd.printerr_literal(`Error: couldn't resolve arguments! "${args.join(' ')
.replace(new RegExp(`^${args[0]}`), "")}"`);
return 1;
}

View File

@@ -0,0 +1,111 @@
import { exec, execAsync } from "ags/process";
import { register } from "ags/gobject";
import { AuthPopup } from "../widget/AuthPopup";
import Polkit from "gi://Polkit?version=1.0";
import PolkitAgent from "gi://PolkitAgent?version=1.0";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
import AstalAuth from "gi://AstalAuth?version=0.1";
@register({ GTypeName: "AuthAgent" })
export class Auth extends PolkitAgent.Listener {
private static instance: Auth;
#handle: any;
#user: Polkit.UnixUser;
#pam: AstalAuth.Pam;
constructor() {
super();
this.#user = Polkit.UnixUser.new(Number.parseInt(exec("id -u"))) as Polkit.UnixUser;
this.#pam = new AstalAuth.Pam;
this.register(
PolkitAgent.RegisterFlags.RUN_IN_THREAD,
Polkit.UnixSession.new(this.#user.get_uid().toString()),
"/io/github/retrozinndev/colorshell/AuthAgent",
null
);
}
vfunc_dispose() {
PolkitAgent.Listener.unregister(this.#handle);
}
public static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array<Polkit.Identity>, cancellable: Gio.Cancellable|null, callback: Gio.AsyncReadyCallback<Auth>|null): void {
const task = Gio.Task.new(
this.getDefault(),
cancellable,
callback as Gio.AsyncReadyCallback|null
);
AuthPopup({
text: message,
iconName: icon_name,
onContinue: (data, reject, approve) => {
this.getDefault().validateAuth(data.passwd, data.user).then((success) => {
approve();
task.return_boolean(success);
}).catch((error: GLib.Error) => {
// TODO implement a number of tries (usually it's 3)
reject(`Authentication failed: ${error.message}`);
task.return_error(error);
});
}
});
}
initiate_authentication_finish(res: Gio.AsyncResult): boolean {
}
// TODO: support fingerprint/facial auth
/** @returns true if data are correct, rejects promise otherwise */
public validateAuth(passwd: string, user?: string): Promise<boolean> {
if(user !== undefined)
this.#pam.username = user;
return new Promise<boolean>((resolve, reject) => {
const connections: Array<number> = [];
connections.push(
this.#pam.connect("fail", () => {
reject(
`Auth: Authentication has failed for user ${this.#pam.username}`
);
connections.forEach(id => this.#pam.disconnect(id));
}),
this.#pam.connect("success", () => {
resolve(true);
connections.forEach(id => this.#pam.disconnect(id));
})
);
this.#pam.start_authenticate();
this.#pam.supply_secret(passwd);
});
}
/** @returns true if successful */
public async polkitExecute(cmd: string | Array<string>): Promise<boolean> {
let success: boolean = true;
await execAsync([
"pkexec",
"--",
...(Array.isArray(cmd) ? cmd : [ cmd ]) ]
).catch((r) => {
success = false;
console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`);
});
return success;
}
public static getDefault(): Auth {
if(!this.instance)
this.instance = new Auth();
return this.instance;
}
}

View File

@@ -0,0 +1,209 @@
import { monitorFile, readFile } from "ags/file";
import { exec } from "ags/process";
import GObject, { getter, ParamSpec, register, setter, signal } from "ags/gobject";
import Gio from "gi://Gio?version=2.0";
export namespace Backlights {
const BacklightParamSpec = (name: string, flags: GObject.ParamFlags) =>
GObject.ParamSpec.jsobject(name, null, null, flags) as ParamSpec<Backlight>;
let instance: Backlights;
export function getDefault(): Backlights {
if(!instance)
instance = new Backlights();
return instance;
}
@register({ GTypeName: "Backlights" })
class _Backlights extends GObject.Object {
#backlights: Array<Backlight> = [];
#default: Backlight|null = null;
#available: boolean = false;
@getter(Array as unknown as ParamSpec<Array<Backlight>>)
get backlights() { return this.#backlights; }
@getter(BacklightParamSpec)
get default() { return this.#default!; }
/** true if there are any backlights available */
@getter(Boolean)
get available() { return this.#available; }
public scan(): Array<Backlight> {
const dir = Gio.File.new_for_path(`/sys/class/backlight`),
backlights: Array<Backlight> = [];
let fileEnum: Gio.FileEnumerator;
try {
fileEnum = dir.enumerate_children("standard::*", Gio.FileQueryInfoFlags.NONE, null);
for(const backlight of fileEnum) {
try {
backlights.push(new Backlight(backlight.get_name()));
} catch(_) {}
}
} catch(_) {
return [];
}
if(backlights.length < 1) {
if(this.#available) {
this.#available = false;
this.notify("available");
}
this.#default = null;
this.notify("default");
}
if(backlights.length > 0) {
if(this.#backlights.length < 1) {
this.#available = true;
this.notify("available");
}
if(!this.#default || !backlights.filter(bk => bk.path === this.#default?.path)[0]) {
this.#default = backlights[0];
this.notify("default");
}
}
this.#backlights = backlights;
this.notify("backlights");
return backlights;
}
public setDefault(bk: Backlight): void {
this.#default = bk;
this.notify("default");
}
constructor(scan: boolean = true) {
super();
scan && this.scan();
}
}
@register({ GTypeName: "Backlight" })
class _Backlight extends GObject.Object {
declare $signals: GObject.Object.SignalSignatures & {
"brightness-changed": (value: number) => void
};
readonly #name: string;
#path: string;
#maxBrightness: number;
#brightness: number;
#monitor: Gio.FileMonitor;
#conn: number;
@signal(Number) brightnessChanged(_: number): void {};
@getter(String)
get name() { return this.#name; }
@getter(String)
get path() { return this.#path; }
@getter(Boolean)
get isDefault() { return this.path === getDefault().default?.path; }
@getter(Number)
get brightness() { return this.#brightness; };
@setter(Number)
set brightness(level: number) {
if(!this.writeBrightness(level)) return;
this.#brightness = level;
this.notify("brightness");
this.emit("brightness-changed", level);
}
@getter(Number)
get maxBrightness() { return this.#maxBrightness;};
// intel_backlight is mostly the default on laptops
constructor(name: string = "intel_backlight") {
super();
// check if backlight exists
if(!Gio.File.new_for_path(`/sys/class/backlight/${name}/brightness`).query_exists(null))
throw new Error(`Brightness: Couldn't find brightness for "${name}"`);
// notify :is-default on default backlight change
this.#conn = getDefault().connect("notify::default", () =>
this.notify("is-default"));
this.#name = name;
this.#path = `/sys/class/backlight/${name}`;
this.notify("path");
this.#maxBrightness = Number.parseInt(readFile(`${this.#path}/max_brightness`));
this.notify("max-brightness");
this.#brightness = Number.parseInt(readFile(`${this.#path}/brightness`))
this.#monitor = monitorFile(`/sys/class/backlight/${name}/brightness`, () => {
this.#brightness = this.readBrightness();
this.notify("brightness");
this.emit("brightness-changed", this.brightness);
});
}
private readBrightness(): number {
try {
const brightness = Number.parseInt(readFile(`${this.#path}/brightness`));
return brightness;
} catch(e) {
console.error(`Backlight: An error occurred while reading brightness from "${this.#name}"`);
}
return this.#brightness ?? this.#maxBrightness ?? 0;
}
private writeBrightness(level: number): boolean {
try {
exec(`brightnessctl -d ${this.#name} s ${level}`);
return true;
} catch(e) {
console.error(`Backlight: Couldn't set brightness for "${this.#name}". Stderr: ${e}`);
}
return false;
}
vfunc_dispose(): void {
this.#monitor.cancel();
getDefault().disconnect(this.#conn);
}
public emit<Signal extends keyof typeof this.$signals>(
signal: Signal,
...args: Parameters<(typeof this.$signals)[Signal]>
): void {
super.emit(signal, ...args);
}
public connect<Signal extends keyof typeof this.$signals>(
signal: Signal,
callback: (self: typeof this, ...args: Parameters<(typeof this.$signals)[Signal]>) => ReturnType<(typeof this.$signals)[Signal]>
): number {
return super.connect(signal, callback);
}
}
export const Backlights = _Backlights;
export const Backlight = _Backlight;
export type Backlight = InstanceType<typeof Backlight>;
export type Backlights = InstanceType<typeof Backlights>;
}

View File

@@ -0,0 +1,38 @@
import { Accessor, createBinding } from "ags";
import AstalBattery from "gi://AstalBattery?version=0.1";
export class Battery {
private static astalBattery: AstalBattery.Device = AstalBattery.get_default();
private static batteryInst: Battery;
constructor() {
AstalBattery.get_default();
}
public static getDefault(): Battery {
if (!this.batteryInst) {
this.batteryInst = new Battery();
}
return this.batteryInst;
}
public static getBattery(): AstalBattery.Device {
return this.astalBattery;
}
public bindHasBattery(): Accessor<boolean> {
return createBinding(Battery.getBattery(), "isBattery");
}
public bindPercentage(): Accessor<string> {
return createBinding(Battery.getBattery(), "percentage").as(
(v) => Math.round(v * 100) + "%"
);
}
public bindIcon(): Accessor<string> {
return createBinding(Battery.getBattery(), "battery_icon_name");
}
}

View File

@@ -0,0 +1,159 @@
import { createRoot, getScope, Scope } from "ags";
import { execAsync } from "ags/process";
import { userData } from "../config";
import { createScopedConnection } from "gnim-utils";
import GObject, { getter, gtype, property, register, setter } from "ags/gobject";
import AstalBluetooth from "gi://AstalBluetooth";
/** AstalBluetooth helper (implements the default adapter feature) */
@register({ GTypeName: "Bluetooth" })
export class Bluetooth extends GObject.Object {
declare $signals: {
"notify": () => void;
"notify::adapter": (adapter: AstalBluetooth.Adapter|null) => void;
"notify::is-available": (available: boolean) => void;
"notify::save-default-adapter": (save: boolean) => void;
"notify::last-device": (device: AstalBluetooth.Device|null) => void;
};
private static instance: Bluetooth;
private astalBl: AstalBluetooth.Bluetooth;
#connections: Map<GObject.Object, Array<number>|number> = new Map();
#adapter: AstalBluetooth.Adapter|null = null;
#scope!: Scope;
#isAvailable: boolean = false;
#lastDevice: AstalBluetooth.Device|null = null;
@property(Boolean)
saveDefaultAdapter: boolean = true;
@getter(Boolean)
get isAvailable() { return this.#isAvailable; }
/** last connected device, can be null */
@getter(AstalBluetooth.Device)
get lastDevice() { return this.#lastDevice!; }
@getter(gtype<AstalBluetooth.Adapter|null>(AstalBluetooth.Adapter))
get adapter() { return this.#adapter; }
@setter(gtype<AstalBluetooth.Adapter|null>(AstalBluetooth.Adapter))
set adapter(newAdapter: AstalBluetooth.Adapter|null) {
this.#adapter = newAdapter;
this.notify("adapter");
if(!newAdapter) return;
AstalBluetooth.get_default().adapters.filter(ad => {
if(ad.address !== newAdapter.address)
return true;
ad.set_powered(true);
return false;
}).forEach(ad => ad.set_powered(false));
execAsync(`bluetoothctl select ${newAdapter.address}`).then(() => {
userData.setProperty("bluetooth_default_adapter", newAdapter.address, true);
}).catch(e => console.error(`Bluetooth: Couldn't select adapter. Stderr: ${e}`));
}
constructor() {
super();
this.astalBl = AstalBluetooth.get_default();
this.#adapter = this.astalBl.adapter ?? null;
if(this.astalBl.adapters.length > 0) {
this.#isAvailable = true;
this.notify("is-available");
}
createRoot(() => {
this.#scope = getScope();
// load previous default adapter
const dataDefaultAdapter = userData.getProperty("bluetooth_default_adapter", "string");
const foundAdapter = this.astalBl.adapters.filter(a => a.address === dataDefaultAdapter)[0];
if(dataDefaultAdapter !== undefined && foundAdapter !== undefined)
this.adapter = foundAdapter;
createScopedConnection(AstalBluetooth.get_default(), "adapter-added", (adapter) => {
if(this.astalBl.adapters.length === 1) // adapter was just added
this.adapter = adapter;
});
createScopedConnection(AstalBluetooth.get_default(), "adapter-removed", (adapter) => {
if(this.astalBl.adapters.length < 1) {
this.adapter = null;
this.#isAvailable = false;
this.notify("is-available");
}
if(this.#adapter?.address !== adapter.address)
return;
// the removed adapter was the default
if(this.astalBl.adapters.length < 1) {
this.adapter = null;
this.#isAvailable = false;
this.notify("is-available");
return;
}
this.#adapter = this.astalBl.adapters[0];
});
this.#lastDevice = this.getLastConnectedDevice();
this.notify("last-device");
this.#connections.set(AstalBluetooth.get_default(), [
AstalBluetooth.get_default().connect("device-added", (_) => {
this.#lastDevice = this.getLastConnectedDevice();
this.notify("last-device");
}),
AstalBluetooth.get_default().connect("device-removed", (_) => {
this.#lastDevice = this.getLastConnectedDevice();
this.notify("last-device");
})
]);
this.#scope.onCleanup(() => this.#connections.forEach((ids, gobj) =>
Array.isArray(ids) ?
ids.forEach(id => gobj.disconnect(id))
: gobj.disconnect(ids)
));
});
}
public static getDefault(): Bluetooth {
if(!this.instance)
this.instance = new Bluetooth();
return this.instance;
}
vfunc_dispose(): void {
this.#scope.dispose();
}
private getLastConnectedDevice(): AstalBluetooth.Device|null {
const connectedDevices = AstalBluetooth.get_default().devices
.filter(d => d.connected);
const lastDevice = connectedDevices[connectedDevices.length - 1];
return lastDevice ?? null;
}
connect<Signal extends keyof (typeof this)["$signals"]>(
signal: Signal, callback: (typeof this["$signals"])[Signal]
): number {
return super.connect(signal as string, callback as () => void);
}
}

View File

@@ -0,0 +1,260 @@
import { timeout } from "ags/time";
import { monitorFile, readFile } from "ags/file";
import { execAsync } from "ags/process";
import GObject, { getter, register, signal } from "ags/gobject";
import AstalIO from "gi://AstalIO";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
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: GObject.Object.SignalSignatures & {
"copied": Clipboard["copied"];
"wiped": Clipboard["wiped"];
};
#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<boolean> {
const proc = Gio.Subprocess.new(
["wl-copy", content],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
const stderr = Gio.DataInputStream.new(proc.get_stderr_pipe()!);
if(!proc.wait_check(null)) {
try {
const [err, ] = stderr.read_upto('\x00', -1);
console.error(`Clipboard: An error occurred while copying text. Stderr: ${err}`);
} catch(_) {
console.error(`Clipboard: An error occurred while copying text and shell couldn't read \
stderr for more info.`);
}
}
return proc.get_exit_status() === 0;
}
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;
}
}

View File

@@ -0,0 +1,69 @@
import { Compositors } from ".";
import { register } from "ags/gobject";
import { createRoot, getScope, Scope } from "ags";
import { createScopedConnection } from "../utils";
import AstalHyprland from "gi://AstalHyprland";
type Event = "activewindow" | "activewindowv2"
| "workspace" | "workspacev2"
| "focusedmon" | "focusedmonv2";
@register({ GTypeName: "CompositorHyprland" })
export class CompositorHyprland extends Compositors.Compositor {
#scope: Scope;
hyprland: AstalHyprland.Hyprland;
protected _focusedClient: Compositors.Client | null = null;
constructor() {
super();
try {
this.hyprland = AstalHyprland.get_default();
} catch(e) {
throw new Error(`Couldn't initialize CompositorHyprland: ${e}`);
}
this.#scope = createRoot(() => {
createScopedConnection(
this.hyprland, "event", (e, args) => {
switch(e as Event) {
case "activewindowv2":
const address = args;
const clients = AstalHyprland.get_default().clients;
const focusedClient = clients.filter(c =>
c.address === address
)[0];
if(focusedClient) {
this._focusedClient = new Compositors.Client({
address: address,
class: focusedClient.class ?? "",
initialClass: focusedClient.initialClass ?? "",
mapped: focusedClient.mapped,
position: [focusedClient.x, focusedClient.y],
title: focusedClient.title ?? ""
});
this.notify("focused-client");
return;
}
this._focusedClient = null;
this.notify("focused-client");
break;
}
}
);
return getScope();
});
}
vfunc_dispose(): void {
this.#scope.dispose();
}
}

View File

@@ -0,0 +1,162 @@
import { CompositorHyprland } from "./hyprland";
import GObject, { getter, gtype, property, register } from "ags/gobject";
import GLib from "gi://GLib?version=2.0";
/** WIP modular implementation of a system that supports implementing
* a variety of Wayland Compositors
* @todo implement more general compositor info + a lot of stuff
* */
export namespace Compositors {
let compositor: Compositor|null = null;
@register({ GTypeName: "CompositorMonitor" })
export class Monitor extends GObject.Object {
#width: number;
#height: number;
@getter(Number)
get width() { return this.#width; }
@getter(Number)
get height() { return this.#height; }
@property(Number)
scaling: number;
constructor(width: number, height: number, scaling: number = 1) {
super();
this.#width = width;
this.#height = height;
this.scaling = scaling;
}
}
@register({ GTypeName: "CompositorWorkspace" })
export class Workspace extends GObject.Object {
#id: number;
#monitor: Monitor;
@getter(Number)
get id() { return this.#id; }
@getter(Monitor)
get monitor() { return this.#monitor; }
constructor(monitor: Monitor, id: number = 0) {
super();
this.#monitor = monitor;
this.#id = id;
}
}
@register({ GTypeName: "CompositorClient" })
export class Client extends GObject.Object {
readonly #address: string|null = null;
#initialClass: string;
#class: string;
#title: string = "";
#mapped: boolean = true;
#position: [number, number] = [0, 0];
#xwayland: boolean = false;
@getter(gtype<string|null>(String))
get address() { return this.#address; }
@getter(String)
get title() { return this.#title; }
@getter(String)
get class() { return this.#class; }
@getter(String)
get initialClass() { return this.#initialClass; }
@getter(gtype<[number, number]>(Array))
get position() { return this.#position; }
@getter(Boolean)
get xwayland() { return this.#xwayland; }
@getter(Boolean)
get mapped() { return this.#mapped; }
constructor(props: {
address?: string;
title?: string;
mapped?: boolean;
class: string;
initialClass?: string;
/** [x, y] */
position?: [number, number];
}) {
super();
this.#class = props.class;
if(props.title !== undefined)
this.#title = props.title;
if(props.mapped !== undefined)
this.#mapped = props.mapped;
if(props.address !== undefined)
this.#address = props.address;
if(props.position !== undefined)
this.#position = props.position;
this.#initialClass = props.initialClass !== undefined ?
props.initialClass
: props.class;
}
}
@register({ GTypeName: "Compositor" })
export class Compositor extends GObject.Object {
protected _workspaces: Array<Workspace> = [];
protected _focusedClient: Client|null = null;
@getter(Array<Workspace>)
get workspaces() { return this._workspaces; }
@getter(gtype<Client|null>(Client))
get focusedClient() { return this._focusedClient; }
constructor() {
super();
}
};
export function getDefault(): Compositor {
if(!compositor)
throw new Error("Compositors haven't been initialized correctly, please call `Compositors.init()` before calling any method in `Compositors`");
return compositor;
}
/** 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")?.toLowerCase() ?? undefined;
}
/** initialize colorshell's wayland compositor implementation abstraction.
* when called, and if it's implemented, sets the default compositor to an equivalent implementation for the current desktop(checks from XDG_CURRENT_DESKTOP) */
export function init(): void {
switch(Compositors.getName()) {
case "hyprland":
compositor = new CompositorHyprland();
break;
default:
console.error(`This compositor(${Compositors.getName()}) is not yet implemented to colorshell. Please contribute by implementing it if you can! :)`);
}
}
}

View File

@@ -0,0 +1,219 @@
import { timeout } from "ags/time";
import { monitorFile, readFileAsync, writeFileAsync } from "ags/file";
import { Notifications } from "./notifications";
import { Accessor } from "ags";
import GObject, { getter, gtype, register } from "ags/gobject";
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" | "any";
@register({ GTypeName: "Config" })
class Config<K extends string, V = 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(gtype<Record<K, V>>(Object))
public get entries() { 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.writeFile().catch(e => Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Write error",
body: `Couldn't write default configuration file to "${this.#file.get_path()!
}".\nStderr: ${e}`
}));
}
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?`
});
}
);
this.readFile().catch(e => console.error(
`Config: An error occurred while read the configuration file. Stderr: ${e}`
));
}
private async writeFile(): Promise<void> {
this.timeout = true;
await writeFileAsync(
this.#file.get_path()!, JSON.stringify(this.entries, undefined, 4)
).finally(() => this.timeout = false);
}
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}`
});
}
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: ${this.#file.get_path()!
}\n${e.message}`.replace(/[<>]/g, "\\&")
});
});
}
public bindProperty(path: string, expectType: "boolean"): Accessor<boolean>;
public bindProperty(path: string, expectType: "number"): Accessor<number>;
public bindProperty(path: string, expectType: "string"): Accessor<string>;
public bindProperty(path: string, expectType: "object"): Accessor<object>;
public bindProperty(path: string, expectType: "any"): Accessor<any>;
public bindProperty(path: string, expectType: undefined): Accessor<any>;
public bindProperty(propertyPath: string, expectType?: ValueTypes): Accessor<boolean|number|string|object|any> {
return new Accessor(() => this.getProperty(propertyPath, expectType as never), (callback: () => void) => {
const id = this.connect("notify::entries", () => callback());
return () => this.disconnect(id);
});
}
public getProperty(path: string, expectType: "boolean"): boolean;
public getProperty(path: string, expectType: "number"): number;
public getProperty(path: string, expectType: "string"): string;
public getProperty(path: string, expectType: "object"): object;
public getProperty(path: string, expectType: "any"): any;
public getProperty(path: string, expectType: undefined): any;
public getProperty(path: string, expectType?: ValueTypes): boolean|number|string|object|any {
return this._getProperty(path, this.#entries, expectType);
}
public getPropertyDefault(path: string, expectType: "boolean"): boolean;
public getPropertyDefault(path: string, expectType: "number"): number;
public getPropertyDefault(path: string, expectType: "string"): string;
public getPropertyDefault(path: string, expectType: "object"): object;
public getPropertyDefault(path: string, expectType: "any"): any;
public getPropertyDefault(path: string, expectType: undefined): any;
public getPropertyDefault(path: string, expectType?: ValueTypes): boolean|number|string|object|any {
return this._getProperty(path, this.defaults, expectType);
}
public setProperty(path: string, value: any, write?: boolean): void {
let property: any = this.#entries,
obj: typeof this.entries = property;
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(typeof property === "object") {
obj = property;
} else {
obj[pathArray[pathArray.length - 1] as keyof typeof obj] = value;
break;
}
}
this.notify("entries");
write && this.writeFile().catch(e => console.error(
`Config: Couldn't save file. Stderr: ${e}`
));
}
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) {
// return default value if not defined by user
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;
}
}

View File

@@ -0,0 +1,86 @@
import { Accessor, createConnection, getScope, Scope } from "ags";
import { createScopedConnection, decoder } from "./utils";
import AstalMpris from "gi://AstalMpris";
import GObject from "gi://GObject?version=2.0";
import { property, register } from "ags/gobject";
@register({ GTypeName: "Media" })
export default class Media extends GObject.Object {
private static instance: Media;
public static readonly dummyPlayer = {
available: false,
busName: "dummy_player",
bus_name: "dummy_player"
} as AstalMpris.Player;
@property(AstalMpris.Player)
player: AstalMpris.Player = Media.dummyPlayer;
constructor(scope: Scope) {
super();
scope.run(() => {
const firstPlayer = AstalMpris.get_default().players[0];
if(firstPlayer)
this.player = firstPlayer;
createScopedConnection(
AstalMpris.get_default(),
"player-added",
(player) => {
if(player.available)
this.player = player;
}
);
createScopedConnection(
AstalMpris.get_default(),
"player-closed", (closedPlayer) => {
const players = AstalMpris.get_default().players.filter(pl => pl?.available &&
pl.busName !== closedPlayer.busName);
// go back to first player(if available) when the active player is closed
if(players.length > 0 && players[0]) {
this.player = players[0];
return;
}
this.player = Media.dummyPlayer;
}
);
});
}
public static getDefault(): Media {
if(!this.instance)
this.instance = new Media(getScope());
return this.instance;
}
public static accessMediaUrl(player: AstalMpris.Player): Accessor<string|undefined> {
return createConnection(player.get_meta("xesam:url"),
[player, "notify::metadata", () => player.get_meta("xesam:url")]
).as(url => {
const byteString = url?.get_data_as_bytes();
return byteString ?
decoder.decode(byteString.toArray())
: undefined;
})
}
public static getMediaUrl(player: AstalMpris.Player): string|undefined {
if(!player.available) return;
const meta = player.get_meta("xesam:url");
const byteString = meta?.get_data_as_bytes();
return byteString ?
decoder.decode(byteString.toArray())
: undefined;
}
}

View File

@@ -0,0 +1,181 @@
import { execAsync, exec } from "ags/process";
import { userData } from "../config";
import GObject, { getter, register, setter } from "ags/gobject";
import GLib from "gi://GLib?version=2.0";
@register({ GTypeName: "NightLight" })
export class NightLight extends GObject.Object {
private static instance: NightLight;
public readonly maxTemperature = 20000;
public readonly minTemperature = 1000;
public readonly identityTemperature = 6000;
public readonly maxGamma = 100;
#watchInterval: GLib.Source;
#temperature: number = this.identityTemperature;
#gamma: number = this.maxGamma;
#identity: boolean = false;
@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); }
@getter(Boolean)
public get identity() { return this.#identity; }
@setter(Boolean)
public set identity(val: boolean) {
val ? this.applyIdentity() : this.filter();
this.#identity = val;
this.notify("identity");
}
constructor() {
super();
this.loadData();
this.#watchInterval = setInterval(() => {
execAsync("hyprctl hyprsunset temperature").then(t => {
if(t.trim() !== "" && t.trim().length <= 5) {
const val = Number.parseInt(t.trim());
if(this.#temperature !== val) {
this.identity = this.#temperature === this.identityTemperature;
this.#temperature = val;
this.notify("temperature");
}
}
}).catch((r: Error) => console.error(`Night Light: Couldn't sync temperature. Stderr: ${
r.message}\n${r.stack}`));
execAsync("hyprctl hyprsunset gamma").then(g => {
if(g.trim() !== "" && g.trim().length <= 5) {
const val = Number.parseInt(g.trim());
if(this.#gamma !== val) {
this.identity = this.#gamma === this.maxGamma;
this.#gamma = val;
this.notify("gamma");
}
}
}).catch((r: Error) => console.error(`Night Light: Couldn't sync. Stderr: ${
r.message}\n${r.stack}`));
}, 10000);
}
vfunc_dispose(): void {
this.#watchInterval?.destroy();
}
public static getDefault(): NightLight {
if(!this.instance)
this.instance = new NightLight();
return this.instance;
}
private setTemperature(value: number): void {
if(value === this.temperature && !this.identity) return;
if(value > this.maxTemperature || value < 1000) {
console.error(`Night Light: provided temperatue ${value
} is out of bounds (min: 1000; max: ${this.maxTemperature})`);
return;
}
this.dispatchAsync("temperature", value).then(() => {
this.#temperature = value;
this.notify("temperature");
this.identity = false;
}).catch((r: Error) => console.error(
`Night Light: Couldn't set temperature. Stderr: ${r.message}\n${r.stack}`
));
}
private setGamma(value: number): void {
if(value === this.gamma && !this.identity) return;
if(value > this.maxGamma || value < 0) {
console.error(`Night Light: provided gamma ${value
} is out of bounds (min: 0; max: ${this.maxTemperature})`);
return;
}
this.dispatchAsync("gamma", value).then(() => {
this.#gamma = value;
this.notify("gamma");
this.identity = false;
}).catch((r: Error) => console.error(
`Night Light: Couldn't set gamma. Stderr: ${r.message}\n${r.stack}`
));
}
public applyIdentity(): void {
this.dispatch("identity");
if(!this.#identity) {
this.#identity = true;
this.notify("identity");
}
}
private dispatch(call: "temperature", val: number): string;
private dispatch(call: "gamma", val: number): string;
private dispatch(call: "identity"): string;
private dispatch(call: "temperature"|"gamma"|"identity", val?: number): string {
return exec(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`);
}
private async dispatchAsync(call: "temperature", val: number): Promise<string>;
private async dispatchAsync(call: "gamma", val: number): Promise<string>;
private async dispatchAsync(call: "identity"): Promise<string>;
private async dispatchAsync(call: "temperature"|"gamma"|"identity", val?: number): Promise<string> {
return await execAsync(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`);
}
public filter(): void {
this.setTemperature(this.temperature);
this.setGamma(this.gamma);
if(this.#identity) {
this.#identity = false;
this.notify("identity");
}
}
public saveData(): void {
userData.setProperty("night_light.temperature", this.#temperature);
userData.setProperty("night_light.gamma", this.#gamma);
userData.setProperty("night_light.identity", this.#identity, true);
}
/** load temperature, gamma and identity(off/on) properties from the user configuration */
public loadData(): void {
const identity = userData.getProperty("night_light.identity", "boolean");
const temperature = userData.getProperty("night_light.temperature", "number");
const gamma = userData.getProperty("night_light.gamma", "number");
if(identity) {
this.#temperature = temperature;
this.notify("temperature");
this.#gamma = gamma;
this.notify("gamma");
} else {
this.temperature = temperature;
this.gamma = gamma;
}
this.identity = identity;
}
}

View File

@@ -0,0 +1,361 @@
import { execAsync } from "ags/process";
import { generalConfig } from "../config";
import { onCleanup } from "ags";
import GObject, { getter, ParamSpec, property, register, signal } from "ags/gobject";
import AstalNotifd from "gi://AstalNotifd";
import GLib from "gi://GLib?version=2.0";
export type HistoryNotification = {
id: number;
appName: string;
body: string;
summary: string;
urgency: AstalNotifd.Urgency;
appIcon?: string;
time: number;
image?: string;
}
export class NotificationTimeout {
#source?: GLib.Source;
#args?: Array<any>;
#millis: number;
#lastRemained!: number;
readonly callback: () => void;
get millis(): number { return this.#millis; }
get remaining(): number { return this.source!.get_time() }
get lastRemained(): number { return this.#lastRemained; }
get running(): boolean { return Boolean(this.source?.is_destroyed()); }
get source(): GLib.Source|undefined { return this.#source; }
constructor(millis: number, callback: () => void, start: boolean = true, ...args: Array<any>) {
this.#millis = millis;
this.callback = callback;
this.#args = args;
if(!start) return;
this.start();
}
cancel(): void {
// use lastRemained to calculate on what time the user hold the notification, so it
// can be released by the remaining time (works like a timeout "pause")
this.#lastRemained = Math.floor(Math.max(this.#source!.get_ready_time() - GLib.get_monotonic_time()) / 1000);
this.#source?.destroy();
this.#source?.unref();
this.#source = undefined;
}
start(newMillis?: number): GLib.Source {
if(this.running)
throw new Error("Notifications: Can't start a new counter if it's already running!");
if(newMillis !== undefined)
this.#millis = newMillis;
this.#source = setTimeout(
this.callback,
this.#millis,
this.#args
);
this.#lastRemained = Math.floor(Math.max(this.#source!.get_ready_time() - GLib.get_monotonic_time()) / 1000);
return this.#source;
}
};
@register({ GTypeName: "Notifications" })
export class Notifications extends GObject.Object {
private static instance: (Notifications|null) = null;
declare $signals: GObject.Object.SignalSignatures & {
"history-added": (notification: HistoryNotification) => void;
"history-removed": (notificationId: number) => void;
"history-cleared": () => void;
"notification-added": (notification: AstalNotifd.Notification) => void;
"notification-removed": (notificationId: number) => void;
"notification-replaced": (notificationId: number) => void;
};
#notifications = new Map<number, [AstalNotifd.Notification, NotificationTimeout]>();
#history: Array<HistoryNotification> = [];
#connections: Array<number> = [];
@getter(Array<AstalNotifd.Notification>)
public get notifications() {
return [...this.#notifications.values()].map(([n]) => n);
};
@getter(Array<HistoryNotification>)
public get history() { return this.#history };
@getter(Array<AstalNotifd.Notification>)
public get notificationsOnHold() {
return [...this.#notifications.values()].filter(([_, s]) =>
typeof s === "undefined"
).map(([n]) => n);
}
@property(Number)
public historyLimit: number = 10;
/** skip notifications directly to notification history */
@property(Boolean)
public ignoreNotifications: boolean = false;
@signal(AstalNotifd.Notification) notificationAdded(_notification: AstalNotifd.Notification) {};
@signal(Number) notificationRemoved(_id: number) {};
@signal(Object as unknown as ParamSpec<HistoryNotification>) historyAdded(_notification: Object) {};
@signal() historyCleared() {};
@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);
if(this.getNotifd().dontDisturb || this.ignoreNotifications) {
this.addHistory(notification, () => notification.dismiss());
return;
}
this.addNotification(notification, this.getNotificationTimeout(notification) > 0);
}),
AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => {
this.removeNotification(id);
this.addHistory(notifd.get_notification(id));
})
);
onCleanup(() => {
this.#connections.map(id =>
AstalNotifd.get_default().disconnect(id));
});
}
public static getDefault(): Notifications {
if(!this.instance)
this.instance = new Notifications();
return this.instance;
}
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?.(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.emit("history-cleared");
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,
removeOnTimeout: boolean = true,
onTimeoutEnd?: () => void
): void {
const replaced = this.#notifications.has(notif.id);
const notifTimeout = this.getNotificationTimeout(notif);
const onEnd = () => {
removeOnTimeout && this.removeNotification(notif);
onTimeoutEnd?.();
}
// destroy timer of replaced notification(if there's any)
if(replaced) {
const data = this.#notifications.get(notif.id)!;
(data?.[1] instanceof NotificationTimeout) &&
data[1].cancel();
}
this.#notifications.set(notif.id, [
notif,
new NotificationTimeout(notifTimeout, onEnd, notifTimeout > 0)
]);
replaced && this.emit("notification-replaced", notif.id);
this.notify("notifications");
this.emit("notification-added", notif);
if(notifTimeout <= 0) onEnd?.();
}
public getNotificationTimeout(notif: AstalNotifd.Notification): number {
return generalConfig.getProperty(
`notifications.timeout_${this.getUrgencyString(notif.urgency)}`,
"number"
);
}
public removeNotification(notif: (AstalNotifd.Notification|number), addToHistory: boolean = true): void {
notif = typeof notif === "number" ?
this.#notifications.get(notif)?.[0]!
: notif;
if(!notif) return;
const timeout = this.#notifications.get(notif.id)![1];
timeout.running && timeout.cancel();
this.#notifications.delete(notif.id);
addToHistory && this.addHistory(notif);
notif.dismiss();
this.notify("notifications");
this.emit("notification-removed", notif.id);
}
public holdNotification(notif: AstalNotifd.Notification|number): void {
const id = typeof notif === "number" ? notif : notif.id;
const data = this.#notifications.get(id);
if(!data) return;
data[1].cancel();
this.notify("notifications-on-hold");
}
public releaseNotification(notif: AstalNotifd.Notification|number): void {
const id = typeof notif === "number" ? notif : notif.id;
const data = this.#notifications.get(id);
if(!data) return;
data[1].start(data[1].lastRemained);
this.notify("notifications-on-hold");
}
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(); }
public emit<Signal extends keyof typeof this.$signals>(
signal: Signal, ...args: Parameters<(typeof this.$signals)[Signal]>
): void {
super.emit(signal, ...args);
}
public connect<Signal extends keyof typeof this.$signals>(
signal: Signal,
callback: (self: typeof this, ...params: Parameters<(typeof this.$signals)[Signal]>) =>
ReturnType<(typeof this.$signals)[Signal]>
): number {
return super.connect(signal, callback);
}
}

View File

@@ -0,0 +1,178 @@
import { execAsync } from "ags/process";
import { getter, register, signal } from "ags/gobject";
import { Gdk } from "ags/gtk4";
import { createRoot, getScope, Scope } from "ags";
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";
@register({ GTypeName: "Recording" })
export class Recording extends GObject.Object {
private static instance: Recording;
@signal() started() {};
@signal() stopped() {};
#recording: boolean = false;
#path: string = "~/Recordings";
#recordingScope?: Scope;
/** 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");
}
@getter(String)
public get recordingTime() {
if(!this.#recording || !this.#startedAt)
return "not recording";
const startedAtSeconds = time.get().to_unix() - Recording.getDefault().startedAt!;
if(startedAtSeconds <= 0) return "00:00";
const seconds = Math.floor(startedAtSeconds % 60);
const minutes = Math.floor(startedAtSeconds / 60);
const hours = Math.floor(minutes / 60);
return `${hours > 0 ? `${hours < 10 ? '0' : ""}${hours}` : ""}${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`;
}
/** 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!");
createRoot(() => {
this.#recordingScope = getScope();
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 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(null, () => {
this.stopRecording();
});
this.#startedAt = time.get().to_unix();
this.notify("started-at");
const timeSub = time.subscribe(() => {
this.notify("recording-time");
});
this.#recordingScope.onCleanup(timeSub);
});
}
public stopRecording() {
if(!this.#process || !this.#recording) return;
!this.#process.get_if_exited() && execAsync([
"kill", "-s", "SIGTERM", this.#process.get_identifier()!
]);
this.#recordingScope?.dispose();
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", // will be hidden(can be triggered by clicking in the notification)
id: "view",
onAction: () => {
execAsync(["xdg-open", `${path}/${output}`]);
}
}
],
appName: "Screen Recording",
summary: "Screen Recording saved",
body: `Saved as ${path}/${output}`
});
}
};

View File

@@ -0,0 +1,15 @@
import { uwsmIsActive } from "./apps";
import Gio from "gi://Gio?version=2.0";
import { Shell } from "../app";
export function restartInstance(): void {
Gio.Subprocess.new(
( uwsmIsActive ?
[ "uwsm", "app", "--", "colorshell" ]
: [ "colorshell" ]),
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
Shell.getDefault().quit();
}

View File

@@ -0,0 +1,147 @@
import { monitorFile, readFile, writeFileAsync } from "ags/file";
import { decoder } from "./utils";
import { execAsync } from "ags/process";
import { Wallpaper } from "./wallpaper";
import { Shell } from "../app";
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;
#outputPath = Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/colorshell/style`);
#stylesPaths: Array<string>;
readonly #sassStyles = {
modules: ["sass:color"].map(mod => `@use "${mod}";`).join('\n'),
colors: "",
mixins: "",
rules: ""
};
public get stylePath() { return this.#outputPath.get_path()!; }
public static getDefault(): Stylesheet {
if(!this.instance)
this.instance = new Stylesheet();
return this.instance;
}
private bundle(): string {
return `${this.#sassStyles.modules}\n\n${this.#sassStyles.colors
}\n${this.#sassStyles.mixins}\n${this.#sassStyles.rules}`.trim();
}
private async compile(): Promise<void> {
const sass = this.bundle();
await writeFileAsync(`${this.stylePath}/sass.scss`, sass).catch(_e => {
const e = _e as Error;
console.error(`Stylesheet: Couldn't write Sass to cache. Stderr: ${
e.message}\n${e.stack}`);
});
await execAsync(
`bash -c "sass ${this.stylePath}/sass.scss ${this.stylePath}/style.css"`
).catch(_e => {
const e = _e as Error;
console.error(`Stylesheet: An error occurred on compile-time! Stderr: ${
e.message}\n${e.stack}`);
});
}
public getStyleSheet(): string {
return readFile(`${this.stylePath}/style.css`);
}
public getColorDefinitions(): string {
const data = Wallpaper.getDefault().getData();
const colors = {
...data.special,
...data.colors
};
return Object.keys(colors).map(name =>
`$${name}: ${colors[name as keyof typeof colors]};`
).join('\n');
}
private organizeModuleImports(sass: string) {
return sass.replaceAll(
/[@](use|forward|import) ["'](.*)["']?[;]?\n/gi,
(_, impType, imp) => {
imp = (imp as string).replace(/["';]/g, "");
// add sass modules on top
if(!this.#sassStyles.modules.includes(imp) && /^(sass|.*http|.*https)/.test(imp))
this.#sassStyles.modules = this.#sassStyles.modules.concat(`\n@${impType} "${imp}";`);
return "";
}
).replace(/(colors|mixins|wal)\./g, "");
}
public compileApply(): void {
this.compile().then(() => {
Shell.getDefault().resetStyle();
Shell.getDefault().applyStyle(this.getStyleSheet());
}).catch(_e => {
const e = _e as Error;
console.error(`Stylesheet: An error occurred at compile-time. Stderr: ${
e.message}\n${e.stack}`);
});
}
private getStyleData(path: string): string {
return decoder.decode(Gio.resources_lookup_data(path, null).get_data()!);
}
constructor() {
if(!this.#outputPath.query_exists(null))
this.#outputPath.make_directory_with_parents(null);
this.#stylesPaths = Gio.resources_enumerate_children(
"/io/github/retrozinndev/colorshell/styles", null
).map(name =>
`/io/github/retrozinndev/colorshell/styles/${name}`
);
// Rules won't change at runtime in a common build,
// so no need to worry about this.
// But in a development build, there should be support
// hot-reloading the gresource, this is a TODO
this.#stylesPaths.forEach(path => {
const name = path.split('/')[path.split('/').length - 1];
switch(name) {
case "colors":
this.#sassStyles.colors = `${this.getColorDefinitions()}\n${
this.organizeModuleImports(this.getStyleData(path))
}`;
break;
case "mixins":
this.#sassStyles.mixins = `${this.organizeModuleImports(
this.getStyleData(path)
)}`;
break;
default:
this.#sassStyles.rules = `${this.#sassStyles.rules}\n${
this.organizeModuleImports(this.getStyleData(path))
}`;
break;
}
});
this.compileApply();
monitorFile(`${GLib.get_user_cache_dir()}/wal/colors`, () => {
this.#sassStyles.colors = `${this.getColorDefinitions()}\n${
this.organizeModuleImports(this.getStyleData(
"/io/github/retrozinndev/colorshell/styles/colors"
))
}`;
this.compileApply();
});
}
}

View File

@@ -0,0 +1,184 @@
import { createPoll } from "ags/time";
import { exec, execAsync } from "ags/process";
import { Astal, Gtk } from "ags/gtk4";
import { getSymbolicIcon } from "./apps";
export {
toBoolean as variableToBoolean,
construct,
transform,
transformWidget,
createSubscription,
createAccessorBinding as baseBinding,
createScopedConnection,
createSecureBinding as secureBinding,
createSecureAccessorBinding as secureBaseBinding,
} from "gnim-utils";
import GLib from "gi://GLib?version=2.0";
import Gio from "gi://Gio?version=2.0";
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 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 escapeSpecialCharacters(str: string): string {
return str.replace(/[\\^$.*?()[\]{}|]/g, "\\$&");
}
/** translate paths with environment variables in it to absolute paths */
export function translateDirWithEnvironment(path: string): string {
path = path.replace(/^[~]/, GLib.get_home_dir());
return path.split('/').map(part => /^\$/.test(part) ?
GLib.getenv(part.replace(/^\$/, "")) ?? part
: part).join('/');
}
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 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 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;
}

View File

@@ -0,0 +1,143 @@
import AstalWp from "gi://AstalWp";
export class Wireplumber {
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() {
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();
}
}

View File

@@ -0,0 +1,226 @@
import { execAsync } from "ags/process";
import { timeout } from "ags/time";
import { monitorFile, readFile } from "ags/file";
import GObject, { register, getter } from "ags/gobject";
import AstalIO from "gi://AstalIO";
import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0";
import { decoder, encoder } from "./utils";
export { Wallpaper };
type WalData = {
checksum: string;
wallpaper: string;
alpha: number;
special: {
background: string;
foreground: string;
cursor: string;
};
colors: {
color0: string;
color1: string;
color2: string;
color3: string;
color4: string;
color5: string;
color6: string;
color7: string;
color8: string;
color9: string;
color10: string;
color11: string;
color12: string;
color13: string;
color14: string;
color15: string;
};
};
@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 = decoder.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(encoder.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 getData(): WalData {
const content = readFile(`${GLib.get_user_cache_dir()}/wal/colors.json`);
return JSON.parse(content) as WalData;
}
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;
}));
}
}