From 15bf9cfad25aadfe80e0ae6fb4ffb1736b43f875 Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Tue, 29 Sep 2026 23:27:37 -0500 Subject: [PATCH] fix: start when the user's stylesheet is empty or lacks a rule Forge reads getCssRule() returned {} for a rule it didn't find, and getCssProperty() read that as a rule ("cssRule.declarations is undefined"), so Forge didn't start and the preferences didn't open when the user's stylesheet was empty, lacked a rule (e.g. from an older Forge) or had a comment in it (no selectors). Lookups now return null / {} for what is missing. The rules and properties the user's stylesheet lacks are taken from the default stylesheet; a stylesheet that can't be parsed is replaced by the default one. In both cases the completed stylesheet is saved, so the shell styles everything, and the user's file is kept as stylesheet.css.bak. Fixes #448 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PXwsqMVgdEQYGec49zjaiP --- lib/shared/theme.js | 99 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 19 deletions(-) diff --git a/lib/shared/theme.js b/lib/shared/theme.js index ae14422e..79ea115d 100644 --- a/lib/shared/theme.js +++ b/lib/shared/theme.js @@ -100,13 +100,10 @@ export class ThemeManagerBase extends GObject.Object { * @param {any} selector */ getCssRule(selector) { - if (this.cssAst) { - const rules = this.cssAst.stylesheet.rules; - // return only the first match, Forge CSS authors should make sure class names are unique :) - const matchRules = rules.filter((r) => r.selectors.filter((s) => s === selector).length > 0); - return matchRules.length > 0 ? matchRules[0] : {}; - } - return {}; + const rules = this.cssAst?.stylesheet?.rules ?? []; + // return only the first match, Forge CSS authors should make sure class names are unique :) + // (comments and @-rules have no selectors) + return rules.find((r) => r.selectors?.includes(selector)) ?? null; } /** @@ -115,13 +112,7 @@ export class ThemeManagerBase extends GObject.Object { */ getCssProperty(selector, propertyName) { const cssRule = this.getCssRule(selector); - - if (cssRule) { - const matchDeclarations = cssRule.declarations.filter((d) => d.property === propertyName); - return matchDeclarations.length > 0 ? matchDeclarations[0] : {}; - } - - return {}; + return cssRule?.declarations?.find((d) => d.property === propertyName) ?? {}; } /** @@ -143,16 +134,86 @@ export class ThemeManagerBase extends GObject.Object { * Returns the AST for stylesheet.css */ _importCss() { + const defaultFile = this.configMgr.defaultStylesheetFile; let cssFile = this.configMgr.stylesheetFile; if (!cssFile || !production) { - cssFile = this.configMgr.defaultStylesheetFile; + cssFile = defaultFile; } - let [success, contents] = cssFile.load_contents(null); - if (success) { - const cssContents = new TextDecoder().decode(contents); - this.cssAst = parse(cssContents); + this.cssAst = cssFile ? this._parseCssFile(cssFile) : null; + if (!defaultFile || cssFile?.equal(defaultFile)) return; + + // The user's stylesheet can be empty, from an older Forge or edited by hand: take the rules + // and properties Forge reads that it lacks from the default stylesheet, so that reading them + // doesn't fail and the shell styles everything (#448). If it can't be parsed at all, use the + // default stylesheet. Either way the stylesheet is saved completed, and the user's own file + // is kept as stylesheet.css.bak. + const defaultAst = this._parseCssFile(defaultFile); + if (!defaultAst) return; + let completed = false; + if (!this.cssAst) { + this.cssAst = defaultAst; + completed = true; + } else { + const rules = this.cssAst.stylesheet.rules; + for (const defaultRule of defaultAst.stylesheet.rules) { + if (!defaultRule.selectors) continue; + const rule = this.getCssRule(defaultRule.selectors[0]); + if (!rule) { + rules.push(defaultRule); + completed = true; + continue; + } + rule.declarations ??= []; + for (const declaration of defaultRule.declarations ?? []) { + if (declaration.type !== "declaration") continue; + if (!rule.declarations.some((d) => d.property === declaration.property)) { + rule.declarations.push(declaration); + completed = true; + } + } + } + } + if (completed) this._saveCompletedCss(cssFile); + } + + /** + * Writes the completed AST to the user's stylesheet, after copying the file to + * stylesheet.css.bak. + * @param {Gio.File} cssFile + */ + _saveCompletedCss(cssFile) { + try { + const backup = Gio.File.new_for_path(`${cssFile.get_path()}.bak`); + const flags = Gio.FileCopyFlags.OVERWRITE | Gio.FileCopyFlags.TARGET_DEFAULT_PERMS; + cssFile.copy(backup, flags, null, null); + cssFile.replace_contents( + stringify(this.cssAst), + null, + false, + Gio.FileCreateFlags.REPLACE_DESTINATION, + null + ); + console.warn( + `Forge: completed the stylesheet ${cssFile.get_path()} (the previous one is ${backup.get_path()})` + ); + } catch (e) { + console.warn(`Forge: can't save the stylesheet ${cssFile.get_path()}: ${e.message}`); + } + } + + /** + * Parses a stylesheet file, or returns null (with a warning) if it can't be read or parsed. + * @param {Gio.File} cssFile + */ + _parseCssFile(cssFile) { + try { + const [success, contents] = cssFile.load_contents(null); + if (success) return parse(new TextDecoder().decode(contents)); + } catch (e) { + console.warn(`Forge: can't use the stylesheet ${cssFile.get_path()}: ${e.message}`); } + return null; } /**