Minor: Refactor the code-base for better organization and compartmentalization. (#934)

* Clean up unused code

* Fix media player formatting issue for labels with new line characteres.

* Refactor the media player handlers into a class.

* More code cleanup and organize shared weather utils into distinct classes.

* Flatten some nesting.

* Move weather manager in dedicated class and build HTTP Utility class for Rest API calling.

* Remove logs

* Rebase master merge

* Reorg code (WIP)

* More reorg

* Delete utility scripts

* Reorg options

* Finish moving all options over

* Fix typescript issues

* Update options imports to default

* missed update

* Screw barrel files honestly, work of the devil.

* Only initialize power profiles if power-profiles-daemon is running.

* Fix window positioning and weather service naming

* style dir

* More organization

* Restructure types to be closer to their source

* Remove lib types and constants

* Update basic weather object to be saner with extensibility.

* Service updates

* Fix initialization strategy for services.

* Fix Config Manager to only emit changed objects and added missing temp converters.

* Update storage service to handle unit changes.

* Added cpu temp sensor auto-discovery

* Added missing JSDocs to services

* remove unused

* Migrate to network service.

* Fix network password issue.

* Move out password input into helper

* Rename password mask constant to be less double-negativey.

* Dropdown menu rename

* Added a component to edit JSON in the settings dialog (rough/WIP)

* Align settings

* Add and style JSON Editor.

* Adjust padding

* perf(shortcuts):  avoid unnecessary polling when shortcuts are disabled

Stops the recording poller when shortcuts are disabled, preventing redundant polling and reducing resource usage.

* Fix types and return value if shortcut not enabled.

* Move the swww daemon checking process outside of the wallpaper service into a dedicated deamon lifecyle processor.

* Add more string formatters and use title case for weather status (as it was).

* Fix startup errors.

* Rgba fix

* Remove zod from dependencies

---------

Co-authored-by: KernelDiego <gonzalezdiego.contact@gmail.com>
This commit is contained in:
Jas Singh
2025-05-26 19:45:11 -07:00
committed by GitHub
parent 436dcbfcf2
commit 8cf5806766
532 changed files with 13134 additions and 8669 deletions

View File

@@ -1,57 +0,0 @@
// TODO: Convert to a real service
import { bind, Variable } from 'astal';
import GTop from 'gi://GTop';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
class Cpu {
private _updateFrequency = Variable(2000);
private _previousCpuData = new GTop.glibtop_cpu();
private _cpuPoller: FunctionPoller<number, []>;
public cpu = Variable(0);
constructor() {
GTop.glibtop_get_cpu(this._previousCpuData);
this.calculateUsage = this.calculateUsage.bind(this);
this._cpuPoller = new FunctionPoller<number, []>(
this.cpu,
[],
bind(this._updateFrequency),
this.calculateUsage,
);
this._cpuPoller.initialize();
}
public calculateUsage(): number {
const currentCpuData = new GTop.glibtop_cpu();
GTop.glibtop_get_cpu(currentCpuData);
// Calculate the differences from the previous to current data
const totalDiff = currentCpuData.total - this._previousCpuData.total;
const idleDiff = currentCpuData.idle - this._previousCpuData.idle;
const cpuUsagePercentage = totalDiff > 0 ? ((totalDiff - idleDiff) / totalDiff) * 100 : 0;
this._previousCpuData = currentCpuData;
return cpuUsagePercentage;
}
public updateTimer(timerInMs: number): void {
this._updateFrequency.set(timerInMs);
}
public stopPoller(): void {
this._cpuPoller.stop();
}
public startPoller(): void {
this._cpuPoller.start();
}
}
export default Cpu;

View File

@@ -1,69 +0,0 @@
// TODO: Convert to a real service
import { bind, exec, Variable } from 'astal';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { GPUStat } from 'src/lib/types/gpustat.types';
class Gpu {
private _updateFrequency = Variable(2000);
private _gpuPoller: FunctionPoller<number, []>;
public gpuUsage = Variable<number>(0);
constructor() {
this.calculateUsage = this.calculateUsage.bind(this);
this._gpuPoller = new FunctionPoller<number, []>(
this.gpuUsage,
[],
bind(this._updateFrequency),
this.calculateUsage,
);
this._gpuPoller.initialize();
}
public calculateUsage(): number {
try {
const gpuStats = exec('gpustat --json');
if (typeof gpuStats !== 'string') {
return 0;
}
const data = JSON.parse(gpuStats);
const totalGpu = 100;
const usedGpu =
data.gpus.reduce((acc: number, gpu: GPUStat) => {
return acc + gpu['utilization.gpu'];
}, 0) / data.gpus.length;
return this._divide([totalGpu, usedGpu]);
} catch (error) {
if (error instanceof Error) {
console.error('Error getting GPU stats:', error.message);
} else {
console.error('Unknown error getting GPU stats');
}
return 0;
}
}
private _divide([total, free]: number[]): number {
return free / total;
}
public updateTimer(timerInMs: number): void {
this._updateFrequency.set(timerInMs);
}
public stopPoller(): void {
this._gpuPoller.stop();
}
public startPoller(): void {
this._gpuPoller.start();
}
}
export default Gpu;

View File

@@ -1,88 +0,0 @@
// TODO: Convert to a real service
import { bind, GLib, Variable } from 'astal';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { GenericResourceData } from 'src/lib/types/customModules/generic.types';
class Ram {
private _updateFrequency = Variable(2000);
private _shouldRound = false;
private _ramPoller: FunctionPoller<GenericResourceData, []>;
public ram = Variable<GenericResourceData>({ total: 0, used: 0, percentage: 0, free: 0 });
constructor() {
this.calculateUsage = this.calculateUsage.bind(this);
this._ramPoller = new FunctionPoller<GenericResourceData, []>(
this.ram,
[],
bind(this._updateFrequency),
this.calculateUsage,
);
this._ramPoller.initialize('ram');
}
public calculateUsage(): GenericResourceData {
try {
const [success, meminfoBytes] = GLib.file_get_contents('/proc/meminfo');
if (!success || meminfoBytes === undefined) {
throw new Error('Failed to read /proc/meminfo or file content is null.');
}
const meminfo = new TextDecoder('utf-8').decode(meminfoBytes);
const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/);
const availableMatch = meminfo.match(/MemAvailable:\s+(\d+)/);
if (!totalMatch || !availableMatch) {
throw new Error('Failed to parse /proc/meminfo for memory values.');
}
const totalRamInBytes = parseInt(totalMatch[1], 10) * 1024;
const availableRamInBytes = parseInt(availableMatch[1], 10) * 1024;
let usedRam = totalRamInBytes - availableRamInBytes;
usedRam = isNaN(usedRam) || usedRam < 0 ? 0 : usedRam;
return {
percentage: this._divide([totalRamInBytes, usedRam]),
total: totalRamInBytes,
used: usedRam,
free: availableRamInBytes,
};
} catch (error) {
console.error('Error calculating RAM usage:', error);
return { total: 0, used: 0, percentage: 0, free: 0 };
}
}
public setShouldRound(round: boolean): void {
this._shouldRound = round;
}
private _divide([total, used]: number[]): number {
const percentageTotal = (used / total) * 100;
if (this._shouldRound) {
return total > 0 ? Math.round(percentageTotal) : 0;
}
return total > 0 ? parseFloat(percentageTotal.toFixed(2)) : 0;
}
public updateTimer(timerInMs: number): void {
this._updateFrequency.set(timerInMs);
}
public stopPoller(): void {
this._ramPoller.stop();
}
public startPoller(): void {
this._ramPoller.start();
}
}
export default Ram;

View File

@@ -1,77 +0,0 @@
// TODO: Convert to a real service
import { bind, Variable } from 'astal';
import GTop from 'gi://GTop';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { GenericResourceData } from 'src/lib/types/customModules/generic.types';
class Storage {
private _updateFrequency = Variable(2000);
private _shouldRound = false;
private _storagePoller: FunctionPoller<GenericResourceData, []>;
public storage = Variable<GenericResourceData>({ total: 0, used: 0, percentage: 0, free: 0 });
constructor() {
this.calculateUsage = this.calculateUsage.bind(this);
this._storagePoller = new FunctionPoller<GenericResourceData, []>(
this.storage,
[],
bind(this._updateFrequency),
this.calculateUsage,
);
this._storagePoller.initialize();
}
public calculateUsage(): GenericResourceData {
try {
const currentFsUsage = new GTop.glibtop_fsusage();
GTop.glibtop_get_fsusage(currentFsUsage, '/');
const total = currentFsUsage.blocks * currentFsUsage.block_size;
const available = currentFsUsage.bavail * currentFsUsage.block_size;
const used = total - available;
return {
total,
used,
free: available,
percentage: this._divide([total, used]),
};
} catch (error) {
console.error('Error calculating Storage usage:', error);
return { total: 0, used: 0, percentage: 0, free: 0 };
}
}
public setShouldRound(round: boolean): void {
this._shouldRound = round;
}
private _divide([total, used]: number[]): number {
const percentageTotal = (used / total) * 100;
if (this._shouldRound) {
return total > 0 ? Math.round(percentageTotal) : 0;
}
return total > 0 ? parseFloat(percentageTotal.toFixed(2)) : 0;
}
public updateTimer(timerInMs: number): void {
this._updateFrequency.set(timerInMs);
}
public stopPoller(): void {
this._storagePoller.stop();
}
public startPoller(): void {
this._storagePoller.start();
}
}
export default Storage;

View File

@@ -1,122 +0,0 @@
import GObject, { GLib, property, register, signal } from 'astal/gobject';
import { dependencies, sh } from '../lib/utils';
import options from '../options';
import { execAsync } from 'astal/process';
import { monitorFile } from 'astal/file';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
const hyprlandService = AstalHyprland.get_default();
const WP = `${GLib.get_home_dir()}/.config/background`;
@register({ GTypeName: 'Wallpaper' })
class Wallpaper extends GObject.Object {
#blockMonitor = false;
#isRunning = false;
#wallpaper(): void {
if (!dependencies('swww')) return;
try {
const cursorPosition = hyprlandService.message('cursorpos');
const transitionCmd = [
'swww',
'img',
'--invert-y',
'--transition-type',
'grow',
'--transition-duration',
'1.5',
'--transition-fps',
'60',
'--transition-pos',
cursorPosition.replace(' ', ''),
`"${WP}"`,
].join(' ');
sh(transitionCmd)
.then(() => {
this.notify('wallpaper');
this.emit('changed', true);
})
.catch((err) => {
console.error('Error setting wallpaper:', err);
});
} catch (err) {
console.error('Error getting cursor position:', err);
}
}
async #setWallpaper(path: string): Promise<void> {
this.#blockMonitor = true;
try {
await sh(`cp "${path}" "${WP}"`);
this.#wallpaper();
} catch (error) {
console.error('Error setting wallpaper:', error);
} finally {
this.#blockMonitor = false;
}
}
public setWallpaper(path: string): void {
this.#setWallpaper(path);
}
public isRunning(): boolean {
return this.#isRunning;
}
@property(String)
declare public wallpaper: string;
@signal(Boolean)
declare public changed: (event: boolean) => void;
constructor() {
super();
this.wallpaper = WP;
options.wallpaper.enable.subscribe(() => {
if (options.wallpaper.enable.get()) {
this.#isRunning = true;
execAsync('swww-daemon')
.then(() => {
this.#wallpaper();
})
.catch((err) => {
console.error('Failed to start swww-daemon:', err);
});
} else {
this.#isRunning = false;
execAsync('pkill swww-daemon')
.then(() => {
console.log('swww-daemon stopped.');
})
.catch((err) => {
console.error('Failed to stop swww-daemon:', err);
});
}
});
if (options.wallpaper.enable.get() && dependencies('swww')) {
this.#isRunning = true;
monitorFile(WP, () => {
if (!this.#blockMonitor) this.#wallpaper();
});
execAsync('swww-daemon')
.then(() => {
this.#wallpaper();
})
.catch((err) => {
console.error('Failed to start swww-daemon:', err);
});
}
}
}
export default new Wallpaper();

View File

@@ -0,0 +1,25 @@
import { CommandRegistry } from './Registry';
import { Command } from './types';
import { createExplainCommand } from './helpers';
import { appearanceCommands } from './commands/appearance';
import { utilityCommands } from './commands/system';
import { windowManagementCommands } from './commands/windowManagement';
import { mediaCommands } from './commands/modules/media';
/**
* Initializes and registers commands in the provided CommandRegistry.
*
* @param registry - The command registry to register commands in.
*/
export function initializeCommands(registry: CommandRegistry): void {
const commandList: Command[] = [
...appearanceCommands,
...utilityCommands,
...windowManagementCommands,
...mediaCommands,
];
commandList.forEach((command) => registry.register(command));
registry.register(createExplainCommand(registry));
}

View File

@@ -0,0 +1,200 @@
import { CommandRegistry } from './Registry';
import { Command, ParsedCommand } from './types';
/**
* Parses an input string into a command and its positional arguments.
*
* Expected format:
* astal <commandName> arg1 arg2 arg3...
*
* 1. Tokenizes the input.
* 2. Identifies the command by the first token.
* 3. Parses positional arguments based on the command definition.
* 4. Converts arguments to their specified types.
* 5. Validates required arguments.
*/
export class CommandParser {
private _registry: CommandRegistry;
/**
* Constructs a CommandParser with the provided command registry.
*
* @param registry - The command registry containing available commands.
*/
constructor(registry: CommandRegistry) {
this._registry = registry;
}
/**
* Parses the entire input string, returning the matching command and its arguments.
*
* @param input - The raw input string to parse.
* @returns A parsed command object, including the command and its arguments.
* @throws If no command token is found.
* @throws If the command token is not registered.
*/
public parse(input: string): ParsedCommand {
const tokens = this._tokenize(input);
if (tokens.length === 0) {
throw new Error('No command provided.');
}
const commandName = tokens.shift() ?? 'non-existent-command';
const command = this._registry.get(commandName);
if (!command) {
throw new Error(
`Unknown command: "${commandName}". Use "hyprpanel explain" for available commands.`,
);
}
const args = this._parseArgs(command, tokens);
return { command, args };
}
/**
* Splits the input string into tokens, respecting quotes.
*
* @param input - The raw input string to break into tokens.
* @returns An array of tokens.
*/
private _tokenize(input: string): string[] {
const regex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g;
const matches = input.match(regex);
return matches ? matches.map((token) => this._stripQuotes(token)) : [];
}
/**
* Removes surrounding quotes from a single token, if they exist.
*
* @param str - The token from which to strip leading or trailing quotes.
* @returns The token without its outer quotes.
*/
private _stripQuotes(str: string): string {
return str.replace(/^["'](.+(?=["']$))["']$/, '$1');
}
/**
* Parses the array of tokens into arguments based on the command's argument definitions.
*
* @param command - The command whose arguments are being parsed.
* @param tokens - The list of tokens extracted from the input.
* @returns An object mapping argument names to their parsed values.
* @throws If required arguments are missing.
* @throws If there are too many tokens for the command definition.
*/
private _parseArgs(command: Command, tokens: string[]): Record<string, unknown> {
const args: Record<string, unknown> = {};
let currentIndex = 0;
for (const argDef of command.args) {
if (currentIndex >= tokens.length) {
if (argDef.required === true) {
throw new Error(`Missing required argument: "${argDef.name}".`);
}
if (argDef.default !== undefined) {
args[argDef.name] = argDef.default;
}
continue;
}
if (argDef.type === 'object') {
const { objectValue, nextIndex } = this._parseObjectTokens(tokens, currentIndex);
args[argDef.name] = objectValue;
currentIndex = nextIndex;
} else {
const value = tokens[currentIndex];
currentIndex++;
args[argDef.name] = this._convertType(value, argDef.type);
}
}
if (currentIndex < tokens.length) {
throw new Error(
`Too many arguments for command "${command.name}". Expected at most ${command.args.length}.`,
);
}
return args;
}
/**
* Accumulates tokens until braces are balanced to form a valid JSON string,
* then parses the result.
*
* @param tokens - The list of tokens extracted from the input.
* @param startIndex - The token index from which to begin JSON parsing.
* @returns An object containing the parsed JSON object and the next token index.
* @throws If the reconstructed JSON is invalid.
*/
private _parseObjectTokens(
tokens: string[],
startIndex: number,
): { objectValue: unknown; nextIndex: number } {
let braceCount = 0;
let started = false;
const objectTokens: string[] = [];
let currentIndex = startIndex;
while (currentIndex < tokens.length) {
const token = tokens[currentIndex];
currentIndex++;
for (const char of token) {
if (char === '{') braceCount++;
if (char === '}') braceCount--;
}
objectTokens.push(token);
if (started && braceCount === 0) break;
if (token.includes('{')) started = true;
}
const objectString = objectTokens.join(' ');
let parsed: unknown;
try {
parsed = JSON.parse(objectString);
} catch {
throw new Error(`Invalid JSON object: "${objectString}".`);
}
return { objectValue: parsed, nextIndex: currentIndex };
}
/**
* Converts a single token to the specified argument type.
*
* @param value - The raw token to be converted.
* @param type - The expected argument type.
* @returns The converted value.
* @throws If the token cannot be converted to the expected type.
*/
private _convertType(value: string, type: 'string' | 'number' | 'boolean' | 'object'): unknown {
switch (type) {
case 'number': {
const num = Number(value);
if (isNaN(num)) {
throw new Error(`Expected a number but got "${value}".`);
}
return num;
}
case 'boolean': {
const lower = value.toLowerCase();
if (lower === 'true') return true;
if (lower === 'false') return false;
throw new Error(`Expected a boolean (true/false) but got "${value}".`);
}
case 'object': {
try {
return JSON.parse(value);
} catch {
throw new Error(`Invalid JSON object: "${value}".`);
}
}
case 'string':
default:
return value;
}
}
}

View File

@@ -0,0 +1,53 @@
import { Command } from './types';
/**
* The CommandRegistry manages the storage and retrieval of commands.
* It supports registration of multiple commands, lookup by name or alias,
* and retrieval of all commands for listing and help functionalities.
*/
export class CommandRegistry {
private _commands: Map<string, Command> = new Map();
/**
* Registers a command. If a command with the same name or alias already exists,
* it will throw an error.
*
* @param command - The command to register.
* @throws If a command with the same name or alias already exists.
*/
public register(command: Command): void {
if (this._commands.has(command.name)) {
throw new Error(`Command "${command.name}" is already registered.`);
}
this._commands.set(command.name, command);
if (command.aliases) {
for (const alias of command.aliases) {
if (this._commands.has(alias)) {
throw new Error(`Alias "${alias}" is already in use.`);
}
this._commands.set(alias, command);
}
}
}
/**
* Retrieves a command by its name or alias. Returns undefined if not found.
*
* @param commandName - The name or alias of the command to retrieve.
* @returns The command if found, otherwise undefined.
*/
public get(commandName: string): Command | undefined {
return this._commands.get(commandName);
}
/**
* Retrieves all registered commands, ensuring each command is returned once even if it has aliases.
*
* @returns An array of all registered commands.
*/
public getAll(): Command[] {
const unique = new Set<Command>(this._commands.values());
return Array.from(unique);
}
}

View File

@@ -0,0 +1,89 @@
import { CommandParser } from './Parser';
import { ResponseCallback } from './types';
/**
* The RequestHandler orchestrates the parsing and execution of commands:
* 1. Uses the CommandParser to parse the input into a command and args.
* 2. Invokes the command handler with the parsed arguments.
* 3. Handles any errors and passes the result back via the response callback.
*/
export class RequestHandler {
private _parser: CommandParser;
/**
* Creates an instance of RequestHandler.
*
* @param parser - The CommandParser instance to use.
*/
constructor(parser: CommandParser) {
this._parser = parser;
}
/**
* Initializes the request handler with the given input and response callback.
*
* @param input - The input string to process.
* @param response - The callback to handle the response.
* @returns A promise that resolves when the request is handled.
*/
public async initializeRequestHandler(input: string, response: ResponseCallback): Promise<void> {
try {
const parsed = this._parser.parse(input);
const { command, args } = parsed;
const result = command.handler(args);
if (result instanceof Promise) {
const resolved = await result;
response(this._formatOutput(resolved));
} else {
response(this._formatOutput(result));
}
} catch (error) {
response(this._formatError(error));
}
}
/**
* Formats the output based on its type.
*
* @param output - The output to format.
* @returns A string representation of the output.
*/
private _formatOutput(output: unknown): string {
if (typeof output === 'string') {
return output;
} else if (typeof output === 'number' || typeof output === 'boolean') {
return output.toString();
} else if (typeof output === 'object' && output !== null) {
try {
return JSON.stringify(output, null, 2);
} catch {
return 'Unable to display object.';
}
} else {
return String(output);
}
}
/**
* Formats the error based on its type.
*
* @param error - The error to format.
* @returns A string representation of the error.
*/
private _formatError(error: unknown): string {
if (error instanceof Error) {
return `Error: ${error.message}`;
} else if (typeof error === 'string') {
return `Error: ${error}`;
} else if (typeof error === 'object' && error !== null) {
try {
return `Error: ${JSON.stringify(error, null, 2)}`;
} catch {
return 'An unknown error occurred.';
}
} else {
return `Error: ${String(error)}`;
}
}
}

View File

@@ -0,0 +1,79 @@
import { Command } from '../../types';
import { setWallpaper } from 'src/services/cli/helpers/wallpaper';
import { useTheme } from 'src/lib/theme/useTheme';
import { BarLayouts } from 'src/lib/options/types';
import { errorHandler } from 'src/core/errors/handler';
import { setLayout } from 'src/lib/bar/helpers';
export const appearanceCommands: Command[] = [
{
name: 'setWallpaper',
aliases: ['sw'],
description: 'Sets the wallpaper based on the provided input.',
category: 'Appearance',
args: [
{
name: 'path',
description: 'Path to the wallpaper image.',
type: 'string',
required: true,
},
],
handler: (args: Record<string, unknown>): string => {
try {
setWallpaper(args['path'] as string);
return 'Wallpaper set successfully.';
} catch (error) {
if (error instanceof Error) {
return `Error setting wallpaper: ${error.message}`;
}
return `Error setting wallpaper: ${error}`;
}
},
},
{
name: 'useTheme',
aliases: ['ut'],
description: 'Sets the theme based on the provided input.',
category: 'Appearance',
args: [
{
name: 'path',
description: 'Path to the JSON file of the HyprPanel theme.',
type: 'string',
required: true,
},
],
handler: (args: Record<string, unknown>): string => {
try {
useTheme(args['path'] as string);
return 'Theme set successfully.';
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'setLayout',
aliases: ['slo'],
description: 'Sets the layout of the modules on the bar.',
category: 'Appearance',
args: [
{
name: 'layout',
description:
'Bar layout to apply. Wiki: https://hyprpanel.com/configuration/panel.html#layouts',
type: 'object',
required: true,
},
],
handler: (args: Record<string, unknown>): string => {
try {
setLayout(args['layout'] as BarLayouts);
return 'Layout applied successfully.';
} catch (error) {
errorHandler(error);
}
},
},
];

View File

@@ -0,0 +1,26 @@
import { errorHandler } from 'src/core/errors/handler';
import { Command } from '../../../types';
import { MediaPlayerService } from 'src/services/media';
const mediaPlayerService = MediaPlayerService.getInstance();
export const mediaCommands: Command[] = [
{
name: 'Play/Pause active media player',
aliases: ['pp'],
description: 'Plays or Pauses the active media player.',
category: 'Media',
args: [],
handler: (): string => {
try {
mediaPlayerService.activePlayer.get()?.play_pause();
const playbackStatus = mediaPlayerService.activePlayer.get()?.playback_status;
return playbackStatus === 0 ? 'Paused' : 'Playing';
} catch (error) {
errorHandler(error);
}
},
},
];

View File

@@ -0,0 +1,257 @@
import { errorHandler } from 'src/core/errors/handler';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
import { ServiceStatus } from 'src/core/system/types';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const STATUS_INSTALLED = '(INSTALLED)';
const STATUS_ACTIVE = '(ACTIVE)';
const STATUS_DISABLED = '(DISABLED)';
const STATUS_MISSING = '(MISSING)';
/**
* Colors a given text using ANSI color codes.
*
* @description Wraps the provided text with ANSI color codes.
*
* @param text - The text to color.
* @param color - The ANSI color code to use.
*/
function colorText(text: string, color: string): string {
return `${color}${text}${RESET}`;
}
/**
* Determines the status string and color for a dependency based on its type and checks.
*
* @description Returns the formatted line indicating the status of the given dependency.
*
* @param dep - The dependency to check.
*/
function getDependencyStatus(dep: Dependency): string {
let status: ServiceStatus | 'INSTALLED' | 'MISSING';
switch (dep.type) {
case 'executable':
status = SystemUtilities.checkExecutable(dep.check) ? 'INSTALLED' : 'MISSING';
break;
case 'library':
status = SystemUtilities.checkLibrary(dep.check) ? 'INSTALLED' : 'MISSING';
break;
case 'service':
status = SystemUtilities.checkServiceStatus(dep.check);
break;
default:
status = 'MISSING';
}
let color: string;
let textStatus: string;
switch (status) {
case 'ACTIVE':
textStatus = STATUS_ACTIVE;
color = GREEN;
break;
case 'INSTALLED':
textStatus = STATUS_INSTALLED;
color = GREEN;
break;
case 'DISABLED':
textStatus = STATUS_DISABLED;
color = YELLOW;
break;
case 'MISSING':
default:
textStatus = STATUS_MISSING;
color = RED;
break;
}
if (dep.description === undefined) {
return ` ${colorText(textStatus, color)} ${dep.package}`;
}
return ` ${colorText(textStatus, color)} ${dep.package}: ${dep.description ?? ''}`;
}
/**
* Checks all dependencies and returns a formatted output.
*
* @description Gathers the status of both required and optional dependencies and formats the result.
*/
export function checkDependencies(): string {
try {
const dependencies: Dependency[] = [
{
package: 'wireplumber',
required: true,
type: 'executable',
check: ['wireplumber'],
},
{
package: 'libgtop',
required: true,
type: 'library',
check: ['gtop-2.0'],
},
{
package: 'bluez',
required: true,
type: 'service',
check: ['bluetooth.service'],
},
{
package: 'bluez-utils',
required: true,
type: 'executable',
check: ['bluetoothctl'],
},
{
package: 'networkmanager',
required: true,
type: 'service',
check: ['NetworkManager.service'],
},
{
package: 'dart-sass',
required: true,
type: 'executable',
check: ['sass'],
},
{
package: 'wl-clipboard',
required: true,
type: 'executable',
check: ['wl-copy', 'wl-paste'],
},
{
package: 'upower',
required: true,
type: 'service',
check: ['upower.service'],
},
{
package: 'aylurs-gtk-shell',
required: true,
type: 'executable',
check: ['ags'],
},
{
package: 'python',
required: false,
type: 'executable',
check: ['python', 'python3'],
description: 'GPU usage tracking (NVidia only)',
},
{
package: 'python-gpustat',
required: false,
type: 'executable',
check: ['gpustat'],
description: 'GPU usage tracking (NVidia only)',
},
{
package: 'pywal',
required: false,
type: 'executable',
check: ['wal'],
description: 'Pywal hook for wallpapers',
},
{
package: 'pacman-contrib',
required: false,
type: 'executable',
check: ['paccache', 'rankmirrors'],
description: 'Checking for pacman updates',
},
{
package: 'power-profiles-daemon',
required: false,
type: 'service',
check: ['power-profiles-daemon.service'],
description: 'Switch power profiles',
},
{
package: 'swww',
required: false,
type: 'executable',
check: ['swww'],
description: 'Setting wallpapers',
},
{
package: 'grimblast',
required: false,
type: 'executable',
check: ['grimblast'],
description: 'For the snapshot shortcut',
},
{
package: 'brightnessctl',
required: false,
type: 'executable',
check: ['brightnessctl'],
description: 'To control keyboard and screen brightness',
},
{
package: 'btop',
required: false,
type: 'executable',
check: ['btop'],
description: 'To view system resource usage',
},
{
package: 'wf-recorder',
required: false,
type: 'executable',
check: ['wf-recorder'],
description: 'To use the built-in screen recorder',
},
{
package: 'hyprpicker',
required: false,
type: 'executable',
check: ['hyprpicker'],
description: 'To use the preset color picker shortcut',
},
{
package: 'matugen',
required: false,
type: 'executable',
check: ['matugen'],
description: 'To use wallpaper-based color schemes',
},
];
let output = `${BOLD}Required Dependencies:${RESET}\n`;
for (const dep of dependencies.filter((d) => d.required)) {
output += getDependencyStatus(dep) + '\n';
}
output += `\n${BOLD}Optional Dependencies:${RESET}\n`;
for (const dep of dependencies.filter((d) => !d.required)) {
output += getDependencyStatus(dep) + '\n';
}
return output;
} catch (error) {
errorHandler(error);
}
}
type DependencyType = 'executable' | 'library' | 'service';
type Dependency = {
package: string;
required: boolean;
type: DependencyType;
check: string[];
description?: string;
};

View File

@@ -0,0 +1,221 @@
import AstalNotifd from 'gi://AstalNotifd?version=0.1';
import AstalWp from 'gi://AstalWp?version=0.1';
import { Command } from '../../types';
import { execAsync, Gio, GLib } from 'astal';
import { checkDependencies } from './checkDependencies';
import { getSystrayItems } from 'src/services/cli/helpers/systray';
import { idleInhibit } from 'src/lib/window/visibility';
import { errorHandler } from 'src/core/errors/handler';
import { clearNotifications } from 'src/lib/shared/notifications';
import options from 'src/configuration';
import { listCpuTempSensors } from './listSensors';
const { clearDelay } = options.notifications;
const notifdService = AstalNotifd.get_default();
const audio = AstalWp.get_default();
export const utilityCommands: Command[] = [
{
name: 'systrayItems',
aliases: ['sti'],
description: 'Gets a list of IDs for the current applications in the system tray.',
category: 'System',
args: [],
handler: (): string => {
try {
return getSystrayItems() ?? 'No items found!';
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'clearNotifications',
aliases: ['cno'],
description: 'Clears all of the notifications that currently exist.',
category: 'System',
args: [],
handler: (): string => {
try {
const allNotifications = notifdService.get_notifications();
clearNotifications(allNotifications, clearDelay.get());
return 'Notifications cleared successfully.';
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'toggleDnd',
aliases: ['dnd'],
description: 'Toggled the Do Not Disturb mode for notifications.',
category: 'System',
args: [],
handler: (): string => {
try {
notifdService.set_dont_disturb(!notifdService.dontDisturb);
return notifdService.dontDisturb ? 'Enabled' : 'Disabled';
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'adjustVolume',
aliases: ['vol'],
description: 'Adjusts the volume of the default audio output device.',
category: 'System',
args: [
{
name: 'volume',
description: 'A positive or negative number to adjust the volume by.',
type: 'number',
required: true,
},
],
handler: (args: Record<string, unknown>): number => {
try {
const speaker = audio?.defaultSpeaker;
if (speaker === undefined) {
throw new Error('A default speaker was not found.');
}
const volumeInput = Number(args['volume']) / 100;
if (options.menus.volume.raiseMaximumVolume.get()) {
speaker.set_volume(Math.min(speaker.volume + volumeInput, 1.5));
} else {
speaker.set_volume(Math.min(speaker.volume + volumeInput, 1));
}
return Math.round((speaker.volume + volumeInput) * 100);
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'isInhibiting',
aliases: ['isi'],
description: 'Returns the status of the Idle Inhibitor.',
category: 'System',
args: [],
handler: (): boolean => {
try {
return idleInhibit.get();
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'idleInhibit',
aliases: ['idi'],
description:
'Enables/Disables the Idle Inhibitor. Toggles the Inhibitor if no parameter is provided.',
category: 'System',
args: [
{
name: 'shouldInhibit',
description: 'The boolean value that enables/disables the inhibitor.',
type: 'boolean',
required: false,
},
],
handler: (args: Record<string, unknown>): boolean => {
try {
const shouldInhibit = args['shouldInhibit'] ?? idleInhibit.get() === false;
idleInhibit.set(Boolean(shouldInhibit));
return idleInhibit.get();
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'migrateConfig',
aliases: ['mcfg'],
description: 'Migrates the configuration file from the old location to the new one.',
category: 'System',
args: [],
handler: (): string => {
const oldPath = `${GLib.get_user_cache_dir()}/ags/hyprpanel/options.json`;
try {
const oldFile = Gio.File.new_for_path(oldPath);
const newFile = Gio.File.new_for_path(CONFIG_FILE);
if (oldFile.query_exists(null)) {
oldFile.move(newFile, Gio.FileCopyFlags.OVERWRITE, null, null);
return `Configuration file moved to ${CONFIG_FILE}`;
} else {
return `Old configuration file does not exist at ${oldPath}`;
}
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'checkDependencies',
aliases: ['chd'],
description: 'Checks the status of required and optional dependencies.',
category: 'System',
args: [],
handler: (): string => {
try {
return checkDependencies();
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'listCpuSensors',
aliases: ['lcs'],
description: 'Lists all available CPU temperature sensors and shows the current one.',
category: 'System',
args: [],
handler: (): string => {
try {
return listCpuTempSensors();
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'restart',
aliases: ['r'],
description: 'Restarts HyprPanel.',
category: 'System',
args: [],
handler: (): string => {
try {
execAsync('bash -c "hyprpanel -q; hyprpanel"');
return '';
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'quit',
aliases: ['q'],
description: 'Quits HyprPanel.',
category: 'System',
args: [],
handler: (): string => {
try {
execAsync('bash -c "hyprpanel -q"');
return '';
} catch (error) {
errorHandler(error);
}
},
},
];

View File

@@ -0,0 +1,34 @@
import { CpuTempSensorDiscovery } from 'src/services/system/cputemp/sensorDiscovery';
import CpuTempService from 'src/services/system/cputemp';
/**
* Lists all available CPU temperature sensors and shows which one is currently active
*/
export function listCpuTempSensors(): string {
const sensors = CpuTempSensorDiscovery.getAllSensors();
const cpuTempService = new CpuTempService();
cpuTempService.initialize();
const currentSensor = cpuTempService.currentSensorPath;
let outputMessage = '';
outputMessage += 'Available CPU Temperature Sensors:\n';
outputMessage += '==================================\n';
if (sensors.length === 0) {
outputMessage += 'No temperature sensors found on the system.\n';
return outputMessage;
}
for (const sensor of sensors) {
const isCurrent = sensor.path === currentSensor;
const marker = isCurrent ? ' [CURRENT]' : '';
outputMessage += `${sensor.type.padEnd(8)} | ${sensor.name.padEnd(20)} | ${sensor.path}${marker}\n`;
}
outputMessage += `Auto-discovered sensor: ${CpuTempSensorDiscovery.discover() || 'None'}\n`;
cpuTempService.destroy();
return outputMessage;
}

View File

@@ -0,0 +1,74 @@
import { Command } from '../../types';
import { App } from 'astal/gtk3';
import { isWindowVisible } from 'src/lib/window/visibility';
import { BarVisibility } from 'src/services/display/bar';
import { errorHandler } from 'src/core/errors/handler';
export const windowManagementCommands: Command[] = [
{
name: 'isWindowVisible',
aliases: ['iwv'],
description: 'Checks if a specified window is visible.',
category: 'Window Management',
args: [
{
name: 'window',
description: 'Name of the window to check.',
type: 'string',
required: true,
},
],
handler: (args: Record<string, unknown>): boolean => {
return isWindowVisible(args['window'] as string);
},
},
{
name: 'toggleWindow',
aliases: ['t'],
description: 'Toggles the visibility of a specified window.',
category: 'Window Management',
args: [
{
name: 'window',
description: 'The name of the window to toggle.',
type: 'string',
required: true,
},
],
handler: (args: Record<string, unknown>): string => {
try {
const windowName = args['window'] as string;
const foundWindow = App.get_window(windowName);
if (!foundWindow) {
throw new Error(`Window ${args['window']} not found.`);
}
const windowStatus = foundWindow.visible ? 'hidden' : 'visible';
App.toggle_window(windowName);
BarVisibility.set(windowName, windowStatus === 'visible');
return windowStatus;
} catch (error) {
errorHandler(error);
}
},
},
{
name: 'listWindows',
aliases: ['lw'],
description: 'Gets a list of all HyprPanel windows.',
category: 'Window Management',
args: [],
handler: (): string => {
try {
const windowList = App.get_windows().map((window) => window.name);
return windowList.join('\n');
} catch (error) {
errorHandler(error);
}
},
},
];

View File

@@ -0,0 +1,196 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { CommandRegistry } from '../Registry';
import { CategoryMap, Command, PositionalArg } from '../types';
const ANSI_RESET = '\x1b[0m';
const ANSI_BOLD = '\x1b[1m';
const ANSI_UNDERLINE = '\x1b[4m';
// Foreground Colors
const ANSI_FG_RED = '\x1b[31m';
const ANSI_FG_GREEN = '\x1b[32m';
const ANSI_FG_YELLOW = '\x1b[33m';
const ANSI_FG_BLUE = '\x1b[34m';
const ANSI_FG_MAGENTA = '\x1b[35m';
const ANSI_FG_CYAN = '\x1b[36m';
const ANSI_FG_WHITE = '\x1b[37m';
// Background Colors
const ANSI_BG_RED = '\x1b[41m';
const ANSI_BG_GREEN = '\x1b[42m';
const ANSI_BG_YELLOW = '\x1b[43m';
const ANSI_BG_BLUE = '\x1b[44m';
const ANSI_BG_MAGENTA = '\x1b[45m';
const ANSI_BG_CYAN = '\x1b[46m';
const ANSI_BG_WHITE = '\x1b[47m';
/**
* Creates the explain command.
*
* This command displays all available commands categorized by their respective
* categories. If a specific command name is provided as an argument, it displays
* detailed information about that command, including its positional parameters and aliases.
*
* @param registry - The command registry to use.
* @returns The explain command.
*/
export function createExplainCommand(registry: CommandRegistry): Command {
return {
name: 'explain',
aliases: ['e'],
description: 'Displays explain information for all commands or a specific command.',
category: 'General',
args: [
{
name: 'commandName',
description: 'Optional name of a command to get detailed info.',
type: 'string',
required: false,
},
],
/**
* Handler for the explain command.
*
* @param args - The arguments passed to the command.
* @returns The formatted explain message.
*/
handler: (args: Record<string, unknown>): string => {
const commandName = args['commandName'] as string | undefined;
if (commandName !== undefined) {
return formatCommandExplain(registry, commandName);
}
return formatGlobalExplain(registry);
},
};
}
/**
* Formats the detailed explain message for a specific command.
*
* @param registry - The command registry to retrieve the command.
* @param commandName - The name of the command to get detailed explain for.
* @returns The formatted detailed explain message.
*/
function formatCommandExplain(registry: CommandRegistry, commandName: string): string {
const cmd = registry.get(commandName);
if (!cmd) {
return `${ANSI_FG_RED}✖ No such command: "${commandName}". Use "explain" to see all commands.${ANSI_RESET}\n`;
}
let message = `${ANSI_BOLD}${ANSI_FG_YELLOW}Command: ${cmd.name}${ANSI_RESET}\n`;
if (cmd.aliases && cmd.aliases.length > 0) {
const aliases = formatAliases(cmd.aliases);
message += `${ANSI_FG_GREEN}Aliases:${ANSI_RESET} ${aliases}\n`;
}
message += `${ANSI_FG_GREEN}Description:${ANSI_RESET} ${cmd.description}\n`;
message += `${ANSI_FG_GREEN}Category:${ANSI_RESET} ${cmd.category}\n`;
if (cmd.args.length > 0) {
message += `${ANSI_FG_GREEN}Arguments:${ANSI_RESET}\n`;
const formattedArgs = formatArguments(cmd.args);
message += formattedArgs;
} else {
message += `${ANSI_FG_GREEN}No positional arguments.${ANSI_RESET}`;
}
return message;
}
/**
* Formats the global explain message listing all available commands categorized by their categories.
*
* @param registry - The command registry to retrieve all commands.
* @returns The formatted global explain message.
*/
function formatGlobalExplain(registry: CommandRegistry): string {
const allCommands = registry.getAll();
const categoryMap: CategoryMap = organizeCommandsByCategory(allCommands);
let explainMessage = `${ANSI_BOLD}${ANSI_FG_CYAN}Available HyprPanel Commands:${ANSI_RESET}\n`;
for (const [category, cmds] of Object.entries(categoryMap)) {
explainMessage += `\n${ANSI_BOLD}${ANSI_FG_BLUE}${category}${ANSI_RESET}\n`;
const formattedCommands = formatCommandList(cmds);
explainMessage += formattedCommands;
}
explainMessage += `\n${ANSI_FG_MAGENTA}Use "hyprpanel explain <commandName>" to get detailed information about a specific hyprpanel command.${ANSI_RESET}\n`;
return explainMessage.trim();
}
/**
* Organizes commands into their respective categories.
*
* @param commands - The list of all commands.
* @returns A mapping of category names to arrays of commands.
*/
function organizeCommandsByCategory(commands: Command[]): CategoryMap {
const categoryMap: CategoryMap = {};
commands.forEach((cmd) => {
if (categoryMap[cmd.category] === undefined) {
categoryMap[cmd.category] = [];
}
categoryMap[cmd.category].push(cmd);
});
return categoryMap;
}
/**
* Formats the list of commands under a specific category.
*
* @param commands - The list of commands in a category.
* @returns A formatted string of commands.
*/
function formatCommandList(commands: Command[]): string {
return (
commands
.map((cmd) => {
const aliasesText =
cmd.aliases && cmd.aliases.length > 0
? ` (${cmd.aliases.map((alias) => `${ANSI_FG_CYAN}${alias}${ANSI_RESET}`).join(', ')})`
: '';
return ` - ${ANSI_FG_YELLOW}${cmd.name}${ANSI_RESET}${aliasesText}: ${cmd.description}`;
})
.join('\n') + '\n'
);
}
/**
* Formats the aliases array into a readable string with appropriate coloring.
*
* @param aliases - The array of alias strings.
* @returns The formatted aliases string.
*/
function formatAliases(aliases: string[]): string {
return aliases.map((alias) => `${ANSI_FG_CYAN}${alias}${ANSI_RESET}`).join(', ');
}
/**
* Formats the arguments array into a readable string with appropriate coloring.
*
* @param args - The array of positional arguments.
* @returns The formatted arguments string.
*/
function formatArguments(args: PositionalArg[]): string {
return (
args
.map((arg) => {
const requirement =
arg.required === true ? `${ANSI_FG_RED}(required)` : `${ANSI_FG_CYAN}(optional)`;
const defaultValue =
arg.default !== undefined
? ` ${ANSI_FG_MAGENTA}[default: ${JSON.stringify(arg.default)}]${ANSI_RESET}`
: '';
return ` ${ANSI_FG_YELLOW}${arg.name}${ANSI_RESET}: ${arg.description} ${requirement}${defaultValue}`;
})
.join('\n') + '\n'
);
}

View File

@@ -0,0 +1,33 @@
import { CommandRegistry } from './Registry';
import { CommandParser } from './Parser';
import { RequestHandler } from './RequestHandler';
import { initializeCommands } from './InitializeCommand';
import { ResponseCallback } from './types';
/**
* This is the entry point for the CLI. It:
* 1. Creates a CommandRegistry
* 2. Initializes all commands
* 3. Creates a CommandParser
* 4. Creates a RequestHandler
* 5. Provides a function `runCLI` to process an input string and respond with a callback.
*/
const registry = new CommandRegistry();
initializeCommands(registry);
const parser = new CommandParser(registry);
const handler = new RequestHandler(parser);
/**
* Run the CLI with a given input and a response callback.
*
* @param input - The input string to process.
* @param response - The callback to handle the response.
*/
export function runCLI(input: string, response: ResponseCallback): void {
handler.initializeRequestHandler(input, response).catch((err) => {
response({ error: err instanceof Error ? err.message : String(err) });
});
}

View File

@@ -0,0 +1,31 @@
export interface PositionalArg {
name: string;
description: string;
type: 'string' | 'number' | 'boolean' | 'object';
required?: boolean;
default?: string | number | boolean | Record<string, unknown>;
}
type HandlerReturn = unknown | Promise<unknown>;
export interface Command {
name: string;
aliases?: string[];
description: string;
category: string;
args: PositionalArg[];
handler: (args: Record<string, unknown>) => HandlerReturn;
}
export interface ParsedCommand {
command: Command;
args: Record<string, unknown>;
}
export interface ResponseCallback {
(res: unknown): void;
}
export interface CategoryMap {
[category: string]: Command[];
}

View File

@@ -0,0 +1,21 @@
import AstalTray from 'gi://AstalTray';
import { errorHandler } from 'src/core/errors/handler';
const systemtray = AstalTray.get_default();
/**
* Retrieves all system tray items and returns their IDs
*
* @returns A newline-separated string of system tray item IDs
*/
export function getSystrayItems(): string {
try {
const items = systemtray
.get_items()
.map((systrayItem) => systrayItem.id)
.join('\n');
return items;
} catch (error) {
errorHandler(error);
}
}

View File

@@ -0,0 +1,35 @@
import GLib from 'gi://GLib?version=2.0';
import options from 'src/configuration';
import { WallpaperService } from 'src/services/wallpaper';
const wallpaperService = WallpaperService.getInstance();
const { EXISTS, IS_REGULAR } = GLib.FileTest;
const { enable: enableWallpaper, image } = options.wallpaper;
/**
* Sets the system wallpaper to the specified image file
*
* @param filePath - The absolute path to the wallpaper image file
* @throws Error if the file doesn't exist or is not a regular file
* @throws Error if setting the wallpaper fails
*/
export function setWallpaper(filePath: string): void {
if (!(GLib.file_test(filePath, EXISTS) && GLib.file_test(filePath, IS_REGULAR))) {
throw new Error('The input file is not a valid wallpaper.');
}
image.set(filePath);
if (!enableWallpaper.get()) {
return;
}
try {
wallpaperService.setWallpaper(filePath);
} catch (error) {
if (error instanceof Error) {
throw new Error(error.message);
} else {
throw new Error(`An error occurred while setting the wallpaper: ${error}`);
}
}
}

View File

@@ -0,0 +1,79 @@
import { App } from 'astal/gtk3';
/**
* Manages Astral's application windows, providing centralized control over visibility
* and state management for all UI windows in the HyprPanel system
*/
export class WindowService {
/**
* Determines whether a given window is currently displayed to the user
*
* @param windowName - The name identifier of the window to check
* @returns Whether the window is currently visible
*/
public isWindowVisible(windowName: string): boolean {
return this._isWindowVisible(windowName);
}
/**
* Makes a window visible on screen, typically called when user interaction
* requires displaying a menu or dialog
*
* @param windowName - The name identifier of the window to show
*/
public showWindow(windowName: string): void {
return this._showWindow(windowName);
}
/**
* Removes a window from display while preserving its state for later
* presentation, commonly used for menus that need to retain their data
*
* @param windowName - The name identifier of the window to hide
*/
public hideWindow(windowName: string): void {
return this._hideWindow(windowName);
}
/**
* Swaps window visibility state based on current display status, useful for
* toggle buttons or keyboard shortcuts that control window appearance
*
* @param windowName - The name identifier of the window to toggle
*/
public toggleWindow(windowName: string): void {
if (this._isWindowVisible(windowName)) {
this._hideWindow(windowName);
} else {
this._showWindow(windowName);
}
}
private _isWindowVisible(windowName: string): boolean {
const appWindow = App.get_window(windowName);
if (appWindow === undefined || appWindow === null) {
throw new Error(`Window with name "${windowName}" not found.`);
}
return appWindow.visible;
}
private _showWindow(windowName: string): void {
const window = App.get_window(windowName);
if (!window) {
throw new Error(`Window with name "${windowName}" not found.`);
}
window.show();
}
private _hideWindow(windowName: string): void {
const window = App.get_window(windowName);
if (!window) {
throw new Error(`Window with name "${windowName}" not found.`);
}
window.hide();
}
}
export const windowService = new WindowService();

View File

@@ -0,0 +1,152 @@
import { bind, Variable } from 'astal';
import { App } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import options from 'src/configuration';
import { BarVisibility } from '.';
import { WorkspaceService } from 'src/services/workspace';
/**
* Service that manages auto-hide behavior for bars across monitors
*/
export class BarAutoHideService {
private static _instance: BarAutoHideService;
private _workspaceService = WorkspaceService.getInstance();
private _hyprlandService = AstalHyprland.get_default();
private _autoHide = options.bar.autoHide;
private _subscriptions: {
workspace: Variable<void> | undefined;
client: Variable<void> | undefined;
autoHide: Variable<void> | undefined;
} = {
workspace: undefined,
client: undefined,
autoHide: undefined,
};
private constructor() {}
/**
* Gets the singleton instance of the BarAutoHideService
*/
public static getInstance(): BarAutoHideService {
if (!this._instance) {
this._instance = new BarAutoHideService();
}
return this._instance;
}
/**
* Initializes the auto-hide behavior for bars
* Manages visibility based on window count, fullscreen state, and user preferences
*/
public initialize(): void {
this.destroy();
this._subscriptions.workspace = Variable.derive(
[
bind(this._autoHide),
bind(this._hyprlandService, 'workspaces'),
bind(this._workspaceService.forceUpdater),
bind(this._hyprlandService, 'focusedWorkspace'),
],
(hideMode) => {
if (hideMode === 'never') {
this._showAllBars();
} else if (hideMode === 'single-window') {
this._updateBarVisibilityByWindowCount();
}
},
);
this._subscriptions.client = Variable.derive(
[bind(this._hyprlandService, 'focusedClient')],
(currentClient) => {
this._handleFullscreenClientVisibility(currentClient);
},
);
this._subscriptions.autoHide = Variable.derive([bind(this._autoHide)], (hideMode) => {
if (hideMode === 'fullscreen') {
this._updateBarVisibilityByFullscreen();
}
});
}
/**
* Cleanup subscriptions and reset state
*/
public destroy(): void {
Object.values(this._subscriptions).forEach((sub) => sub?.drop());
}
/**
* Sets bar visibility for a specific monitor
*
* @param monitorId - The ID of the monitor whose bar visibility to set
* @param isVisible - Whether the bar should be visible
*/
private _setBarVisibility(monitorId: number, isVisible: boolean): void {
const barName = `bar-${monitorId}`;
if (BarVisibility.get(barName)) {
App.get_window(barName)?.set_visible(isVisible);
}
}
/**
* Handles bar visibility when a client's fullscreen state changes
*
* @param client - The Hyprland client whose fullscreen state to monitor
*/
private _handleFullscreenClientVisibility(client: AstalHyprland.Client): void {
if (client === null) {
return;
}
const fullscreenBinding = bind(client, 'fullscreen');
Variable.derive([bind(fullscreenBinding)], (isFullScreen) => {
if (this._autoHide.get() === 'fullscreen') {
this._setBarVisibility(client.monitor.id, !Boolean(isFullScreen));
}
});
}
/**
* Shows bars on all monitors
*/
private _showAllBars(): void {
const monitors = this._hyprlandService.get_monitors();
monitors.forEach((monitor) => {
if (BarVisibility.get(`bar-${monitor.id}`)) {
this._setBarVisibility(monitor.id, true);
}
});
}
/**
* Updates bar visibility based on workspace window count
*/
private _updateBarVisibilityByWindowCount(): void {
const monitors = this._hyprlandService.get_monitors();
const activeWorkspaces = monitors.map((monitor) => monitor.active_workspace);
activeWorkspaces.forEach((workspace) => {
const hasOneClient = workspace.get_clients().length !== 1;
this._setBarVisibility(workspace.monitor.id, hasOneClient);
});
}
/**
* Updates bar visibility based on workspace fullscreen state
*/
private _updateBarVisibilityByFullscreen(): void {
this._hyprlandService.get_workspaces().forEach((workspace) => {
this._setBarVisibility(workspace.monitor.id, !workspace.hasFullscreen);
});
}
}

View File

@@ -0,0 +1,28 @@
import { BarToggleStates } from './types';
/**
* Service that manages the visibility state of bars across different monitors
*/
export class BarVisibility {
private static _toggleStates: BarToggleStates = {};
/**
* Gets the visibility state of a specific bar
*
* @param barName - The name identifier of the bar
* @returns Whether the bar is visible (defaults to true if not set)
*/
public static get(barName: string): boolean {
return this._toggleStates[barName] ?? true;
}
/**
* Sets the visibility state of a specific bar
*
* @param barName - The name identifier of the bar
* @param isVisible - Whether the bar should be visible
*/
public static set(barName: string, isVisible: boolean): void {
this._toggleStates[barName] = isVisible;
}
}

View File

@@ -0,0 +1 @@
export type BarToggleStates = Record<string, boolean | undefined>;

View File

@@ -0,0 +1,268 @@
import { Gdk } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
const hyprlandService = AstalHyprland.get_default();
/**
* The MonitorMapper class encapsulates the conversion logic between GDK and Hyprland monitor IDs.
* It maintains internal state for monitors that have already been used so that duplicate assignments are avoided.
*/
export class GdkMonitorService {
private _usedGdkMonitors: Set<number>;
private _usedHyprlandMonitors: Set<number>;
constructor() {
this._usedGdkMonitors = new Set();
this._usedHyprlandMonitors = new Set();
}
/**
* Resets the internal state for both GDK and Hyprland monitor mappings.
*/
public reset(): void {
this._usedGdkMonitors.clear();
this._usedHyprlandMonitors.clear();
}
/**
* Converts a GDK monitor id to the corresponding Hyprland monitor id.
*
* @param monitor - The GDK monitor id.
* @returns The corresponding Hyprland monitor id.
*/
public mapGdkToHyprland(monitor: number): number {
const gdkMonitors = this._getGdkMonitors();
if (Object.keys(gdkMonitors).length === 0) {
return monitor;
}
const gdkMonitor = gdkMonitors[monitor];
const hyprlandMonitors = hyprlandService.get_monitors();
return this._matchMonitor(
hyprlandMonitors,
gdkMonitor,
monitor,
this._usedHyprlandMonitors,
(mon) => mon.id,
(mon, gdkMon) => this._matchMonitorKey(mon, gdkMon),
);
}
/**
* Converts a Hyprland monitor id to the corresponding GDK monitor id.
*
* @param monitor - The Hyprland monitor id.
* @returns The corresponding GDK monitor id.
*/
public mapHyprlandToGdk(monitor: number): number {
const gdkMonitors = this._getGdkMonitors();
const gdkCandidates = Object.entries(gdkMonitors).map(([monitorId, monitorMetadata]) => ({
id: Number(monitorId),
monitor: monitorMetadata,
}));
if (gdkCandidates.length === 0) {
return monitor;
}
const hyprlandMonitors = hyprlandService.get_monitors();
const foundHyprlandMonitor =
hyprlandMonitors.find((mon) => mon.id === monitor) || hyprlandMonitors[0];
return this._matchMonitor(
gdkCandidates,
foundHyprlandMonitor,
monitor,
this._usedGdkMonitors,
(candidate) => candidate.id,
(candidate, hyprlandMonitor) => this._matchMonitorKey(hyprlandMonitor, candidate.monitor),
);
}
/**
* Generic helper that finds the best matching candidate monitor based on:
* 1. A direct match (candidate matches the source and has the same id as the target).
* 2. A relaxed match (candidate matches the source, regardless of id).
* 3. A fallback match (first candidate that hasnt been used).
*
* @param candidates - Array of candidate monitors.
* @param source - The source monitor object to match against.
* @param target - The desired monitor id.
* @param usedMonitors - A Set of already used candidate ids.
* @param getId - Function to extract the id from a candidate.
* @param compare - Function that determines if a candidate matches the source.
* @returns The chosen monitor id.
*/
private _matchMonitor<T, U>(
candidates: T[],
source: U,
target: number,
usedMonitors: Set<number>,
getId: (candidate: T) => number,
compare: (candidate: T, source: U) => boolean,
): number {
// Direct match: candidate matches the source and has the same id as the target.
const directMatch = candidates.find(
(candidate) =>
compare(candidate, source) &&
!usedMonitors.has(getId(candidate)) &&
getId(candidate) === target,
);
if (directMatch !== undefined) {
usedMonitors.add(getId(directMatch));
return getId(directMatch);
}
// Relaxed match: candidate matches the source regardless of id.
const relaxedMatch = candidates.find(
(candidate) => compare(candidate, source) && !usedMonitors.has(getId(candidate)),
);
if (relaxedMatch !== undefined) {
usedMonitors.add(getId(relaxedMatch));
return getId(relaxedMatch);
}
// Fallback: use the first candidate that hasn't been used.
const fallback = candidates.find((candidate) => !usedMonitors.has(getId(candidate)));
if (fallback !== undefined) {
usedMonitors.add(getId(fallback));
return getId(fallback);
}
// As a last resort, iterate over candidates.
for (const candidate of candidates) {
const candidateId = getId(candidate);
if (!usedMonitors.has(candidateId)) {
usedMonitors.add(candidateId);
return candidateId;
}
}
console.warn(`Returning original monitor index as a last resort: ${target}`);
return target;
}
/**
* Determines if a Hyprland monitor matches a GDK monitor by comparing their keys
*
* @param hyprlandMonitor - Hyprland monitor object
* @param gdkMonitor - GDK monitor object
* @returns boolean indicating if the monitors match
*/
private _matchMonitorKey(hyprlandMonitor: AstalHyprland.Monitor, gdkMonitor: GdkMonitor): boolean {
const isRotated90 = hyprlandMonitor.transform % 2 !== 0;
const gdkScaleFactor = Math.ceil(hyprlandMonitor.scale);
const scaleFactorWidth = Math.trunc(hyprlandMonitor.width / gdkScaleFactor);
const scaleFactorHeight = Math.trunc(hyprlandMonitor.height / gdkScaleFactor);
const gdkScaleFactorKey = `${hyprlandMonitor.model}_${scaleFactorWidth}x${scaleFactorHeight}_${gdkScaleFactor}`;
const transWidth = isRotated90 ? hyprlandMonitor.height : hyprlandMonitor.width;
const transHeight = isRotated90 ? hyprlandMonitor.width : hyprlandMonitor.height;
const scaleWidth = Math.trunc(transWidth / hyprlandMonitor.scale);
const scaleHeight = Math.trunc(transHeight / hyprlandMonitor.scale);
const hyprlandScaleFactorKey = `${hyprlandMonitor.model}_${scaleWidth}x${scaleHeight}_${gdkScaleFactor}`;
const keyMatch = gdkMonitor.key === gdkScaleFactorKey || gdkMonitor.key === hyprlandScaleFactorKey;
this._logMonitorInfo(
gdkMonitor,
hyprlandMonitor,
isRotated90,
gdkScaleFactor,
gdkScaleFactorKey,
hyprlandScaleFactorKey,
keyMatch,
);
return keyMatch;
}
/**
* Retrieves all GDK monitors from the default display
*
* @returns Object containing GDK monitor information indexed by monitor ID
*/
private _getGdkMonitors(): GdkMonitors {
const display = Gdk.Display.get_default();
if (display === null) {
console.error('Failed to get Gdk display.');
return {};
}
const numGdkMonitors = display.get_n_monitors();
const gdkMonitors: GdkMonitors = {};
for (let i = 0; i < numGdkMonitors; i++) {
const curMonitor = display.get_monitor(i);
if (curMonitor === null) {
console.warn(`Monitor at index ${i} is null.`);
continue;
}
const model = curMonitor.get_model() ?? '';
const geometry = curMonitor.get_geometry();
const scaleFactor = curMonitor.get_scale_factor();
// GDK3 only supports integer scale factors
const key = `${model}_${geometry.width}x${geometry.height}_${scaleFactor}`;
gdkMonitors[i] = { key, model, used: false };
}
return gdkMonitors;
}
/**
* Logs detailed monitor information for debugging purposes
* @param gdkMonitor - GDK monitor object
* @param hyprlandMonitor - Hyprland monitor information
* @param isRotated90 - Whether the monitor is rotated 90 degrees
* @param gdkScaleFactor - The GDK monitor's scale factor
* @param gdkScaleFactorKey - Key used for scale factor matching
* @param hyprlandScaleFactorKey - Key used for general scale matching
* @param keyMatch - Whether the monitor keys match
*/
private _logMonitorInfo(
gdkMonitor: GdkMonitor,
hyprlandMonitor: AstalHyprland.Monitor,
isRotated90: boolean,
gdkScaleFactor: number,
gdkScaleFactorKey: string,
hyprlandScaleFactorKey: string,
keyMatch: boolean,
): void {
console.debug('=== Monitor Matching Debug Info ===');
console.debug('GDK Monitor');
console.debug(` Key: ${gdkMonitor.key}`);
console.debug('Hyprland Monitor');
console.debug(` ID: ${hyprlandMonitor.id}`);
console.debug(` Model: ${hyprlandMonitor.model}`);
console.debug(` Resolution: ${hyprlandMonitor.width}x${hyprlandMonitor.height}`);
console.debug(` Scale: ${hyprlandMonitor.scale}`);
console.debug(` Transform: ${hyprlandMonitor.transform}`);
console.debug('Calculated Values');
console.debug(` Rotation: ${isRotated90 ? '90°' : '0°'}`);
console.debug(` GDK Scale Factor: ${gdkScaleFactor}`);
console.debug('Calculated Keys');
console.debug(` GDK Scale Factor Key: ${gdkScaleFactorKey}`);
console.debug(` Hyprland Scale Factor Key: ${hyprlandScaleFactorKey}`);
console.debug('Match Result');
console.debug(` ${keyMatch ? '✅ Monitors Match' : '❌ No Match'}`);
console.debug('===============================\n');
}
}
type GdkMonitor = {
key: string;
model: string;
used: boolean;
};
type GdkMonitors = {
[key: string]: GdkMonitor;
};

View File

@@ -0,0 +1,60 @@
export const defaultColorMap = {
rosewater: '#f5e0dc',
flamingo: '#f2cdcd',
pink: '#f5c2e7',
mauve: '#cba6f7',
red: '#f38ba8',
maroon: '#eba0ac',
peach: '#fab387',
yellow: '#f9e2af',
green: '#a6e3a1',
teal: '#94e2d5',
sky: '#89dceb',
sapphire: '#74c7ec',
blue: '#89b4fa',
lavender: '#b4befe',
text: '#cdd6f4',
subtext1: '#bac2de',
subtext2: '#a6adc8',
overlay2: '#9399b2',
overlay1: '#7f849c',
overlay0: '#6c7086',
surface2: '#585b70',
surface1: '#45475a',
surface0: '#313244',
base2: '#242438',
base: '#1e1e2e',
mantle: '#181825',
crust: '#11111b',
surface1_2: '#454759',
text2: '#cdd6f3',
pink2: '#f5c2e6',
red2: '#f38ba7',
peach2: '#fab386',
mantle2: '#181824',
surface0_2: '#313243',
surface2_2: '#585b69',
overlay1_2: '#7f849b',
lavender2: '#b4befd',
mauve2: '#cba6f6',
green2: '#a6e3a0',
sky2: '#89dcea',
teal2: '#94e2d4',
yellow2: '#f9e2ad',
maroon2: '#eba0ab',
crust2: '#11111a',
pink3: '#f5c2e8',
red3: '#f38ba9',
mantle3: '#181826',
surface0_3: '#313245',
surface2_3: '#585b71',
overlay1_3: '#7f849d',
lavender3: '#b4beff',
mauve3: '#cba6f8',
green3: '#a6e3a2',
sky3: '#89dcec',
teal3: '#94e2d6',
yellow3: '#f9e2ae',
maroon3: '#eba0ad',
crust3: '#11111c',
} as const;

View File

@@ -1,58 +1,59 @@
import { ColorMapKey, HexColor, MatugenColors } from '../../lib/options/options.types';
import { ColorMapKey, HexColor, MatugenColors } from '../../lib/options/types';
import { getMatugenVariations } from './variations';
import { bash, dependencies, Notify, isAnImage } from '../../lib/utils';
import options from '../../options';
import icons from '../../lib/icons/icons';
import { defaultColorMap } from 'src/lib/types/defaults/options.types';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
import options from 'src/configuration';
import { isAnImage } from 'src/lib/validation/images';
import { defaultColorMap } from './defaults';
const MATUGEN_ENABLED = options.theme.matugen;
const MATUGEN_SETTINGS = options.theme.matugen_settings;
interface SystemDependencies {
checkDependencies(dep: string): boolean;
executeCommand(cmd: string): Promise<string>;
notify(notification: { summary: string; body: string; iconName: string }): void;
isValidImage(path: string): boolean;
}
/**
* Service that integrates with Matugen to generate color schemes from wallpapers
*/
export class MatugenService {
private static _instance: MatugenService;
class DefaultSystemDependencies implements SystemDependencies {
public checkDependencies(dep: string): boolean {
return dependencies(dep);
}
public async executeCommand(cmd: string): Promise<string> {
return bash(cmd);
}
public notify(notification: { summary: string; body: string; iconName: string }): void {
Notify(notification);
}
public isValidImage(path: string): boolean {
return isAnImage(path);
}
}
class MatugenService {
private _deps: SystemDependencies;
constructor(deps: SystemDependencies = new DefaultSystemDependencies()) {
this._deps = deps;
private constructor() {}
/**
* Gets the singleton instance of the MatugenService
*
* @returns The MatugenService instance
*/
public static getInstance(): MatugenService {
if (this._instance === undefined) {
this._instance = new MatugenService();
}
return this._instance;
}
/**
* Normalizes contrast value to be within Matugen's acceptable range
*
* @param contrast - The raw contrast value
* @returns Normalized contrast value between -1 and 1
*/
private _normalizeContrast(contrast: number): number {
return Math.max(-1, Math.min(1, contrast));
}
/**
* Generates a color scheme from the current wallpaper using Matugen
*
* @returns The generated color palette or undefined if generation fails
*/
public async generateMatugenColors(): Promise<MatugenColors | undefined> {
if (!MATUGEN_ENABLED.get() || !this._deps.checkDependencies('matugen')) {
if (!MATUGEN_ENABLED.get() || !SystemUtilities.checkDependencies('matugen')) {
return;
}
const wallpaperPath = options.wallpaper.image.get();
if (!wallpaperPath || !this._deps.isValidImage(wallpaperPath)) {
this._deps.notify({
if (!wallpaperPath || !isAnImage(wallpaperPath)) {
SystemUtilities.notify({
summary: 'Matugen Failed',
body: "Please select a wallpaper in 'Theming > General' first.",
iconName: icons.ui.warning,
@@ -68,13 +69,13 @@ class MatugenService {
const baseCommand = `matugen image -q "${wallpaperPath}" -t scheme-${schemeType} --contrast ${normalizedContrast}`;
const jsonResult = await this._deps.executeCommand(`${baseCommand} --dry-run --json hex`);
await this._deps.executeCommand(baseCommand);
const jsonResult = await SystemUtilities.bash(`${baseCommand} --dry-run --json hex`);
await SystemUtilities.bash(baseCommand);
const parsedResult = JSON.parse(jsonResult);
return parsedResult?.colors?.[mode];
} catch (error) {
this._deps.notify({
SystemUtilities.notify({
summary: 'Matugen Error',
body: `An error occurred: ${error}`,
iconName: icons.ui.info,
@@ -84,10 +85,23 @@ class MatugenService {
}
}
/**
* Validates if a color string is a valid key in the default color map
*
* @param color - The color key to validate
* @returns Whether the color is a valid ColorMapKey
*/
public isColorKeyValid(color: string): color is ColorMapKey {
return Object.prototype.hasOwnProperty.call(defaultColorMap, color);
}
/**
* Maps a default color hex value to its Matugen-generated equivalent
*
* @param incomingHex - The original hex color to map
* @param matugenColors - The Matugen color palette to use for mapping
* @returns The mapped hex color or original if no mapping exists
*/
public getMatugenHex(incomingHex: HexColor, matugenColors?: MatugenColors): HexColor {
if (!MATUGEN_ENABLED.get() || !matugenColors) {
return incomingHex;
@@ -110,7 +124,3 @@ class MatugenService {
return incomingHex;
}
}
const matugenService = new MatugenService();
export { matugenService };

View File

@@ -1,10 +1,17 @@
import { MatugenColors, MatugenVariations, MatugenVariation } from 'src/lib/options/options.types';
import { MatugenColors, MatugenVariations, MatugenVariation } from 'src/lib/options/types';
/*
* NOTE: This maps the values of the default colors to the values generated by Matugen.
* Each of the variations are carefully tested and curated to make sure that colors don't
* have weird luminocity overlaps (light on light, dark on dark).
*/
/**
* Maps Matugen color palette to predefined color variations for theme consistency
*
* @param matugenColors - The Matugen-generated color palette
* @param variation - The specific variation style to apply
* @returns Mapped color variation object
*/
export const getMatugenVariations = (
matugenColors: MatugenColors,
variation: MatugenVariations,

526
src/services/media/index.ts Normal file
View File

@@ -0,0 +1,526 @@
import AstalMpris from 'gi://AstalMpris?version=0.1';
import { bind, Variable } from 'astal';
import { getTimeStamp } from 'src/components/menus/media/components/timebar/helpers';
import { CurrentPlayer, MediaSubscriptionNames, MediaSubscriptions } from './types';
import options from 'src/configuration';
/**
* MediaManager handles media player state management across the application
*
* This class provides a centralized way to track and interact with media players.
* It handles connection/disconnection events, manages state variables, and keeps
* media information synchronized across the UI.
*
* Since Astal doesn't provide an intuitive way to bind to dynamically changing media
* players' properties, we have to handle that ourselves. This class will provide a collection
* of useful bindings to display the media info of the current media player.
*/
export class MediaPlayerService {
private static _instance: MediaPlayerService;
public activePlayer: Variable<CurrentPlayer> = Variable(undefined);
public timeStamp: Variable<string> = Variable('00:00');
public currentPosition: Variable<number> = Variable(0);
public loopStatus: Variable<AstalMpris.Loop> = Variable(AstalMpris.Loop.NONE);
public shuffleStatus: Variable<AstalMpris.Shuffle> = Variable(AstalMpris.Shuffle.OFF);
public playbackStatus: Variable<AstalMpris.PlaybackStatus> = Variable(AstalMpris.PlaybackStatus.STOPPED);
public canPlay: Variable<boolean> = Variable(false);
public canGoNext: Variable<boolean> = Variable(false);
public canGoPrevious: Variable<boolean> = Variable(false);
public mediaTitle: Variable<string> = Variable('');
public mediaAlbum: Variable<string> = Variable('-----');
public mediaArtist: Variable<string> = Variable('-----');
public mediaArtUrl: Variable<string> = Variable('');
private _mprisService: AstalMpris.Mpris;
private _subscriptions: MediaSubscriptions = {
position: undefined,
loop: undefined,
shuffle: undefined,
canPlay: undefined,
playbackStatus: undefined,
canGoNext: undefined,
canGoPrevious: undefined,
title: undefined,
album: undefined,
artist: undefined,
artUrl: undefined,
};
private constructor() {
this._mprisService = AstalMpris.get_default();
const { noMediaText } = options.menus.media;
this.mediaTitle.set(noMediaText.get());
this._mprisService.connect('player-closed', (_, closedPlayer) =>
this._handlePlayerClosed(closedPlayer),
);
this._mprisService.connect('player-added', (_, addedPlayer) => this._handlePlayerAdded(addedPlayer));
Variable.derive([bind(this.activePlayer)], (player) => {
this._updateAllMediaProperties(player);
});
}
public static getInstance(): MediaPlayerService {
if (this._instance === undefined) {
this._instance = new MediaPlayerService();
}
return this._instance;
}
/**
* Handles a new player being added
*
* Sets the new player as active if no player is currently active.
*
* @param addedPlayer The player that was added
*/
private _handlePlayerAdded(addedPlayer: AstalMpris.Player): void {
if (this.activePlayer.get() === undefined) {
this.activePlayer.set(addedPlayer);
}
}
/**
* Handles a player being closed
*
* Switches to another player if available or clears the active player
* when the current player is closed.
*
* @param closedPlayer The player that was closed
*/
private _handlePlayerClosed(closedPlayer: AstalMpris.Player): void {
if (
this._mprisService.get_players().length === 1 &&
closedPlayer.busName === this._mprisService.get_players()[0]?.busName
) {
return this.activePlayer.set(undefined);
}
if (closedPlayer.busName === this.activePlayer.get()?.busName) {
const nextPlayer = this._mprisService
.get_players()
.find((player) => player.busName !== closedPlayer.busName);
this.activePlayer.set(nextPlayer);
}
}
/**
* Updates all media properties based on the current player
*
* This synchronizes all state variables with the current media player's state.
*
* @param player The current media player
*/
private _updateAllMediaProperties(player: CurrentPlayer): void {
this._updatePosition(player);
this._updateLoop(player);
this._updateShuffle(player);
this._updatePlaybackStatus(player);
this._updateCanPlay(player);
this._updateCanGoNext(player);
this._updateCanGoPrevious(player);
this._updateTitle(player);
this._updateAlbum(player);
this._updateArtist(player);
this._updateArtUrl(player);
}
/**
* Updates the current playback position
*
* Tracks both the numeric position and formatted timestamp.
*
* @param player The current media player
*/
private _updatePosition(player: CurrentPlayer): void {
this._resetSubscription('position');
if (player === undefined) {
this.timeStamp.set('00:00');
this.currentPosition.set(0);
return;
}
const positionBinding = bind(player, 'position');
this._subscriptions.position = Variable.derive(
[bind(positionBinding), bind(player, 'playbackStatus')],
(pos) => {
if (player?.length > 0) {
this.timeStamp.set(getTimeStamp(pos, player.length));
this.currentPosition.set(pos);
} else {
this.timeStamp.set('00:00');
this.currentPosition.set(0);
}
},
);
const initialPos = positionBinding.get();
this.timeStamp.set(getTimeStamp(initialPos, player.length));
this.currentPosition.set(initialPos);
}
/**
* Updates the loop status for the current player
*
* Tracks whether playback loops none, track, or playlist.
*
* @param player The current media player
*/
private _updateLoop(player: CurrentPlayer): void {
this._resetSubscription('loop');
if (player === undefined) {
this.loopStatus.set(AstalMpris.Loop.NONE);
return;
}
const loopBinding = bind(player, 'loopStatus');
this._subscriptions.loop = Variable.derive(
[bind(loopBinding), bind(player, 'playbackStatus')],
(status) => {
if (player?.length > 0) {
this.loopStatus.set(status);
} else {
this.loopStatus.set(AstalMpris.Loop.NONE);
}
},
);
this.loopStatus.set(loopBinding.get());
}
/**
* Updates the shuffle status for the current player
*
* Tracks whether playback order is shuffled.
*
* @param player The current media player
*/
private _updateShuffle(player: CurrentPlayer): void {
this._resetSubscription('shuffle');
if (player === undefined) {
this.shuffleStatus.set(AstalMpris.Shuffle.OFF);
return;
}
const shuffleBinding = bind(player, 'shuffleStatus');
this._subscriptions.shuffle = Variable.derive(
[bind(shuffleBinding), bind(player, 'playbackStatus')],
(status) => {
this.shuffleStatus.set(status ?? AstalMpris.Shuffle.OFF);
},
);
this.shuffleStatus.set(shuffleBinding.get());
}
/**
* Updates whether playback is possible with current player
*
* Used to enable/disable playback controls.
*
* @param player The current media player
*/
private _updateCanPlay(player: CurrentPlayer): void {
this._resetSubscription('canPlay');
if (player === undefined) {
this.canPlay.set(false);
return;
}
const canPlayBinding = bind(player, 'canPlay');
this._subscriptions.canPlay = Variable.derive(
[canPlayBinding, bind(player, 'playbackStatus')],
(playable) => {
this.canPlay.set(playable ?? false);
},
);
this.canPlay.set(player.canPlay);
}
/**
* Updates the playback status (playing, paused, stopped)
*
* Used to show the correct playback status and control state.
*
* @param player The current media player
*/
private _updatePlaybackStatus(player: CurrentPlayer): void {
this._resetSubscription('playbackStatus');
if (player === undefined) {
this.playbackStatus.set(AstalMpris.PlaybackStatus.STOPPED);
return;
}
const playbackStatusBinding = bind(player, 'playbackStatus');
this._subscriptions.playbackStatus = Variable.derive([playbackStatusBinding], (status) => {
this.playbackStatus.set(status ?? AstalMpris.PlaybackStatus.STOPPED);
});
this.playbackStatus.set(player.playbackStatus);
}
/**
* Updates whether the next track control is enabled
*
* Used to enable/disable skip forward controls.
*
* @param player The current media player
*/
private _updateCanGoNext(player: CurrentPlayer): void {
this._resetSubscription('canGoNext');
if (player === undefined) {
this.canGoNext.set(false);
return;
}
const canGoNextBinding = bind(player, 'canGoNext');
this._subscriptions.canGoNext = Variable.derive(
[canGoNextBinding, bind(player, 'playbackStatus')],
(canNext) => {
this.canGoNext.set(canNext ?? false);
},
);
this.canGoNext.set(player.canGoNext);
}
/**
* Updates whether the previous track control is enabled
*
* Used to enable/disable skip backward controls.
*
* @param player The current media player
*/
private _updateCanGoPrevious(player: CurrentPlayer): void {
this._resetSubscription('canGoPrevious');
if (player === undefined) {
this.canGoPrevious.set(false);
return;
}
const canGoPreviousBinding = bind(player, 'canGoPrevious');
this._subscriptions.canGoPrevious = Variable.derive(
[canGoPreviousBinding, bind(player, 'playbackStatus')],
(canPrev) => {
this.canGoPrevious.set(canPrev ?? false);
},
);
this.canGoPrevious.set(player.canGoPrevious);
}
/**
* Updates the media title display
*
* Shows title of current track or a placeholder when nothing is playing.
*
* @param player The current media player
*/
private _updateTitle(player: CurrentPlayer): void {
this._resetSubscription('title');
const { noMediaText } = options.menus.media;
if (player === undefined) {
this.mediaTitle.set(noMediaText.get());
return;
}
const titleBinding = bind(player, 'title');
this._subscriptions.title = Variable.derive(
[titleBinding, bind(player, 'playbackStatus')],
(newTitle, pbStatus) => {
if (pbStatus === AstalMpris.PlaybackStatus.STOPPED) {
return this.mediaTitle.set(noMediaText.get() ?? '-----');
}
this.mediaTitle.set(newTitle.length > 0 ? this._normalizeLabel(newTitle) : '-----');
},
);
const initialTitle = player.title;
this.mediaTitle.set(initialTitle.length > 0 ? this._normalizeLabel(initialTitle) : '-----');
}
/**
* Updates the album name display
*
* Shows album of current track or a placeholder when not available.
*
* @param player The current media player
*/
private _updateAlbum(player: CurrentPlayer): void {
this._resetSubscription('album');
if (player === undefined) {
this.mediaAlbum.set('-----');
return;
}
const albumBinding = bind(player, 'album');
this._subscriptions.album = Variable.derive(
[albumBinding, bind(player, 'playbackStatus')],
(newAlbum) => {
this.mediaAlbum.set(newAlbum?.length > 0 ? this._normalizeLabel(newAlbum) : '-----');
},
);
const initialAlbum = player.album;
this.mediaAlbum.set(initialAlbum?.length > 0 ? this._normalizeLabel(initialAlbum) : '-----');
}
/**
* Updates the artist name display
*
* Shows artist of current track or a placeholder when not available.
*
* @param player The current media player
*/
private _updateArtist(player: CurrentPlayer): void {
this._resetSubscription('artist');
if (player === undefined) {
this.mediaArtist.set('-----');
return;
}
const artistBinding = bind(player, 'artist');
this._subscriptions.artist = Variable.derive(
[artistBinding, bind(player, 'playbackStatus')],
(newArtist) => {
this.mediaArtist.set(newArtist?.length > 0 ? this._normalizeLabel(newArtist) : '-----');
},
);
const initialArtist = player.artist;
this.mediaArtist.set(initialArtist?.length > 0 ? this._normalizeLabel(initialArtist) : '-----');
}
/**
* Updates the album art URL
*
* Tracks the URL to the current album artwork if available.
*
* @param player The current media player
*/
private _updateArtUrl(player: CurrentPlayer): void {
this._resetSubscription('artUrl');
if (player === undefined) {
this.mediaArtUrl.set('');
return;
}
const artUrlBinding = bind(player, 'artUrl');
this._subscriptions.artUrl = Variable.derive(
[artUrlBinding, bind(player, 'playbackStatus')],
(newArtUrl) => {
this.mediaArtUrl.set(newArtUrl ?? '');
},
);
this.mediaArtUrl.set(player.artUrl ?? '');
}
/**
* Normalizes a label by removing newlines
*
* Ensures text displays properly in the UI by converting newlines to spaces.
*
* @param label The label to normalize
* @returns Normalized label string
*/
private _normalizeLabel(label: string): string {
return label.replace(/\r?\n/g, ' ');
}
/**
* Resets a subscription by dropping it and clearing its reference
*
* This helper method safely cleans up a specific subscription to prevent
* memory leaks and prepare for new subscription assignment. It's used
* when updating media properties to ensure proper cleanup of previous bindings.
*
* @param subscription - The key of the subscription to reset
*/
private _resetSubscription(subscription: MediaSubscriptionNames): void {
this._subscriptions[subscription]?.drop();
this._subscriptions[subscription] = undefined;
}
/**
* Cleans up all subscriptions and bindings
*
* Should be called when the media manager is no longer needed
* to prevent memory leaks.
*/
public dispose(): void {
Object.values(this._subscriptions).forEach((sub) => sub?.drop());
this.activePlayer.drop();
this.timeStamp.drop();
this.currentPosition.drop();
this.loopStatus.drop();
this.shuffleStatus.drop();
this.playbackStatus.drop();
this.canPlay.drop();
this.canGoNext.drop();
this.canGoPrevious.drop();
this.mediaTitle.drop();
this.mediaAlbum.drop();
this.mediaArtist.drop();
this.mediaArtUrl.drop();
}
}
const mediaPlayerManager = MediaPlayerService.getInstance();
export const {
activePlayer,
timeStamp,
currentPosition,
loopStatus,
shuffleStatus,
canPlay,
playbackStatus,
canGoNext,
canGoPrevious,
mediaTitle,
mediaAlbum,
mediaArtist,
mediaArtUrl,
} = mediaPlayerManager;

View File

@@ -0,0 +1,20 @@
import { Variable } from 'astal';
import AstalMpris from 'gi://AstalMpris?version=0.1';
export interface MediaSubscriptions {
position: Variable<void> | undefined;
loop: Variable<void> | undefined;
shuffle: Variable<void> | undefined;
canPlay: Variable<void> | undefined;
playbackStatus: Variable<void> | undefined;
canGoNext: Variable<void> | undefined;
canGoPrevious: Variable<void> | undefined;
title: Variable<void> | undefined;
album: Variable<void> | undefined;
artist: Variable<void> | undefined;
artUrl: Variable<void> | undefined;
}
export type MediaSubscriptionNames = keyof MediaSubscriptions;
export type CurrentPlayer = AstalMpris.Player | undefined;

View File

@@ -0,0 +1,102 @@
import { bind, Variable } from 'astal';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
/**
* EthernetManager handles ethernet-related functionality for dropdowns
*/
export class EthernetManager {
private _astalNetwork: AstalNetwork.Network;
public wiredState: Variable<AstalNetwork.DeviceState> = Variable(AstalNetwork.DeviceState.UNKNOWN);
public wiredInternet: Variable<AstalNetwork.Internet> = Variable(AstalNetwork.Internet.DISCONNECTED);
public wiredIcon: Variable<string> = Variable('');
public wiredSpeed: Variable<number> = Variable(0);
private _wiredStateBinding: Variable<void> | undefined;
private _wiredInternetBinding: Variable<void> | undefined;
private _wiredIconBinding: Variable<void> | undefined;
private _wiredSpeedBinding: Variable<void> | undefined;
constructor(networkService: AstalNetwork.Network) {
this._astalNetwork = networkService;
}
/**
* Called when the wired service changes to update bindings
*/
public onWiredServiceChanged(): void {
this._getWiredState();
this._getWiredInternet();
this._getWiredIcon();
this._getWiredSpeed();
}
/**
* Retrieves the current state of the wired network.
*/
private _getWiredState(): void {
this._wiredStateBinding?.drop();
this._wiredStateBinding = undefined;
if (this._astalNetwork.wired === null) {
this.wiredState.set(AstalNetwork.DeviceState.UNAVAILABLE);
return;
}
this._wiredStateBinding = Variable.derive([bind(this._astalNetwork.wired, 'state')], (state) => {
this.wiredState.set(state);
});
}
/**
* Retrieves the current internet status of the wired network.
*/
private _getWiredInternet(): void {
this._wiredInternetBinding?.drop();
this._wiredInternetBinding = undefined;
if (this._astalNetwork.wired === null) {
return;
}
this._wiredInternetBinding = Variable.derive(
[bind(this._astalNetwork.wired, 'internet')],
(internet) => {
this.wiredInternet.set(internet);
},
);
}
/**
* Retrieves the current icon for the wired network.
*/
private _getWiredIcon(): void {
this._wiredIconBinding?.drop();
this._wiredIconBinding = undefined;
if (this._astalNetwork.wired === null) {
this.wiredIcon.set('network-wired-symbolic');
return;
}
this._wiredIconBinding = Variable.derive([bind(this._astalNetwork.wired, 'iconName')], (icon) => {
this.wiredIcon.set(icon);
});
}
/**
* Retrieves the current speed of the wired network.
*/
private _getWiredSpeed(): void {
this._wiredSpeedBinding?.drop();
this._wiredSpeedBinding = undefined;
if (this._astalNetwork.wired === null) {
return;
}
this._wiredSpeedBinding = Variable.derive([bind(this._astalNetwork.wired, 'speed')], (speed) => {
this.wiredSpeed.set(speed);
});
}
}

View File

@@ -0,0 +1,70 @@
import { bind, Variable } from 'astal';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import { WifiManager } from './wifi';
import { EthernetManager } from './ethernet';
import { WifiIcon, wifiIconMap } from './types';
/**
* NetworkService consolidates all network-related functionality from various components
* into a single service for better organization and maintainability.
*/
export class NetworkService {
private static _instance: NetworkService;
private _astalNetwork: AstalNetwork.Network;
public wifi: WifiManager;
public ethernet: EthernetManager;
private constructor() {
this._astalNetwork = AstalNetwork.get_default();
this.wifi = new WifiManager(this._astalNetwork);
this.ethernet = new EthernetManager(this._astalNetwork);
this._setupBindings();
}
/**
* Gets the singleton instance of NetworkService
*
* @returns The NetworkService instance
*/
public static getInstance(): NetworkService {
if (!this._instance) {
this._instance = new NetworkService();
}
return this._instance;
}
/**
* Sets up bindings to monitor network service changes
*/
private _setupBindings(): void {
Variable.derive([bind(this._astalNetwork, 'wifi')], () => {
this.wifi.onWifiServiceChanged();
});
Variable.derive([bind(this._astalNetwork, 'wired')], () => {
this.ethernet.onWiredServiceChanged();
});
}
/**
* Retrieves the appropriate WiFi icon based on the provided icon name.
*
* @param iconName - The name of the icon to look up. If not provided, a default icon is returned.
* @returns The corresponding WiFi icon as a string.
*/
public getWifiIcon(iconName?: string): WifiIcon {
if (iconName === undefined) {
return '󰤫';
}
const wifiIcon = wifiIconMap.get(iconName.toLowerCase());
if (wifiIcon) {
return wifiIcon;
}
return '󰤨';
}
}

View File

@@ -0,0 +1,43 @@
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
export type WifiIcon = '󰤩' | '󰤨' | '󰤪' | '󰤨' | '󰤩' | '󰤮' | '󰤨' | '󰤥' | '󰤢' | '󰤟' | '󰤯' | '󰤫';
type DeviceSate = AstalNetwork.DeviceState;
type DevceStates = {
[key in DeviceSate]: string;
};
export const DEVICE_STATES: DevceStates = {
[AstalNetwork.DeviceState.UNKNOWN]: 'Unknown',
[AstalNetwork.DeviceState.UNMANAGED]: 'Unmanaged',
[AstalNetwork.DeviceState.UNAVAILABLE]: 'Unavailable',
[AstalNetwork.DeviceState.DISCONNECTED]: 'Disconnected',
[AstalNetwork.DeviceState.PREPARE]: 'Prepare',
[AstalNetwork.DeviceState.CONFIG]: 'Config',
[AstalNetwork.DeviceState.NEED_AUTH]: 'Need Authentication',
[AstalNetwork.DeviceState.IP_CONFIG]: 'IP Configuration',
[AstalNetwork.DeviceState.IP_CHECK]: 'IP Check',
[AstalNetwork.DeviceState.SECONDARIES]: 'Secondaries',
[AstalNetwork.DeviceState.ACTIVATED]: 'Activated',
[AstalNetwork.DeviceState.DEACTIVATING]: 'Deactivating',
[AstalNetwork.DeviceState.FAILED]: 'Failed',
} as const;
export const wifiIconMap = new Map<string, WifiIcon>([
['network-wireless-acquiring', '󰤩'],
['network-wireless-connected', '󰤨'],
['network-wireless-encrypted', '󰤪'],
['network-wireless-hotspot', '󰤨'],
['network-wireless-no-route', '󰤩'],
['network-wireless-offline', '󰤮'],
['network-wireless-signal-excellent', '󰤨'],
['network-wireless-signal-good', '󰤥'],
['network-wireless-signal-ok', '󰤢'],
['network-wireless-signal-weak', '󰤟'],
['network-wireless-signal-none', '󰤯'],
]);
export const AP_FLAGS = {
NONE: 0,
PRIVACY: 1,
} as const;

View File

@@ -0,0 +1,351 @@
import { bind, execAsync, Variable } from 'astal';
import { Astal } from 'astal/gtk3';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
import { isPrimaryClick } from 'src/lib/events/mouse';
import { AP_FLAGS, DEVICE_STATES } from './types';
/**
* WifiManager handles all WiFi-related functionality for staging and connecting to
* wireless networks
*/
export class WifiManager {
private _astalNetwork: AstalNetwork.Network;
public isWifiEnabled: Variable<boolean> = Variable(false);
public isScanning: Variable<boolean> = Variable(false);
public wifiAccessPoints: Variable<AstalNetwork.AccessPoint[]> = Variable([]);
public staging = Variable<AstalNetwork.AccessPoint | undefined>(undefined);
public connecting = Variable<string>('');
private _wifiEnabledBinding: Variable<void> | undefined;
private _scanningBinding: Variable<void> | undefined;
private _accessPointBinding: Variable<void> | undefined;
constructor(networkService: AstalNetwork.Network) {
this._astalNetwork = networkService;
}
/**
* Called when the WiFi service changes to update bindings
*/
public onWifiServiceChanged(): void {
this._wifiEnabled();
this._scanningStatus();
this._accessPoints();
}
/**
* Checks if WiFi is enabled and updates the `isWifiEnabled` variable.
*/
private _wifiEnabled(): void {
this._wifiEnabledBinding?.drop();
this._wifiEnabledBinding = undefined;
if (this._astalNetwork.wifi === null) {
return;
}
this._wifiEnabledBinding = Variable.derive(
[bind(this._astalNetwork.wifi, 'enabled')],
(isEnabled) => {
this.isWifiEnabled.set(isEnabled);
},
);
}
/**
* Updates the WiFi scanning status.
*/
private _scanningStatus(): void {
this._scanningBinding?.drop();
this._scanningBinding = undefined;
if (this._astalNetwork.wifi === null) {
return;
}
this._scanningBinding = Variable.derive([bind(this._astalNetwork.wifi, 'scanning')], (scanning) => {
this.isScanning.set(scanning);
});
}
/**
* Updates the list of WiFi access points.
*/
private _accessPoints(): void {
this._accessPointBinding?.drop();
this._accessPointBinding = undefined;
if (this._astalNetwork.wifi === null) {
return;
}
Variable.derive([bind(this._astalNetwork.wifi, 'accessPoints')], (axsPoints) => {
this.wifiAccessPoints.set(axsPoints);
});
}
/**
* Removes duplicate access points based on their SSID.
*
* @returns An array of deduplicated access points.
*/
private _dedupeWAPs(): AstalNetwork.AccessPoint[] {
if (this._astalNetwork.wifi === null) {
return [];
}
const WAPs = this._astalNetwork.wifi.get_access_points();
const dedupMap: Record<string, AstalNetwork.AccessPoint> = {};
WAPs.forEach((item: AstalNetwork.AccessPoint) => {
if (item.ssid !== null && !Object.prototype.hasOwnProperty.call(dedupMap, item.ssid)) {
dedupMap[item.ssid] = item;
}
});
return Object.keys(dedupMap).map((itm) => dedupMap[itm]);
}
/**
* Determines if a given access point is currently in the staging area.
*
* @param wap - The access point to check.
* @returns True if the access point is in staging; otherwise, false.
*/
private _isInStaging(wap: AstalNetwork.AccessPoint): boolean {
const wapInStaging = this.staging.get();
if (wapInStaging === undefined) {
return false;
}
return wap.bssid === wapInStaging.bssid;
}
/**
* Retrieves a list of filtered wireless access points by removing duplicates and excluding specific entries.
*
* @returns A filtered array of wireless access points.
*/
public getFilteredWirelessAPs(): AstalNetwork.AccessPoint[] {
const dedupedWAPs = this._dedupeWAPs();
const filteredWAPs = dedupedWAPs
.filter((ap: AstalNetwork.AccessPoint) => {
return ap.ssid !== 'Unknown' && !this._isInStaging(ap);
})
.sort((a: AstalNetwork.AccessPoint, b: AstalNetwork.AccessPoint) => {
if (this.isApActive(a)) {
return -1;
}
if (this.isApActive(b)) {
return 1;
}
return b.strength - a.strength;
});
return filteredWAPs;
}
/**
* Determines whether the device is in an active state.
*
* @param state - The current state of the device.
* @returns True if the device is in an active state; otherwise, false.
*/
public isApEnabled(state: AstalNetwork.DeviceState | undefined): boolean {
if (state === null) {
return false;
}
return !(
state === AstalNetwork.DeviceState.DISCONNECTED ||
state === AstalNetwork.DeviceState.UNAVAILABLE ||
state === AstalNetwork.DeviceState.FAILED
);
}
/**
* Checks if the given access point is the currently active one.
*
* @param accessPoint - The access point to check.
* @returns True if the access point is active; otherwise, false.
*/
public isApActive(accessPoint: AstalNetwork.AccessPoint): boolean {
return accessPoint.ssid === this._astalNetwork.wifi?.activeAccessPoint?.ssid;
}
/**
* Checks if the specified access point is in the process of disconnecting.
*
* @param accessPoint - The access point to check.
* @returns True if the access point is disconnecting; otherwise, false.
*/
public isDisconnecting(accessPoint: AstalNetwork.AccessPoint): boolean {
if (this.isApActive(accessPoint)) {
return this._astalNetwork.wifi?.state === AstalNetwork.DeviceState.DEACTIVATING;
}
return false;
}
/**
* Retrieves the current Wi-Fi status based on the network service state.
*
* @returns A string representing the current Wi-Fi status.
*/
public getWifiStatus(): string {
const wifiState = this._astalNetwork.wifi?.state;
if (wifiState !== null) {
return DEVICE_STATES[wifiState];
}
return DEVICE_STATES[AstalNetwork.DeviceState.UNKNOWN];
}
/**
* Initiates a connection to the specified access point.
*
* @param accessPoint - The access point to connect to.
* @param event - The click event triggering the connection.
*/
public connectToAP(accessPoint: AstalNetwork.AccessPoint, event: Astal.ClickEvent): void {
if (
accessPoint.bssid === this.connecting.get() ||
this.isApActive(accessPoint) ||
!isPrimaryClick(event)
) {
return;
}
if (!accessPoint.flags || accessPoint.flags === AP_FLAGS.NONE) {
this.connecting.set(accessPoint.bssid ?? '');
execAsync(`nmcli device wifi connect ${accessPoint.bssid}`)
.then(() => {
this.connecting.set('');
this.staging.set({} as AstalNetwork.AccessPoint);
})
.catch((err: Error) => {
this.connecting.set('');
SystemUtilities.notify({
summary: 'Network',
body: err.message,
});
});
} else {
this.staging.set(accessPoint);
}
}
/**
* Connects to a secured access point with a password.
*
* @param accessPoint - The access point to connect to.
* @param password - The password for the network.
*/
public async connectToAPWithPassword(
accessPoint: AstalNetwork.AccessPoint,
password: string,
): Promise<void> {
if (!accessPoint.ssid || !password) {
return Promise.reject(new Error('SSID and password are required'));
}
this.connecting.set(accessPoint.bssid || '');
const connectCommand = `nmcli device wifi connect "${accessPoint.ssid}" password "${password}"`;
return execAsync(connectCommand)
.then(() => {
this.connecting.set('');
this.staging.set(undefined);
})
.catch((err: Error) => {
this.connecting.set('');
throw err;
});
}
/**
* Disconnects from the specified access point.
*
* @param accessPoint - The access point to disconnect from.
* @param event - The click event triggering the disconnection.
*/
public disconnectFromAP(accessPoint: AstalNetwork.AccessPoint, event: Astal.ClickEvent): void {
if (!isPrimaryClick(event)) {
return;
}
this.connecting.set(accessPoint.bssid || '');
execAsync('nmcli connection show --active').then((res: string) => {
const connectionId = this._getIdFromSsid(accessPoint.ssid || '', res);
if (connectionId === undefined) {
console.error(`Error while disconnecting "${accessPoint.ssid}": Connection ID not found`);
return;
}
execAsync(`nmcli connection down ${connectionId} "${accessPoint.ssid}"`)
.then(() => {
this.connecting.set('');
})
.catch((err: unknown) => {
this.connecting.set('');
console.error(`Error while disconnecting "${accessPoint.ssid}": ${err}`);
});
});
}
/**
* Forgets the specified access point by deleting its connection.
*
* @param accessPoint - The access point to forget.
* @param event - The click event triggering the forget action.
*/
public forgetAP(accessPoint: AstalNetwork.AccessPoint, event: Astal.ClickEvent): void {
if (!isPrimaryClick(event)) {
return;
}
this.connecting.set(accessPoint.bssid || '');
execAsync('nmcli connection show --active').then((res: string) => {
const connectionId = this._getIdFromSsid(accessPoint.ssid || '', res);
if (connectionId === undefined) {
console.error(`Error while forgetting "${accessPoint.ssid}": Connection ID not found`);
return;
}
execAsync(`nmcli connection delete ${connectionId} "${accessPoint.ssid}"`)
.then(() => {
this.connecting.set('');
})
.catch((err: unknown) => {
this.connecting.set('');
console.error(`Error while forgetting "${accessPoint.ssid}": ${err}`);
});
});
}
/**
* Extracts the connection ID associated with a given SSID from the `nmcli` command output.
*
* @param ssid - The SSID of the network.
* @param nmcliOutput - The output string from the `nmcli` command.
* @returns The connection ID if found; otherwise, undefined.
*/
private _getIdFromSsid(ssid: string, nmcliOutput: string): string | undefined {
const lines = nmcliOutput.trim().split('\n');
for (const line of lines) {
const columns = line.trim().split(/\s{2,}/);
if (columns[0].includes(ssid)) {
return columns[1];
}
}
}
}

View File

@@ -1,59 +1,16 @@
import { exec, GObject, monitorFile, property, readFileAsync, register } from 'astal';
import { sh } from 'src/lib/utils';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
const get = (args: string): number => Number(exec(`brightnessctl ${args}`));
const screen = exec('bash -c "ls -w1 /sys/class/backlight | head -1"');
const kbd = exec('bash -c "ls -w1 /sys/class/leds | grep \'::kbd_backlight$\' | head -1"');
/**
* Service for managing screen and keyboard backlight brightness
*/
@register({ GTypeName: 'Brightness' })
export default class Brightness extends GObject.Object {
public static instance: Brightness;
public static get_default(): Brightness {
if (Brightness.instance === undefined) {
Brightness.instance = new Brightness();
}
return Brightness.instance;
}
#kbdMax = kbd?.length ? get(`--device ${kbd} max`) : 0;
#kbd = kbd?.length ? get(`--device ${kbd} get`) : 0;
#screenMax = screen?.length ? get(`--device ${screen} max`) : 0;
#screen = screen?.length ? get(`--device ${screen} get`) / (get(`--device ${screen} max`) || 1) : 0;
@property(Number)
public get kbd(): number {
return this.#kbd;
}
@property(Number)
public get screen(): number {
return this.#screen;
}
public set kbd(value: number) {
if (value < 0 || value > this.#kbdMax || !kbd?.length) return;
sh(`brightnessctl -d ${kbd} s ${value} -q`).then(() => {
this.#kbd = value;
this.notify('kbd');
});
}
public set screen(percent: number) {
if (!screen?.length) return;
let brightnessPct = percent;
if (percent < 0) brightnessPct = 0;
if (percent > 1) brightnessPct = 1;
sh(`brightnessctl set ${Math.round(brightnessPct * 100)}% -d ${screen} -q`).then(() => {
this.#screen = brightnessPct;
this.notify('screen');
});
}
export default class BrightnessService extends GObject.Object {
public static instance: BrightnessService;
constructor() {
super();
@@ -73,4 +30,77 @@ export default class Brightness extends GObject.Object {
this.notify('kbd');
});
}
/**
* Gets the singleton instance of BrightnessService
*
* @returns The BrightnessService instance
*/
public static getInstance(): BrightnessService {
if (BrightnessService.instance === undefined) {
BrightnessService.instance = new BrightnessService();
}
return BrightnessService.instance;
}
#kbdMax = kbd?.length ? get(`--device ${kbd} max`) : 0;
#kbd = kbd?.length ? get(`--device ${kbd} get`) : 0;
#screenMax = screen?.length ? get(`--device ${screen} max`) : 0;
#screen = screen?.length ? get(`--device ${screen} get`) / (get(`--device ${screen} max`) || 1) : 0;
/**
* Gets the keyboard backlight brightness level
*
* @returns The keyboard brightness as a number between 0 and the maximum value
*/
@property(Number)
public get kbd(): number {
return this.#kbd;
}
/**
* Gets the screen brightness level
*
* @returns The screen brightness as a percentage (0-1)
*/
@property(Number)
public get screen(): number {
return this.#screen;
}
/**
* Sets the keyboard backlight brightness level
*
* @param value - The brightness value to set (0 to maximum)
*/
public set kbd(value: number) {
if (value < 0 || value > this.#kbdMax || !kbd?.length) return;
SystemUtilities.sh(`brightnessctl -d ${kbd} s ${value} -q`).then(() => {
this.#kbd = value;
this.notify('kbd');
});
}
/**
* Sets the screen brightness level
*
* @param percent - The brightness percentage to set (0-1)
*/
public set screen(percent: number) {
if (!screen?.length) return;
let brightnessPct = percent;
if (percent < 0) brightnessPct = 0;
if (percent > 1) brightnessPct = 1;
SystemUtilities.sh(`brightnessctl set ${Math.round(brightnessPct * 100)}% -d ${screen} -q`).then(
() => {
this.#screen = brightnessPct;
this.notify('screen');
},
);
}
}

View File

@@ -0,0 +1,109 @@
import { bind, Variable } from 'astal';
import GTop from 'gi://GTop';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { CpuServiceCtor } from './types';
/**
* Service for monitoring CPU usage percentage
*/
class CpuUsageService {
private _updateFrequency: Variable<number>;
private _previousCpuData = new GTop.glibtop_cpu();
private _cpuPoller: FunctionPoller<number, []>;
private _isInitialized = false;
private _cpu = Variable(0);
constructor({ frequency }: CpuServiceCtor = {}) {
this._updateFrequency = frequency ?? Variable(2000);
GTop.glibtop_get_cpu(this._previousCpuData);
this._calculateUsage = this._calculateUsage.bind(this);
this._cpuPoller = new FunctionPoller<number, []>(
this.cpu,
[bind(this._updateFrequency)],
bind(this._updateFrequency),
this._calculateUsage,
);
}
/**
* Manually refreshes the CPU usage reading
*/
public refresh(): void {
this._cpu.set(this._calculateUsage());
}
/**
* Gets the CPU usage percentage variable
*
* @returns Variable containing CPU usage percentage (0-100)
*/
public get cpu(): Variable<number> {
return this._cpu;
}
/**
* Calculates the current CPU usage percentage based on CPU time deltas
*
* @returns Current CPU usage percentage
*/
private _calculateUsage(): number {
const currentCpuData = new GTop.glibtop_cpu();
GTop.glibtop_get_cpu(currentCpuData);
const totalDiff = currentCpuData.total - this._previousCpuData.total;
const idleDiff = currentCpuData.idle - this._previousCpuData.idle;
const cpuUsagePercentage = totalDiff > 0 ? ((totalDiff - idleDiff) / totalDiff) * 100 : 0;
this._previousCpuData = currentCpuData;
return cpuUsagePercentage;
}
/**
* Updates the polling frequency for CPU usage monitoring
*
* @param timerInMs - New polling interval in milliseconds
*/
public updateTimer(timerInMs: number): void {
this._updateFrequency.set(timerInMs);
}
/**
* Initializes the CPU usage monitoring service
*/
public initialize(): void {
if (!this._isInitialized) {
this._cpuPoller.initialize();
this._isInitialized = true;
}
}
/**
* Stops the CPU usage polling
*/
public stopPoller(): void {
this._cpuPoller.stop();
}
/**
* Starts the CPU usage polling
*/
public startPoller(): void {
this._cpuPoller.start();
}
/**
* Cleans up resources and stops monitoring
*/
public destroy(): void {
this._cpuPoller.stop();
this._cpu.drop();
this._updateFrequency.drop();
}
}
export default CpuUsageService;

View File

@@ -0,0 +1,5 @@
import { Variable } from 'astal';
export interface CpuServiceCtor {
frequency?: Variable<number>;
}

View File

@@ -0,0 +1,163 @@
import { bind, Variable } from 'astal';
import GLib from 'gi://GLib?version=2.0';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { CpuTempServiceCtor } from './types';
import { CpuTempSensorDiscovery } from './sensorDiscovery';
/**
* Service for monitoring CPU temperature from system sensors
*/
class CpuTempService {
private _sensor: Variable<string>;
private _updateFrequency: Variable<number>;
private _tempPoller: FunctionPoller<number, []>;
private _isInitialized = false;
private _temperature = Variable(0);
private _resolvedSensorPath?: string;
constructor({ sensor, frequency }: CpuTempServiceCtor = {}) {
this._sensor = sensor ?? Variable('auto');
this._updateFrequency = frequency || Variable(2000);
this._readTemperature = this._readTemperature.bind(this);
this._tempPoller = new FunctionPoller<number, []>(
this._temperature,
[],
bind(this._updateFrequency),
this._readTemperature,
);
this._sensor.subscribe(() => this._resolveSensorPath());
}
/**
* Resolves the sensor path based on configuration
*/
private _resolveSensorPath(): void {
const sensorValue = this._sensor.get();
if (sensorValue === 'auto' || sensorValue === '') {
this._resolvedSensorPath = CpuTempSensorDiscovery.discover();
if (!this._resolvedSensorPath) console.error('No CPU temperature sensor found');
return;
}
if (CpuTempSensorDiscovery.isValid(sensorValue)) {
this._resolvedSensorPath = sensorValue;
return;
}
console.error(`Invalid sensor: ${sensorValue}, falling back to auto-discovery`);
this._resolvedSensorPath = CpuTempSensorDiscovery.discover();
}
/**
* Reads CPU temperature from the sensor file and returns it in Celsius
*/
private _readTemperature(): number {
if (!this._resolvedSensorPath) return 0;
try {
const [success, tempBytes] = GLib.file_get_contents(this._resolvedSensorPath);
if (!success || !tempBytes) return 0;
const tempInfo = new TextDecoder('utf-8').decode(tempBytes);
const tempValueMillidegrees = parseInt(tempInfo.trim(), 10);
return tempValueMillidegrees / 1000;
} catch (error) {
console.error('Error reading CPU temperature:', error);
return 0;
}
}
/**
* Gets the CPU temperature variable
*
* @returns Variable containing temperature in Celsius
*/
public get temperature(): Variable<number> {
return this._temperature;
}
/**
* Gets the sensor configuration variable
*
* @returns Variable containing sensor path or 'auto'
*/
public get sensor(): Variable<string> {
return this._sensor;
}
/**
* Gets the currently resolved sensor file path
*
* @returns The actual sensor path being used
*/
public get currentSensorPath(): string | undefined {
return this._resolvedSensorPath;
}
/**
* Manually refreshes the temperature reading
*/
public refresh(): void {
this._temperature.set(this._readTemperature());
}
/**
* Updates the sensor path and refreshes the temperature
*
* @param sensor - New sensor path or 'auto' for auto-discovery
*/
public updateSensor(sensor: string): void {
this._sensor.set(sensor);
this.refresh();
}
/**
* Updates the polling frequency
*
* @param frequency - New polling interval in milliseconds
*/
public updateFrequency(frequency: number): void {
this._updateFrequency.set(frequency);
}
/**
* Initializes the CPU temperature monitoring poller
*/
public initialize(): void {
if (this._isInitialized) return;
this._resolveSensorPath();
this._tempPoller.initialize();
this._isInitialized = true;
}
/**
* Stops the temperature polling
*/
public stopPoller(): void {
this._tempPoller.stop();
}
/**
* Starts the temperature polling
*/
public startPoller(): void {
this._tempPoller.start();
}
/**
* Cleans up resources and stops monitoring
*/
public destroy(): void {
this._tempPoller.stop();
this._temperature.drop();
this._sensor.drop();
this._updateFrequency.drop();
}
}
export default CpuTempService;

View File

@@ -0,0 +1,242 @@
import GLib from 'gi://GLib?version=2.0';
import { SensorInfo } from './types';
export class CpuTempSensorDiscovery {
private static readonly _PRIORITY_SENSORS = [
/** Intel */
'coretemp',
/** AMD Ryzen */
'k10temp',
];
private static readonly _HWMON_PATH = '/sys/class/hwmon';
private static readonly _THERMAL_PATH = '/sys/class/thermal';
private static readonly _THERMAL_FALLBACK = '/sys/class/thermal/thermal_zone0/temp';
/**
* Auto-discovers the best CPU temperature sensor available on the system
*/
public static discover(): string | undefined {
const prioritySensor = this._findPrioritySensor();
if (prioritySensor) return prioritySensor;
if (this.isValid(this._THERMAL_FALLBACK)) return this._THERMAL_FALLBACK;
return;
}
/**
* Gets all available temperature sensors on the system
*/
public static getAllSensors(): SensorInfo[] {
const hwmonSensors = this._getAllHwmonSensors();
const thermalSensors = this._getAllThermalSensors();
return [...hwmonSensors, ...thermalSensors];
}
/**
* Validates if sensor path exists and is readable
*
* @param path - Sensor file path to validate
*/
public static isValid(path: string): boolean {
try {
const [success] = GLib.file_get_contents(path);
return success;
} catch {
return false;
}
}
/**
* Searches for priority CPU sensors (Intel coretemp, AMD k10temp) in order of preference
*/
private static _findPrioritySensor(): string | undefined {
for (const sensorName of this._PRIORITY_SENSORS) {
const sensor = this._findHwmonSensor(sensorName);
if (!sensor || !this.isValid(sensor)) continue;
return sensor;
}
return;
}
/**
* Finds a specific hardware monitor sensor by chip name
*
* @param chipName - Name of the chip to search for (e.g., 'coretemp', 'k10temp')
*/
private static _findHwmonSensor(chipName: string): string | undefined {
const dir = this._openDirectory(this._HWMON_PATH);
if (!dir) return;
try {
return this._searchDirectoryForChip(dir, chipName);
} finally {
dir.close();
}
}
/**
* Searches through a directory for a specific chip by name
*
* @param dir - Open directory handle to search through
* @param chipName - Name of the chip to find
*/
private static _searchDirectoryForChip(dir: GLib.Dir, chipName: string): string | undefined {
let dirname: string | null;
while ((dirname = dir.read_name()) !== null) {
const sensor = this._checkHwmonDir(dirname, chipName);
if (sensor) return sensor;
}
return;
}
/**
* Checks if a hwmon directory contains the specified chip and returns its temp sensor path
*
* @param dirname - Directory name to check (e.g., 'hwmon0')
* @param chipName - Expected chip name to match against
*/
private static _checkHwmonDir(dirname: string, chipName: string): string | undefined {
const nameFile = `${this._HWMON_PATH}/${dirname}/name`;
const name = this._readFileContent(nameFile);
if (!name || name !== chipName) return;
return `${this._HWMON_PATH}/${dirname}/temp1_input`;
}
/**
* Collects all hardware monitor sensors from the system
*/
private static _getAllHwmonSensors(): SensorInfo[] {
const dir = this._openDirectory(this._HWMON_PATH);
if (!dir) return [];
try {
return this._collectHwmonSensors(dir);
} finally {
dir.close();
}
}
/**
* Iterates through hwmon directory entries and collects valid sensor information
*
* @param dir - Open hwmon directory handle
*/
private static _collectHwmonSensors(dir: GLib.Dir): SensorInfo[] {
const sensors: SensorInfo[] = [];
let dirname: string | null;
while ((dirname = dir.read_name()) !== null) {
const sensor = this._createHwmonSensorInfo(dirname);
if (sensor) sensors.push(sensor);
}
return sensors;
}
/**
* Creates sensor info object for a hwmon device if it has valid temperature input
* @param dirname - hwmon directory name (e.g., 'hwmon0')
*/
private static _createHwmonSensorInfo(dirname: string): SensorInfo | undefined {
const nameFile = `${this._HWMON_PATH}/${dirname}/name`;
const name = this._readFileContent(nameFile);
if (!name) return;
const tempPath = `${this._HWMON_PATH}/${dirname}/temp1_input`;
if (!this.isValid(tempPath)) return;
return {
path: tempPath,
name,
type: 'hwmon',
};
}
/**
* Collects all thermal zone sensors from the system
*/
private static _getAllThermalSensors(): SensorInfo[] {
const dir = this._openDirectory(this._THERMAL_PATH);
if (!dir) return [];
try {
return this._collectThermalSensors(dir);
} finally {
dir.close();
}
}
/**
* Iterates through thermal zone entries and collects valid sensor information
*
* @param dir - Open thermal directory handle
*/
private static _collectThermalSensors(dir: GLib.Dir): SensorInfo[] {
const sensors: SensorInfo[] = [];
let dirname: string | null;
while ((dirname = dir.read_name()) !== null) {
if (!dirname.startsWith('thermal_zone')) continue;
const sensor = this._createThermalSensorInfo(dirname);
if (sensor) sensors.push(sensor);
}
return sensors;
}
/**
* Creates sensor info object for a thermal zone if it has valid temperature file
*
* @param dirname - Thermal zone directory name (e.g., 'thermal_zone0')
*/
private static _createThermalSensorInfo(dirname: string): SensorInfo | undefined {
const tempPath = `${this._THERMAL_PATH}/${dirname}/temp`;
if (!this.isValid(tempPath)) return;
return {
path: tempPath,
name: dirname,
type: 'thermal',
};
}
/**
* Safely opens a directory for reading, returns undefined on failure
*
* @param path - Full path to the directory to open
*/
private static _openDirectory(path: string): GLib.Dir | undefined {
try {
return GLib.Dir.open(path, 0);
} catch {
return;
}
}
/**
* Reads and returns trimmed file content, returns undefined on failure
*
* @param path - Full path to the file to read
*/
private static _readFileContent(path: string): string | undefined {
try {
const [success, bytes] = GLib.file_get_contents(path);
if (!success || !bytes) return;
return new TextDecoder('utf-8').decode(bytes).trim();
} catch {
return;
}
}
}

View File

@@ -0,0 +1,12 @@
import { Variable } from 'astal';
export interface CpuTempServiceCtor {
sensor?: Variable<string>;
frequency?: Variable<number>;
}
export interface SensorInfo {
path: string;
name: string;
type: 'hwmon' | 'thermal';
}

View File

@@ -0,0 +1,127 @@
import { bind, exec, Variable } from 'astal';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { GpuServiceCtor, GPUStat } from './types';
/**
* Service for monitoring GPU usage percentage using gpustat
*/
class GpuUsageService {
private _updateFrequency: Variable<number>;
private _gpuPoller: FunctionPoller<number, []>;
private _isInitialized = false;
public _gpu = Variable<number>(0);
constructor({ frequency }: GpuServiceCtor = {}) {
this._updateFrequency = frequency ?? Variable(2000);
this._calculateUsage = this._calculateUsage.bind(this);
this._gpuPoller = new FunctionPoller<number, []>(
this._gpu,
[],
bind(this._updateFrequency),
this._calculateUsage,
);
}
/**
* Manually refreshes the GPU usage reading
*/
public refresh(): void {
this._gpu.set(this._calculateUsage());
}
/**
* Gets the GPU usage percentage variable
*
* @returns Variable containing GPU usage percentage (0-1)
*/
public get gpu(): Variable<number> {
return this._gpu;
}
/**
* Calculates average GPU usage across all available GPUs
*
* @returns GPU usage as a decimal between 0 and 1
*/
private _calculateUsage(): number {
try {
const gpuStats = exec('gpustat --json');
if (typeof gpuStats !== 'string') {
return 0;
}
const data = JSON.parse(gpuStats);
const totalGpu = 100;
const usedGpu =
data.gpus.reduce((acc: number, gpu: GPUStat) => {
return acc + gpu['utilization.gpu'];
}, 0) / data.gpus.length;
return this._divide([totalGpu, usedGpu]);
} catch (error) {
if (error instanceof Error) {
console.error('Error getting GPU stats:', error.message);
} else {
console.error('Unknown error getting GPU stats');
}
return 0;
}
}
/**
* Converts usage percentage to decimal
*
* @param values - Tuple of [total, used] values
* @returns Usage as decimal between 0 and 1
*/
private _divide([total, free]: number[]): number {
return free / total;
}
/**
* Updates the polling frequency
*
* @param timerInMs - New polling interval in milliseconds
*/
public updateTimer(timerInMs: number): void {
this._updateFrequency.set(timerInMs);
}
/**
* Initializes the GPU usage monitoring poller
*/
public initialize(): void {
if (!this._isInitialized) {
this._gpuPoller.initialize();
this._isInitialized = true;
}
}
/**
* Stops the GPU usage polling
*/
public stopPoller(): void {
this._gpuPoller.stop();
}
/**
* Starts the GPU usage polling
*/
public startPoller(): void {
this._gpuPoller.start();
}
/**
* Cleans up resources and stops monitoring
*/
public destroy(): void {
this._gpuPoller.stop();
this._gpu.drop();
this._updateFrequency.drop();
}
}
export default GpuUsageService;

View File

@@ -0,0 +1,21 @@
import { Process, Variable } from 'astal';
export interface GpuServiceCtor {
frequency?: Variable<number>;
}
export type GPUStat = {
index: number;
uuid: string;
name: string;
'temperature.gpu': number;
'fan.speed': number;
'utilization.gpu': number;
'utilization.enc': number;
'utilization.dec': number;
'power.draw': number;
'enforced.power.limit': number;
'memory.used': number;
'memory.total': number;
processes: Process[];
};

View File

@@ -0,0 +1,277 @@
import { bind, Variable } from 'astal';
import GLib from 'gi://GLib';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { ByteMultiplier, NetworkResourceData, RateUnit } from '../types';
import { NetworkServiceCtor, NetworkUsage } from './types';
/**
* Service for monitoring network interface traffic and bandwidth usage
*/
class NetworkUsageService {
private _updateFrequency: Variable<number>;
private _shouldRound = false;
private _interfaceName = Variable('');
private _rateUnit = Variable<RateUnit>('auto');
private _previousNetUsage = { rx: 0, tx: 0, time: 0 };
private _networkPoller: FunctionPoller<NetworkResourceData, []>;
private _isInitialized = false;
public _network: Variable<NetworkResourceData>;
constructor({ frequency }: NetworkServiceCtor = {}) {
this._updateFrequency = frequency ?? Variable(2000);
const defaultNetstatData = this._getDefaultNetstatData(this._rateUnit.get());
this._network = Variable<NetworkResourceData>(defaultNetstatData);
this._calculateUsage = this._calculateUsage.bind(this);
this._networkPoller = new FunctionPoller<NetworkResourceData, []>(
this._network,
[],
bind(this._updateFrequency),
this._calculateUsage,
);
}
/**
* Manually refreshes the network usage statistics
*/
public refresh(): void {
this._network.set(this._calculateUsage());
}
/**
* Gets the network usage data variable
*
* @returns Variable containing incoming and outgoing network rates
*/
public get network(): Variable<NetworkResourceData> {
return this._network;
}
/**
* Calculates network usage rates for the configured interface
*/
private _calculateUsage(): NetworkResourceData {
const rateUnit = this._rateUnit.get();
const interfaceName = this._interfaceName.get();
const DEFAULT_NETSTAT_DATA = this._getDefaultNetstatData(rateUnit);
try {
const { rx, tx, name } = this._getNetworkUsage(interfaceName);
const currentTime = Date.now();
if (!name) {
return DEFAULT_NETSTAT_DATA;
}
if (this._previousNetUsage.time === 0) {
this._previousNetUsage = { rx, tx, time: currentTime };
return DEFAULT_NETSTAT_DATA;
}
const timeDiff = Math.max((currentTime - this._previousNetUsage.time) / 1000, 1);
const rxRate = (rx - this._previousNetUsage.rx) / timeDiff;
const txRate = (tx - this._previousNetUsage.tx) / timeDiff;
this._previousNetUsage = { rx, tx, time: currentTime };
return {
in: this._formatRate(rxRate, rateUnit, this._shouldRound),
out: this._formatRate(txRate, rateUnit, this._shouldRound),
};
} catch (error) {
console.error('Error calculating network usage:', error);
return DEFAULT_NETSTAT_DATA;
}
}
/**
* Sets the network interface to monitor
*
* @param interfaceName - Name of the network interface (e.g., 'eth0', 'wlan0')
*/
public setInterface(interfaceName: string): void {
this._interfaceName.set(interfaceName);
this._resetUsageHistory();
}
/**
* Sets the rate unit for formatting network speeds
*
* @param unit - Unit to display rates in ('auto', 'KiB', 'MiB', 'GiB')
*/
public setRateUnit(unit: RateUnit): void {
this._rateUnit.set(unit);
}
/**
* Sets whether to round the rates to whole numbers
*
* @param round - Whether to round rates to integers
*/
public setShouldRound(round: boolean): void {
this._shouldRound = round;
}
/**
* Updates the polling frequency
*
* @param timerInMs - New polling interval in milliseconds
*/
public updateTimer(timerInMs: number): void {
this._updateFrequency.set(timerInMs);
}
/**
* Initializes the network usage monitoring poller
*/
public initialize(): void {
if (!this._isInitialized) {
this._networkPoller.initialize();
this._isInitialized = true;
}
}
/**
* Stops the network monitoring poller
*/
public stopPoller(): void {
this._networkPoller.stop();
}
/**
* Starts the network monitoring poller
*/
public startPoller(): void {
this._networkPoller.start();
}
/**
* Resets the usage history for accurate rate calculation
*/
private _resetUsageHistory(): void {
this._previousNetUsage = { rx: 0, tx: 0, time: 0 };
}
/**
* Formats the network rate based on the provided rate, type, and rounding option
*
* @param rate - Raw rate in bytes per second
* @param type - Unit type to format to
* @param round - Whether to round to whole numbers
* @returns Formatted rate string with unit suffix
*/
private _formatRate(rate: number, type: RateUnit, round: boolean): string {
const fixed = round ? 0 : 2;
switch (true) {
case type === 'KiB':
return `${(rate / ByteMultiplier.KIBIBYTE).toFixed(fixed)} KiB/s`;
case type === 'MiB':
return `${(rate / ByteMultiplier.MEBIBYTE).toFixed(fixed)} MiB/s`;
case type === 'GiB':
return `${(rate / ByteMultiplier.GIBIBYTE).toFixed(fixed)} GiB/s`;
case rate >= ByteMultiplier.GIBIBYTE:
return `${(rate / ByteMultiplier.GIBIBYTE).toFixed(fixed)} GiB/s`;
case rate >= ByteMultiplier.MEBIBYTE:
return `${(rate / ByteMultiplier.MEBIBYTE).toFixed(fixed)} MiB/s`;
case rate >= ByteMultiplier.KIBIBYTE:
return `${(rate / ByteMultiplier.KIBIBYTE).toFixed(fixed)} KiB/s`;
case rate >= ByteMultiplier.BYTE:
default:
return `${rate.toFixed(fixed)} bytes/s`;
}
}
/**
* Parses a line of network interface data from /proc/net/dev
*
* @param line - Raw line from /proc/net/dev
* @returns Parsed network usage data or null if invalid
*/
private _parseInterfaceData(line: string): NetworkUsage | null {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('Inter-') || trimmedLine.startsWith('face')) {
return null;
}
const [iface, rx, , , , , , , , tx] = trimmedLine.split(/\s+/);
const rxValue = parseInt(rx, 10);
const txValue = parseInt(tx, 10);
const cleanedIface = iface.replace(':', '');
return { name: cleanedIface, rx: rxValue, tx: txValue };
}
/**
* Validates a network interface for monitoring
*
* @param iface - Interface data to validate
* @param interfaceName - Specific interface name to match (empty for auto)
* @returns Whether the interface is valid for monitoring
*/
private _isValidInterface(iface: NetworkUsage | null, interfaceName: string): boolean {
if (!iface) return false;
if (interfaceName) return iface.name === interfaceName;
return iface.name !== 'lo' && iface.rx > 0 && iface.tx > 0;
}
/**
* Retrieves network usage for the specified interface from /proc/net/dev
*
* @param interfaceName - Name of interface to monitor (empty for auto-detect)
* @returns Network usage statistics
*/
private _getNetworkUsage(interfaceName: string = ''): NetworkUsage {
const [success, data] = GLib.file_get_contents('/proc/net/dev');
const defaultStats = { name: '', rx: 0, tx: 0 };
if (!success) {
console.error('Failed to read /proc/net/dev');
return defaultStats;
}
const lines = new TextDecoder('utf-8').decode(data).split('\n');
for (const line of lines) {
const iface = this._parseInterfaceData(line);
if (this._isValidInterface(iface, interfaceName)) {
return iface ?? defaultStats;
}
}
return { name: '', rx: 0, tx: 0 };
}
/**
* Gets default network statistics data for initialization
*
* @param dataType - Rate unit type
* @returns Default network resource data
*/
private _getDefaultNetstatData = (dataType: RateUnit): NetworkResourceData => {
if (dataType === 'auto') {
return { in: '0 Kib/s', out: '0 Kib/s' };
}
return { in: `0 ${dataType}/s`, out: `0 ${dataType}/s` };
};
/**
* Cleans up resources and stops monitoring
*/
public destroy(): void {
this._networkPoller.stop();
this._network.drop();
this._interfaceName.drop();
this._rateUnit.drop();
this._updateFrequency.drop();
}
}
export default NetworkUsageService;

View File

@@ -0,0 +1,11 @@
import { Variable } from 'astal';
export interface NetworkServiceCtor {
frequency?: Variable<number>;
}
export interface NetworkUsage {
name: string;
rx: number;
tx: number;
}

View File

@@ -0,0 +1,139 @@
import { bind, GLib, Variable } from 'astal';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { GenericResourceData } from '../types';
import { RamServiceCtor } from './types';
/**
* Service for monitoring system RAM usage and statistics
*/
class RamUsageService {
private _updateFrequency: Variable<number>;
private _ramPoller: FunctionPoller<GenericResourceData, []>;
private _isInitialized = false;
private _ram = Variable<GenericResourceData>({ total: 0, used: 0, percentage: 0, free: 0 });
constructor({ frequency }: RamServiceCtor = {}) {
this._updateFrequency = frequency ?? Variable(2000);
this._calculateUsage = this._calculateUsage.bind(this);
this._ramPoller = new FunctionPoller<GenericResourceData, []>(
this._ram,
[bind(this._updateFrequency)],
bind(this._updateFrequency),
this._calculateUsage,
);
}
/**
* Manually refreshes the RAM usage statistics
*/
public refresh(): void {
this._ram.set(this._calculateUsage());
}
/**
* Gets the RAM usage data variable
*
* @returns Variable containing RAM statistics (total, used, free, percentage)
*/
public get ram(): Variable<GenericResourceData> {
return this._ram;
}
/**
* Calculates current RAM usage by parsing /proc/meminfo
*
* @returns RAM usage statistics including total, used, free, and percentage
*/
private _calculateUsage(): GenericResourceData {
try {
const [success, meminfoBytes] = GLib.file_get_contents('/proc/meminfo');
if (!success || meminfoBytes === undefined) {
throw new Error('Failed to read /proc/meminfo or file content is null.');
}
const meminfo = new TextDecoder('utf-8').decode(meminfoBytes);
const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/);
const availableMatch = meminfo.match(/MemAvailable:\s+(\d+)/);
if (!totalMatch || !availableMatch) {
throw new Error('Failed to parse /proc/meminfo for memory values.');
}
const totalRamInBytes = parseInt(totalMatch[1], 10) * 1024;
const availableRamInBytes = parseInt(availableMatch[1], 10) * 1024;
let usedRam = totalRamInBytes - availableRamInBytes;
usedRam = isNaN(usedRam) || usedRam < 0 ? 0 : usedRam;
return {
percentage: this._divide([totalRamInBytes, usedRam]),
total: totalRamInBytes,
used: usedRam,
free: availableRamInBytes,
};
} catch (error) {
console.error('Error calculating RAM usage:', error);
return { total: 0, used: 0, percentage: 0, free: 0 };
}
}
/**
* Calculates percentage of RAM used
*
* @param values - Tuple of [total, used] RAM values
* @returns RAM usage percentage with 2 decimal places
*/
private _divide([total, used]: number[]): number {
const percentageTotal = (used / total) * 100;
return total > 0 ? parseFloat(percentageTotal.toFixed(2)) : 0;
}
/**
* Updates the polling frequency
*
* @param timerInMs - New polling interval in milliseconds
*/
public updateTimer(timerInMs: number): void {
this._updateFrequency.set(timerInMs);
}
/**
* Initializes the RAM usage monitoring
*/
public initialize(): void {
if (!this._isInitialized) {
this._ramPoller.initialize();
this._isInitialized = true;
}
}
/**
* Stops the RAM usage polling
*/
public stopPoller(): void {
this._ramPoller.stop();
}
/**
* Starts the RAM usage polling
*/
public startPoller(): void {
this._ramPoller.start();
}
/**
* Cleans up resources and stops monitoring
*/
public destroy(): void {
this._ramPoller.stop();
this._ram.drop();
this._updateFrequency.drop();
}
}
export default RamUsageService;

View File

@@ -0,0 +1,5 @@
import { Variable } from 'astal';
export interface RamServiceCtor {
frequency?: Variable<number>;
}

View File

@@ -0,0 +1,255 @@
import { bind, Variable } from 'astal';
import GTop from 'gi://GTop';
import { FunctionPoller } from 'src/lib/poller/FunctionPoller';
import { GenericResourceData } from '../types';
import { StorageServiceCtor, MultiDriveStorageData, DriveStorageData } from './types';
import { unique } from 'src/lib/array/helpers';
/**
* Monitors storage usage across multiple drives and provides real-time updates
*
* This service polls filesystem usage data for configured mount points and maintains
* both individual drive statistics and aggregated totals. The data updates automatically
* at the configured interval and supports dynamic path configuration.
*/
class StorageService {
private _updateFrequency: Variable<number>;
private _shouldRound: Variable<boolean>;
private _storagePoller: FunctionPoller<MultiDriveStorageData, []>;
private _pathsToMonitor: Variable<string[]>;
private _isInitialized = false;
private _storage = Variable<GenericResourceData>({ total: 0, used: 0, percentage: 0, free: 0 });
private _statBreakdown = Variable<MultiDriveStorageData>({
total: { total: 0, used: 0, percentage: 0, free: 0 },
drives: [],
});
/**
* Creates a new storage monitoring service
* @param frequency - Optional polling frequency variable
* @param round - Optional rounding preference variable
* @param pathsToMonitor - Optional array of mount paths to monitor
*/
constructor({ frequency, round, pathsToMonitor }: StorageServiceCtor) {
this._updateFrequency = frequency ?? Variable(2000);
this._shouldRound = round ?? Variable(false);
this._pathsToMonitor = pathsToMonitor ?? Variable(['/']);
this._pathsToMonitor.set(unique(this._pathsToMonitor.get()));
this._storagePoller = new FunctionPoller<MultiDriveStorageData, []>(
this._statBreakdown,
[bind(this._updateFrequency), bind(this._pathsToMonitor), bind(this._shouldRound)],
bind(this._updateFrequency),
this._calculateMultiDriveUsage.bind(this),
);
}
/**
* Starts the storage monitoring poller and performs initial data collection
*/
public initialize(): void {
if (!this._isInitialized) {
this._storagePoller.initialize();
this._isInitialized = true;
this._statBreakdown.subscribe(() => {
this._storage.set(this._statBreakdown.get().total);
});
this.refresh();
}
}
/**
* Manually triggers a storage data update outside the polling cycle
*/
public refresh(): void {
const multiDriveData = this._calculateMultiDriveUsage();
this._statBreakdown.set(multiDriveData);
this._storage.set(multiDriveData.total);
}
/**
* Gets storage data for a specific drive by path
* @param path - The mount path of the drive
*/
public getDriveInfo(path: string): DriveStorageData | undefined {
const data = this._statBreakdown.get();
return data.drives.find((drive) => drive.path === path);
}
/**
* Stops the automatic polling without destroying the service
*/
public stopPoller(): void {
this._storagePoller.stop();
}
/**
* Resumes automatic polling after it has been stopped
*/
public startPoller(): void {
this._storagePoller.start();
}
/**
* Cleans up all resources and stops monitoring
*/
public destroy(): void {
this._storagePoller.stop();
this._storage.drop();
this._statBreakdown.drop();
this._pathsToMonitor.drop();
this._updateFrequency.drop();
}
/**
* Gets the aggregated storage data across all monitored drives
*/
public get storage(): Variable<GenericResourceData> {
return this._storage;
}
/**
* Gets the detailed multi-drive storage data including individual drives
*/
public get statBreakdown(): Variable<MultiDriveStorageData> {
return this._statBreakdown;
}
/**
* Updates the paths to monitor for storage usage
* @param paths - Array of mount paths to monitor
*/
public set pathsToMonitor(paths: string[]) {
this._pathsToMonitor.set(unique(paths));
}
/**
* Sets whether percentage values should be rounded to whole numbers
* @param round - True to round percentages, false for 2 decimal places
*/
public set round(round: boolean) {
this._shouldRound.set(round);
}
/**
* Updates the polling interval
* @param timerInMs - Interval in milliseconds between updates
*/
public set frequency(timerInMs: number) {
this._updateFrequency.set(timerInMs);
}
/**
* Calculates storage usage for multiple drives and returns both individual and total data
*/
private _calculateMultiDriveUsage(): MultiDriveStorageData {
try {
const paths = this._pathsToMonitor.get();
const drives = this._collectDriveData(paths);
const total = this._calculateTotalUsage(drives);
return { total, drives };
} catch (error) {
console.error('Error calculating multi-drive storage usage:', error);
return this._getEmptyStorageData();
}
}
/**
* Collects storage data for each monitored drive
* @param paths - Array of mount paths to monitor
*/
private _collectDriveData(paths: string[]): DriveStorageData[] {
return paths
.map((path) => this._getDriveUsage(path))
.filter((drive): drive is DriveStorageData => drive !== null);
}
/**
* Gets storage usage for a single drive
* @param path - The mount path of the drive
*/
private _getDriveUsage(path: string): DriveStorageData | null {
try {
const fsUsage = new GTop.glibtop_fsusage();
GTop.glibtop_get_fsusage(fsUsage, path);
const total = fsUsage.blocks * fsUsage.block_size;
const available = fsUsage.bavail * fsUsage.block_size;
const used = total - available;
if (total === 0) return null;
return {
path,
name: this._extractDriveName(path),
total,
used,
free: available,
percentage: this._calculatePercentage(total, used),
};
} catch (error) {
console.error(`Error getting storage info for ${path}:`, error);
return null;
}
}
/**
* Extracts a readable name from a mount path
* @param path - The mount path
*/
private _extractDriveName(path: string): string {
return path.split('/').filter(Boolean).pop() || path;
}
/**
* Calculates total usage across all drives
* @param drives - Array of drive data
*/
private _calculateTotalUsage(drives: DriveStorageData[]): GenericResourceData {
const totals = drives.reduce(
(acc, drive) => ({
total: acc.total + drive.total,
used: acc.used + drive.used,
free: acc.free + drive.free,
}),
{ total: 0, used: 0, free: 0 },
);
return {
...totals,
percentage: this._calculatePercentage(totals.total, totals.used),
};
}
/**
* Calculates percentage with rounding support
* @param total - Total amount
* @param used - Used amount
*/
private _calculatePercentage(total: number, used: number): number {
if (total === 0) return 0;
const percentage = (used / total) * 100;
const shouldRound = this._shouldRound.get();
return shouldRound ? Math.round(percentage) : parseFloat(percentage.toFixed(2));
}
/**
* Returns empty storage data structure
*/
private _getEmptyStorageData(): MultiDriveStorageData {
return {
total: { total: 0, used: 0, percentage: 0, free: 0 },
drives: [],
};
}
}
export default StorageService;

View File

@@ -0,0 +1,18 @@
import { Variable } from 'astal';
import { GenericResourceData } from '../types';
export interface StorageServiceCtor {
pathsToMonitor: Variable<string[]>;
frequency?: Variable<number>;
round?: Variable<boolean>;
}
export interface DriveStorageData extends GenericResourceData {
path: string;
name: string;
}
export interface MultiDriveStorageData {
total: GenericResourceData;
drives: DriveStorageData[];
}

View File

@@ -0,0 +1,47 @@
export interface HardwarePollerInterface {
updateTimer(timerInMs: number): void;
stopPoller(): void;
startPoller(): void;
}
export interface ResourceUsageData {
total: number;
used: number;
percentage: number;
free: number;
}
export interface HardwareServiceConfig {
updateFrequency?: number;
shouldRound?: boolean;
}
type GenericResourceMetrics = {
total: number;
used: number;
percentage: number;
};
export type GenericResourceData = GenericResourceMetrics & {
free: number;
};
export type NetworkResourceData = {
in: string;
out: string;
};
export type ResourceLabelType = 'used/total' | 'used' | 'percentage' | 'free';
export type NetstatLabelType = 'full' | 'in' | 'out';
export type RateUnit = 'GiB' | 'MiB' | 'KiB' | 'auto';
export enum ByteMultiplier {
BYTE = 1,
KIBIBYTE = 1024,
MEBIBYTE = 1024 * 1024,
GIBIBYTE = 1024 * 1024 * 1024,
}
export const LABEL_TYPES: ResourceLabelType[] = ['used/total', 'used', 'free', 'percentage'] as const;

View File

@@ -0,0 +1,7 @@
import { Variable } from 'astal';
export const uptime = Variable(0).poll(
60_00,
'cat /proc/uptime',
(line): number => Number.parseInt(line.split('.')[0]) / 60,
);

View File

@@ -0,0 +1,136 @@
import { execAsync } from 'astal/process';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
/**
* Manages the lifecycle of the swww daemon process
*/
export class SwwwDaemon {
private _isRunning = false;
/**
* Gets whether the daemon is currently running
*/
public get isRunning(): boolean {
return this._isRunning;
}
/**
* Checks if swww is installed on the system
*/
public isInstalled(): boolean {
return SystemUtilities.checkDependencies('swww');
}
/**
* Starts the swww daemon if not already running
*/
public async start(): Promise<boolean> {
if (!this.isInstalled()) {
console.warn('swww is not installed, cannot start daemon');
return false;
}
const isAlreadyRunning = await this._checkIfRunning();
if (isAlreadyRunning) {
console.debug('swww-daemon is already running...');
this._isRunning = true;
return true;
}
return await this._startNewDaemon();
}
/**
* Stops the swww daemon
*/
public async stop(): Promise<void> {
try {
await execAsync('swww kill');
this._isRunning = false;
} catch (err) {
await this._handleStopError(err);
}
}
/**
* Checks if the swww daemon is currently running
*/
private async _checkIfRunning(): Promise<boolean> {
try {
await execAsync('swww query');
return true;
} catch {
return false;
}
}
/**
* Starts a new swww daemon instance
*/
private async _startNewDaemon(): Promise<boolean> {
try {
await execAsync('swww-daemon');
const ready = await this._waitForReady();
this._isRunning = ready;
if (!ready) {
await this._cleanupFailedDaemon();
return false;
}
return ready;
} catch (err) {
console.error('Failed to start swww-daemon:', err);
this._isRunning = false;
return false;
}
}
/**
* Cleans up a failed daemon start attempt
*/
private async _cleanupFailedDaemon(): Promise<void> {
try {
await execAsync('swww kill');
} catch {}
console.error('swww-daemon failed to become ready');
}
/**
* Handles errors when stopping the daemon
*/
private async _handleStopError(err: unknown): Promise<void> {
const wasRunning = await this._checkIfRunning();
if (wasRunning) {
console.error('[SwwwDaemon] Failed to stop swww-daemon:', err);
} else {
console.debug('[SwwwDaemon] swww-daemon was not running');
}
this._isRunning = false;
}
/**
* Waits for swww daemon to be ready using exponential backoff
*/
private async _waitForReady(): Promise<boolean> {
const maxAttempts = 10;
let delay = 50;
for (let i = 0; i < maxAttempts; i++) {
try {
await execAsync('swww query');
return true;
} catch {
if (i < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 2, 1000);
}
}
}
return false;
}
}

View File

@@ -0,0 +1,144 @@
import GObject, { GLib, property, register, signal } from 'astal/gobject';
import { monitorFile } from 'astal/file';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import options from 'src/configuration';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
import { SwwwDaemon } from './SwwwDaemon';
const hyprlandService = AstalHyprland.get_default();
const WP = `${GLib.get_home_dir()}/.config/background`;
/**
* Service for managing desktop wallpaper using swww daemon
*/
@register({ GTypeName: 'Wallpaper' })
export class WallpaperService extends GObject.Object {
@property(String)
declare public wallpaper: string;
@signal(Boolean)
declare public changed: (event: boolean) => void;
private static _instance: WallpaperService;
private _blockMonitor = false;
private _daemon = new SwwwDaemon();
constructor() {
super();
this.wallpaper = WP;
monitorFile(WP, () => {
if (!this._blockMonitor && this._daemon.isRunning) {
this._wallpaper();
}
});
options.wallpaper.enable.subscribe(async (isWallpaperEnabled) => {
if (isWallpaperEnabled) {
const started = await this._daemon.start();
if (started) {
this._wallpaper();
}
} else {
await this._daemon.stop();
}
});
if (options.wallpaper.enable.get()) {
this._daemon.start().then((started) => {
if (started) {
this._wallpaper();
}
});
}
}
/**
* Gets the singleton instance of WallpaperService
*
* @returns The WallpaperService instance
*/
public static getInstance(): WallpaperService {
if (this._instance === undefined) {
this._instance = new WallpaperService();
}
return this._instance;
}
/**
* Sets a new wallpaper from the specified file path
*
* @param path - Path to the wallpaper image file
*/
public setWallpaper(path: string): void {
this._setWallpaper(path);
}
/**
* Checks if the wallpaper service is currently running
*
* @returns Whether swww daemon is active
*/
public isRunning(): boolean {
return this._daemon.isRunning;
}
/**
* Applies the wallpaper using swww with a transition effect from cursor position
*/
private _wallpaper(): void {
if (!this._daemon.isRunning) {
console.warn('Cannot set wallpaper: swww-daemon is not running');
return;
}
try {
const cursorPosition = hyprlandService.message('cursorpos');
const transitionCmd = [
'swww',
'img',
'--invert-y',
'--transition-type',
'grow',
'--transition-duration',
'1.5',
'--transition-fps',
'60',
'--transition-pos',
cursorPosition.replace(' ', ''),
`"${WP}"`,
].join(' ');
SystemUtilities.sh(transitionCmd)
.then(() => {
this.notify('wallpaper');
this.emit('changed', true);
})
.catch((err) => {
console.error('Error setting wallpaper:', err);
});
} catch (err) {
console.error('Error getting cursor position:', err);
}
}
/**
* Copies wallpaper to config location and applies it
*
* @param path - Path to the wallpaper image file
*/
private async _setWallpaper(path: string): Promise<void> {
this._blockMonitor = true;
try {
await SystemUtilities.sh(`cp "${path}" "${WP}"`);
this._wallpaper();
} catch (error) {
console.error('Error setting wallpaper:', error);
} finally {
this._blockMonitor = false;
}
}
}

View File

@@ -0,0 +1,31 @@
import { WeatherProvider } from './types';
import { WeatherApiAdapter } from './weatherApi';
const weatherProviders: Record<string, WeatherProvider> = {
weatherapi: {
name: 'WeatherAPI.com',
baseUrl: 'https://api.weatherapi.com/v1',
adapter: new WeatherApiAdapter(),
formatUrl: (location: string, apiKey: string) =>
`https://api.weatherapi.com/v1/forecast.json?key=${apiKey}&q=${location}&days=1&aqi=no&alerts=no`,
},
};
/**
* Retrieves a weather provider configuration by its identifier
*
* @param providerId - Provider identifier (e.g., 'weatherapi', 'openweathermap')
* @returns Provider configuration or undefined if not found
*/
export function getWeatherProvider(providerId: string): WeatherProvider | undefined {
return weatherProviders[providerId];
}
/**
* Lists all available weather provider identifiers
*
* @returns Array of provider IDs
*/
export function getAvailableProviders(): string[] {
return Object.keys(weatherProviders);
}

View File

@@ -0,0 +1,28 @@
import { Weather } from '../types';
export interface WeatherProvider {
/** Provider display name */
name: string;
/** Base API URL */
baseUrl?: string;
/** Adapter instance for data transformation */
adapter?: WeatherAdapter;
/** Function to construct API URL with parameters */
formatUrl?: (location: string, apiKey: string) => string;
}
export interface WeatherAdapter<T = unknown> {
/**
* Converts provider-specific response to standard Weather format
* @param data Raw data from weather provider
* @returns Standardized weather data
*/
toStandardFormat(data: T): Weather;
/**
* Validates that required data is present in provider response
* @param data Raw data from weather provider
* @returns True if data contains all required fields
*/
validate?(data: T): boolean;
}

View File

@@ -0,0 +1,111 @@
import type {
CurrentWeather,
DailyForecast,
HourlyForecast,
Weather,
WeatherLocation,
Wind,
} from '../../types';
import { WeatherAdapter } from '../types';
import { WeatherApiStatusMapper } from './mapper';
import type { WeatherApiForecastDay, WeatherApiHour, WeatherApiResponse } from './types';
export class WeatherApiAdapter implements WeatherAdapter<WeatherApiResponse> {
private readonly _statusMapper: WeatherApiStatusMapper;
constructor() {
this._statusMapper = new WeatherApiStatusMapper();
}
/**
* Transforms WeatherAPI.com's response structure to the standard format
*
* @param data - Raw response from WeatherAPI.com
* @returns Normalized weather data
*/
public toStandardFormat(data: WeatherApiResponse): Weather {
return {
location: this._mapLocation(data),
current: this._mapCurrentWeather(data),
forecast: data.forecast.forecastday.map(this._mapDailyForecast.bind(this)),
lastUpdated: new Date(),
};
}
/**
* Maps WeatherAPI location data to standard format
*
* @param data - WeatherAPI response data
* @returns Standardized location information
*/
private _mapLocation(data: WeatherApiResponse): WeatherLocation {
const location = data.location;
return {
name: location.name,
region: location.region,
};
}
/**
* Maps current weather conditions to standard format
*
* @param data - WeatherAPI response data
* @returns Standardized current weather data
*/
private _mapCurrentWeather(data: WeatherApiResponse): CurrentWeather {
const currentWeather = data.current;
const currentRainChance = data.forecast.forecastday[0].hour[0].chance_of_rain;
return {
temperature: currentWeather.temp_c,
condition: {
text: this._statusMapper.toStatus(currentWeather.condition.text),
isDay: currentWeather.is_day === 1,
},
wind: {
speed: currentWeather.wind_kph,
direction: currentWeather.wind_dir as Wind['direction'],
},
chanceOfRain: currentRainChance,
humidity: currentWeather.humidity,
feelsLike: currentWeather.feelslike_c,
};
}
/**
* Maps daily forecast data to standard format
*
* @param forecastDay - WeatherAPI forecast day data
* @returns Standardized daily forecast
*/
private _mapDailyForecast(forecastDay: WeatherApiForecastDay): DailyForecast {
return {
date: new Date(forecastDay.date),
tempMin: forecastDay.day.mintemp_c,
tempMax: forecastDay.day.maxtemp_c,
condition: {
text: this._statusMapper.toStatus(forecastDay.day.condition.text),
},
chanceOfRain: forecastDay.day.daily_chance_of_rain,
hourly: forecastDay.hour.map(this._mapHourlyForecast.bind(this)),
};
}
/**
* Maps hourly forecast data to standard format
*
* @param hourlyForecast - WeatherAPI hourly forecast data
* @returns Standardized hourly forecast
*/
private _mapHourlyForecast(hourlyForecast: WeatherApiHour): HourlyForecast {
return {
time: new Date(hourlyForecast.time),
temperature: hourlyForecast.temp_c,
condition: {
text: this._statusMapper.toStatus(hourlyForecast.condition.text.trim()),
isDay: hourlyForecast.is_day === 1,
},
chanceOfRain: hourlyForecast.chance_of_rain,
};
}
}

View File

@@ -0,0 +1,71 @@
import { WeatherStatus } from '../../types';
import { WeatherApiIcon } from './types';
export class WeatherApiStatusMapper {
private readonly _WEATHER_API_STATUS_MAP: Record<WeatherApiIcon, WeatherStatus> = {
warning: 'WARNING',
sunny: 'SUNNY',
clear: 'CLEAR',
partly_cloudy: 'PARTLY CLOUDY',
partly_cloudy_night: 'PARTLY CLOUDY NIGHT',
cloudy: 'CLOUDY',
overcast: 'PARTLY CLOUDY',
mist: 'FOG',
patchy_rain_nearby: 'LIGHT RAIN',
patchy_rain_possible: 'LIGHT RAIN',
patchy_snow_possible: 'SNOW',
patchy_sleet_possible: 'SLEET',
patchy_freezing_drizzle_possible: 'SLEET',
thundery_outbreaks_possible: 'THUNDERSTORM',
blowing_snow: 'HEAVY SNOW',
blizzard: 'HEAVY SNOW',
fog: 'FOG',
freezing_fog: 'FOG',
patchy_light_drizzle: 'LIGHT RAIN',
light_drizzle: 'LIGHT RAIN',
freezing_drizzle: 'SLEET',
heavy_freezing_drizzle: 'SLEET',
patchy_light_rain: 'LIGHT RAIN',
light_rain: 'LIGHT RAIN',
moderate_rain_at_times: 'RAIN',
moderate_rain: 'LIGHT RAIN',
heavy_rain_at_times: 'HEAVY RAIN',
heavy_rain: 'HEAVY RAIN',
light_freezing_rain: 'SLEET',
moderate_or_heavy_freezing_rain: 'SLEET',
light_sleet: 'SLEET',
moderate_or_heavy_sleet: 'SLEET',
patchy_light_snow: 'SNOW',
light_snow: 'SNOW',
patchy_moderate_snow: 'SNOW',
moderate_snow: 'HEAVY SNOW',
patchy_heavy_snow: 'HEAVY SNOW',
heavy_snow: 'HEAVY SNOW',
ice_pellets: 'HAIL',
light_rain_shower: 'HEAVY RAIN',
moderate_or_heavy_rain_shower: 'HEAVY RAIN',
torrential_rain_shower: 'HEAVY RAIN',
light_sleet_showers: 'SLEET',
moderate_or_heavy_sleet_showers: 'SLEET',
light_snow_showers: 'SNOW',
moderate_or_heavy_snow_showers: 'SNOW',
light_showers_of_ice_pellets: 'HAIL',
moderate_or_heavy_showers_of_ice_pellets: 'HAIL',
patchy_light_rain_with_thunder: 'THUNDERSTORM',
moderate_or_heavy_rain_with_thunder: 'THUNDERSTORM',
moderate_or_heavy_rain_in_area_with_thunder: 'THUNDERSTORM',
patchy_light_snow_with_thunder: 'HEAVY SNOW',
moderate_or_heavy_snow_with_thunder: 'HEAVY SNOW',
};
/**
* Maps weather API status strings to standardized WeatherStatus
*
* @param status - The weather status string from the API
* @returns The mapped WeatherStatus
*/
public toStatus(status: string): WeatherStatus {
const snakeCasedStatus = status.toLowerCase().replace(' ', '_');
return this._WEATHER_API_STATUS_MAP[snakeCasedStatus as WeatherApiIcon] ?? 'WARNING';
}
}

View File

@@ -0,0 +1,192 @@
export interface WeatherApiResponse {
location: WeatherApiLocation;
current: WeatherApiCurrent;
forecast: WeatherApiForecast;
}
export interface WeatherApiLocation {
name: string;
region: string;
country: string;
lat: number;
lon: number;
tz_id: string;
localtime_epoch: number;
localtime: string;
}
export interface WeatherApiCurrent {
last_updated_epoch: number;
last_updated: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: WeatherApiCondition;
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
vis_km: number;
vis_miles: number;
uv: number;
gust_mph: number;
gust_kph: number;
}
export interface WeatherApiCondition {
text: string;
icon: string;
code: number;
}
export interface WeatherApiForecast {
forecastday: WeatherApiForecastDay[];
}
export interface WeatherApiForecastDay {
date: string;
date_epoch: number;
day: WeatherApiDay;
astro: WeatherApiAstro;
hour: WeatherApiHour[];
}
export interface WeatherApiDay {
maxtemp_c: number;
maxtemp_f: number;
mintemp_c: number;
mintemp_f: number;
avgtemp_c: number;
avgtemp_f: number;
maxwind_mph: number;
maxwind_kph: number;
totalprecip_mm: number;
totalprecip_in: number;
totalsnow_cm: number;
avgvis_km: number;
avgvis_miles: number;
avghumidity: number;
daily_will_it_rain: number;
daily_chance_of_rain: number;
daily_will_it_snow: number;
daily_chance_of_snow: number;
condition: WeatherApiCondition;
uv: number;
}
export interface WeatherApiAstro {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: number;
is_moon_up: number;
is_sun_up: number;
}
export interface WeatherApiHour {
time_epoch: number;
time: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: WeatherApiCondition;
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
snow_cm: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
will_it_rain: number;
chance_of_rain: number;
will_it_snow: number;
chance_of_snow: number;
vis_km: number;
vis_miles: number;
gust_mph: number;
gust_kph: number;
uv: number;
}
export type WeatherApiIcon =
| 'warning'
| 'sunny'
| 'clear'
| 'partly_cloudy'
| 'partly_cloudy_night'
| 'cloudy'
| 'overcast'
| 'mist'
| 'patchy_rain_nearby'
| 'patchy_rain_possible'
| 'patchy_snow_possible'
| 'patchy_sleet_possible'
| 'patchy_freezing_drizzle_possible'
| 'thundery_outbreaks_possible'
| 'blowing_snow'
| 'blizzard'
| 'fog'
| 'freezing_fog'
| 'patchy_light_drizzle'
| 'light_drizzle'
| 'freezing_drizzle'
| 'heavy_freezing_drizzle'
| 'patchy_light_rain'
| 'light_rain'
| 'moderate_rain_at_times'
| 'moderate_rain'
| 'heavy_rain_at_times'
| 'heavy_rain'
| 'light_freezing_rain'
| 'moderate_or_heavy_freezing_rain'
| 'light_sleet'
| 'moderate_or_heavy_sleet'
| 'patchy_light_snow'
| 'light_snow'
| 'patchy_moderate_snow'
| 'moderate_snow'
| 'patchy_heavy_snow'
| 'heavy_snow'
| 'ice_pellets'
| 'light_rain_shower'
| 'moderate_or_heavy_rain_shower'
| 'torrential_rain_shower'
| 'light_sleet_showers'
| 'moderate_or_heavy_sleet_showers'
| 'light_snow_showers'
| 'moderate_or_heavy_snow_showers'
| 'light_showers_of_ice_pellets'
| 'moderate_or_heavy_showers_of_ice_pellets'
| 'patchy_light_rain_with_thunder'
| 'moderate_or_heavy_rain_with_thunder'
| 'moderate_or_heavy_rain_in_area_with_thunder'
| 'patchy_light_snow_with_thunder'
| 'moderate_or_heavy_snow_with_thunder';

View File

@@ -0,0 +1,38 @@
import { Weather } from './types';
export const DEFAULT_WEATHER: Weather = {
location: {
name: 'Unknown',
region: '',
country: '',
},
current: {
temperature: 0,
feelsLike: 0,
condition: {
text: 'WARNING',
isDay: true,
},
wind: {
speed: 0,
direction: 'N',
degree: 0,
},
humidity: 0,
},
forecast: [
{
date: new Date(),
tempMin: 0,
tempMax: 0,
condition: {
text: 'WARNING',
isDay: true,
},
chanceOfRain: 0,
hourly: [],
},
],
lastUpdated: new Date(),
provider: 'none',
};

View File

@@ -0,0 +1,40 @@
import { WindDirection } from '../types';
/**
* Converts wind degrees to compass direction
*
* @param degrees - Wind direction in degrees (0-360)
* @returns Compass direction
*/
export const windDegreesToDirection = (degrees: number): WindDirection => {
const directions: WindDirection[] = [
'N',
'NNE',
'NE',
'ENE',
'E',
'ESE',
'SE',
'SSE',
'S',
'SSW',
'SW',
'WSW',
'W',
'WNW',
'NW',
'NNW',
];
const index = Math.round(degrees / 22.5) % 16;
return directions[index];
};
/**
* Normalizes weather condition codes to string format
*
* @param providerCode - Code from weather provider (string or number)
* @returns Normalized string code
*/
export const normalizeConditionCode = (providerCode: string | number): string => {
return String(providerCode);
};

View File

@@ -0,0 +1,332 @@
import { AstalIO, bind, interval, Variable } from 'astal';
import { getWeatherProvider } from 'src/services/weather/adapters/registry';
import { WeatherApiKeyManager } from './keyManager';
import options from 'src/configuration';
import { Opt } from 'src/lib/options';
import { httpClient } from 'src/lib/httpClient';
import { GaugeIcon, Percentage, Weather, WeatherIcon } from './types';
import { DEFAULT_WEATHER } from './default';
import { WeatherProvider } from './adapters/types';
import { TemperatureConverter } from 'src/lib/units/temperature';
import { SpeedConverter } from 'src/lib/units/speed';
import { UnitType } from 'src/lib/units/temperature/types';
/**
* Service for fetching and managing weather data from various providers
*/
export default class WeatherService {
public static instance: WeatherService;
private _currentProvider = 'weatherapi';
private readonly _location: Opt<string>;
private readonly _intervalFrequency: Opt<number>;
private _interval: null | AstalIO.Time = null;
private _unitType: Variable<UnitType> = Variable('imperial');
private _weatherData: Variable<Weather> = Variable(DEFAULT_WEATHER);
private _temperature: Variable<string> = Variable(this._getTemperature());
private _rainChance: Variable<Percentage> = Variable(this._getRainChance());
private _windCondition: Variable<string> = Variable(this._getWindConditions());
private _statusIcon: Variable<WeatherIcon> = Variable(this._getWeatherStatusIcon());
private _gaugeIcon: Variable<GaugeIcon> = Variable(this._getGaugeIcon());
private constructor() {
const { interval, location } = options.menus.clock.weather;
this._intervalFrequency = interval;
this._location = location;
this._initializeConfigTracker();
this._initializeWeatherTracker();
}
/**
* Gets the singleton instance of WeatherService
*
* @returns The WeatherService instance
*/
public static getInstance(): WeatherService {
if (WeatherService.instance === undefined) {
WeatherService.instance = new WeatherService();
}
return WeatherService.instance;
}
/**
* Changes the active weather provider
*
* @param providerId - Provider identifier (e.g., 'weatherapi', 'openweathermap')
*/
public setProvider(providerId: string): void {
const provider = getWeatherProvider(providerId);
if (!provider) {
throw new Error(`Weather provider '${providerId}' not found`);
}
this._currentProvider = providerId;
const weatherKeyManager = new WeatherApiKeyManager();
const weatherKey = weatherKeyManager.weatherApiKey.get();
if (weatherKey && this._location.get()) {
this._initializeWeatherPolling(this._intervalFrequency.get(), this._location.get(), weatherKey);
}
}
/**
* Gets the complete weather data variable
*
* @returns Variable containing all weather information
*/
public get weatherData(): Variable<Weather> {
return this._weatherData;
}
/**
* Gets the formatted temperature string variable
*
* @returns Variable containing temperature with unit
*/
public get temperature(): Variable<string> {
return this._temperature;
}
/**
* Gets the rain probability percentage variable
*
* @returns Variable containing rain chance percentage
*/
public get rainChance(): Variable<Percentage> {
return this._rainChance;
}
/**
* Gets the formatted wind conditions variable
*
* @returns Variable containing wind speed with unit
*/
public get windCondition(): Variable<string> {
return this._windCondition;
}
/**
* Gets the weather condition icon variable
*
* @returns Variable containing weather icon enum value
*/
public get statusIcon(): Variable<WeatherIcon> {
return this._statusIcon;
}
/**
* Gets the temperature gauge icon and color variable
*
* @returns Variable containing gauge icon and color class
*/
public get gaugeIcon(): Variable<GaugeIcon> {
return this._gaugeIcon;
}
/**
* Gets the current temperature unit type
*
* @returns Current unit type ('imperial' or 'metric')
*/
public get unit(): UnitType {
return this._unitType.get();
}
/**
* Sets the temperature unit type
*
* @param unitType - New unit type ('imperial' or 'metric')
*/
public set unit(unitType: UnitType) {
this._unitType.set(unitType);
}
/**
* Gets the temperature from the weather data in the specified unit.
*
* @returns - The temperature formatted as a string with the appropriate unit.
*/
private _getTemperature(): string {
const { temperature } = this.weatherData.get().current;
const tempConverter = TemperatureConverter.fromCelsius(temperature);
const isImperial = this._unitType.get() === 'imperial';
return isImperial ? tempConverter.formatFahrenheit() : tempConverter.formatCelsius();
}
/**
* Gets the appropriate weather icon for a condition
*
* @returns Weather icon
*/
private _getWeatherStatusIcon(): WeatherIcon {
const { condition } = this.weatherData.get().current;
if (condition.text === 'PARTLY CLOUDY NIGHT' && !condition.isDay) {
return WeatherIcon['PARTLY CLOUDY NIGHT'];
}
return WeatherIcon[condition.text] ?? WeatherIcon.WARNING;
}
/**
* Returns the weather gauge icon and color class based on the temperature in Celsius.
*
* @returns - An object containing the weather icon and color class.
*/
private _getGaugeIcon(): GaugeIcon {
const { temperature } = this.weatherData.get().current;
const icons = {
38: '',
24: '',
10: '',
[-4]: '',
[-18]: '',
} as const;
const colors = {
38: 'weather-color red',
24: 'weather-color orange',
10: 'weather-color lavender',
[-4]: 'weather-color blue',
[-18]: 'weather-color sky',
} as const;
type IconKeys = keyof typeof icons;
const threshold: IconKeys =
temperature < -18
? -18
: (([38, 24, 10, -4, -18] as IconKeys[]).find((threshold) => threshold <= temperature) ?? 10);
const icon = icons[threshold || 10];
const color = colors[threshold || 10];
return {
icon,
color,
};
}
/**
* Gets the wind conditions from the weather data in the specified unit.
*
* @returns - The wind conditions formatted as a string with the appropriate unit.
*/
private _getWindConditions(): string {
const windConditions = this.weatherData.get().current.wind;
const isImperial = this._unitType.get() === 'imperial';
const windSpeed = windConditions?.speed ?? 0;
const speedConverter = SpeedConverter.fromKph(windSpeed);
return isImperial ? speedConverter.formatMph() : speedConverter.formatKph();
}
/**
* Gets the chance of rain from the weather forecast data.
*
* @returns - The chance of rain formatted as a percentage string.
*/
private _getRainChance(): number {
const chanceOfRain = this.weatherData.get().current.chanceOfRain;
if (!chanceOfRain) {
return 0;
}
return chanceOfRain;
}
/**
* Sets up configuration tracking for dynamic weather updates
*/
private _initializeConfigTracker(): void {
const weatherKeyManager = new WeatherApiKeyManager();
Variable.derive(
[bind(weatherKeyManager.weatherApiKey), bind(this._intervalFrequency), bind(this._location)],
(weatherKey, weatherInterval, loc) => {
if (!weatherKey) {
return this._weatherData.set(DEFAULT_WEATHER);
}
this._initializeWeatherPolling(weatherInterval, loc, weatherKey);
},
)();
}
/**
* Sets up weather data tracking to update derived values
*/
private _initializeWeatherTracker(): void {
Variable.derive([bind(this._weatherData), bind(this._unitType)], () => {
this._statusIcon.set(this._getWeatherStatusIcon());
this._temperature.set(this._getTemperature());
this._rainChance.set(this._getRainChance());
this._windCondition.set(this._getWindConditions());
this._statusIcon.set(this._getWeatherStatusIcon());
this._gaugeIcon.set(this._getGaugeIcon());
});
}
/**
* Sets up a weather update interval function.
*
* @param weatherInterval - The interval in milliseconds at which to fetch weather updates
* @param loc - The location for which to fetch weather data
* @param weatherKey - The API key for accessing the weather service
*/
private _initializeWeatherPolling(weatherInterval: number, loc: string, weatherKey: string): void {
if (this._interval !== null) {
this._interval.cancel();
}
const provider = getWeatherProvider(this._currentProvider);
if (!provider) {
console.error(`Weather provider '${this._currentProvider}' not found`);
return;
}
this._interval = interval(weatherInterval, async () => {
this._fetchWeatherData(provider, loc, weatherKey);
});
}
/**
* Fetches weather data from the specified provider
*
* @param provider - The weather provider to use
* @param loc - The location to fetch weather for
* @param weatherKey - The API key for authentication
*/
private async _fetchWeatherData(
provider: WeatherProvider,
loc: string,
weatherKey: string,
): Promise<void> {
const formattedLocation = loc.replaceAll(' ', '%20');
const url =
provider.formatUrl?.(formattedLocation, weatherKey) ||
`${provider.baseUrl}?location=${formattedLocation}&key=${weatherKey}`;
try {
const response = await httpClient.get(url);
if (response.data && provider.adapter) {
const transformedData = provider.adapter.toStandardFormat(response.data);
this._weatherData.set(transformedData);
} else {
this._weatherData.set(DEFAULT_WEATHER);
}
} catch (error) {
console.error(`Failed to fetch weather from ${provider.name}: ${error}`);
this._weatherData.set(DEFAULT_WEATHER);
}
}
}

View File

@@ -0,0 +1,71 @@
import { GLib, Variable } from 'astal';
import options from 'src/configuration';
const { EXISTS, IS_REGULAR } = GLib.FileTest;
/**
* Manages weather API key retrieval and validation
* Supports loading keys from files or direct input
*/
export class WeatherApiKeyManager {
public weatherApiKey: Variable<string> = Variable('');
private readonly _apiKeyUserInput = options.menus.clock.weather.key;
constructor() {
this._mountWeatherKey(this._apiKeyUserInput.get());
this._apiKeyUserInput.subscribe((key) => {
this._mountWeatherKey(key);
});
}
/**
* Updates the weather API key variable with the processed key value
*
* @param key - The API key input which could be a direct key or file path
*/
private _mountWeatherKey(key: string): void {
const fetchedKey = this._getWeatherKey(key);
this.weatherApiKey.set(fetchedKey);
}
/**
* Retrieves the weather API key from a file if it exists and is valid.
*
* @param apiKey - The path to the file containing the weather API key.
* @returns The weather API key if found, otherwise the original apiKey.
*/
private _getWeatherKey(apiKey: string): string {
const weatherKey = apiKey;
const keyIsAFilePath = GLib.file_test(weatherKey, EXISTS) && GLib.file_test(weatherKey, IS_REGULAR);
if (!keyIsAFilePath) {
return apiKey;
}
try {
const fileContentArray = GLib.file_get_contents(weatherKey)[1];
const fileContent = new TextDecoder().decode(fileContentArray);
if (!fileContent) {
console.error('weather_api_key file is empty');
return '';
}
const parsedContent = JSON.parse(fileContent);
if (parsedContent.weather_api_key !== undefined) {
return parsedContent.weather_api_key;
}
console.error('weather_api_key is missing in the JSON content');
return '';
} catch (error) {
console.error(`Failed to read or parse weather key file: ${error}`);
return '';
}
}
}

View File

@@ -0,0 +1,127 @@
export type WindDirection =
| 'N'
| 'NNE'
| 'NE'
| 'ENE'
| 'E'
| 'ESE'
| 'SE'
| 'SSE'
| 'S'
| 'SSW'
| 'SW'
| 'WSW'
| 'W'
| 'WNW'
| 'NW'
| 'NNW';
export type Percentage = number;
export type WeatherStatus = keyof typeof WeatherIcon;
export interface Wind {
/** Wind speed in kilometers per hour */
speed: number;
/** Compass direction (16-point) */
direction?: WindDirection;
/** Wind direction in degrees (0-360) */
degree?: number;
}
export interface WeatherCondition {
/** Human-readable weather description */
text: WeatherStatus;
/** Whether it's daytime (for icon selection) */
isDay?: boolean;
}
export interface CurrentWeather {
/** Temperature value in Celsius */
temperature: number;
/** Feels like temperature in Celsius */
feelsLike?: number;
/** Weather condition */
condition: WeatherCondition;
/** Wind information */
wind?: Wind;
/** Chance of rain */
chanceOfRain?: Percentage;
/** Relative humidity (0-100) */
humidity?: Percentage;
}
export interface HourlyForecast {
/** Forecast time as Date */
time: Date;
/** Forecasted temperature in Celsius */
temperature: number;
/** Weather condition */
condition?: WeatherCondition;
/** Probability of rain (0-100) */
chanceOfRain?: Percentage;
}
export interface DailyForecast {
/** Forecast date as Date */
date: Date;
/** Minimum temperature for the day in Celsius */
tempMin: number;
/** Maximum temperature for the day in Celsius */
tempMax: number;
/** Predominant weather condition */
condition: WeatherCondition;
/** Daily rain probability (0-100) */
chanceOfRain?: Percentage;
/** Hourly breakdown (if available) */
hourly?: HourlyForecast[];
}
export interface WeatherLocation {
/** City/location name */
name: string;
/** State/province/region */
region?: string;
/** Country name */
country?: string;
}
export interface Weather {
/** Location information */
location: WeatherLocation;
/** Current weather conditions */
current: CurrentWeather;
/** Weather forecast (if available) */
forecast?: DailyForecast[];
/** Last update time as Date */
lastUpdated: Date;
/** Provider name for debugging */
provider?: string;
}
export enum WeatherIcon {
WARNING = '󰼯',
SUNNY = '󰖙',
CLEAR = '󰖔',
'PARTLY CLOUDY' = '󰖕',
'PARTLY CLOUDY NIGHT' = '󰼱',
CLOUDY = '󰖐',
FOG = '󰖑',
'LIGHT RAIN' = '󰼳',
RAIN = '󰖗',
'HEAVY RAIN' = '󰖖',
SNOW = '󰼴',
'HEAVY SNOW' = '󰼶',
SLEET = '󰙿',
HAIL = '󰖒',
THUNDERSTORM = '󰙾',
}
export interface GaugeIcon {
icon: '' | '' | '' | '' | '';
color:
| 'weather-color red'
| 'weather-color orange'
| 'weather-color lavender'
| 'weather-color blue'
| 'weather-color sky';
}

View File

@@ -0,0 +1,35 @@
import { Percentage, WindDirection } from '../types';
/**
* Type guard to check if a value is a valid percentage
*
* @param value - Number to check
* @returns True if value is between 0 and 100
*/
export const isValidPercentage = (value: number): value is Percentage => value >= 0 && value <= 100;
/**
* Type guard to check if a string is a valid wind direction
*
* @param dir - String to check
* @returns True if string is a valid WindDirection
*/
export const isValidWindDirection = (dir: string): dir is WindDirection =>
[
'N',
'NNE',
'NE',
'ENE',
'E',
'ESE',
'SE',
'SSE',
'S',
'SSW',
'SW',
'WSW',
'W',
'WNW',
'NW',
'NNW',
].includes(dir);

View File

@@ -0,0 +1,309 @@
import { Variable } from 'astal';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { range, unique } from 'src/lib/array/helpers';
import options from 'src/configuration';
import { WorkspaceMonitorMap, MonitorMap, WorkspaceRule } from './types';
const hyprlandService = AstalHyprland.get_default();
/**
* Manages Hyprland workspace operations and monitor relationships, providing centralized
* workspace navigation and rule management across the panel system
*/
export class WorkspaceService {
public static instance: WorkspaceService;
private _ignored = options.bar.workspaces.ignored;
public workspaceRules = Variable(this._getWorkspaceMonitorMap());
public forceUpdater = Variable(true);
private constructor() {}
/**
* Gets the singleton instance of WorkspaceService
*
* @returns The WorkspaceService instance
*/
public static getInstance(): WorkspaceService {
if (WorkspaceService.instance === undefined) {
WorkspaceService.instance = new WorkspaceService();
}
return WorkspaceService.instance;
}
/** Computes which workspace numbers should be rendered for a given monitor.
*
* This function consolidates both active and all possible workspaces (based on rules),
* then filters them by the selected monitor if `isMonitorSpecific` is set to `true`.
*
* @param totalWorkspaces - The total number of workspaces (a fallback if workspace rules are not enforced).
* @param workspaceInstances - A list of Hyprland workspace objects.
* @param workspaceMonitorRules - The map of monitor names to assigned workspace numbers.
* @param monitorId - The numeric identifier of the monitor.
* @param isMonitorSpecific - If `true`, only include the workspaces that match this monitor.
* @param hyprlandMonitorInstances - A list of Hyprland monitor objects.
*
* @returns An array of workspace numbers that should be shown.
*/
public getWorkspaces(
totalWorkspaces: number,
workspaceInstances: AstalHyprland.Workspace[],
workspaceMonitorRules: WorkspaceMonitorMap,
monitorId: number,
isMonitorSpecific: boolean,
hyprlandMonitorInstances: AstalHyprland.Monitor[],
): number[] {
let allPotentialWorkspaces = range(totalWorkspaces || 8);
const allWorkspaceInstances = workspaceInstances ?? [];
const activeWorkspaceIds = allWorkspaceInstances.map((workspaceInstance) => workspaceInstance.id);
const monitorReferencesForActiveWorkspaces = allWorkspaceInstances.map((workspaceInstance) => {
return {
id: workspaceInstance.monitor?.id ?? -1,
name: workspaceInstance.monitor?.name ?? '',
};
});
const currentMonitorInstance =
hyprlandMonitorInstances.find((monitorObj) => monitorObj.id === monitorId) ||
monitorReferencesForActiveWorkspaces.find((monitorObj) => monitorObj.id === monitorId);
const allWorkspacesWithRules = Object.keys(workspaceMonitorRules).reduce(
(accumulator: number[], monitorName: string) => {
return [...accumulator, ...workspaceMonitorRules[monitorName]];
},
[],
);
const activeWorkspacesForCurrentMonitor = activeWorkspaceIds.filter((workspaceId) => {
const metadataForWorkspace = allWorkspaceInstances.find(
(workspaceObj) => workspaceObj.id === workspaceId,
);
if (metadataForWorkspace) {
return metadataForWorkspace?.monitor?.id === monitorId;
}
if (
currentMonitorInstance &&
Object.hasOwnProperty.call(workspaceMonitorRules, currentMonitorInstance.name) &&
allWorkspacesWithRules.includes(workspaceId)
) {
return workspaceMonitorRules[currentMonitorInstance.name].includes(workspaceId);
}
return false;
});
if (isMonitorSpecific) {
const validWorkspaceNumbers = range(totalWorkspaces).filter((workspaceNumber) => {
return this._isWorkspaceValidForMonitor(
workspaceNumber,
workspaceMonitorRules,
monitorId,
allWorkspaceInstances,
hyprlandMonitorInstances,
);
});
allPotentialWorkspaces = unique([...activeWorkspacesForCurrentMonitor, ...validWorkspaceNumbers]);
} else {
allPotentialWorkspaces = unique([...allPotentialWorkspaces, ...activeWorkspaceIds]);
}
return allPotentialWorkspaces
.filter((workspace) => !this._isWorkspaceIgnored(workspace))
.sort((a, b) => a - b);
}
/**
* Navigates to the next workspace in the current monitor.
*/
public goToNextWorkspace(): void {
this._navigateWorkspace('next');
}
/**
* Navigates to the previous workspace in the current monitor.
*/
public goToPreviousWorkspace(): void {
this._navigateWorkspace('prev');
}
/**
* Gets a new set of workspace rules. Used to update stale rules.
*/
public refreshWorkspaceRules(): void {
this.workspaceRules.set(this._getWorkspaceMonitorMap());
}
/**
* Forces a UI update by toggling the forceUpdater variable
*/
public forceAnUpdate(): void {
this.forceUpdater.set(!this.forceUpdater.get());
}
/**
* Checks whether a given workspace is valid (assigned) for the specified monitor.
*
* This function inspects the workspace rules object to determine if the current workspace belongs
* to the target monitor. If no workspace rules exist, the function defaults to returning `true`.
*
* @param workspaceId - The number representing the current workspace.
* @param workspaceMonitorRules - The map of monitor names to assigned workspace numbers.
* @param monitorId - The numeric identifier for the monitor.
* @param workspaceList - A list of Hyprland workspace objects.
* @param monitorList - A list of Hyprland monitor objects.
*
* @returns `true` if the workspace is assigned to the monitor or if no rules exist. Otherwise, `false`.
*/
private _isWorkspaceValidForMonitor(
workspaceId: number,
workspaceMonitorRules: WorkspaceMonitorMap,
monitorId: number,
workspaceList: AstalHyprland.Workspace[],
monitorList: AstalHyprland.Monitor[],
): boolean {
const monitorNameMap: MonitorMap = {};
const allWorkspaceInstances = workspaceList ?? [];
const workspaceMonitorReferences = allWorkspaceInstances
.filter((workspaceInstance) => workspaceInstance !== null)
.map((workspaceInstance) => {
return {
id: workspaceInstance.monitor?.id,
name: workspaceInstance.monitor?.name,
};
});
const mergedMonitorInstances = [
...new Map(
[...workspaceMonitorReferences, ...monitorList].map((monitorCandidate) => [
monitorCandidate.id,
monitorCandidate,
]),
).values(),
];
mergedMonitorInstances.forEach((monitorInstance) => {
monitorNameMap[monitorInstance.id] = monitorInstance.name;
});
const currentMonitorName = monitorNameMap[monitorId];
const currentMonitorWorkspaceRules = workspaceMonitorRules[currentMonitorName] ?? [];
const activeWorkspaceIds = new Set(allWorkspaceInstances.map((ws) => ws.id));
const filteredWorkspaceRules = currentMonitorWorkspaceRules.filter(
(ws) => !activeWorkspaceIds.has(ws),
);
if (filteredWorkspaceRules === undefined) {
return false;
}
return filteredWorkspaceRules.includes(workspaceId);
}
/**
* Changes the active workspace in the specified direction ('next' or 'prev').
*
* This function uses the current monitor's set of active or assigned workspaces and
* cycles through them in the chosen direction. It also respects the list of ignored
* workspaces, skipping any that match the ignored pattern.
*
* @param direction - The direction to navigate ('next' or 'prev').
* @param currentMonitorWorkspacesVariable - A Variable containing an array of workspace numbers for the current monitor.
* @param onlyActiveWorkspaces - Whether to only include active (occupied) workspaces when navigating.
* @param ignoredWorkspacesVariable - A Variable that contains the ignored workspaces pattern.
*/
private _navigateWorkspace(direction: 'next' | 'prev'): void {
const allHyprlandWorkspaces = hyprlandService.get_workspaces() ?? [];
const activeWorkspaceIds = allHyprlandWorkspaces
.filter(
(workspaceInstance) => hyprlandService.focusedMonitor.id === workspaceInstance.monitor?.id,
)
.map((workspaceInstance) => workspaceInstance.id);
const assignedOrOccupiedWorkspaces = activeWorkspaceIds.sort((a, b) => a - b);
if (assignedOrOccupiedWorkspaces.length === 0) {
return;
}
const workspaceIndex = assignedOrOccupiedWorkspaces.indexOf(hyprlandService.focusedWorkspace?.id);
const step = direction === 'next' ? 1 : -1;
let newIndex =
(workspaceIndex + step + assignedOrOccupiedWorkspaces.length) %
assignedOrOccupiedWorkspaces.length;
let attempts = 0;
while (attempts < assignedOrOccupiedWorkspaces.length) {
const targetWorkspaceNumber = assignedOrOccupiedWorkspaces[newIndex];
if (!this._isWorkspaceIgnored(targetWorkspaceNumber)) {
hyprlandService.dispatch('workspace', targetWorkspaceNumber.toString());
return;
}
newIndex =
(newIndex + step + assignedOrOccupiedWorkspaces.length) % assignedOrOccupiedWorkspaces.length;
attempts++;
}
}
/**
* Fetches a map of monitors to the workspace numbers that belong to them.
*
* This function communicates with the Hyprland service to retrieve workspace rules in JSON format.
* Those rules are parsed, and a map of monitor names to lists of assigned workspace numbers is constructed.
*
* @returns An object where each key is a monitor name, and each value is an array of workspace numbers.
*/
private _getWorkspaceMonitorMap(): WorkspaceMonitorMap {
try {
const rulesResponse = hyprlandService.message('j/workspacerules');
const workspaceMonitorRules: WorkspaceMonitorMap = {};
const parsedWorkspaceRules = JSON.parse(rulesResponse);
parsedWorkspaceRules.forEach((rule: WorkspaceRule) => {
const workspaceNumber = parseInt(rule.workspaceString, 10);
if (rule.monitor === undefined || isNaN(workspaceNumber)) {
return;
}
const doesMonitorExistInRules = Object.hasOwnProperty.call(
workspaceMonitorRules,
rule.monitor,
);
if (doesMonitorExistInRules) {
workspaceMonitorRules[rule.monitor].push(workspaceNumber);
} else {
workspaceMonitorRules[rule.monitor] = [workspaceNumber];
}
});
return workspaceMonitorRules;
} catch (error) {
console.error(error);
return {};
}
}
/**
* Checks if a workspace number should be ignored based on a regular expression.
*
* @param workspaceNumber - The numeric representation of the workspace to check.
* @returns `true` if the workspace should be ignored, otherwise `false`.
*/
private _isWorkspaceIgnored(workspaceNumber: number): boolean {
if (this._ignored.get() === '') {
return false;
}
const ignoredWorkspacesRegex = new RegExp(this._ignored.get());
return ignoredWorkspacesRegex.test(workspaceNumber.toString());
}
}

View File

@@ -0,0 +1,12 @@
export type WorkspaceRule = {
workspaceString: string;
monitor: string;
};
export type WorkspaceMonitorMap = {
[key: string]: number[];
};
export type MonitorMap = {
[key: number]: string;
};