Files
mesh-catalog/modules/betterbird/cmd/betterbird-tools/config.go
T
jochen 854c85393c
mesh/merge-gate out of date: judged against trunk 2fe5601397ae, the trunk is now 0863ec2a059d; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 2fe5601397ae, the trunk is now 0863ec2a059d; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
betterbird: ship the operator's approved configuration, and show and fix drift from it (hq issue 345)
A module that installs Betterbird but leaves every node to its own settings does not set Betterbird up.
The operator approved the configuration on 2026-10-12, and it now ships in three layers. The policy
locks what must never differ: telemetry, add-on updates, the vendor's notices and the start page. A
defaults file carries the laptop's setup, so a fresh node gets it untouched. The setting prefs stays a
node's exceptions.

A default loses to a value the profile saved. betterbird_config shows that drift, betterbird_check
reports it, and betterbird_config_apply clears it while Betterbird is quit. keep proposes a node layer
instead of writing user.js. betterbird_prefs now reads Betterbird's real defaults from omni.ja, and
withholds of mailnews.* only what names an account. The default-client ask is decided from its own
policy entry, not from the first thing the policy lacks.
2026-10-12 20:58:39 +02:00

533 lines
19 KiB
Go

package main
// The configuration the module ships (novox/hq issue 345), in three layers:
//
// - LOCKED: the enterprise policy (/opt/betterbird/distribution/policies.json). Its Preferences entries with
// Status locked win over everything, and Betterbird's settings show them greyed out.
// - DEFAULT: the module's defaults file (/opt/betterbird/defaults/pref/mesh-defaults.js), the operator's
// setup. Betterbird reads it as a default, so a value the profile saved in prefs.js wins over it: that is
// drift, unless a node layer says so.
// - NODE: the setting prefs, which the node-engine writes to ~/.config/betterbird/user.js and the tools place
// in the profile's user.js, where it wins over prefs.js at every start.
//
// betterbird_config shows each managed preference through the layers and flags the drift; betterbird_config_apply
// clears a drifting value from prefs.js while Betterbird is not running; keep proposes a node layer that holds the
// profile's value for this node instead, through the controller's settings verb, never by writing user.js.
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
const (
// prefDir is the installation's own defaults directory; the module places its files there.
prefDir = installDir + "/defaults/pref"
// meshDefaults is the module's DEFAULT layer.
meshDefaults = prefDir + "/mesh-defaults.js"
// omniJa is Betterbird's packed application, holding its own default preferences.
omniJa = installDir + "/omni.ja"
)
// moduleFiles are the module's files in the installation's defaults directory: their values are the module's
// defaults, not Betterbird's.
var moduleFiles = map[string]bool{"mesh-defaults.js": true, "vendor-prefs.js": true}
var (
// defaultLine is one pref(…) or sticky_pref(…) line of a defaults file; a comment may follow it.
defaultLine = regexp.MustCompile(`^\s*(?:sticky_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;`)
// keepsDefault is a line of the module's defaults naming a preference it manages without a value.
keepsDefault = regexp.MustCompile(`^//\s*keeps Betterbird's default:\s*([A-Za-z0-9_.@{}\-]+)\s*$`)
)
// nodeName is the machine this runtime serves, as the mesh names it: a variable so a test answers.
var nodeName = func() string { return os.Getenv("MESH_NODE") }
// shipped is the module's DEFAULT layer as placed on the machine.
type shipped struct {
values map[string]string
// keeps are the managed names whose module value is Betterbird's own default.
keeps map[string]bool
// order is every managed name, in the file's order.
order []string
}
// parseShipped reads the module's defaults file.
func parseShipped(raw []byte) shipped {
s := shipped{values: map[string]string{}, keeps: map[string]bool{}}
for _, line := range strings.Split(string(raw), "\n") {
if mm := keepsDefault.FindStringSubmatch(strings.TrimSpace(line)); mm != nil {
if !s.keeps[mm[1]] {
s.keeps[mm[1]] = true
s.order = append(s.order, mm[1])
}
continue
}
if mm := defaultLine.FindStringSubmatch(line); mm != nil {
if _, seen := s.values[mm[1]]; !seen {
s.order = append(s.order, mm[1])
}
s.values[mm[1]] = mm[2]
}
}
return s
}
// shippedDefaults reads the module's defaults file from the installation.
func (m *Machine) shippedDefaults() (shipped, error) {
raw, err := readBounded(m.path(meshDefaults))
if err != nil {
return shipped{values: map[string]string{}, keeps: map[string]bool{}}, err
}
return parseShipped(raw), nil
}
// DefaultValue is a default preference and the file that gives it.
type DefaultValue struct {
Value string
From string
// Module says the module's files give it, not Betterbird's.
Module bool
}
// readDefaultsInto reads the pref(…) lines of the files of one place, in Gecko's order: it reads a place's
// files last-first by name (Preferences.cpp, pref_ReadDefaultPrefs), so of a name in several files the value
// of the file first by name holds. A later place overrides an earlier one.
func readDefaultsInto(out map[string]DefaultValue, files map[string][]byte, label func(string) (string, bool)) {
names := make([]string, 0, len(files))
for n := range files {
names = append(names, n)
}
sort.Strings(names)
place := map[string]DefaultValue{}
for i := len(names) - 1; i >= 0; i-- {
from, module := label(names[i])
for _, line := range strings.Split(string(files[names[i]]), "\n") {
if mm := defaultLine.FindStringSubmatch(line); mm != nil {
place[mm[1]] = DefaultValue{Value: mm[2], From: from, Module: module}
}
}
}
for k, v := range place {
out[k] = v
}
}
// mostDefaultsFile bounds one defaults file read from omni.ja.
const mostDefaultsFile = 4 << 20
// defaults reads every default preference Betterbird starts with: greprefs.js and defaults/pref/*.js in
// omni.ja, then the installation's defaults/pref/*.js, where the module's files are. A preference compiled into
// Betterbird (a static preference) is in no file, and is absent here. withModule false leaves the module's own
// files out, so what remains is Betterbird's. The error says what could not be read.
func (m *Machine) defaults(withModule bool) (map[string]DefaultValue, error) {
out := map[string]DefaultValue{}
var problems []string
if r, err := zip.OpenReader(m.path(omniJa)); err != nil {
problems = append(problems, omniJa+" is unreadable: "+err.Error())
} else {
gre, app := map[string][]byte{}, map[string][]byte{}
for _, f := range r.File {
switch {
case f.Name == "greprefs.js":
case strings.HasPrefix(f.Name, "defaults/pref/") && strings.HasSuffix(f.Name, ".js") && !strings.Contains(f.Name[len("defaults/pref/"):], "/"):
default:
continue
}
rc, err := f.Open()
if err != nil {
continue
}
b, _ := io.ReadAll(io.LimitReader(rc, mostDefaultsFile))
rc.Close()
if f.Name == "greprefs.js" {
gre[f.Name] = b
} else {
app[filepath.Base(f.Name)] = b
}
}
r.Close()
readDefaultsInto(out, gre, func(n string) (string, bool) { return "Betterbird's default (omni.ja " + n + ")", false })
readDefaultsInto(out, app, func(n string) (string, bool) {
return "Betterbird's default (omni.ja defaults/pref/" + n + ")", false
})
}
installed := map[string][]byte{}
entries, _ := os.ReadDir(m.path(prefDir))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") || (moduleFiles[e.Name()] && !withModule) {
continue
}
if b, err := readBounded(m.path(filepath.Join(prefDir, e.Name()))); err == nil {
installed[e.Name()] = b
}
}
readDefaultsInto(out, installed, func(n string) (string, bool) {
if moduleFiles[n] {
return "the module's default (" + prefDir + "/" + n + ")", true
}
return "Betterbird's default (" + prefDir + "/" + n + ")", false
})
if len(problems) > 0 {
return out, errors.New(strings.Join(problems, "; "))
}
return out, nil
}
// policyPrefs are the Preferences entries of the placed policy: name to its value as a preference file writes
// it, and whether it is locked. An unreadable policy has none.
type policyPref struct {
Value string
Locked bool
}
func policyPrefsOf(raw []byte) map[string]policyPref {
var p struct {
Policies struct {
Preferences map[string]struct {
Value any `json:"Value"`
Status string `json:"Status"`
} `json:"Preferences"`
} `json:"policies"`
}
out := map[string]policyPref{}
if json.Unmarshal(raw, &p) != nil {
return out
}
for name, e := range p.Policies.Preferences {
out[name] = policyPref{Value: literal(e.Value), Locked: e.Status == "locked"}
}
return out
}
// locks are the preferences the placed policy locks: name to value.
func (m *Machine) locks() map[string]string {
raw, err := readBounded(m.path(policyFile))
if err != nil {
return map[string]string{}
}
out := map[string]string{}
for name, p := range policyPrefsOf(raw) {
if p.Locked {
out[name] = p.Value
}
}
return out
}
// literal is a JSON value as a preference file writes it.
func literal(v any) string {
switch x := v.(type) {
case bool:
return strconv.FormatBool(x)
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case string:
return quoted(x)
case nil:
return ""
}
b, _ := json.Marshal(v)
return string(b)
}
// statementsOf are the user_pref(…) statements of a user.js, several to a line allowed: name to value.
func statementsOf(raw []byte) map[string]string {
out := map[string]string{}
for _, line := range strings.Split(string(raw), "\n") {
for _, st := range splitStatements(strings.TrimSpace(line)) {
if mm := prefLine.FindStringSubmatch(st); mm != nil {
out[mm[1]] = mm[2]
}
}
}
return out
}
// ConfigPref is one managed preference through the layers.
type ConfigPref struct {
Name string `json:"name"`
// Layer is locked (the policy), default (the module's defaults file) or "Betterbird's default" (a managed
// preference whose module value is Betterbird's own).
Layer string `json:"layer"`
Locked string `json:"locked,omitempty"`
ModuleDefault string `json:"module_default,omitempty"`
BetterbirdDefault string `json:"betterbird_default,omitempty"`
// NodeLayer is what the setting prefs on this machine says (the module's user.js).
NodeLayer string `json:"node_layer,omitempty"`
// Profile is what the profile saved in its prefs.js.
Profile string `json:"profile,omitempty"`
Effective string `json:"effective"`
From string `json:"effective_from"`
// Drift says how the profile's own value beats the module's without a node layer saying so.
Drift string `json:"drift,omitempty"`
// module is the value the module means: its default, or Betterbird's.
module string
}
// ConfigAnswer is what betterbird_config answers.
type ConfigAnswer struct {
Profile string `json:"profile"`
Defaults string `json:"module_defaults"`
Prefs []ConfigPref `json:"prefs"`
// Drifting are the names that drift.
Drifting []string `json:"drifting"`
// Proposed is the controller's answer to a node layer proposed with keep.
Proposed string `json:"proposed,omitempty"`
Note string `json:"note,omitempty"`
}
// config reads every managed preference through the layers: the policy's locks, the module's defaults, the
// node layer (the declared user.js), the profile's prefs.js and user.js.
func (m *Machine) config() (ConfigAnswer, Profile, error) {
a := ConfigAnswer{Prefs: []ConfigPref{}, Drifting: []string{}}
p, err := m.profile()
if err != nil {
return a, p, err
}
a.Profile = p.Dir
sh, serr := m.shippedDefaults()
if serr != nil {
a.Defaults = "not placed: " + meshDefaults + " is missing; the module's push places it"
} else {
a.Defaults = fmt.Sprintf("placed: %s, %d preference(s) with a value and %d kept at Betterbird's default",
meshDefaults, len(sh.values), len(sh.keeps))
}
locked := m.locks()
own, _ := m.defaults(false)
declaredRaw, _ := readBounded(m.home(declared))
node := statementsOf(declaredRaw)
prefsRaw, _ := readBounded(filepath.Join(p.path, "prefs.js"))
saved := readPrefs(prefsRaw)
userRaw, _ := readBounded(filepath.Join(p.path, "user.js"))
placed := statementsOf(userRaw)
var names []string
for n := range locked {
names = append(names, n)
}
sort.Strings(names)
for _, n := range sh.order {
if _, isLocked := locked[n]; !isLocked {
names = append(names, n)
}
}
for _, n := range names {
c := ConfigPref{Name: n, NodeLayer: node[n], Profile: saved[n]}
bb, hasBB := own[n]
if hasBB {
c.BetterbirdDefault = bb.Value
}
switch v, isLocked := locked[n]; {
case isLocked:
c.Layer, c.Locked, c.module = "locked", v, v
case sh.keeps[n]:
c.Layer = "Betterbird's default"
c.module = c.BetterbirdDefault
default:
c.Layer, c.ModuleDefault, c.module = "default", sh.values[n], sh.values[n]
}
switch {
case c.Locked != "":
c.Effective, c.From = c.Locked, "locked by the module's policy"
case placed[n] != "":
c.Effective, c.From = placed[n], "the profile's user.js (wins at the start)"
case c.Profile != "":
c.Effective, c.From = c.Profile, "the profile's prefs.js"
case c.ModuleDefault != "":
c.Effective, c.From = c.ModuleDefault, "the module's default"
case c.BetterbirdDefault != "":
c.Effective, c.From = c.BetterbirdDefault, bb.From
default:
c.From = "Betterbird's built-in default (in no defaults file)"
}
if why := drift(c); why != "" {
c.Drift = why
a.Drifting = append(a.Drifting, n)
}
c.NodeLayer, c.Profile, c.Effective = m.maskValue(c.NodeLayer), m.maskValue(c.Profile), m.maskValue(c.Effective)
a.Prefs = append(a.Prefs, c)
}
return a, p, nil
}
// drift says how the profile's saved value beats the module's, or "": a value in prefs.js that is not the
// module's, where no lock holds and no node layer names the preference.
func drift(c ConfigPref) string {
if c.Locked != "" || c.NodeLayer != "" || c.Profile == "" || c.Profile == c.module {
return ""
}
want := c.module
if want == "" {
want = "Betterbird's built-in default"
}
return fmt.Sprintf("the profile's prefs.js holds %s, which beats the module's %s, and no node layer says so", c.Profile, want)
}
// Config is what betterbird_config answers; with keep it proposes a node layer of the setting prefs that holds
// the profile's own values of the named managed preferences on this machine (novox/hq issue 345). The module
// never writes user.js for it: the operator approves the layer, and the next push writes it.
func (m *Machine) Config(keep []string) (ConfigAnswer, error) {
a, p, err := m.config()
if err != nil {
return a, err
}
a.Note = "drift is a value the profile saved that beats the module's default without a node layer saying so: " +
"betterbird_config_apply clears it (Betterbird quit), or keep proposes a node layer that holds it for this machine"
if len(keep) == 0 {
return a, nil
}
node := nodeName()
if node == "" {
return a, errors.New("nothing was proposed: this runtime does not say which machine it serves (MESH_NODE)")
}
by := map[string]ConfigPref{}
for _, c := range a.Prefs {
by[c.Name] = c
}
prefsRaw, _ := readBounded(filepath.Join(p.path, "prefs.js"))
saved := readPrefs(prefsRaw)
declaredRaw, err := readBounded(m.home(declared))
if err != nil {
return a, fmt.Errorf("nothing was proposed: the module's user.js is not on this machine (~/%s)", declared)
}
kept := map[string]string{}
for _, n := range keep {
c, managed := by[n]
switch {
case !managed:
return a, fmt.Errorf("nothing was proposed: %s is not a preference the module manages (betterbird_config lists them)", n)
case c.Locked != "":
return a, fmt.Errorf("nothing was proposed: %s is locked by the module's policy; a lock changes only in the catalogue", n)
case saved[n] == "":
return a, fmt.Errorf("nothing was proposed: the profile saved no value of its own for %s", n)
}
kept[n] = saved[n]
}
// The setting is one string, so a node layer replaces it whole: it carries what the setting says on this
// machine today, with the kept values in place of any the same names had.
var parts []string
for _, line := range strings.Split(string(declaredRaw), "\n") {
for _, st := range splitStatements(strings.TrimSpace(line)) {
if mm := prefLine.FindStringSubmatch(st); mm != nil {
if _, replaced := kept[mm[1]]; !replaced {
parts = append(parts, fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), mm[2]))
}
}
}
}
for _, n := range keep {
if v, ok := kept[n]; ok {
parts = append(parts, fmt.Sprintf("user_pref(%s, %s);", quoted(n), v))
delete(kept, n)
}
}
value := strings.Join(parts, " ")
if err := validUserJS(mark + ")\n" + value + "\n"); err != nil {
return a, fmt.Errorf("nothing was proposed: the layer would be refused: %v", err)
}
body, _ := json.Marshal(map[string]string{"prefs": value})
raw, err := asker("seat:mesh-controller.settings", map[string]any{"module": "betterbird", "node": node,
"values": string(body), "propose": "true"})
if err != nil {
return a, fmt.Errorf("the controller's settings verb did not answer: %v", err)
}
said, ok := verbSaid(raw)
a.Proposed = m.maskValue(said)
if !ok {
return a, fmt.Errorf("the controller did not take the proposal: %s", a.Proposed)
}
a.Note = "proposed for " + node + ": the operator approves it on a channel that proves who answers; the next " +
"push writes it, and it is placed at Betterbird's next start from the launcher (or betterbird_restart). " +
"The layer carries the whole setting prefs as it stands on this machine, since a layer replaces it whole"
return a, nil
}
// Cleared is one value betterbird_config_apply took out of prefs.js.
type Cleared struct {
Name string `json:"name"`
Was string `json:"was"`
// Now is the value that holds once Betterbird starts again.
Now string `json:"now"`
}
// ConfigApplyAnswer is what betterbird_config_apply answers.
type ConfigApplyAnswer struct {
Profile string `json:"profile"`
Applied bool `json:"applied"`
Clears []Cleared `json:"clears"`
Note string `json:"note"`
}
// ConfigApply takes the drifting values out of the profile's prefs.js, so the module's value holds at the next
// start. Without apply it lists them. It acts only while Betterbird is not running, because Betterbird
// rewrites prefs.js when it quits. Every other line of prefs.js is kept as it was.
func (m *Machine) ConfigApply(apply bool) (ConfigApplyAnswer, error) {
c, p, err := m.config()
a := ConfigApplyAnswer{Profile: c.Profile, Clears: []Cleared{}}
if err != nil {
return a, err
}
if strings.HasPrefix(c.Defaults, "not placed") {
return a, fmt.Errorf("the module's defaults are %s: nothing to bring the profile back to", c.Defaults)
}
drifting := map[string]bool{}
for _, cp := range c.Prefs {
if cp.Drift != "" {
drifting[cp.Name] = true
now := m.maskValue(cp.module)
if now == "" {
now = "Betterbird's built-in default"
}
a.Clears = append(a.Clears, Cleared{Name: cp.Name, Was: cp.Profile, Now: now})
}
}
if !apply {
a.Note = "a dry run: these values would be taken out of prefs.js. Quit Betterbird, then call with apply"
return a, nil
}
if ps := m.mainProcs(); len(ps) > 0 {
return a, fmt.Errorf("Betterbird is running (pid %d): it rewrites prefs.js when it quits, so nothing was cleared; quit it, then apply again", ps[0].PID)
}
if len(drifting) == 0 {
a.Applied, a.Note = true, "nothing drifts: prefs.js was left as it was"
return a, nil
}
target := filepath.Join(p.path, "prefs.js")
raw, err := readBounded(target)
if err != nil {
return a, err
}
st, err := os.Stat(target)
if err != nil {
return a, err
}
lines := strings.SplitAfter(string(raw), "\n")
var kept strings.Builder
for _, l := range lines {
if mm := prefLine.FindStringSubmatch(strings.TrimRight(l, "\n")); mm != nil && drifting[mm[1]] {
continue
}
kept.WriteString(l)
}
tmp := target + ".mesh-new"
if err := os.WriteFile(tmp, []byte(kept.String()), st.Mode().Perm()); err != nil {
return a, err
}
if err := os.Rename(tmp, target); err != nil {
os.Remove(tmp)
return a, err
}
a.Applied = true
a.Note = fmt.Sprintf("cleared %d value(s) from prefs.js; the module's values hold from Betterbird's next start", len(a.Clears))
return a, nil
}