Feat: Add live reloading of configuration file (#684)

* Add live reloading of configuration file

This also removes the need for a file with all the available
configuration and a shadow configuration file.

Additionally, added several improvements:
1. Reduce I/O on initial configuration loading by only reading file once
2. Remove unnecesary back and forth events when editing configuration

* Add missing return type

* Consistently reset on config changes and error if failed to initialize config

* Fix massive I/O load on startup by numerical options

* Use _findVal when monitoring config file

* Apply PR requested changes

Signed-off-by: davfsa <davfsa@gmail.com>

* Add missing =>

Signed-off-by: davfsa <davfsa@gmail.com>

* Fix reassignment to const, change to let.

---------

Signed-off-by: davfsa <davfsa@gmail.com>
Co-authored-by: Jas Singh <jaskiratpal.singh@outlook.com>
This commit is contained in:
davfsa
2025-03-16 10:39:25 +01:00
committed by GitHub
parent 50faa14621
commit a949b34632
8 changed files with 170 additions and 181 deletions

View File

@@ -272,32 +272,23 @@ export const importFiles = (themeOnly: boolean = false): void => {
iconName: icons.ui.info, iconName: icons.ui.info,
}); });
const tmpConfigFile = Gio.File.new_for_path(`${TMP}/config.json`);
const optionsConfigFile = Gio.File.new_for_path(CONFIG); const optionsConfigFile = Gio.File.new_for_path(CONFIG);
const [tmpSuccess, tmpContent] = tmpConfigFile.load_contents(null);
const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null); const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null);
if (!tmpSuccess || !optionsSuccess) { if (!optionsSuccess) {
console.error('Failed to read existing configuration files.'); console.error('Failed to read existing configuration file.');
dialog.destroy(); dialog.destroy();
return; return;
} }
let tmpConfig = JSON.parse(new TextDecoder('utf-8').decode(tmpContent));
let optionsConfig = JSON.parse(new TextDecoder('utf-8').decode(optionsContent)); let optionsConfig = JSON.parse(new TextDecoder('utf-8').decode(optionsContent));
if (themeOnly) { const filteredConfig = themeOnly
const filteredConfig = filterConfigForThemeOnly(importedConfig); ? filterConfigForThemeOnly(importedConfig)
tmpConfig = { ...tmpConfig, ...filteredConfig }; : filterConfigForNonTheme(importedConfig);
optionsConfig = { ...optionsConfig, ...filteredConfig }; optionsConfig = { ...optionsConfig, ...filteredConfig };
} else {
const filteredConfig = filterConfigForNonTheme(importedConfig);
tmpConfig = { ...tmpConfig, ...filteredConfig };
optionsConfig = { ...optionsConfig, ...filteredConfig };
}
saveConfigToFile(tmpConfig, `${TMP}/config.json`);
saveConfigToFile(optionsConfig, CONFIG); saveConfigToFile(optionsConfig, CONFIG);
} }
dialog.destroy(); dialog.destroy();

View File

@@ -31,7 +31,6 @@ export const BooleanInputter = <T extends string | number | boolean | object>({
interface BooleanInputterProps<T> { interface BooleanInputterProps<T> {
opt: Opt<T>; opt: Opt<T>;
isUnsaved?: Variable<boolean>;
disabledBinding?: Variable<boolean>; disabledBinding?: Variable<boolean>;
dependencies?: string[]; dependencies?: string[];
} }

View File

@@ -29,14 +29,18 @@ export const NumberInputter = <T extends string | number | boolean | object>({
})} })}
</box> </box>
<SpinButton <SpinButton
onChanged={(self) => {
const currentText = self.value;
const optValue = opt.get();
isUnsaved.set(currentText !== optValue);
}}
onActivate={(self) => {
opt.set(self.value as T);
}}
setup={(self) => { setup={(self) => {
self.set_range(min, max); self.set_range(min, max);
self.set_increments(1 * increment, 5 * increment); self.set_increments(1 * increment, 5 * increment);
self.connect('value-changed', () => {
opt.set(self.value as T);
});
useHook(self, opt, () => { useHook(self, opt, () => {
self.set_value(opt.get() as number); self.set_value(opt.get() as number);
isUnsaved.set(Number(self.get_text()) !== opt.get()); isUnsaved.set(Number(self.get_text()) !== opt.get());

View File

@@ -14,24 +14,19 @@ globalThis.useTheme = (filePath: string): void => {
return; return;
} }
const tmpConfigFile = Gio.File.new_for_path(`${TMP}/config.json`);
const optionsConfigFile = Gio.File.new_for_path(CONFIG); const optionsConfigFile = Gio.File.new_for_path(CONFIG);
const [tmpSuccess, tmpContent] = tmpConfigFile.load_contents(null);
const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null); const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null);
if (!tmpSuccess || !optionsSuccess) { if (!optionsSuccess) {
throw new Error('Failed to load theme file.'); throw new Error('Failed to load theme file.');
} }
let tmpConfig = JSON.parse(new TextDecoder('utf-8').decode(tmpContent));
let optionsConfig = JSON.parse(new TextDecoder('utf-8').decode(optionsContent)); let optionsConfig = JSON.parse(new TextDecoder('utf-8').decode(optionsContent));
const filteredConfig = filterConfigForThemeOnly(importedConfig); const filteredConfig = filterConfigForThemeOnly(importedConfig);
tmpConfig = { ...tmpConfig, ...filteredConfig };
optionsConfig = { ...optionsConfig, ...filteredConfig }; optionsConfig = { ...optionsConfig, ...filteredConfig };
saveConfigToFile(tmpConfig, `${TMP}/config.json`);
saveConfigToFile(optionsConfig, CONFIG); saveConfigToFile(optionsConfig, CONFIG);
bash(restartCommand.get()); bash(restartCommand.get());
} catch (error) { } catch (error) {

View File

@@ -1,20 +1,17 @@
import { isHexColor } from '../globals/variables';
import { MkOptionsResult } from './types/options'; import { MkOptionsResult } from './types/options';
import { ensureDirectory } from './session';
import Variable from 'astal/variable'; import Variable from 'astal/variable';
import { monitorFile, readFile, writeFile } from 'astal/file'; import { monitorFile, readFile, writeFile } from 'astal/file';
import GLib from 'gi://GLib?version=2.0'; import { errorHandler, Notify } from './utils';
import { errorHandler } from './utils'; import { ensureDirectory } from './session';
import icons from './icons/icons';
type OptProps = { type OptProps = {
persistent?: boolean; persistent?: boolean;
}; };
/** type WriteDiskProps = {
* A file to store default configurations. Placed inside the cache directory. writeDisk?: boolean;
* NOTE: We need to move this out into the .config directory instead. };
*/
export const defaultFile = `${GLib.get_tmp_dir()}/ags/hyprpanel/default.json`;
export class Opt<T = unknown> extends Variable<T> { export class Opt<T = unknown> extends Variable<T> {
/** /**
@@ -76,95 +73,87 @@ export class Opt<T = unknown> extends Variable<T> {
} }
/** /**
* Initializes this option by attempting to read its value from a cache file. * Initializes this option based on the provided configuration, if available.
* If found, sets the current value. Also sets up a subscription to write updates back.
* *
* @param cacheFile - The path to the cache file. * @param config - The configuration.
*/ */
public init(cacheFile: string): void { public init(config: Record<string, unknown>): void {
const rawData = readFile(cacheFile); const value = _findVal(config, this._id.split('.'));
let cacheData: Record<string, unknown> = {}; if (value !== undefined) {
this.set(value as T, { writeDisk: false });
if (rawData && rawData.trim() !== '') {
try {
cacheData = JSON.parse(rawData) as Record<string, unknown>;
} catch (error) {
errorHandler(error);
} }
} }
const cachedVariable = this._findKey(cacheData, this._id.split('.'));
if (cachedVariable !== undefined) {
this.set(cachedVariable as T);
}
this.subscribe((newVal) => {
const reRaw = readFile(cacheFile);
let currentCache: Record<string, unknown> = {};
if (reRaw && reRaw.trim() !== '') {
try {
currentCache = JSON.parse(reRaw) as Record<string, unknown>;
} catch {
// Do nuffin
}
}
currentCache[this._id] = newVal;
writeFile(cacheFile, JSON.stringify(currentCache, null, 2));
});
}
/** /**
* Initializes this option by attempting to read its default value from the default file. * Set the given configuration value and write it to disk, if specified.
* If found, sets the current value. *
* @param value - The new value.
* @param writeDisk - Whether to write the changes to disk. Defaults to true.
*/ */
public createDefault(): void { public set = (value: T, { writeDisk = true }: WriteDiskProps = {}): void => {
const rawData = readFile(defaultFile); if (value === this.get()) {
// If nothing actually changed, exit quick
return;
}
let defaultData: Record<string, unknown> = {}; super.set(value);
if (rawData && rawData.trim() !== '') { if (writeDisk) {
const raw = readFile(CONFIG);
let config: Record<string, unknown> = {};
if (raw && raw.trim() !== '') {
try { try {
defaultData = JSON.parse(rawData) as Record<string, unknown>; config = JSON.parse(raw) as Record<string, unknown>;
} catch { } catch (error) {
// do nuffin // Last thing we want is to reset someones entire config
} // so notify them instead
} console.error(`Failed to load config file: ${error}`);
Notify({
summary: 'Failed to load config file',
body: `${error}`,
iconName: icons.ui.warning,
});
const defaultVal = defaultData[this._id]; errorHandler(error);
if (defaultVal !== undefined) {
this.set(defaultVal as T);
} }
} }
config[this._id] = value;
writeFile(CONFIG, JSON.stringify(config, null, 2));
}
};
/** /**
* Resets the value of this option to its initial value if not persistent and if it differs from the current value. * Resets the value of this option to its initial value if not persistent and if it differs from the current value.
* *
* @param writeDisk - Whether to write the changes to disk. Defaults to true.
* @returns Returns the option's ID if reset occurred, otherwise undefined. * @returns Returns the option's ID if reset occurred, otherwise undefined.
*/ */
public reset(): string | undefined { public reset(writeDiskProps: WriteDiskProps = {}): string | undefined {
if (this.persistent) { if (this.persistent) {
return undefined; return undefined;
} }
const current = this.get(); let currentValue: string | T = this.get();
currentValue = typeof currentValue === 'object' ? JSON.stringify(currentValue) : currentValue;
let initialValue: string | T = this.initial;
initialValue = typeof initialValue === 'object' ? JSON.stringify(initialValue) : initialValue;
if (JSON.stringify(current) !== JSON.stringify(this.initial)) { if (currentValue !== initialValue) {
this.set(this.initial); this.set(this.initial, writeDiskProps);
return this._id; return this._id;
} }
return undefined; return undefined;
} }
}
private _findKey(obj: Record<string, unknown>, path: string[]): T | undefined { function _findVal(obj: Record<string, unknown>, path: string[]): unknown | undefined {
const top = path.shift(); const top = path.shift();
if (!top) { if (!top) {
// The path is empty, so this is our value. // The path is empty, so this is our value.
return obj as T; return obj;
} }
if (typeof obj !== 'object') { if (typeof obj !== 'object') {
@@ -177,17 +166,16 @@ export class Opt<T = unknown> extends Variable<T> {
if (mergedPath in obj) { if (mergedPath in obj) {
// The key exists on this level with dot-notation, so we return that. // The key exists on this level with dot-notation, so we return that.
return obj[mergedPath] as T; return obj[mergedPath];
} }
if (top in obj) { if (top in obj) {
// The value exists but we are not there yet, so we recurse. // The value exists but we are not there yet, so we recurse.
return this._findKey(obj[top] as Record<string, unknown>, path); return _findVal(obj[top] as Record<string, unknown>, path);
} }
// Key does not exist :( // Key does not exist :(
return undefined; return undefined;
}
} }
/** /**
@@ -203,15 +191,15 @@ export function opt<T>(initial: T, props?: OptProps): Opt<T> {
/** /**
* Recursively traverses the provided object to extract all `Opt` instances, assigning IDs to each. * Recursively traverses the provided object to extract all `Opt` instances, assigning IDs to each.
* *
* @param object - The object containing `Opt` instances. * @param optionsObj - The object containing `Opt` instances.
* @param [path=''] - The current path (used internally). * @param [path=''] - The current path (used internally).
* @param [arr=[]] - The accumulator array for found `Opt` instances. * @param [arr=[]] - The accumulator array for found `Opt` instances.
* @returns An array of all found `Opt` instances. * @returns An array of all found `Opt` instances.
*/ */
function getOptions(object: Record<string, unknown>, path = '', arr: Opt[] = []): Opt[] { function getOptions(optionsObj: Record<string, unknown>, path = '', arr: Opt[] = []): Opt[] {
try { try {
for (const key in object) { for (const key in optionsObj) {
const value = object[key]; const value = optionsObj[key];
const id = path ? `${path}.${key}` : key; const id = path ? `${path}.${key}` : key;
if (value instanceof Variable) { if (value instanceof Variable) {
@@ -233,66 +221,78 @@ function getOptions(object: Record<string, unknown>, path = '', arr: Opt[] = [])
* includes methods to reset values, reset theme colors, and handle dependencies. * includes methods to reset values, reset theme colors, and handle dependencies.
* *
* @template T extends object * @template T extends object
* @param cacheFile - The file path to store cached values. * @param optionsObj - The object containing nested `Opt` instances.
* @param object - The object containing nested `Opt` instances.
* @param [confFile='config.json'] - The configuration file name stored in TMP.
* @returns The original object extended with additional methods for handling options. * @returns The original object extended with additional methods for handling options.
*/ */
export function mkOptions<T extends object>( export function mkOptions<T extends object>(optionsObj: T): T & MkOptionsResult {
cacheFile: string, ensureDirectory(CONFIG.split('/').slice(0, -1).join('/'));
object: T,
confFile: string = 'config.json',
): T & MkOptionsResult {
const allOptions = getOptions(object as Record<string, unknown>);
for (let i = 0; i < allOptions.length; i++) { const rawConfig = readFile(CONFIG);
allOptions[i].init(cacheFile);
}
ensureDirectory(cacheFile.split('/').slice(0, -1).join('/'));
ensureDirectory(defaultFile.split('/').slice(0, -1).join('/'));
const configFile = `${TMP}/${confFile}`;
const values: Record<string, unknown> = {};
const defaultValues: Record<string, unknown> = {};
for (let i = 0; i < allOptions.length; i++) {
const option = allOptions[i];
const val = option.value;
values[option.id] = val;
if (isHexColor(val as string)) {
defaultValues[option.id] = option.initial;
} else {
defaultValues[option.id] = val;
}
}
writeFile(defaultFile, JSON.stringify(defaultValues, null, 2));
writeFile(configFile, JSON.stringify(values, null, 2));
monitorFile(configFile, () => {
const raw = readFile(configFile);
if (!raw || raw.trim() === '') return;
let cache: Record<string, unknown>;
let config: Record<string, unknown> = {};
if (rawConfig && rawConfig.trim() !== '') {
try { try {
cache = JSON.parse(raw) as Record<string, unknown>; config = JSON.parse(rawConfig) as Record<string, unknown>;
} catch { } catch (error) {
Notify({
summary: 'Failed to load config file',
body: `${error}`,
iconName: icons.ui.warning,
});
// Continue with a broken config, the user has
// been warned
}
}
// Initialize the config options
const allOptions = getOptions(optionsObj as Record<string, unknown>);
for (let i = 0; i < allOptions.length; i++) {
allOptions[i].init(config);
}
// Setup a file monitor to allow live config edit preview from outside
// the config menu
const debounceTimeMs = 200;
let lastEventTime = Date.now();
monitorFile(CONFIG, () => {
if (Date.now() - lastEventTime < debounceTimeMs) {
return; return;
} }
lastEventTime = Date.now();
let newConfig: Record<string, unknown> = {};
const rawConfig = readFile(CONFIG);
if (rawConfig && rawConfig.trim() !== '') {
try {
newConfig = JSON.parse(rawConfig) as Record<string, unknown>;
} catch (error) {
console.error(`Error loading configuration file: ${error}`);
Notify({
summary: 'Loading configuration file failed',
body: `${error}`,
iconName: icons.ui.warning,
});
return;
}
}
for (let i = 0; i < allOptions.length; i++) { for (let i = 0; i < allOptions.length; i++) {
const opt = allOptions[i]; const opt = allOptions[i];
const newVal = cache[opt.id]; const newVal = _findVal(newConfig, opt.id.split('.'));
const oldVal = opt.get();
if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) { if (newVal === undefined) {
opt.set(newVal as T); // Set the variable but don't write it back to the file,
// as we are getting it from there
opt.reset({ writeDisk: false });
continue;
}
const oldVal = opt.get();
if (newVal !== oldVal) {
// Set the variable but don't write it back to the file,
// as we are getting it from there
opt.set(newVal, { writeDisk: false });
} }
} }
}); });
@@ -325,8 +325,7 @@ export function mkOptions<T extends object>(
return results; return results;
} }
return Object.assign(object, { return Object.assign(optionsObj, {
configFile,
array: (): Opt[] => allOptions, array: (): Opt[] => allOptions,
async reset(): Promise<string> { async reset(): Promise<string> {
const ids = await resetAll(allOptions); const ids = await resetAll(allOptions);

View File

@@ -5,7 +5,6 @@ import { Astal } from 'astal/gtk3';
import { dropdownMenuList } from '../constants/options'; import { dropdownMenuList } from '../constants/options';
export type MkOptionsResult = { export type MkOptionsResult = {
configFile: string;
array: () => Opt[]; array: () => Opt[];
reset: () => Promise<string>; reset: () => Promise<string>;
handler: (deps: string[], callback: () => void) => void; handler: (deps: string[], callback: () => void) => void;

View File

@@ -90,7 +90,7 @@ const tertiary_colors = {
surface2: '#585b71', surface2: '#585b71',
}; };
const options = mkOptions(CONFIG, { const options = mkOptions({
theme: { theme: {
tooltip: { tooltip: {
scaling: opt(100), scaling: opt(100),

View File

@@ -1,13 +1,12 @@
import options from '../options'; import options from '../options';
import { bash, dependencies } from '../lib/utils'; import { bash, dependencies } from '../lib/utils';
import { MatugenColors, RecursiveOptionsObject } from '../lib/types/options'; import { HexColor, MatugenColors, RecursiveOptionsObject } from '../lib/types/options';
import { initializeTrackers } from './optionsTrackers'; import { initializeTrackers } from './optionsTrackers';
import { generateMatugenColors, getMatugenHex, replaceHexValues } from '../services/matugen/index'; import { generateMatugenColors, getMatugenHex, replaceHexValues } from '../services/matugen/index';
import { isHexColor } from '../globals/variables'; import { isHexColor } from '../globals/variables';
import { readFile, writeFile } from 'astal/file'; import { readFile, writeFile } from 'astal/file';
import { App } from 'astal/gtk3'; import { App } from 'astal/gtk3';
import { initializeHotReload } from './utils/hotReload'; import { initializeHotReload } from './utils/hotReload';
import { defaultFile } from 'src/lib/option';
const deps = ['font', 'theme', 'bar.flatButtons', 'bar.position', 'bar.battery.charging', 'bar.battery.blocks']; const deps = ['font', 'theme', 'bar.flatButtons', 'bar.position', 'bar.battery.charging', 'bar.battery.blocks'];
@@ -46,23 +45,26 @@ function extractVariables(theme: RecursiveOptionsObject, prefix = '', matugenCol
async function extractMatugenizedVariables(matugenColors: MatugenColors): Promise<string[]> { async function extractMatugenizedVariables(matugenColors: MatugenColors): Promise<string[]> {
try { try {
const result = [] as string[]; const result = [] as string[];
const optArray = options.array();
const defaultFileContent = JSON.parse(readFile(defaultFile) || '{}'); for (let i = 0; i < optArray.length; i++) {
const opt = optArray[i];
const name = opt.id;
for (const key in defaultFileContent) { if (name.startsWith('theme.') === false) {
if (key.startsWith('theme.') === false) {
continue;
}
const configValue = defaultFileContent[key];
if (!isHexColor(configValue) && matugenColors !== undefined) {
result.push(`$${key.replace('theme.', '').split('.').join('-')}: ${configValue};`);
continue; continue;
} }
const matugenColor = getMatugenHex(configValue, matugenColors); const initialValue = opt.initial;
result.push(`$${key.replace('theme.', '').split('.').join('-')}: ${matugenColor};`); if (!isHexColor(initialValue) && matugenColors !== undefined) {
result.push(`$${name.replace('theme.', '').split('.').join('-')}: ${initialValue};`);
continue;
}
const matugenColor = getMatugenHex(initialValue as HexColor, matugenColors);
result.push(`$${name.replace('theme.', '').split('.').join('-')}: ${matugenColor};`);
} }
return result; return result;