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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
25
src/services/cli/commander/InitializeCommand.ts
Normal file
25
src/services/cli/commander/InitializeCommand.ts
Normal 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));
|
||||
}
|
||||
200
src/services/cli/commander/Parser.ts
Normal file
200
src/services/cli/commander/Parser.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/services/cli/commander/Registry.ts
Normal file
53
src/services/cli/commander/Registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
89
src/services/cli/commander/RequestHandler.ts
Normal file
89
src/services/cli/commander/RequestHandler.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/services/cli/commander/commands/appearance/index.ts
Normal file
79
src/services/cli/commander/commands/appearance/index.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
26
src/services/cli/commander/commands/modules/media/index.ts
Normal file
26
src/services/cli/commander/commands/modules/media/index.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
257
src/services/cli/commander/commands/system/checkDependencies.ts
Normal file
257
src/services/cli/commander/commands/system/checkDependencies.ts
Normal 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;
|
||||
};
|
||||
221
src/services/cli/commander/commands/system/index.ts
Normal file
221
src/services/cli/commander/commands/system/index.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
34
src/services/cli/commander/commands/system/listSensors.ts
Normal file
34
src/services/cli/commander/commands/system/listSensors.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
196
src/services/cli/commander/helpers/index.ts
Normal file
196
src/services/cli/commander/helpers/index.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
33
src/services/cli/commander/index.ts
Normal file
33
src/services/cli/commander/index.ts
Normal 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) });
|
||||
});
|
||||
}
|
||||
31
src/services/cli/commander/types.ts
Normal file
31
src/services/cli/commander/types.ts
Normal 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[];
|
||||
}
|
||||
21
src/services/cli/helpers/systray.ts
Normal file
21
src/services/cli/helpers/systray.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
35
src/services/cli/helpers/wallpaper.ts
Normal file
35
src/services/cli/helpers/wallpaper.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/services/cli/services/window/index.ts
Normal file
79
src/services/cli/services/window/index.ts
Normal 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();
|
||||
152
src/services/display/bar/autoHide.ts
Normal file
152
src/services/display/bar/autoHide.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
28
src/services/display/bar/index.ts
Normal file
28
src/services/display/bar/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
src/services/display/bar/types.ts
Normal file
1
src/services/display/bar/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type BarToggleStates = Record<string, boolean | undefined>;
|
||||
268
src/services/display/monitor/index.ts
Normal file
268
src/services/display/monitor/index.ts
Normal 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 hasn’t 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;
|
||||
};
|
||||
60
src/services/matugen/defaults.ts
Normal file
60
src/services/matugen/defaults.ts
Normal 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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
526
src/services/media/index.ts
Normal 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;
|
||||
20
src/services/media/types.ts
Normal file
20
src/services/media/types.ts
Normal 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;
|
||||
102
src/services/network/ethernet.ts
Normal file
102
src/services/network/ethernet.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
70
src/services/network/index.ts
Normal file
70
src/services/network/index.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
43
src/services/network/types.ts
Normal file
43
src/services/network/types.ts
Normal 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;
|
||||
351
src/services/network/wifi.ts
Normal file
351
src/services/network/wifi.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
109
src/services/system/cpuUsage/index.ts
Normal file
109
src/services/system/cpuUsage/index.ts
Normal 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;
|
||||
5
src/services/system/cpuUsage/types.ts
Normal file
5
src/services/system/cpuUsage/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Variable } from 'astal';
|
||||
|
||||
export interface CpuServiceCtor {
|
||||
frequency?: Variable<number>;
|
||||
}
|
||||
163
src/services/system/cputemp/index.ts
Normal file
163
src/services/system/cputemp/index.ts
Normal 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;
|
||||
242
src/services/system/cputemp/sensorDiscovery.ts
Normal file
242
src/services/system/cputemp/sensorDiscovery.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/services/system/cputemp/types.ts
Normal file
12
src/services/system/cputemp/types.ts
Normal 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';
|
||||
}
|
||||
127
src/services/system/gpuUsage/index.ts
Normal file
127
src/services/system/gpuUsage/index.ts
Normal 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;
|
||||
21
src/services/system/gpuUsage/types.ts
Normal file
21
src/services/system/gpuUsage/types.ts
Normal 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[];
|
||||
};
|
||||
277
src/services/system/networkUsage/index.ts
Normal file
277
src/services/system/networkUsage/index.ts
Normal 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;
|
||||
11
src/services/system/networkUsage/types.ts
Normal file
11
src/services/system/networkUsage/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Variable } from 'astal';
|
||||
|
||||
export interface NetworkServiceCtor {
|
||||
frequency?: Variable<number>;
|
||||
}
|
||||
|
||||
export interface NetworkUsage {
|
||||
name: string;
|
||||
rx: number;
|
||||
tx: number;
|
||||
}
|
||||
139
src/services/system/ramUsage/index.ts
Normal file
139
src/services/system/ramUsage/index.ts
Normal 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;
|
||||
5
src/services/system/ramUsage/types.ts
Normal file
5
src/services/system/ramUsage/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Variable } from 'astal';
|
||||
|
||||
export interface RamServiceCtor {
|
||||
frequency?: Variable<number>;
|
||||
}
|
||||
255
src/services/system/storage/index.ts
Normal file
255
src/services/system/storage/index.ts
Normal 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;
|
||||
18
src/services/system/storage/types.ts
Normal file
18
src/services/system/storage/types.ts
Normal 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[];
|
||||
}
|
||||
47
src/services/system/types.ts
Normal file
47
src/services/system/types.ts
Normal 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;
|
||||
7
src/services/system/uptime/index.ts
Normal file
7
src/services/system/uptime/index.ts
Normal 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,
|
||||
);
|
||||
136
src/services/wallpaper/SwwwDaemon.ts
Normal file
136
src/services/wallpaper/SwwwDaemon.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
144
src/services/wallpaper/index.ts
Normal file
144
src/services/wallpaper/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/services/weather/adapters/registry.ts
Normal file
31
src/services/weather/adapters/registry.ts
Normal 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);
|
||||
}
|
||||
28
src/services/weather/adapters/types.ts
Normal file
28
src/services/weather/adapters/types.ts
Normal 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;
|
||||
}
|
||||
111
src/services/weather/adapters/weatherApi/index.ts
Normal file
111
src/services/weather/adapters/weatherApi/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
71
src/services/weather/adapters/weatherApi/mapper.ts
Normal file
71
src/services/weather/adapters/weatherApi/mapper.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
192
src/services/weather/adapters/weatherApi/types.ts
Normal file
192
src/services/weather/adapters/weatherApi/types.ts
Normal 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';
|
||||
38
src/services/weather/default.ts
Normal file
38
src/services/weather/default.ts
Normal 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',
|
||||
};
|
||||
40
src/services/weather/formatters/index.ts
Normal file
40
src/services/weather/formatters/index.ts
Normal 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);
|
||||
};
|
||||
332
src/services/weather/index.ts
Normal file
332
src/services/weather/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/services/weather/keyManager/index.ts
Normal file
71
src/services/weather/keyManager/index.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
127
src/services/weather/types/index.ts
Normal file
127
src/services/weather/types/index.ts
Normal 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';
|
||||
}
|
||||
35
src/services/weather/validators/index.ts
Normal file
35
src/services/weather/validators/index.ts
Normal 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);
|
||||
309
src/services/workspace/index.ts
Normal file
309
src/services/workspace/index.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
12
src/services/workspace/types.ts
Normal file
12
src/services/workspace/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type WorkspaceRule = {
|
||||
workspaceString: string;
|
||||
monitor: string;
|
||||
};
|
||||
|
||||
export type WorkspaceMonitorMap = {
|
||||
[key: string]: number[];
|
||||
};
|
||||
|
||||
export type MonitorMap = {
|
||||
[key: number]: string;
|
||||
};
|
||||
Reference in New Issue
Block a user