mirror of
https://github.com/zephrynis/nix-flake.git
synced 2026-02-18 20:21:53 +00:00
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:
105
home/ags-config/modules/apps.ts
Normal file
105
home/ags-config/modules/apps.ts
Normal 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;
|
||||
}
|
||||
104
home/ags-config/modules/apps.ts.backup
Normal file
104
home/ags-config/modules/apps.ts.backup
Normal 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;
|
||||
}
|
||||
406
home/ags-config/modules/arg-handler.ts
Normal file
406
home/ags-config/modules/arg-handler.ts
Normal 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;
|
||||
}
|
||||
111
home/ags-config/modules/auth.ts
Normal file
111
home/ags-config/modules/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
209
home/ags-config/modules/backlight.ts
Normal file
209
home/ags-config/modules/backlight.ts
Normal 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>;
|
||||
}
|
||||
38
home/ags-config/modules/battery.ts
Normal file
38
home/ags-config/modules/battery.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
159
home/ags-config/modules/bluetooth.ts
Normal file
159
home/ags-config/modules/bluetooth.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
260
home/ags-config/modules/clipboard.ts
Normal file
260
home/ags-config/modules/clipboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
69
home/ags-config/modules/compositors/hyprland.ts
Normal file
69
home/ags-config/modules/compositors/hyprland.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
162
home/ags-config/modules/compositors/index.ts
Normal file
162
home/ags-config/modules/compositors/index.ts
Normal 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! :)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
219
home/ags-config/modules/config.ts
Normal file
219
home/ags-config/modules/config.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
home/ags-config/modules/media.ts
Normal file
86
home/ags-config/modules/media.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
181
home/ags-config/modules/nightlight.ts
Normal file
181
home/ags-config/modules/nightlight.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
361
home/ags-config/modules/notifications.ts
Normal file
361
home/ags-config/modules/notifications.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
178
home/ags-config/modules/recording.ts
Normal file
178
home/ags-config/modules/recording.ts
Normal 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}`
|
||||
});
|
||||
}
|
||||
};
|
||||
15
home/ags-config/modules/reload-handler.ts
Normal file
15
home/ags-config/modules/reload-handler.ts
Normal 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();
|
||||
}
|
||||
147
home/ags-config/modules/stylesheet.ts
Normal file
147
home/ags-config/modules/stylesheet.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
184
home/ags-config/modules/utils.ts
Normal file
184
home/ags-config/modules/utils.ts
Normal 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 "<";
|
||||
case ">": return ">";
|
||||
case "&": return "&";
|
||||
case "\"": return """;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
143
home/ags-config/modules/volume.ts
Normal file
143
home/ags-config/modules/volume.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
226
home/ags-config/modules/wallpaper.ts
Normal file
226
home/ags-config/modules/wallpaper.ts
Normal 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;
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user