fonts: the five decided faces as packages, and what the generic families mean (hq to-be 42 phase 2.1)
JetBrains Mono Nerd Font for monospace, Inter for the interface, the Nerd Fonts symbols and Noto Color Emoji as fallbacks, Noto for serif. One owned fontconfig file in the account's conf.d maps monospace, sans-serif, system-ui, serif and emoji, bound `same`: measured with fontconfig 2.18, a weakly bound preference loses to Noto Sans Mono. Families today's configs name but the laptop lacks (Iosevka, the old JetBrains name) stop falling back to sans-serif. Six Go tools: families, match, glyph, sources (which hand-copied files can go and why), config, cache-rebuild. The README lists the hand-copied files to remove and the modules that must name the new family.
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "<?xml") || !strings.Contains(content, `<!DOCTYPE fontconfig`) {
|
||||
t.Fatal("not a fontconfig document")
|
||||
}
|
||||
if strings.Contains(content, "$") {
|
||||
t.Error("the file holds no placeholder: it is the same on every machine")
|
||||
}
|
||||
var doc fcDoc
|
||||
if err := xml.Unmarshal([]byte(content), &doc); err != nil {
|
||||
t.Fatalf("not well-formed: %v", err)
|
||||
}
|
||||
firstPrefer := map[string]string{}
|
||||
order := []string{}
|
||||
for _, a := range doc.Aliases {
|
||||
order = append(order, a.Family)
|
||||
if len(a.Prefer) > 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("<fontconfig/>"), 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 <prefix>_…, 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module fonts
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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": "<?xml version=\"1.0\"?>\n<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">\n<!--\n The mesh's file (module fonts, novox/hq research 026/04): which face each generic family means for\n this account, so every program that asks for monospace, sans-serif, serif or emoji gets the same\n face. Replaced at every push. Other files in this directory are yours.\n-->\n<fontconfig>\n <description>The mesh: the faces monospace, sans-serif, serif and emoji mean</description>\n\n <!--\n Families the desktop's files named before this module, mapped to monospace for as long as they\n are not installed: a configuration still naming one gets the chosen monospace face rather than\n fontconfig's last resort, sans-serif. Where the family is still installed, it is used as before.\n Placed first, so the monospace they lead to is resolved by the rule below.\n -->\n <alias><family>Hack Nerd Font</family><accept><family>monospace</family></accept></alias>\n <alias><family>MesloLGS NF</family><accept><family>monospace</family></accept></alias>\n <alias><family>Iosevka Nerd Font</family><accept><family>monospace</family></accept></alias>\n <alias><family>JetBrains Mono Nerd Font</family><accept><family>JetBrainsMono Nerd Font</family></accept></alias>\n\n <!--\n The decided faces, bound the same as the request: a program asking for monospace asks strongly,\n and a face added weakly loses to the distribution's own choice (measured with fontconfig 2.18).\n -->\n <alias binding=\"same\"><family>monospace</family><prefer><family>JetBrainsMono Nerd Font</family></prefer></alias>\n <alias binding=\"same\"><family>sans-serif</family><prefer><family>Inter</family><family>Noto Sans</family></prefer></alias>\n <alias binding=\"same\"><family>system-ui</family><prefer><family>Inter</family></prefer></alias>\n <alias binding=\"same\"><family>serif</family><prefer><family>Noto Serif</family></prefer></alias>\n <alias binding=\"same\"><family>emoji</family><prefer><family>Noto Color Emoji</family></prefer></alias>\n\n <!--\n Fallbacks for any face: an icon a face lacks comes from the Nerd Fonts symbols, an emoji from\n Noto Color Emoji. Appended last, so they never displace a face that has the character.\n -->\n <match target=\"pattern\">\n <edit name=\"family\" mode=\"append_last\"><string>Symbols Nerd Font</string></edit>\n <edit name=\"family\" mode=\"append_last\"><string>Noto Color Emoji</string></edit>\n </match>\n</fontconfig>\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/fonts-tools",
|
||||
"binary": "fonts-tools",
|
||||
"loads": [
|
||||
"fonts-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user