package main import ( "bufio" "context" "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "adwaita/internal/desktop" ) type adwaita struct { d desktop.Desk home string iconDirs []string portalDirs []string uid int } const iface = "org.gnome.desktop.interface" // ParseINI reads a key file (GTK's settings.ini, qt6ct.conf, a .portal, index.theme): sections of // key=value, `#` and `;` comments. func ParseINI(text string) map[string]map[string]string { out := map[string]map[string]string{} section := "" sc := bufio.NewScanner(strings.NewReader(text)) for sc.Scan() { l := strings.TrimSpace(sc.Text()) if l == "" || strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") { continue } if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") { section = l[1 : len(l)-1] continue } if k, v, ok := strings.Cut(l, "="); ok { if out[section] == nil { out[section] = map[string]string{} } out[section][strings.TrimSpace(k)] = strings.TrimSpace(v) } } return out } func readINI(path string) map[string]map[string]string { b, err := os.ReadFile(path) if err != nil { return nil } return ParseINI(string(b)) } // gsetting is one GSettings value, unquoted. func (a adwaita) gsetting(ctx context.Context, schema, key string) (string, error) { r := a.d.AsUser(ctx, "gsettings", "get", schema, key) if !r.OK() { return "", r.Err() } return strings.Trim(strings.TrimSpace(r.Stdout), "'"), nil } // themeWords are the words of the user manager's environment the theme is about; nothing else of it // is read back. var themeWords = []string{"GTK_THEME", "GTK2_RC_FILES", "QT_QPA_PLATFORMTHEME", "QT_STYLE_OVERRIDE", "QT_SELECT", "XCURSOR_THEME", "XCURSOR_SIZE", "XDG_CURRENT_DESKTOP"} func (a adwaita) userEnvironment(ctx context.Context) map[string]string { r := a.d.AsUser(ctx, "systemctl", "--user", "show-environment") all := desktop.ParseProperties(r.Stdout) out := map[string]string{} for _, w := range themeWords { if v, ok := all[w]; ok { out[w] = v } } return out } var portalScheme = regexp.MustCompile(`^v u (\d)`) // portalColourScheme asks the portal what it tells applications: 0 no preference, 1 dark, 2 light. func (a adwaita) portalColourScheme(ctx context.Context) string { r := a.d.AsUser(ctx, "busctl", "--user", "call", "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings", "ReadOne", "ss", "org.freedesktop.appearance", "color-scheme") m := portalScheme.FindStringSubmatch(strings.TrimSpace(r.Stdout)) if !r.OK() || m == nil { return "unanswered" } return map[string]string{"0": "no-preference", "1": "dark", "2": "light"}[m[1]] } func (a adwaita) appearance(ctx context.Context, args desktop.Args) (any, error) { mode := args.Opt("mode", "") if mode != "" && mode != "dark" && mode != "light" { return nil, fmt.Errorf("mode is dark or light") } if mode != "" { scheme := map[string]string{"dark": "prefer-dark", "light": "prefer-light"}[mode] if r := a.d.AsUser(ctx, "gsettings", "set", iface, "color-scheme", scheme); !r.OK() { return nil, r.Err() } } scheme, err := a.gsetting(ctx, iface, "color-scheme") if err != nil { return nil, fmt.Errorf("GSettings does not answer on the account's bus: %w", err) } gtkTheme, _ := a.gsetting(ctx, iface, "gtk-theme") gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"] gtk4 := readINI(filepath.Join(a.home, ".config", "gtk-4.0", "settings.ini"))["Settings"] qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"] env := a.userEnvironment(ctx) answer := map[string]any{ "gsettings": map[string]string{"color-scheme": scheme, "gtk-theme": gtkTheme}, "portal": a.portalColourScheme(ctx), "gtk3": map[string]string{"theme": gtk3["gtk-theme-name"], "prefer-dark": gtk3["gtk-application-prefer-dark-theme"]}, "gtk4": map[string]string{"theme": gtk4["gtk-theme-name"], "prefer-dark": gtk4["gtk-application-prefer-dark-theme"]}, "qt": map[string]string{"style": qt["style"], "palette": filepath.Base(qt["color_scheme_path"])}, "environment": env, } if mode != "" { var stays []string if strings.HasSuffix(env["GTK_THEME"], ":dark") && mode == "light" { stays = append(stays, "GTK 3 programs: GTK_THEME="+env["GTK_THEME"]+" in the environment pins them dark") } if mode == "light" { stays = append(stays, "the GTK settings files and Qt's palette, which the module declares dark") } answer["switched"] = mode answer["follows_live"] = "programs asking the portal: Electron, Chromium, Firefox, libadwaita and flatpaks" if len(stays) > 0 { answer["stays"] = stays } answer["lasts"] = "until the next login, which sets the module's default (dark) again; a persistent choice waits for settings (hq issue 168)" } return answer, nil } // Theme is one installed icon or cursor theme. type Theme struct { Name string `json:"name"` Dir string `json:"dir"` Title string `json:"title,omitempty"` Inherits []string `json:"inherits,omitempty"` Cursors bool `json:"cursors"` Icons bool `json:"icons"` } // Themes lists the themes in dirs; a name found earlier hides the same name later, as lookups do. func Themes(dirs []string) []Theme { seen := map[string]bool{} var out []Theme for _, d := range dirs { entries, _ := os.ReadDir(d) for _, e := range entries { if !e.IsDir() && e.Type()&os.ModeSymlink == 0 || seen[e.Name()] { continue } dir := filepath.Join(d, e.Name()) t := Theme{Name: e.Name(), Dir: dir} if info, err := os.Stat(filepath.Join(dir, "cursors")); err == nil && info.IsDir() { t.Cursors = true } if ini := readINI(filepath.Join(dir, "index.theme")); ini != nil { th := ini["Icon Theme"] t.Title = th["Name"] if th["Directories"] != "" { t.Icons = true } for _, i := range strings.Split(th["Inherits"], ",") { if i = strings.TrimSpace(i); i != "" { t.Inherits = append(t.Inherits, i) } } } if !t.Cursors && !t.Icons && t.Title == "" { continue } seen[e.Name()] = true out = append(out, t) } } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } var cursorName = regexp.MustCompile(`^[A-Za-z0-9._ -]{1,64}$`) func (a adwaita) cursor(ctx context.Context, args desktop.Args) (any, error) { theme := args.Opt("theme", "") size, err := args.Whole("size", 0, 8, 256) if err != nil { return nil, err } var cursors []string installed := map[string]bool{} for _, t := range Themes(a.iconDirs) { if t.Cursors { cursors = append(cursors, t.Name) installed[t.Name] = true } } changed := theme != "" || size != 0 if theme != "" && (!cursorName.MatchString(theme) || !installed[theme]) { return nil, fmt.Errorf("%q is not an installed cursor theme; installed: %s", theme, strings.Join(cursors, ", ")) } var resources []string if theme != "" { if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-theme", theme); !r.OK() { return nil, r.Err() } resources = append(resources, "Xcursor.theme: "+theme) } if size != 0 { if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-size", strconv.Itoa(size)); !r.OK() { return nil, r.Err() } resources = append(resources, "Xcursor.size: "+strconv.Itoa(size)) } answer := map[string]any{"installed": cursors} gTheme, _ := a.gsetting(ctx, iface, "cursor-theme") gSize, _ := a.gsetting(ctx, iface, "cursor-size") answer["gsettings"] = map[string]string{"cursor-theme": gTheme, "cursor-size": gSize} env := a.userEnvironment(ctx) answer["environment"] = map[string]string{"XCURSOR_THEME": env["XCURSOR_THEME"], "XCURSOR_SIZE": env["XCURSOR_SIZE"]} if s, err := a.d.Find(); err == nil { senv := s.Env(a.d.Base) if len(resources) > 0 { if r := a.d.Run(ctx, senv, []byte(strings.Join(resources, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() { return nil, r.Err() } } q := a.d.Run(ctx, senv, nil, "xrdb", "-query") x := map[string]string{} for _, l := range strings.Split(q.Stdout, "\n") { if k, v, ok := strings.Cut(l, ":"); ok && strings.HasPrefix(k, "Xcursor.") { x[k] = strings.TrimSpace(v) } } answer["x_resources"] = x } else { answer["x_resources"] = nil if changed { answer["note"] = "no graphical session: GSettings changed, the X resources not" } } if changed { answer["lasts"] = "for windows opened from now on, until the next login" } return answer, nil } func (a adwaita) icons(ctx context.Context, args desktop.Args) (any, error) { var themes []Theme for _, t := range Themes(a.iconDirs) { if t.Icons { themes = append(themes, t) } } gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"] qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"] g, _ := a.gsetting(ctx, iface, "icon-theme") return map[string]any{ "installed": themes, "in_use": map[string]string{"gsettings": g, "gtk": gtk3["gtk-icon-theme-name"], "qt": qt["icon_theme"]}, }, nil } // Backend is one installed portal backend. type Backend struct { Name string `json:"name"` DBusName string `json:"dbus_name"` Interfaces []string `json:"interfaces"` UseIn []string `json:"use_in,omitempty"` Running bool `json:"running"` } func splitList(v string) []string { var out []string for _, x := range strings.Split(v, ";") { if x = strings.TrimSpace(x); x != "" { out = append(out, x) } } return out } // Backends reads the installed `.portal` files. func Backends(dirs []string) []Backend { var out []Backend for _, d := range dirs { files, _ := filepath.Glob(filepath.Join(d, "*.portal")) sort.Strings(files) for _, f := range files { p := readINI(f)["portal"] out = append(out, Backend{Name: strings.TrimSuffix(filepath.Base(f), ".portal"), DBusName: p["DBusName"], Interfaces: splitList(p["Interfaces"]), UseIn: splitList(p["UseIn"])}) } } return out } // PortalConfigs are the files xdg-desktop-portal looks for, in its order (portals.conf(5)): for each // directory, `-portals.conf` for each of the desktops named, then `portals.conf`. The first // that exists decides everything. func PortalConfigs(home string, desktops []string) []string { dirs := []string{ filepath.Join(home, ".config", "xdg-desktop-portal"), "/etc/xdg/xdg-desktop-portal", "/etc/xdg-desktop-portal", filepath.Join(home, ".local", "share", "xdg-desktop-portal"), "/usr/local/share/xdg-desktop-portal", "/usr/share/xdg-desktop-portal", } var out []string for _, d := range dirs { for _, desk := range desktops { out = append(out, filepath.Join(d, strings.ToLower(desk)+"-portals.conf")) } out = append(out, filepath.Join(d, "portals.conf")) } return out } // Resolve says which backend answers each interface the backends implement, given the deciding // file's [preferred] section: an interface's own key first, else `default`; each a list of backend // names, the first one that implements the interface wins; `none` answers nothing, `*` any. // With no file, a backend whose UseIn names the desktop answers. func Resolve(backends []Backend, preferred map[string]string, desktops []string) map[string]string { out := map[string]string{} implements := func(b Backend, i string) bool { for _, x := range b.Interfaces { if x == i { return true } } return false } var all []string seen := map[string]bool{} for _, b := range backends { for _, i := range b.Interfaces { if !seen[i] { seen[i] = true all = append(all, i) } } } sort.Strings(all) for _, i := range all { var want []string if preferred != nil { if v, ok := preferred[i]; ok { want = splitList(v) } else { want = splitList(preferred["default"]) } } else { for _, b := range backends { for _, u := range b.UseIn { for _, d := range desktops { if strings.EqualFold(u, d) { want = append(want, b.Name) } } } } } out[i] = "(none)" pick: for _, w := range want { if w == "none" { break } for _, b := range backends { if (w == "*" || w == b.Name) && implements(b, i) { out[i] = b.Name break pick } } } } return out } func (a adwaita) portalCheck(ctx context.Context, args desktop.Args) (any, error) { env := a.userEnvironment(ctx) desktops := splitColon(env["XDG_CURRENT_DESKTOP"]) backends := Backends(a.portalDirs) names := map[string]bool{} if r := a.d.AsUser(ctx, "busctl", "--user", "list", "--no-legend", "--no-pager"); r.OK() { for _, l := range strings.Split(r.Stdout, "\n") { if f := strings.Fields(l); len(f) > 1 && f[1] != "-" { names[f[0]] = true } } } for i := range backends { backends[i].Running = names[backends[i].DBusName] } var decided string var preferred map[string]string looked := PortalConfigs(a.home, desktops) for _, f := range looked { if ini := readINI(f); ini != nil { decided, preferred = f, ini["preferred"] if preferred == nil { preferred = map[string]string{} } break } } return map[string]any{ "desktop": env["XDG_CURRENT_DESKTOP"], "portal": map[string]bool{"running": names["org.freedesktop.portal.Desktop"]}, "backends": backends, "decided_by": decided, "preferred": preferred, "answers": Resolve(backends, preferred, desktops), "colour_scheme": a.portalColourScheme(ctx), }, nil } func splitColon(v string) []string { var out []string for _, x := range strings.Split(v, ":") { if x = strings.TrimSpace(x); x != "" { out = append(out, x) } } return out }