diff --git a/modules/fonts/README.md b/modules/fonts/README.md
new file mode 100644
index 0000000..96d41c7
--- /dev/null
+++ b/modules/fonts/README.md
@@ -0,0 +1,99 @@
+# fonts
+
+The faces the workstations draw text with, as a module (novox/hq research 026/04, to-be 42 phase 2
+step 1). Assigned to the two workstations.
+
+## Owns
+
+| what | where | class (ADR 0182) |
+|---|---|---|
+| JetBrains Mono Nerd Font, for monospace: terminal, window manager, bar, launcher, prompt | package `ttf-jetbrains-mono-nerd` | package |
+| Inter, for the interface: GTK, Qt, notifications | package `inter-font` | package |
+| Nerd Fonts Symbols, the icon fallback for any face | package `ttf-nerd-fonts-symbols` | package |
+| Noto, for serif and every other script | package `noto-fonts` | package |
+| Noto Color Emoji | package `noto-fonts-emoji` | package |
+| what `monospace`, `sans-serif`, `system-ui`, `serif` and `emoji` mean | `~/.config/fontconfig/conf.d/50-mesh-fonts.conf` | owned, whole |
+
+All five packages are in the distribution's official repositories.
+
+**Why the account's fontconfig directory, not `/etc/fonts/conf.d`.** The choice of faces is the
+operator's taste on the operator's machine, and every program that draws for the operator runs as the
+account. The account's directory is read by fontconfig's stock `50-user.conf`, before the
+distribution's `60-latin.conf`, so the module's file decides the generic families without touching
+anything the distribution owns. Root's tools and any other account keep the distribution's defaults.
+A system file would need a symlink into `conf.d` the way the distribution does it, which the mesh
+never makes (ADR 0012).
+
+**The file in short.** Each generic family prefers the decided face, bound `same` as the request.
+Measured with fontconfig 2.18 on a workstation: a face added with the default (weak) binding loses to
+`Noto Sans Mono` for a plain `monospace` request, because the distribution's own rule decides first.
+The Nerd Fonts symbols and the colour emoji are appended last to every pattern, so an icon or emoji a
+face lacks is still drawn, and they never displace a face that has the character.
+
+## Improves
+
+- **Every program agrees on monospace.** Today `monospace` is `Noto Sans Mono`, while the terminal, the
+ window manager and the bar use a hand-copied Hack. After the push, all of them can say the family
+ and get one face.
+- **Configurations naming a font that is not installed stop falling back to a proportional face.** The
+ launcher's theme asks for `Iosevka Nerd Font` and its power menu for `JetBrains Mono Nerd Font`.
+ Neither is installed on the laptop, so fontconfig falls back to `sans-serif`. The file maps
+ `Hack Nerd Font`, `MesloLGS NF` and `Iosevka Nerd Font` to `monospace` and the old JetBrains name to
+ the new one, for as long as those families are not installed. Where one still is, it is used as
+ before.
+- **Packages instead of copies.** The hand-copied files can go (below), and an upgrade of a face is a
+ package upgrade.
+
+## Tools
+
+All answer JSON; `(r)` reads, `(a)` acts.
+
+| tool | what |
+|---|---|
+| `fonts_families` (r) | installed families: styles, file count, monospaced or not, and source (`package`, `account` for a hand copy, `other`) |
+| `fonts_match` (r) | what each generic family resolves to, beside the decided face, with `all_as_decided`; or any patterns given |
+| `fonts_glyph` (r) | which installed families have a character (given as itself or `U+F120`), and which face monospace and sans-serif draw it with |
+| `fonts_sources` (r) | every hand-copied font file with its family, duplicates, and whether a package now provides the family; marks each `remove` with the reasons; lists packaged families with their package. Removes nothing |
+| `fonts_config` (r) | whether the module's file is in place and loaded by fontconfig, and the account's other fontconfig files beside it |
+| `fonts_cache_rebuild` (a) | rebuild the account's font cache, or with `system: true` the system's (through `sudo -n`) |
+
+## The one-off steps for the operator (ADR 0182)
+
+The mesh removes nothing it did not place. After the first push that assigns this module, on each
+workstation:
+
+1. **Remove the hand-copied files** in `~/.local/share/fonts` that `fonts_sources` marks `remove`, then
+ run `fonts_cache_rebuild`. Measured on 2026-10-04:
+ - **laptop:** the four `HackNerdFont-*.ttf`; the four `MesloLGS NF *.ttf`; the four
+ `MesloLGS%20NF%20*.ttf`, which are the same bytes under URL-encoded names. Twelve files, 30 MB.
+ Nothing stays.
+ - **desktop:** the same twelve, plus `Iosevka-Nerd-Font-Complete.ttf` and
+ `JetBrains-Mono-Nerd-Font-Complete.ttf` (version-2 Nerd builds). `GrapeNuts-Regular.ttf` and
+ `Icomoon-Feather.ttf` came from a theme's repository. They are kept until the theme module decides
+ what it ships.
+ - Do this **after** the modules below name the new family, or the terminal and the bar fall back
+ through the file's aliases to `monospace`, which is the right face anyway.
+2. **Nothing else.** No fontconfig file of the account's own exists today on either workstation.
+
+## Modules that must name the family
+
+Fonts are not a seat (research 026/04): this module says what the generic families mean, and each
+desktop module names its family. Each switches to `JetBrainsMono Nerd Font` when it is written:
+
+| module | today, on both workstations |
+|---|---|
+| `xterm` | `~/.Xresources.d/xterm`: `*faceName: Hack Nerd Font` |
+| `i3` | `~/.config/i3/config`: `font pango: Hack Nerd Font 11`, three times (the window manager and both bars) |
+| the bar (`i3status-rust`) | takes the font i3's bar block names, above |
+| `rofi` | `theme.rasi`: `Iosevka Nerd Font 10`; `powermenu.rasi`: `JetBrains Mono Nerd Font 10` and `Hack Nerd Font bold 32` |
+| `dunst` | `font = Hack Nerd Font 10`, which becomes `Inter` (the interface face) |
+| the prompt (`powerlevel10k`) | nothing: it uses whichever Nerd Font the terminal has |
+
+The DPI fixed in an X resource is the display server's setting (issue 168), not this module's.
+
+## Leaves as found
+
+- `noto-fonts-cjk` and `noto-fonts-extra`, installed on both workstations, and every other font
+ package. The module does not remove a package it did not install.
+- The distribution's fontconfig files under `/etc/fonts`.
+- The theme's two fonts on the desktop, above.
diff --git a/modules/fonts/cmd/fonts-tools/fonts.go b/modules/fonts/cmd/fonts-tools/fonts.go
new file mode 100644
index 0000000..c3a2868
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/fonts.go
@@ -0,0 +1,522 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+// ConfigFile is where the module's fontconfig file is placed, under the account's home. fontconfig's
+// stock 50-user.conf reads this directory, before the distribution's 60-latin.conf, so what it says
+// is what the generic families mean.
+const ConfigFile = ".config/fontconfig/conf.d/50-mesh-fonts.conf"
+
+// Decided is the face each generic family means (novox/hq research 026/04).
+var Decided = map[string]string{
+ "monospace": "JetBrainsMono Nerd Font",
+ "sans-serif": "Inter",
+ "system-ui": "Inter",
+ "serif": "Noto Serif",
+ "emoji": "Noto Color Emoji",
+}
+
+// generics is the order fonts_match answers them in.
+var generics = []string{"monospace", "sans-serif", "system-ui", "serif", "emoji"}
+
+// Retired are the monospace families the desktop's files named before the module; a hand-copied
+// file of one of them is replaced by the decided face.
+var Retired = []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font", "JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font"}
+
+// fontDirs are the account's own font directories, relative to its home.
+var fontDirs = []string{".local/share/fonts", ".fonts"}
+
+const packagedRoot = "/usr/share/fonts/"
+
+// Family is one installed family.
+type Family struct {
+ Family string `json:"family"`
+ Styles []string `json:"styles"`
+ Files int `json:"files"`
+ Monospace bool `json:"monospace"`
+ Source string `json:"source"`
+}
+
+// FamiliesAnswer is what fonts_families answers.
+type FamiliesAnswer struct {
+ Count int `json:"count"`
+ Families []Family `json:"families"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+func sourceOf(file string) string {
+ home := accountHome()
+ switch {
+ case home != "" && strings.HasPrefix(file, strings.TrimRight(home, "/")+"/"):
+ return "account"
+ case strings.HasPrefix(file, packagedRoot):
+ return "package"
+ }
+ return "other"
+}
+
+func accountHome() string {
+ if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
+ return h
+ }
+ h, _ := os.UserHomeDir()
+ return h
+}
+
+// Families lists what fc-list knows, by family.
+func Families(contains string, limit int) (FamiliesAnswer, error) {
+ r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n"}})
+ if err != nil {
+ return FamiliesAnswer{}, err
+ }
+ by := map[string]*Family{}
+ styles := map[string]map[string]bool{}
+ sources := map[string]map[string]bool{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) < 4 || f[0] == "" {
+ continue
+ }
+ name := f[0]
+ if contains != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(contains)) {
+ continue
+ }
+ if by[name] == nil {
+ by[name] = &Family{Family: name, Monospace: true}
+ styles[name], sources[name] = map[string]bool{}, map[string]bool{}
+ }
+ fam := by[name]
+ fam.Files++
+ if f[1] != "" {
+ styles[name][f[1]] = true
+ }
+ // fontconfig's spacing: 100 mono, 110 charcell, 90 dual; absent is proportional.
+ if f[2] != "100" && f[2] != "110" && f[2] != "90" {
+ fam.Monospace = false
+ }
+ sources[name][sourceOf(f[3])] = true
+ }
+ out := FamiliesAnswer{Families: []Family{}}
+ for name, fam := range by {
+ fam.Styles = keys(styles[name])
+ fam.Source = strings.Join(keys(sources[name]), "+")
+ out.Families = append(out.Families, *fam)
+ }
+ sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family })
+ out.Count = len(out.Families)
+ if len(out.Families) > limit {
+ out.Families, out.Truncated = out.Families[:limit], true
+ }
+ return out, nil
+}
+
+func keys(m map[string]bool) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// Resolved is what one pattern resolves to.
+type Resolved struct {
+ Asked string `json:"asked"`
+ Family string `json:"family"`
+ Style string `json:"style"`
+ File string `json:"file"`
+ Source string `json:"source"`
+ Decided string `json:"decided,omitempty"`
+ AsDecided *bool `json:"as_decided,omitempty"`
+}
+
+func checkPattern(p string) error {
+ if strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\n\t") {
+ return fmt.Errorf("%q is not a fontconfig pattern", p)
+ }
+ return nil
+}
+
+func resolve(pattern string) (Resolved, error) {
+ if err := checkPattern(pattern); err != nil {
+ return Resolved{}, err
+ }
+ r, err := call(Cmd{Name: "fc-match", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{file}", pattern}})
+ if err != nil {
+ return Resolved{}, err
+ }
+ f := strings.Split(strings.TrimSpace(r.Stdout), "\t")
+ if len(f) < 3 || f[0] == "" {
+ return Resolved{}, fmt.Errorf("fc-match answered nothing usable for %q: %q", pattern, strings.TrimSpace(r.Stdout))
+ }
+ return Resolved{Asked: pattern, Family: f[0], Style: f[1], File: f[2], Source: sourceOf(f[2])}, nil
+}
+
+// MatchAnswer is what fonts_match answers.
+type MatchAnswer struct {
+ Resolved []Resolved `json:"resolved"`
+ // AllAsDecided is set when only the generics were asked, and says whether each is the decided face.
+ AllAsDecided *bool `json:"all_as_decided,omitempty"`
+}
+
+// Match resolves the generic families, or the patterns given.
+func Match(patterns []string) (MatchAnswer, error) {
+ generic := len(patterns) == 0
+ if generic {
+ patterns = generics
+ }
+ out := MatchAnswer{Resolved: []Resolved{}}
+ all := true
+ for _, p := range patterns {
+ r, err := resolve(p)
+ if err != nil {
+ return MatchAnswer{}, err
+ }
+ if want, ok := Decided[p]; ok {
+ agree := r.Family == want
+ r.Decided, r.AsDecided = want, &agree
+ all = all && agree
+ }
+ out.Resolved = append(out.Resolved, r)
+ }
+ if generic {
+ out.AllAsDecided = &all
+ }
+ return out, nil
+}
+
+// codePoint reads one character, or a code point written U+XXXX or 0xXXXX.
+func codePoint(s string) (rune, error) {
+ s = strings.TrimSpace(s)
+ if utf8.RuneCountInString(s) == 1 {
+ r, _ := utf8.DecodeRuneInString(s)
+ return r, nil
+ }
+ up := strings.ToUpper(s)
+ for _, prefix := range []string{"U+", "0X"} {
+ if strings.HasPrefix(up, prefix) {
+ n, err := strconv.ParseUint(up[len(prefix):], 16, 32)
+ if err != nil || n == 0 || n > utf8.MaxRune {
+ return 0, fmt.Errorf("%q is not a code point", s)
+ }
+ return rune(n), nil
+ }
+ }
+ return 0, fmt.Errorf("give one character, or its code point as U+XXXX or 0xXXXX, not %q", s)
+}
+
+// GlyphAnswer is what fonts_glyph answers.
+type GlyphAnswer struct {
+ CodePoint string `json:"code_point"`
+ Character string `json:"character"`
+ Count int `json:"count"`
+ Families []Family `json:"families"`
+ DrawnBy map[string]string `json:"drawn_by"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Glyph answers which fonts have a character and which face the generics would draw it with.
+func Glyph(s string) (GlyphAnswer, error) {
+ cp, err := codePoint(s)
+ if err != nil {
+ return GlyphAnswer{}, err
+ }
+ hx := strconv.FormatInt(int64(cp), 16)
+ r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n", ":charset=" + hx}})
+ if err != nil {
+ return GlyphAnswer{}, err
+ }
+ by := map[string]*Family{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) < 4 || f[0] == "" {
+ continue
+ }
+ if by[f[0]] == nil {
+ by[f[0]] = &Family{Family: f[0], Monospace: f[2] == "100" || f[2] == "110" || f[2] == "90", Source: sourceOf(f[3]), Styles: []string{}}
+ }
+ by[f[0]].Files++
+ }
+ out := GlyphAnswer{CodePoint: fmt.Sprintf("U+%04X", cp), Character: string(cp), Families: []Family{}, DrawnBy: map[string]string{}}
+ for _, f := range by {
+ out.Families = append(out.Families, *f)
+ }
+ sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family })
+ out.Count = len(out.Families)
+ if len(out.Families) > 200 {
+ out.Families, out.Truncated = out.Families[:200], true
+ }
+ for _, g := range []string{"monospace", "sans-serif"} {
+ res, err := resolve(g + ":charset=" + hx)
+ if err != nil {
+ return GlyphAnswer{}, err
+ }
+ out.DrawnBy[g] = res.Family
+ }
+ return out, nil
+}
+
+// HandFile is one font file copied into the account's home.
+type HandFile struct {
+ File string `json:"file"`
+ Family string `json:"family"`
+ Style string `json:"style"`
+ Bytes int64 `json:"bytes"`
+ Remove bool `json:"remove"`
+ Why []string `json:"why,omitempty"`
+ sum string
+}
+
+// Packaged is a family a package installs.
+type Packaged struct {
+ Family string `json:"family"`
+ Package string `json:"package"`
+}
+
+// SourcesAnswer is what fonts_sources answers.
+type SourcesAnswer struct {
+ Hand []HandFile `json:"hand_copied"`
+ HandBytes int64 `json:"hand_copied_bytes"`
+ RemoveCount int `json:"removable"`
+ Packaged []Packaged `json:"packaged"`
+ Note string `json:"note"`
+}
+
+var fontExt = map[string]bool{".ttf": true, ".otf": true, ".ttc": true, ".otc": true, ".pfb": true, ".pcf": true, ".woff": true, ".woff2": true, ".bdf": true}
+
+// handFiles walks the account's font directories.
+func handFiles(home string) ([]HandFile, error) {
+ out := []HandFile{}
+ for _, d := range fontDirs {
+ root := filepath.Join(home, d)
+ err := filepath.WalkDir(root, func(p string, e fs.DirEntry, err error) error {
+ if err != nil {
+ if p == root && os.IsNotExist(err) {
+ return filepath.SkipDir
+ }
+ return err
+ }
+ if e.IsDir() || !fontExt[strings.ToLower(filepath.Ext(p))] {
+ return nil
+ }
+ info, err := e.Info()
+ if err != nil {
+ return err
+ }
+ f, err := os.Open(p)
+ if err != nil {
+ return err
+ }
+ h := sha256.New()
+ _, err = io.Copy(h, f)
+ f.Close()
+ if err != nil {
+ return err
+ }
+ out = append(out, HandFile{File: p, Bytes: info.Size(), sum: hex.EncodeToString(h.Sum(nil))})
+ return nil
+ })
+ if err != nil && err != filepath.SkipDir {
+ return nil, fmt.Errorf("reading %s: %w", root, err)
+ }
+ }
+ sort.Slice(out, func(i, k int) bool { return out[i].File < out[k].File })
+ return out, nil
+}
+
+// Sources says which font files were copied by hand, which of them can go, and which families
+// packages install.
+func Sources() (SourcesAnswer, error) {
+ home := accountHome()
+ hand, err := handFiles(home)
+ if err != nil {
+ return SourcesAnswer{}, err
+ }
+ out := SourcesAnswer{Hand: hand, Packaged: []Packaged{},
+ Note: "Nothing is removed by this tool. The mesh removes nothing it did not place (ADR 0182): a file marked remove is for the operator to delete, once; then run fonts_cache_rebuild."}
+ if len(hand) > 0 {
+ args := []string{"--format", "%{file}\t%{family[0]}\t%{style[0]}\n"}
+ for _, h := range hand {
+ args = append(args, h.File)
+ }
+ r, err := call(Cmd{Name: "fc-scan", Args: args})
+ if err != nil {
+ return SourcesAnswer{}, err
+ }
+ named := map[string][2]string{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) >= 3 {
+ if _, seen := named[f[0]]; !seen {
+ named[f[0]] = [2]string{f[1], f[2]}
+ }
+ }
+ }
+ for i := range out.Hand {
+ n := named[out.Hand[i].File]
+ out.Hand[i].Family, out.Hand[i].Style = n[0], n[1]
+ }
+ }
+ // Families the packages install, each with one file to ask pacman about.
+ r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{file}\n"}})
+ if err != nil {
+ return SourcesAnswer{}, err
+ }
+ fileOf := map[string]string{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) >= 2 && f[0] != "" && strings.HasPrefix(f[1], packagedRoot) {
+ if _, seen := fileOf[f[0]]; !seen {
+ fileOf[f[0]] = f[1]
+ }
+ }
+ }
+ owner := map[string]string{}
+ if len(fileOf) > 0 {
+ args := []string{"-Qo"}
+ for _, fam := range keys(boolSet(fileOf)) {
+ args = append(args, fileOf[fam])
+ }
+ // pacman exits 1 when one file is unowned and still answers the rest: read what it said.
+ r := run(Cmd{Name: "pacman", Args: args})
+ if r.Error != "" {
+ return SourcesAnswer{}, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r)
+ }
+ for _, l := range lines(r.Stdout) {
+ // "/usr/share/fonts/x.ttf is owned by noto-fonts 1:2026.08.01-1"
+ if i := strings.Index(l, " is owned by "); i > 0 {
+ pkg := strings.Fields(l[i+len(" is owned by "):])
+ if len(pkg) > 0 {
+ owner[l[:i]] = pkg[0]
+ }
+ }
+ }
+ }
+ for _, fam := range keys(boolSet(fileOf)) {
+ pkg := owner[fileOf[fam]]
+ if pkg == "" {
+ pkg = "(no package)"
+ }
+ out.Packaged = append(out.Packaged, Packaged{Family: fam, Package: pkg})
+ }
+ // Which hand-copied files can go, and why.
+ firstOf := map[string]string{}
+ for i := range out.Hand {
+ h := &out.Hand[i]
+ if prev, dup := firstOf[h.sum]; dup {
+ h.Why = append(h.Why, "the same bytes as "+filepath.Base(prev))
+ } else {
+ firstOf[h.sum] = h.File
+ }
+ if strings.Contains(filepath.Base(h.File), "%20") {
+ h.Why = append(h.Why, "a URL-encoded copy of a name")
+ }
+ if pkg := owner[fileOf[h.Family]]; h.Family != "" && pkg != "" {
+ h.Why = append(h.Why, "its family is installed by the package "+pkg)
+ }
+ for _, r := range Retired {
+ if h.Family == r {
+ h.Why = append(h.Why, "a monospace face the desktop named before the fonts module; "+Decided["monospace"]+" replaces it once the terminal, window manager, bar and launcher name that family")
+ break
+ }
+ }
+ h.Remove = len(h.Why) > 0
+ out.HandBytes += h.Bytes
+ if h.Remove {
+ out.RemoveCount++
+ }
+ }
+ return out, nil
+}
+
+func boolSet(m map[string]string) map[string]bool {
+ out := map[string]bool{}
+ for k := range m {
+ out[k] = true
+ }
+ return out
+}
+
+// ConfigAnswer is what fonts_config answers.
+type ConfigAnswer struct {
+ Path string `json:"path"`
+ Present bool `json:"present"`
+ Loaded bool `json:"loaded"`
+ Bytes int `json:"bytes,omitempty"`
+ Others []string `json:"account_files_beside_it"`
+ Note string `json:"note,omitempty"`
+}
+
+// Config says whether the module's file is in place and loaded.
+func Config() (ConfigAnswer, error) {
+ path := filepath.Join(accountHome(), ConfigFile)
+ out := ConfigAnswer{Path: path, Others: []string{}}
+ if b, err := os.ReadFile(path); err == nil {
+ out.Present, out.Bytes = true, len(b)
+ } else if !os.IsNotExist(err) {
+ return ConfigAnswer{}, fmt.Errorf("reading %s: %w", path, err)
+ }
+ entries, _ := os.ReadDir(filepath.Dir(path))
+ for _, e := range entries {
+ if p := filepath.Join(filepath.Dir(path), e.Name()); p != path {
+ out.Others = append(out.Others, p)
+ }
+ }
+ if b, err := os.ReadFile(filepath.Join(accountHome(), ".config/fontconfig/fonts.conf")); err == nil && len(b) > 0 {
+ out.Others = append(out.Others, filepath.Join(accountHome(), ".config/fontconfig/fonts.conf"))
+ }
+ r, err := call(Cmd{Name: "fc-conflist"})
+ if err != nil {
+ return ConfigAnswer{}, err
+ }
+ for _, l := range lines(r.Stdout) {
+ // "+ /path: description" for a file in use, "- /path" for one skipped
+ if strings.HasPrefix(l, "+ "+path+":") || strings.TrimSpace(l) == "+ "+path {
+ out.Loaded = true
+ }
+ }
+ if !out.Present {
+ out.Note = "the file is not in place: the fonts module is not assigned to this machine, or its push has not reached it"
+ } else if !out.Loaded {
+ out.Note = "the file is in place and fontconfig does not load it: check that /etc/fonts/conf.d/50-user.conf is enabled"
+ }
+ if len(out.Others) > 0 {
+ out.Note = strings.TrimSpace(out.Note + " The other files are the account's own: they are read too, and one sorting after 50-mesh-fonts.conf can override it.")
+ }
+ return out, nil
+}
+
+// RebuildAnswer is what fonts_cache_rebuild answers.
+type RebuildAnswer struct {
+ Scope string `json:"scope"`
+ ElapsedMS int64 `json:"elapsed_ms"`
+ Output string `json:"output,omitempty"`
+}
+
+// CacheRebuild rebuilds the account's font cache, or the system's.
+func CacheRebuild(system bool) (RebuildAnswer, error) {
+ c := Cmd{Name: "fc-cache", Args: []string{"-f"}}
+ scope := "account"
+ if system {
+ c, scope = Cmd{Name: "fc-cache", Args: []string{"-s", "-f"}, Root: true}, "system"
+ }
+ start := time.Now()
+ r, err := call(c)
+ if err != nil {
+ return RebuildAnswer{}, err
+ }
+ return RebuildAnswer{Scope: scope, ElapsedMS: time.Since(start).Milliseconds(), Output: tail(strings.TrimSpace(r.Stdout+r.Stderr), 4000)}, nil
+}
diff --git a/modules/fonts/cmd/fonts-tools/fonts_test.go b/modules/fonts/cmd/fonts-tools/fonts_test.go
new file mode 100644
index 0000000..8fe9b19
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/fonts_test.go
@@ -0,0 +1,302 @@
+package main
+
+import (
+ "encoding/xml"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestTheManifestInstallsTheFiveDecidedFacesAndOwnsOneAccountFile(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "fonts")
+ want := "inter-font,noto-fonts,noto-fonts-emoji,ttf-jetbrains-mono-nerd,ttf-nerd-fonts-symbols"
+ if got := strings.Join(m.packages(), ","); got != want {
+ t.Errorf("packages %s, want %s", got, want)
+ }
+ files := 0
+ for _, r := range m.Resources {
+ if r["type"] == "file" {
+ files++
+ }
+ if r["type"] == "service" || r["type"] == "directory" {
+ t.Errorf("a font needs no %s: %v", r["type"], r["id"])
+ }
+ }
+ f := m.resource("defaults")
+ if files != 1 || f == nil {
+ t.Fatalf("one owned file, got %d", files)
+ }
+ if f["path"] != "${machine:account-home}/"+ConfigFile || f["owner"] != "${machine:account}" || f["mode"] != "0644" {
+ t.Errorf("the file is the account's, at %s: %v", ConfigFile, f)
+ }
+ if _, into := f["into"]; into {
+ t.Error("the file is the module's whole, not written into")
+ }
+}
+
+// fontconfig's document, as far as the module's file uses it.
+type fcDoc struct {
+ Aliases []struct {
+ Binding string `xml:"binding,attr"`
+ Family string `xml:"family"`
+ Prefer []string `xml:"prefer>family"`
+ Accept []string `xml:"accept>family"`
+ } `xml:"alias"`
+ Matches []struct {
+ Target string `xml:"target,attr"`
+ Edits []struct {
+ Name string `xml:"name,attr"`
+ Mode string `xml:"mode,attr"`
+ String string `xml:"string"`
+ } `xml:"edit"`
+ } `xml:"match"`
+}
+
+func TestTheFontconfigFileMapsEachGenericToTheDecidedFaceStronglyAndFallsBackLast(t *testing.T) {
+ m := readManifest(t)
+ content, _ := m.resource("defaults")["content"].(string)
+ if !strings.HasPrefix(content, " 0 {
+ firstPrefer[a.Family] = a.Prefer[0]
+ if a.Binding != "same" {
+ t.Errorf("%s is bound %q: a weakly added face loses to the distribution's choice", a.Family, a.Binding)
+ }
+ }
+ }
+ for generic, face := range Decided {
+ if firstPrefer[generic] != face {
+ t.Errorf("%s prefers %q, decided %q", generic, firstPrefer[generic], face)
+ }
+ }
+ // The retired families lead to monospace, and are placed before the monospace rule.
+ mono := strings.Index(strings.Join(order, "|"), "|monospace|")
+ for _, r := range []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font"} {
+ at := strings.Index(strings.Join(order, "|"), r)
+ if at < 0 || at > mono {
+ t.Errorf("%s is not mapped to monospace before the monospace rule", r)
+ }
+ }
+ if len(doc.Matches) != 1 || doc.Matches[0].Target != "pattern" {
+ t.Fatalf("one pattern match for the fallbacks: %+v", doc.Matches)
+ }
+ got := []string{}
+ for _, e := range doc.Matches[0].Edits {
+ if e.Name != "family" || e.Mode != "append_last" {
+ t.Errorf("a fallback is appended last, never prepended: %+v", e)
+ }
+ got = append(got, e.String)
+ }
+ if strings.Join(got, ",") != "Symbols Nerd Font,Noto Color Emoji" {
+ t.Errorf("fallbacks %v", got)
+ }
+}
+
+func TestFamiliesGroupsFilesAndTellsAHandCopiedFaceFromAPackagedOne(t *testing.T) {
+ t.Setenv("MESH_OPERATOR_HOME", "/home/op")
+ using(t, func(line string, c Cmd) Result {
+ return ok("Inter\tRegular\t\t/usr/share/fonts/inter/Inter.ttc\n" +
+ "Inter\tBold\t\t/usr/share/fonts/inter/Inter-Bold.ttc\n" +
+ "Hack Nerd Font\tRegular\t100\t/home/op/.local/share/fonts/Hack.ttf\n" +
+ "Noto Sans Mono\tRegular\t100\t/usr/share/fonts/noto/NotoSansMono.ttf\n")
+ })
+ got, err := Families("", 10)
+ if err != nil || got.Count != 3 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if f := got.Families[0]; f.Family != "Hack Nerd Font" || f.Source != "account" || !f.Monospace {
+ t.Errorf("%+v", f)
+ }
+ if f := got.Families[1]; f.Family != "Inter" || f.Files != 2 || f.Monospace || f.Source != "package" || len(f.Styles) != 2 {
+ t.Errorf("%+v", f)
+ }
+ got, _ = Families("noto", 10)
+ if got.Count != 1 {
+ t.Errorf("filtered: %+v", got)
+ }
+ got, _ = Families("", 1)
+ if !got.Truncated || len(got.Families) != 1 || got.Count != 3 {
+ t.Errorf("bounded: %+v", got)
+ }
+}
+
+func TestMatchSaysWhetherEachGenericIsTheDecidedFace(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ switch c.Args[len(c.Args)-1] {
+ case "monospace":
+ return ok("JetBrainsMono Nerd Font\tRegular\t/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf")
+ case "sans-serif", "system-ui":
+ return ok("Noto Sans\tRegular\t/usr/share/fonts/noto/NotoSans-Regular.ttf")
+ case "serif":
+ return ok("Noto Serif\tRegular\t/usr/share/fonts/noto/NotoSerif-Regular.ttf")
+ }
+ return ok("Noto Color Emoji\tRegular\t/usr/share/fonts/noto/NotoColorEmoji.ttf")
+ })
+ got, err := Match(nil)
+ if err != nil || len(got.Resolved) != 5 || got.AllAsDecided == nil || *got.AllAsDecided {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if r := got.Resolved[0]; !*r.AsDecided || r.Source != "package" {
+ t.Errorf("monospace: %+v", r)
+ }
+ if r := got.Resolved[1]; *r.AsDecided || r.Decided != "Inter" {
+ t.Errorf("sans-serif is not Inter here: %+v", r)
+ }
+ if len(f.asked) != 5 {
+ t.Errorf("one fc-match per generic: %v", f.lines())
+ }
+ got, _ = Match([]string{"Inter:bold"})
+ if got.AllAsDecided != nil || got.Resolved[0].AsDecided != nil {
+ t.Errorf("a pattern of the caller's own has no decided face: %+v", got)
+ }
+ if _, err := Match([]string{"--help"}); err == nil {
+ t.Error("an option as a pattern")
+ }
+}
+
+func TestMatchAnEmptyAnswerIsAnError(t *testing.T) {
+ using(t, func(string, Cmd) Result { return ok("") })
+ if _, err := Match(nil); err == nil {
+ t.Fatal("an empty fc-match answer was read as a face")
+ }
+}
+
+func TestGlyphReadsACharacterOrACodePointAndAsksForIt(t *testing.T) {
+ for in, want := range map[string]rune{"\uf120": 0xf120, "U+F120": 0xf120, "0x1f600": 0x1f600, "a": 'a', "é": 'é'} {
+ got, err := codePoint(in)
+ if err != nil || got != want {
+ t.Errorf("%q: %x %v", in, got, err)
+ }
+ }
+ for _, bad := range []string{"ab", "U+ZZ", "U+0", "120"} {
+ if _, err := codePoint(bad); err == nil {
+ t.Errorf("%q was read as a code point", bad)
+ }
+ }
+ f := using(t, func(line string, c Cmd) Result {
+ if c.Name == "fc-list" {
+ return ok("Symbols Nerd Font\tRegular\t100\t/usr/share/fonts/TTF/SymbolsNerdFont-Regular.ttf\n" +
+ "JetBrainsMono Nerd Font\tBold\t100\t/usr/share/fonts/TTF/a.ttf\nJetBrainsMono Nerd Font\tRegular\t100\t/usr/share/fonts/TTF/b.ttf\n")
+ }
+ return ok("JetBrainsMono Nerd Font\tRegular\t/usr/share/fonts/TTF/b.ttf")
+ })
+ got, err := Glyph("U+F120")
+ if err != nil || got.Count != 2 || got.CodePoint != "U+F120" || got.DrawnBy["monospace"] != "JetBrainsMono Nerd Font" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if !strings.Contains(f.lines()[0], ":charset=f120") || !strings.Contains(f.lines()[1], "monospace:charset=f120") {
+ t.Errorf("asked %v", f.lines())
+ }
+}
+
+func TestSourcesMarksDuplicatesEncodedNamesPackagedAndRetiredFacesForRemoval(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("MESH_OPERATOR_HOME", home)
+ dir := filepath.Join(home, ".local/share/fonts")
+ _ = os.MkdirAll(dir, 0o755)
+ write := func(name, body string) string {
+ p := filepath.Join(dir, name)
+ _ = os.WriteFile(p, []byte(body), 0o644)
+ return p
+ }
+ meslo := write("MesloLGS NF Regular.ttf", "meslo")
+ encoded := write("MesloLGS%20NF%20Regular.ttf", "meslo")
+ grape := write("GrapeNuts-Regular.ttf", "grape")
+ noto := write("NotoSans-Copy.ttf", "noto")
+ _ = os.WriteFile(filepath.Join(dir, ".uuid"), []byte("x"), 0o644)
+ f := using(t, func(line string, c Cmd) Result {
+ switch c.Name {
+ case "fc-scan":
+ return ok(meslo + "\tMesloLGS NF\tRegular\n" + encoded + "\tMesloLGS NF\tRegular\n" + grape + "\tGrape Nuts\tRegular\n" + noto + "\tNoto Sans\tRegular\n")
+ case "fc-list":
+ return ok("Noto Sans\t/usr/share/fonts/noto/NotoSans-Regular.ttf\nNoto Sans\t/usr/share/fonts/noto/NotoSans-Bold.ttf\nInter\t/usr/share/fonts/inter/Inter.ttc\nMesloLGS NF\t" + meslo + "\n")
+ case "pacman":
+ return Result{Status: 1, Stdout: "/usr/share/fonts/noto/NotoSans-Regular.ttf is owned by noto-fonts 1:2026.08.01-1\n", Stderr: "error: No package owns /usr/share/fonts/inter/Inter.ttc\n"}
+ }
+ return Result{Status: 9}
+ })
+ got, err := Sources()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Hand) != 4 {
+ t.Fatalf("four font files, the .uuid is not one: %+v", got.Hand)
+ }
+ by := map[string]HandFile{}
+ for _, h := range got.Hand {
+ by[filepath.Base(h.File)] = h
+ }
+ if h := by["GrapeNuts-Regular.ttf"]; h.Remove || h.Family != "Grape Nuts" {
+ t.Errorf("a face nothing replaces is kept: %+v", h)
+ }
+ if h := by["MesloLGS%20NF%20Regular.ttf"]; !h.Remove || len(h.Why) < 3 {
+ t.Errorf("an encoded duplicate of a retired face: %+v", h)
+ }
+ if h := by["NotoSans-Copy.ttf"]; !h.Remove || !strings.Contains(strings.Join(h.Why, ";"), "noto-fonts") {
+ t.Errorf("a face a package installs: %+v", h)
+ }
+ if got.RemoveCount != 3 || got.HandBytes != int64(len("meslo")*2+len("grape")+len("noto")) {
+ t.Errorf("%d removable, %d bytes", got.RemoveCount, got.HandBytes)
+ }
+ pk := map[string]string{}
+ for _, p := range got.Packaged {
+ pk[p.Family] = p.Package
+ }
+ if pk["Noto Sans"] != "noto-fonts" || pk["Inter"] != "(no package)" || pk["MesloLGS NF"] != "" {
+ t.Errorf("packaged %v", pk)
+ }
+ for _, l := range f.lines() {
+ if strings.Contains(l, "rm ") || strings.HasPrefix(l, "sudo") {
+ t.Errorf("sources only reads: %s", l)
+ }
+ }
+}
+
+func TestConfigSaysWhetherTheFileIsInPlaceAndLoaded(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("MESH_OPERATOR_HOME", home)
+ path := filepath.Join(home, ConfigFile)
+ using(t, func(string, Cmd) Result { return ok("+ " + path + ": The mesh\n- /etc/fonts/conf.d/x.conf\n") })
+ got, err := Config()
+ if err != nil || got.Present || !strings.Contains(got.Note, "not in place") {
+ t.Fatalf("absent: %+v %v", got, err)
+ }
+ _ = os.MkdirAll(filepath.Dir(path), 0o755)
+ _ = os.WriteFile(path, []byte(""), 0o644)
+ _ = os.WriteFile(filepath.Join(filepath.Dir(path), "99-mine.conf"), []byte("x"), 0o644)
+ got, err = Config()
+ if err != nil || !got.Present || !got.Loaded || len(got.Others) != 1 {
+ t.Fatalf("present: %+v %v", got, err)
+ }
+}
+
+func TestCacheRebuildIsTheAccountsUnlessTheSystemIsAskedWhichNeedsRoot(t *testing.T) {
+ f := using(t, func(string, Cmd) Result { return ok("") })
+ if got, err := CacheRebuild(false); err != nil || got.Scope != "account" {
+ t.Fatal(got, err)
+ }
+ if got, err := CacheRebuild(true); err != nil || got.Scope != "system" {
+ t.Fatal(got, err)
+ }
+ if l := f.lines(); l[0] != "fc-cache -f" || l[1] != "sudo -n fc-cache -s -f" {
+ t.Errorf("%v", l)
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "sudo: a password is required"} })
+ if _, err := CacheRebuild(true); err == nil || !strings.Contains(err.Error(), "sudo -n refused") {
+ t.Errorf("a refused escalation: %v", err)
+ }
+}
diff --git a/modules/fonts/cmd/fonts-tools/kit.go b/modules/fonts/cmd/fonts-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/fonts/cmd/fonts-tools/kit_test.go b/modules/fonts/cmd/fonts-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/fonts/cmd/fonts-tools/main.go b/modules/fonts/cmd/fonts-tools/main.go
new file mode 100644
index 0000000..4d00345
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/main.go
@@ -0,0 +1,117 @@
+// The fonts module's tools (novox/hq research 026/04, 026/05): what faces the account has, what the
+// generic families resolve to, which face draws a character, which font files were copied by hand
+// and may go, and rebuilding the font cache. A Go bundle the node's runtime launches and speaks MCP
+// to over stdio (ADR 0188, ADR 0193); it runs as the operator account.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+// providedBy names what installs a program the tools run, for a failure that says so.
+var providedBy = map[string]string{
+ "fc-list": "the fontconfig package",
+ "fc-match": "the fontconfig package",
+ "fc-scan": "the fontconfig package",
+ "fc-cache": "the fontconfig package",
+ "fc-conflist": "the fontconfig package",
+ "pacman": "this is not an Arch machine",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "fonts_families",
+ Description: "The font families installed for the operator account, each with its styles, how many files, " +
+ "whether it is monospaced, and where it comes from: package (under /usr/share/fonts), account (copied into " +
+ "the home by hand) or other. (r)",
+ Input: map[string]any{
+ "contains": map[string]any{"type": "string", "description": "only families whose name contains this, any case"},
+ "limit": map[string]any{"type": "integer", "description": "at most this many families (default 500, at most 2000)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ contains, err := optText(args, "contains", "")
+ if err != nil {
+ return nil, err
+ }
+ limit, err := optWhole(args, "limit", 500, 1, 2000)
+ if err != nil {
+ return nil, err
+ }
+ return Families(contains, limit)
+ },
+ },
+ {
+ Name: "fonts_match",
+ Description: "What the generic families resolve to for this account: monospace, sans-serif, system-ui, serif and " +
+ "emoji by default, or the patterns given. Each answer names the face, its file, the face the fonts module " +
+ "decided on, and whether they agree. (r)",
+ Input: map[string]any{
+ "patterns": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
+ "description": "fontconfig patterns to resolve instead, such as \"monospace:bold\" or \"Inter\""},
+ },
+ Run: func(args map[string]any) (any, error) {
+ patterns, err := optList(args, "patterns")
+ if err != nil {
+ return nil, err
+ }
+ return Match(patterns)
+ },
+ },
+ {
+ Name: "fonts_glyph",
+ Description: "Which installed fonts have a given character, and which face monospace and sans-serif would draw " +
+ "it with. The character is given as itself or as a code point (U+F120, 0xF120). (r)",
+ Input: map[string]any{
+ "character": map[string]any{"type": "string", "description": "one character, or its code point as U+XXXX or 0xXXXX"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ ch, err := text(args, "character")
+ if err != nil {
+ return nil, err
+ }
+ return Glyph(ch)
+ },
+ },
+ {
+ Name: "fonts_sources",
+ Description: "Every font file copied into the account's font directories by hand, with its family, whether it " +
+ "duplicates another, and whether a package now provides that family; and the families installed by " +
+ "packages, with the package. Says which hand-copied files can be removed and why; removes nothing. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Sources() },
+ },
+ {
+ Name: "fonts_config",
+ Description: "The fontconfig file the fonts module owns: whether it is in place, whether fontconfig loads it, " +
+ "and the account's other fontconfig files beside it. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Config() },
+ },
+ {
+ Name: "fonts_cache_rebuild",
+ Description: "Rebuild the font cache: the account's (default), or the system's with system: true, which needs " +
+ "root and goes through sudo without a prompt. Answers how long it took. (a)",
+ Input: map[string]any{
+ "system": map[string]any{"type": "boolean", "description": "rebuild the system cache instead of the account's"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ system, err := optFlag(args, "system", false)
+ if err != nil {
+ return nil, err
+ }
+ return CacheRebuild(system)
+ },
+ },
+ }
+}
diff --git a/modules/fonts/cmd/fonts-tools/manifest_kit_test.go b/modules/fonts/cmd/fonts-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/fonts/go.mod b/modules/fonts/go.mod
new file mode 100644
index 0000000..9341c24
--- /dev/null
+++ b/modules/fonts/go.mod
@@ -0,0 +1,5 @@
+module fonts
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/fonts/go.sum b/modules/fonts/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/fonts/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/fonts/module.json b/modules/fonts/module.json
new file mode 100644
index 0000000..e668440
--- /dev/null
+++ b/modules/fonts/module.json
@@ -0,0 +1,65 @@
+{
+ "module": "fonts",
+ "version": "1",
+ "capabilities": [
+ "package-manager"
+ ],
+ "tools": [
+ "fonts_families",
+ "fonts_match",
+ "fonts_glyph",
+ "fonts_sources",
+ "fonts_config",
+ "fonts_cache_rebuild"
+ ],
+ "resources": [
+ {
+ "id": "monospace",
+ "type": "package",
+ "package": "ttf-jetbrains-mono-nerd"
+ },
+ {
+ "id": "interface",
+ "type": "package",
+ "package": "inter-font"
+ },
+ {
+ "id": "symbols",
+ "type": "package",
+ "package": "ttf-nerd-fonts-symbols"
+ },
+ {
+ "id": "noto",
+ "type": "package",
+ "package": "noto-fonts"
+ },
+ {
+ "id": "emoji",
+ "type": "package",
+ "package": "noto-fonts-emoji"
+ },
+ {
+ "id": "defaults",
+ "type": "file",
+ "path": "${machine:account-home}/.config/fontconfig/conf.d/50-mesh-fonts.conf",
+ "owner": "${machine:account}",
+ "mode": "0644",
+ "content": "\n\n\n\n The mesh: the faces monospace, sans-serif, serif and emoji mean\n\n \n Hack Nerd Fontmonospace\n MesloLGS NFmonospace\n Iosevka Nerd Fontmonospace\n JetBrains Mono Nerd FontJetBrainsMono Nerd Font\n\n \n monospaceJetBrainsMono Nerd Font\n sans-serifInterNoto Sans\n system-uiInter\n serifNoto Serif\n emojiNoto Color Emoji\n\n \n \n Symbols Nerd Font\n Noto Color Emoji\n \n\n"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/fonts-tools",
+ "binary": "fonts-tools",
+ "loads": [
+ "fonts-tools"
+ ]
+ }
+ ]
+ }
+}