From 854c85393cd8722ba4abc7ac8d6de244a17ed205 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 20:58:39 +0200 Subject: [PATCH] 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. --- modules/betterbird/README.md | 60 +- .../cmd/betterbird-tools/betterbird.go | 56 +- .../betterbird/cmd/betterbird-tools/config.go | 532 ++++++++++++++++++ .../cmd/betterbird-tools/config_test.go | 437 ++++++++++++++ .../cmd/betterbird-tools/install.go | 61 +- .../betterbird/cmd/betterbird-tools/main.go | 52 +- .../install/defaults-pref/mesh-defaults.js | 83 +++ .../install/distribution/policies.json | 10 + modules/betterbird/module.json | 2 + 9 files changed, 1254 insertions(+), 39 deletions(-) create mode 100644 modules/betterbird/cmd/betterbird-tools/config.go create mode 100644 modules/betterbird/cmd/betterbird-tools/config_test.go create mode 100644 modules/betterbird/install/defaults-pref/mesh-defaults.js diff --git a/modules/betterbird/README.md b/modules/betterbird/README.md index 082e2ec2..cc54075f 100644 --- a/modules/betterbird/README.md +++ b/modules/betterbird/README.md @@ -18,13 +18,14 @@ issue 345. | Betterbird, the upstream release (`archive`, pinned by URL and sha256) | `/opt/betterbird` | owned, whole | | the enterprise policy (`install/distribution/policies.json`) | `/opt/betterbird/distribution` | owned, whole | | Owl's xpi (`owl`, pinned by the vendor's sha256) | `/opt/betterbird/owl` | owned, whole | -| the spelling dictionaries' default (`install/defaults-pref/vendor-prefs.js`) | `/opt/betterbird/defaults/pref` | owned: its one file | +| the module's defaults (`install/defaults-pref/mesh-defaults.js`) and the spelling dictionaries' default (`vendor-prefs.js`) | `/opt/betterbird/defaults/pref` | owned: its two files | | the tools' program, for the start's preparation | `/usr/local/lib/mesh-betterbird` | owned, whole | | the start | `/usr/local/bin/betterbird` | owned, whole | | the launcher entry | `/usr/local/share/applications/eu.betterbird.Betterbird.desktop` | owned, whole | | the module's directory, with its `user.js` (setting `prefs`), `accounts.json` (setting `accounts`) and the record of adopted and created accounts | `~/.config/betterbird/` | owned, whole | | the profile's `user.js`, placed by the tools | the profile | the module's, or never touched | | the account lists in the profile's `prefs.js` | the profile | written into: the module's keys only | +| the profile's own values of the preferences the module manages, in `prefs.js` | the profile | cleared only by `betterbird_config_apply` with apply, Betterbird quit | | the profile: mail, address books, calendars, logins | `~/.thunderbird/` | found (ADR 0182) | **The community package `betterbird-bin` is declared absent** (ADR 0180), before anything is placed in @@ -73,6 +74,56 @@ starts Betterbird either way. `betterbird_restart` starts it as the transient us `mesh-betterbird`, so its output then goes to the journal. +## The configuration this module ships + +Betterbird's configuration comes in three layers (novox/hq issue 345). The operator approved the set on +2026-10-12. It is the laptop's setup, with the start page locked off, reminders missed while asleep shown at the +next start, and chat off. + +| layer | file | who changes it | holds | +|---|---|---|---| +| **locked** | `/opt/betterbird/distribution/policies.json` (`install/distribution/`) | only a reviewed catalogue change | what must never differ on a node; Betterbird's settings show it greyed out | +| **default** | `/opt/betterbird/defaults/pref/mesh-defaults.js` (`install/defaults-pref/`) | a catalogue change; or the operator in Betterbird's settings, where the change lands in the profile's `prefs.js` | the operator's setup: what a fresh node gets without anyone touching it | +| **node** | the setting `prefs`, written to `~/.config/betterbird/user.js` and placed in the profile's `user.js` | the operator, per node, through the settings layers | a node's exceptions, such as a font size for a larger screen | + +A locked value wins over everything. A node layer (`user.js`) wins over the profile's `prefs.js`, and +`prefs.js` wins over a default. + +**Locked** (the policy): Betterbird's updater off (`DisableAppUpdate`); telemetry off (`DisableTelemetry`); +add-ons change only through the mesh's pins (`ExtensionUpdate` false); and three preferences locked off: +`mail.shell.checkDefaultClient` (the ask to be the default client), `mail.inappnotifications.enabled` (the +vendor's in-app notices), and `mailnews.start_page.enabled` (the start page, which otherwise loads from the +vendor at every start). The `Preferences` policy accepts only some prefixes. `privacy.` and `toolkit.` are not +among them, so such a preference can only be a default. + +**Default** (`mesh-defaults.js`, each line commented with why): + +- display: compact density (`mail.uidensity` 0), 14-point interface text, and message bodies not darkened; +- layout: the vertical view with multi-line rows, the list as cards, threads grouped; +- no confirmation on Shift+Delete; +- new mail: no pop-up, no sound, no tray icon, and alerts through the desktop's notifier if a node turns them on; +- calendar: the day from 09:00 to 20:00, deleting without asking, the node's time zone, reminders shown, and + reminders missed while asleep shown at the next start; +- chat off; +- remote content blocked, and Do Not Track sent. + +A line `// keeps Betterbird's default: ` names a preference the module manages without a value of its own. +For these the operator's setup is Betterbird's own default: the theme, system colours, always-underlined +links, manual spam marking, and the composer's attachment pane. + +**Drift.** A default does not beat a value the profile saved in `prefs.js`. That value may be a choice made in +the settings window, or one the profile already held. `betterbird_config` shows each managed preference through +the layers, and flags **drift**: a saved value that beats the module's default without a node layer saying so. +`betterbird_check` reports it. There are two remedies: + +- `betterbird_config_apply` with `apply` takes the drifting values out of `prefs.js`, so the module's value + holds at the next start. It acts only while Betterbird is quit, because Betterbird rewrites `prefs.js` when it + quits, and it says what it cleared. Without `apply` it lists what it would clear. +- `betterbird_config` with `keep` proposes, through the controller's settings verb, a node layer of `prefs` that + holds the profile's value for this machine. The operator approves it on a channel that proves who answers. + The tool never writes `user.js` itself. The setting is one string, so the layer carries what `prefs` says on + this machine today, with the kept values added. + ## The user.js: how a preference is declared Betterbird reads `user.js` in its profile when it starts, and a preference set there wins over the @@ -163,11 +214,13 @@ and key. | tool | does | |---|---| | `betterbird_status` (r) | whether it runs (pid, since, scope, children); what is installed and from where (the mesh's release, or the community package before the switch), its version and build beside the newest published build (Betterbird's own `versions.txt`); the policy; Owl's version against the pin and whether it is active; where its output goes; the profile and how it was found; the user.js (declared, placed, in effect); the declared accounts by count and, per place, adopted, created or refused with their keys; the add-ons in the profile (ids, versions); the preferences that decide how its windows and notifications look | -| `betterbird_prefs` (r) | named preferences or prefixes (ending in `.` or `*`, at most 200; an empty name, `*` or `.` is refused): value and source (user.js, prefs.js or the default). Withheld without its value: a name that can hold a credential (`password`, `token`, `oauth`, `signon`, …); one that names an account, server, calendar, recent folder or person (`mail.server.`, `mail.identity.`, `mail.last_msg…`, `messenger.account.`, `mail.cloud_files.`, `calendar.registry.`, …); and every `extensions.*` but the theme's id (`extensions.activeThemeID`). In every value answered, the home is `~`, and a mail address, a `user%40domain@host` URI and a host name are masked | +| `betterbird_prefs` (r) | named preferences or prefixes (ending in `.` or `*`, at most 200; an empty name, `*` or `.` is refused): value and source (the policy's lock, user.js, prefs.js or the default), and the default itself with the file that gives it. Betterbird's own defaults are read from `omni.ja` (`greprefs.js`, `defaults/pref/*.js`) and the installation's `defaults/pref`, the module's from its files there. Of several files, the one first by name holds, as Gecko reads them. A preference compiled into Betterbird is said as built in. Withheld without its value: a name that can hold a credential (`password`, `token`, `oauth`, `signon`, …); one that names an account, server, calendar, recent folder or person (`mail.server.`, `mail.identity.`, `mail.last_msg…`, `messenger.account.`, `mail.cloud_files.`, `calendar.registry.`, and of `mailnews.*` only a name that holds a server, account, identity or tag); and every `extensions.*` but the theme's id (`extensions.activeThemeID`). In every value answered, the home is `~`, and a mail address, a `user%40domain@host` URI and a host name are masked | +| `betterbird_config` (r; a with `keep`) | each managed preference: its lock, the module's default (or Betterbird's, where the module keeps that), the node layer, the profile's saved value, the value in effect, and drift; with `keep`, a node layer holding the profile's values proposed for this machine | +| `betterbird_config_apply` (r; a with `apply`) | the drifting values listed; with `apply`, while Betterbird is quit, taken out of `prefs.js`, each said with the value it had and the one that now holds | | `betterbird_user_js` (r; a with `apply`) | the declared user.js against the profile's; `apply` places it | | `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked (the value after `password=`, `token:` and the like, and the token after `Bearer`/`Basic`). A Betterbird the module did not start prints elsewhere, and the answer says where | | `betterbird_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js where allowed, starts it as `mesh-betterbird`. **Refused while a composer window is open** (X instance `Msgcompose`, whatever its class), because the unsent message would be lost, unless `discard_unsent` is true, which only the operator's yes justifies. It answers within the runtime's 30 s: a start not yet seen after 4 s is said in `note`, and `betterbird_status` shows it | -| `betterbird_check` (r) | installed from the mesh's release and not behind the newest published build; the policy placed; no ask at the start to be the default mail client; Owl at its pin and active; one running; profile found; the user.js on the machine, valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; its output read | +| `betterbird_check` (r) | installed from the mesh's release and not behind the newest published build; the policy placed, and the module's defaults with it; no managed preference drifting from them; no ask at the start to be the default mail client; Owl at its pin and active; one running; profile found; the user.js on the machine, valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; its output read | | `betterbird_accounts` (r; a with `propose`) | the profile's accounts by count and kind (declarable, Owl's, others); with `propose`, the setting `accounts` built from them and proposed to the operator (ADR 0317 §9) | | `betterbird_reminder_test` (a) | a reminder on demand without the operator's calendar (below) | @@ -233,6 +286,7 @@ outside the mesh. With this version, a push to a node (each waits for the operat | `/usr/local` | the start, the launcher entry, the tools' program | | `~/.config/betterbird/` | `user.js`, `accounts.json` (no account until the setting `accounts` is approved) | | the profile | untouched until the next start from the launcher, `betterbird_user_js` with apply, or `betterbird_restart` | +| the configuration | the policy's locks at Betterbird's next start; the module's defaults wherever the profile saved no value of its own; a saved value is drift, said by `betterbird_check`, until `betterbird_config_apply` or a node layer | ## Relies on diff --git a/modules/betterbird/cmd/betterbird-tools/betterbird.go b/modules/betterbird/cmd/betterbird-tools/betterbird.go index a84c73c1..6bd15030 100644 --- a/modules/betterbird/cmd/betterbird-tools/betterbird.go +++ b/modules/betterbird/cmd/betterbird-tools/betterbird.go @@ -263,11 +263,15 @@ func readPrefs(raw []byte) map[string]string { var ( passwordSecret = regexp.MustCompile(`(?i)(password|passwd|secret|token|oauth|auth|cookie|credential|signon|apikey|api_key|privatekey|private_key|\.key$)`) accountPrefix = []string{"mail.server.", "mail.identity.", "mail.smtpserver.", "mail.smtp.", "mail.account.", - "mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.", "mailnews.", + "mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.", "services.sync.", "mail.displayname", "mail.default_", "mail.last_msg", "messenger.account.", "mail.cloud_files."} // allowedExtensions are the only extensions.* preferences answered: the add-ons' own preferences can // hold anything, and a theme's id is what a window's look depends on. allowedExtensions = map[string]bool{"extensions.activeThemeID": true} + // mailnewsAccount is a mailnews.* name that holds a server, an account, an identity or the operator's own + // tags: only those are withheld of mailnews.*, so a setting such as mailnews.start_page.enabled or + // mailnews.default_view_flags is answered (novox/hq issue 345). + mailnewsAccount = regexp.MustCompile(`(?i)^mailnews\..*(server|account|ident|smtp|ldap|imap|pop3|nntp|tags\.|email|addr)`) ) // accountOwned are the preferences of an account and of the account lists: the setting accounts' alone, so @@ -288,16 +292,24 @@ func withheld(name string) string { return "it names an account, a server, a calendar or a person" } } + if mailnewsAccount.MatchString(name) { + return "it names an account, a server, a calendar or a person" + } return "" } // Pref is one preference as the profile holds it. type Pref struct { Name string `json:"name"` - // Value is what Betterbird uses: user.js's at the start, else prefs.js's. Absent: the default. - Value string `json:"value,omitempty"` - From string `json:"from,omitempty"` - Withheld string `json:"withheld,omitempty"` + // Value is what Betterbird uses: the policy's lock, else user.js's at the start, else prefs.js's, else the + // default, when a defaults file gives it. + Value string `json:"value,omitempty"` + From string `json:"from,omitempty"` + // Default is the default Betterbird starts with, and the file that gives it: Betterbird's own (omni.ja), or + // the module's (novox/hq issue 345). Absent for a preference compiled into Betterbird. + Default string `json:"default,omitempty"` + DefaultFrom string `json:"default_from,omitempty"` + Withheld string `json:"withheld,omitempty"` } // mostPrefs bounds how many preferences one answer carries. @@ -319,7 +331,9 @@ func (m *Machine) Prefs(names []string) (map[string]any, error) { } prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js")) userJS, _ := readBounded(filepath.Join(p.path, "user.js")) - fromPrefs, fromUser := readPrefs(prefsJS), readPrefs(userJS) + fromPrefs, fromUser := readPrefs(prefsJS), statementsOf(userJS) + own, ownErr := m.defaults(true) + locked := m.locks() all := map[string]bool{} for n := range fromPrefs { all[n] = true @@ -354,25 +368,40 @@ func (m *Machine) Prefs(names []string) (map[string]any, error) { break } pr := Pref{Name: n} + d, hasDefault := own[n] v, from := fromPrefs[n], "prefs.js" - if u, ok := fromUser[n]; ok { + if l, ok := locked[n]; ok { + v, from = l, "locked by the module's policy" + } else if u, ok := fromUser[n]; ok { v, from = u, "user.js (wins at the start)" } else if _, ok := fromPrefs[n]; !ok { - from = "not set: Betterbird's default" - v = "" + switch { + case hasDefault: + v, from = d.Value, "not set: "+d.From + default: + v, from = "", "not set: Betterbird's built-in default (in no defaults file)" + } } pr.From = from + if hasDefault { + pr.DefaultFrom = d.From + } if why := withheld(n); why != "" { pr.Withheld = why } else if allowedExtensions[n] { pr.Value = v + pr.Default = d.Value } else { pr.Value = m.maskValue(v) + pr.Default = m.maskValue(d.Value) } out = append(out, pr) } } answer := map[string]any{"profile": p, "prefs": out} + if ownErr != nil { + answer["defaults_problem"] = ownErr.Error() + } if cut { answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs) } @@ -876,6 +905,15 @@ func (m *Machine) Check() (CheckAnswer, error) { if in.From == "the mesh" && !strings.HasPrefix(in.Policy, "placed:") { add("the enterprise policy is "+in.Policy, "push the module to this node") } + if in.From == "the mesh" && !exists(m.path(meshDefaults)) { + add("the module's defaults are not placed ("+meshDefaults+")", "push the module to this node") + } + if c, _, err := m.config(); err == nil && len(c.Drifting) > 0 { + add(fmt.Sprintf("%d managed preference(s) drift from the module's defaults: the profile saved its own value and no "+ + "node layer says so (%s)", len(c.Drifting), strings.Join(c.Drifting, ", ")), + "betterbird_config shows each; to keep a value on this node, betterbird_config with keep proposes a node "+ + "layer; otherwise quit Betterbird and call betterbird_config_apply with apply") + } if why := m.defaultClientAsk(profilePath); why != "" && in.From != "not installed" { add("Betterbird asks at its start to be the default mail client: "+why, "push the module to this node: its policy locks "+defaultClientPref+" off (novox/hq issue 345)") diff --git a/modules/betterbird/cmd/betterbird-tools/config.go b/modules/betterbird/cmd/betterbird-tools/config.go new file mode 100644 index 00000000..94d45e81 --- /dev/null +++ b/modules/betterbird/cmd/betterbird-tools/config.go @@ -0,0 +1,532 @@ +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 +} diff --git a/modules/betterbird/cmd/betterbird-tools/config_test.go b/modules/betterbird/cmd/betterbird-tools/config_test.go new file mode 100644 index 00000000..9c6107d5 --- /dev/null +++ b/modules/betterbird/cmd/betterbird-tools/config_test.go @@ -0,0 +1,437 @@ +package main + +import ( + "archive/zip" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// The configuration the module ships (novox/hq issue 345): the policy's locks, the module's defaults file, and the +// tools that show the layers, flag drift, clear it and propose a node layer. + +// shippedDefaultsWant is the DEFAULT layer the operator approved on 2026-10-12: the laptop's setup, with +// reminders missed while asleep shown at the next start and chat off. +var shippedDefaultsWant = map[string]string{ + "mail.uidensity": "0", + "mail.uifontsize": "14", + "mail.dark-reader.enabled": "false", + "mail.pane_config.dynamic": "2", + "mail.pane_config.multiline_all": "true", + "mail.threadpane.listview": "0", + "mailnews.default_view_flags": "1", + "mail.warn_on_shift_delete": "false", + "mail.biff.show_alert": "false", + "mail.biff.play_sound": "false", + "mail.biff.show_tray_icon": "false", + "mail.biff.use_system_alert": "true", + "calendar.view.daystarthour": "9", + "calendar.view.dayendhour": "20", + "calendar.item.promptDelete": "false", + "calendar.timezone.useSystemTimezone": "true", + "calendar.alarms.show": "true", + "calendar.alarms.showmissed": "true", + "mail.chat.enabled": "false", + "mailnews.message_display.disable_remote_image": "true", + "privacy.donottrackheader.enabled": "true", +} + +var shippedKeepsWant = []string{"extensions.activeThemeID", "browser.display.use_system_colors", + "layout.css.always_underline_links", "mail.spam.manualMark", "mail.compose.show_attachment_pane"} + +func repoFile(t *testing.T, rel string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", rel)) + if err != nil { + t.Fatal(err) + } + return raw +} + +// withDefaults places the module's defaults file as the push does. +func (f *fake) withDefaults() { + f.t.Helper() + f.write(meshDefaults, string(repoFile(f.t, "install/defaults-pref/mesh-defaults.js"))) +} + +// omni places a packed application holding the given files. +func (f *fake) omni(files map[string]string) { + f.t.Helper() + full := filepath.Join(f.Root, omniJa) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + f.t.Fatal(err) + } + out, err := os.Create(full) + if err != nil { + f.t.Fatal(err) + } + w := zip.NewWriter(out) + for name, body := range files { + e, _ := w.Create(name) + e.Write([]byte(body)) + } + w.Close() + out.Close() +} + +// betterbirdsOwn is a small omni.ja: two files that contradict each other, as all-thunderbird.js and +// mailnews.js do in the pinned release. +func (f *fake) betterbirdsOwn() { + f.omni(map[string]string{ + "greprefs.js": `pref("gfx.webrender.software", false);` + "\n", + "defaults/pref/all-thunderbird.js": `pref("offline.autoDetect", true);` + "\n" + + `pref("mail.threadpane.listview", 1);` + "\n" + `pref("mail.uidensity", 1);` + "\n" + + `pref("mail.shell.checkDefaultClient", true);` + "\n", + "defaults/pref/mailnews.js": `pref("offline.autoDetect", false);` + "\n" + + `pref("mail.biff.show_alert", true); // a comment after it` + "\n" + `pref("mail.spam.manualMark", false);` + "\n" + + `pref("mailnews.start_page.enabled", true);` + "\n", + "defaults/pref/calendar.js": `pref("calendar.alarms.showmissed", false);` + "\n", + "chrome/other.js": `pref("not.a.default", 1);` + "\n", + }) +} + +func stopBetterbird(t *testing.T, f *fake) { + t.Helper() + for _, pid := range []string{"4000", "4001"} { + if err := os.RemoveAll(filepath.Join(f.Root, "proc", pid)); err != nil { + t.Fatal(err) + } + } +} + +func TestTheModuleShipsTheApprovedLocksAndDefaults(t *testing.T) { + m, _ := readManifest(t) + if a := artifact(t, m, "defaults"); a["from"] != "install/defaults-pref" { + t.Fatalf("the defaults file ships through the defaults artifact: %v", a) + } + if r := resource(t, m, "spelling"); r["path"] != prefDir || r["artifact"] != "defaults" { + t.Fatalf("placed in Betterbird's own defaults directory: %v", r) + } + raw := repoFile(t, "install/distribution/policies.json") + if why := policyProblem(raw); why != "" { + t.Fatal(why) + } + var p struct { + Policies map[string]json.RawMessage `json:"policies"` + } + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatal(err) + } + for k, want := range map[string]string{"DisableAppUpdate": "true", "DisableTelemetry": "true", "ExtensionUpdate": "false"} { + if string(p.Policies[k]) != want { + t.Errorf("%s is %s, not %s", k, p.Policies[k], want) + } + } + prefs := policyPrefsOf(raw) + for _, n := range []string{"mail.shell.checkDefaultClient", "mail.inappnotifications.enabled", "mailnews.start_page.enabled"} { + if prefs[n] != (policyPref{Value: "false", Locked: true}) { + t.Errorf("%s is locked off: %+v", n, prefs[n]) + } + } + // Betterbird's Preferences policy accepts only these prefixes (Policies.sys.mjs); privacy. and toolkit. are not. + allowed := []string{"accessibility.", "app.update.", "browser.", "calendar.", "chat.", "datareporting.policy.", "dom.", + "extensions.", "general.autoScroll", "general.smoothScroll", "geo.", "gfx.", "intl.", "layers.", "layout.", "mail.", + "mailnews.", "media.", "network.", "pdfjs.", "places.", "print.", "signon.", "spellchecker.", "ui.", "widget."} + for n := range prefs { + ok := false + for _, a := range allowed { + ok = ok || strings.HasPrefix(n, a) + } + if !ok { + t.Errorf("the policy cannot lock %s: its prefix is not one Betterbird accepts", n) + } + } + + text := string(repoFile(t, "install/defaults-pref/mesh-defaults.js")) + sh := parseShipped([]byte(text)) + if !reflect.DeepEqual(sh.values, shippedDefaultsWant) { + t.Errorf("the defaults are the approved ones:\n got %v\nwant %v", sh.values, shippedDefaultsWant) + } + var keeps []string + for _, n := range sh.order { + if sh.keeps[n] { + keeps = append(keeps, n) + } + } + if !reflect.DeepEqual(keeps, shippedKeepsWant) { + t.Errorf("the preferences kept at Betterbird's default: %v", keeps) + } + lines := strings.Split(text, "\n") + for i, l := range lines { + if defaultLine.MatchString(l) && (i == 0 || !strings.HasPrefix(lines[i-1], "// ")) { + t.Errorf("line %d has no comment saying why: %s", i+1, l) + } + if t2 := strings.TrimSpace(l); t2 != "" && !strings.HasPrefix(t2, "//") && !defaultLine.MatchString(l) { + t.Errorf("line %d is neither a comment nor a pref(…) line: %s", i+1, l) + } + } + for n := range sh.values { + if withheld(n) != "" || passwordSecret.MatchString(n) { + t.Errorf("%s is an account's or a credential's preference, never a default of the module", n) + } + if _, locked := prefs[n]; locked { + t.Errorf("%s is both locked and a default", n) + } + } +} + +// T2: Betterbird's own defaults are values read from omni.ja, in Gecko's order, and the module's beside them. +func TestPrefsSayTheDefaultAndTheFileThatGivesIt(t *testing.T) { + f := newFake(t) + f.betterbird() + f.placedByTheMesh() + f.withDefaults() + f.betterbirdsOwn() + links(t, nil) + got, err := f.Prefs([]string{"offline.autoDetect", "mail.threadpane.listview", "mail.uidensity", "gfx.webrender.software", + "mailnews.start_page.enabled", "layout.css.always_underline_links", "not.a.default"}) + if err != nil { + t.Fatal(err) + } + by := map[string]Pref{} + for _, p := range got["prefs"].([]Pref) { + by[p.Name] = p + } + if p := by["offline.autoDetect"]; p.Value != "true" || p.Default != "true" || !strings.Contains(p.DefaultFrom, "all-thunderbird.js") { + t.Errorf("of two files, the one first by name holds, as Gecko reads them: %+v", p) + } + if p := by["mail.threadpane.listview"]; p.Value != "0" || !strings.Contains(p.From, "the module's default") { + t.Errorf("the module's default is over Betterbird's: %+v", p) + } + if p := by["gfx.webrender.software"]; p.Value != "false" || !strings.Contains(p.From, "greprefs.js") { + t.Errorf("%+v", p) + } + if p := by["mailnews.start_page.enabled"]; p.Value != "false" || p.From != "locked by the module's policy" || p.Default != "true" { + t.Errorf("a locked preference says its lock and Betterbird's default under it: %+v", p) + } + if p := by["layout.css.always_underline_links"]; p.Value != "" || !strings.Contains(p.From, "built-in") { + t.Errorf("a preference in no file is said as such: %+v", p) + } + if p := by["not.a.default"]; p.Default != "" { + t.Errorf("only defaults/pref and greprefs.js are defaults: %+v", p) + } +} + +// T4: of mailnews.* only what names an account, a server or the operator's tags is withheld. +func TestMailnewsIsWithheldOnlyWhereItNamesAnAccount(t *testing.T) { + for name, held := range map[string]bool{ + "mailnews.start_page.enabled": false, + "mailnews.default_view_flags": false, + "mailnews.message_display.disable_remote_image": false, + "mailnews.tags.$label1.tag": true, + "mailnews.smtp.loglevel": true, + "mailnews.reply_to_self_check_all_ident": true, + "mailnews.auto_config.fetchFromISP.sendEmailAddress": true, + "mailnews.oauth.useSchemeRedirect": true, + "mail.server.server1.hostname": true, + } { + if got := withheld(name) != ""; got != held { + t.Errorf("%s: withheld %v, want %v", name, got, held) + } + } +} + +// T1: every managed preference through the layers, and drift where the profile's own value beats the module's +// default without a node layer saying so. +func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) { + f := newFake(t) + f.betterbird() + f.placedByTheMesh() + f.withDefaults() + f.betterbirdsOwn() + links(t, nil) + f.declare(`user_pref("mail.uifontsize", 16);`) + f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+ + `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+ + `user_pref("mail.uifontsize", 16);`+"\n"+ + `user_pref("mail.uidensity", 0);`+"\n"+ + `user_pref("mail.biff.show_alert", true);`+"\n"+ + `user_pref("mail.spam.manualMark", false);`+"\n"+ + `user_pref("mailnews.start_page.enabled", true);`+"\n") + c, err := f.Config(nil) + if err != nil { + t.Fatal(err) + } + by := map[string]ConfigPref{} + for _, p := range c.Prefs { + by[p.Name] = p + } + if len(c.Prefs) != 3+len(shippedDefaultsWant)+len(shippedKeepsWant) || !strings.HasPrefix(c.Defaults, "placed") { + t.Fatalf("every lock, default and kept default: %d, %s", len(c.Prefs), c.Defaults) + } + if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID", "mail.biff.show_alert"}) { + t.Fatalf("drift: %v", c.Drifting) + } + if p := by["mail.biff.show_alert"]; p.Layer != "default" || p.ModuleDefault != "false" || p.BetterbirdDefault != "true" || + p.Profile != "true" || p.Effective != "true" || p.From != "the profile's prefs.js" || !strings.Contains(p.Drift, "beats the module's false") { + t.Errorf("%+v", p) + } + if p := by["extensions.activeThemeID"]; p.Layer != "Betterbird's default" || !strings.Contains(p.Drift, "built-in default") { + t.Errorf("a kept default with a saved value is drift: %+v", p) + } + if p := by["mail.spam.manualMark"]; p.Drift != "" || p.BetterbirdDefault != "false" { + t.Errorf("a saved value equal to Betterbird's default is not drift: %+v", p) + } + if p := by["mail.uifontsize"]; p.Drift != "" || p.NodeLayer != "16" { + t.Errorf("a node layer that says so is not drift: %+v", p) + } + if p := by["mailnews.start_page.enabled"]; p.Layer != "locked" || p.Effective != "false" || p.Drift != "" { + t.Errorf("a lock wins over the profile: %+v", p) + } + if p := by["calendar.alarms.showmissed"]; p.Effective != "true" || p.From != "the module's default" { + t.Errorf("%+v", p) + } + // T5: the check says the drift, with both remedies. + ch, err := f.Check() + if err != nil { + t.Fatal(err) + } + found := false + for _, x := range ch.Findings { + found = found || (strings.Contains(x.What, "2 managed preference(s) drift") && strings.Contains(x.What, "mail.biff.show_alert") && + strings.Contains(x.Do, "betterbird_config_apply") && strings.Contains(x.Do, "keep")) + } + if !found { + t.Fatalf("the check reports the drift: %+v", ch.Findings) + } + os.Remove(filepath.Join(f.Root, meshDefaults)) + ch, _ = f.Check() + found = false + for _, x := range ch.Findings { + found = found || strings.Contains(x.What, "the module's defaults are not placed") + } + if !found { + t.Fatalf("defaults missing on a node of the mesh's release is a finding: %+v", ch.Findings) + } +} + +// T3: the drifting values are cleared from prefs.js only while Betterbird is not running, and nothing else. +func TestConfigApplyClearsTheDriftOnlyWhileBetterbirdIsQuit(t *testing.T) { + f := newFake(t) + f.betterbird() + f.placedByTheMesh() + f.withDefaults() + f.betterbirdsOwn() + links(t, nil) + f.declare("// none") + prefs := "// Mozilla User Preferences\n" + + `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" + + `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" + + `user_pref("mail.biff.show_alert", true);` + "\n" + + `user_pref("mail.uidensity", 0);` + "\n" + f.write(profileDir+"/prefs.js", prefs) + dry, err := f.ConfigApply(false) + if err != nil || dry.Applied || len(dry.Clears) != 2 { + t.Fatalf("a dry run lists and changes nothing: %+v %v", dry, err) + } + if f.read(profileDir+"/prefs.js") != prefs { + t.Fatal("a dry run wrote prefs.js") + } + if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "running") { + t.Fatalf("refused while Betterbird runs: %v", err) + } + if f.read(profileDir+"/prefs.js") != prefs { + t.Fatal("prefs.js changed while Betterbird runs") + } + stopBetterbird(t, f) + a, err := f.ConfigApply(true) + if err != nil || !a.Applied { + t.Fatalf("%+v %v", a, err) + } + want := "// Mozilla User Preferences\n" + + `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" + + `user_pref("mail.uidensity", 0);` + "\n" + if got := f.read(profileDir + "/prefs.js"); got != want { + t.Fatalf("only the drifting lines go:\n%s", got) + } + said := map[string]Cleared{} + for _, c := range a.Clears { + said[c.Name] = c + } + if c := said["mail.biff.show_alert"]; c.Was != "true" || c.Now != "false" { + t.Errorf("it says what it cleared and what holds now: %+v", c) + } + if c := said["extensions.activeThemeID"]; c.Now != "Betterbird's built-in default" { + t.Errorf("%+v", c) + } + raw, _ := json.Marshal(a) + if strings.Contains(string(raw), "example.org") { + t.Fatalf("no account value leaves: %s", raw) + } + if c, _ := f.Config(nil); len(c.Drifting) != 0 { + t.Fatalf("no drift once applied: %v", c.Drifting) + } + os.Remove(filepath.Join(f.Root, meshDefaults)) + if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "not placed") { + t.Fatalf("without the module's defaults there is nothing to come back to: %v", err) + } +} + +// T6: keeping a value on one node is a node layer proposed through the controller, never a write to user.js. +func TestKeepProposesANodeLayerThroughTheController(t *testing.T) { + f := newFake(t) + f.betterbird() + f.placedByTheMesh() + f.withDefaults() + links(t, nil) + f.declare(`user_pref("calendar.alarms.playsound", false); user_pref("mail.uifontsize", 12);`) + f.write(profileDir+"/prefs.js", `user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("mail.biff.show_alert", true);`+"\n") + oldNode, oldAsk := nodeName, asker + t.Cleanup(func() { nodeName, asker = oldNode, oldAsk }) + nodeName = func() string { return "shanks" } + var verb string + var args map[string]any + asker = func(v string, a any) (json.RawMessage, error) { + verb, args = v, a.(map[string]any) + return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil + } + before := f.read(testHome + "/.config/betterbird/user.js") + c, err := f.Config([]string{"mail.uifontsize", "mail.biff.show_alert"}) + if err != nil { + t.Fatal(err) + } + if verb != "seat:mesh-controller.settings" || args["node"] != "shanks" || args["propose"] != "true" || args["module"] != "betterbird" { + t.Fatalf("%s %v", verb, args) + } + var values map[string]string + if err := json.Unmarshal([]byte(args["values"].(string)), &values); err != nil { + t.Fatal(err) + } + want := `user_pref("calendar.alarms.playsound", false); user_pref("mail.uifontsize", 16); user_pref("mail.biff.show_alert", true);` + if values["prefs"] != want { + t.Fatalf("the layer keeps what the setting says and the kept values:\n got %s\nwant %s", values["prefs"], want) + } + if c.Proposed != "proposed: p-1" || f.read(testHome+"/.config/betterbird/user.js") != before { + t.Fatalf("proposed, and user.js untouched: %+v", c) + } + for _, bad := range [][]string{{"mailnews.start_page.enabled"}, {"some.other.pref"}, {"mail.uidensity"}} { + verb = "" + if _, err := f.Config(bad); err == nil || verb != "" { + t.Errorf("%v: a lock, an unmanaged name or a value the profile did not save is refused: %v", bad, err) + } + } + nodeName = func() string { return "" } + if _, err := f.Config([]string{"mail.uifontsize"}); err == nil || !strings.Contains(err.Error(), "MESH_NODE") { + t.Fatalf("%v", err) + } +} + +// The review of pull request 253: whether Betterbird asks to be the default client is decided from the policy's +// own Preferences entry, never from what else the policy lacks. +func TestTheDefaultClientAskIsDecidedFromThePreferencesEntry(t *testing.T) { + f := newFake(t) + f.betterbird() + f.placedByTheMesh() + links(t, nil) + f.write(policyFile, `{"policies": {"ExtensionSettings": {}}}`) + if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why == "" { + t.Fatal("a policy lacking the updater and the lock still asks") + } + f.write(policyFile, `{"policies": {"Preferences": {"mail.shell.checkDefaultClient": {"Value": false, "Status": "locked"}}}}`) + if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why != "" { + t.Fatalf("the lock holds whatever else the policy lacks: %s", why) + } +} diff --git a/modules/betterbird/cmd/betterbird-tools/install.go b/modules/betterbird/cmd/betterbird-tools/install.go index 1ec0204d..e9826e07 100644 --- a/modules/betterbird/cmd/betterbird-tools/install.go +++ b/modules/betterbird/cmd/betterbird-tools/install.go @@ -162,7 +162,7 @@ func newestBuild(version, build string) (string, bool) { return "unknown: " + versionsURL + " names no build for " + major, false } -// policyState says whether the module's policy is placed and does what ADR 0316 §5 and §7 say. +// policyState says whether the module's policy is placed and does what ADR 0316 §5 and §7 and issue 345 ask. func (m *Machine) policyState() string { raw, err := readBounded(m.path(policyFile)) if err != nil { @@ -171,52 +171,69 @@ func (m *Machine) policyState() string { if why := policyProblem(raw); why != "" { return "placed, and " + why } - return "placed: Betterbird's updater off, Owl installed into every profile from the mesh's xpi, its updates off, " + - "and the ask to be the default mail client locked off" + return "placed: Betterbird's updater, its telemetry and add-on updates off, Owl installed into every profile from " + + "the mesh's xpi with its updates off, and the ask to be the default mail client, the vendor's in-app notices " + + "and the start page locked off" } -// policyProblem is what the policy fails to say of what ADR 0316 asks, or "". +// lockedOff are the preferences the module's policy locks to false (novox/hq issue 345), each with what it is. +var lockedOff = []struct{ Name, What string }{ + {defaultClientPref, "the ask to be the default mail client"}, + {"mail.inappnotifications.enabled", "the vendor's in-app notices"}, + {"mailnews.start_page.enabled", "the start page, loaded from the vendor at every start"}, +} + +// policyProblem is everything the policy fails to say of what ADR 0316 and issue 345 ask, or "". func policyProblem(raw []byte) string { var p struct { Policies struct { - DisableAppUpdate bool `json:"DisableAppUpdate"` + DisableAppUpdate bool `json:"DisableAppUpdate"` + DisableTelemetry bool `json:"DisableTelemetry"` + ExtensionUpdate *bool `json:"ExtensionUpdate"` ExtensionSettings map[string]struct { Mode string `json:"installation_mode"` URL string `json:"install_url"` Disabled bool `json:"updates_disabled"` } `json:"ExtensionSettings"` - Preferences map[string]struct { - Value any `json:"Value"` - Status string `json:"Status"` - } `json:"Preferences"` } `json:"policies"` } if err := json.Unmarshal(raw, &p); err != nil { return "it is not JSON: " + err.Error() } + var problems []string + if !p.Policies.DisableAppUpdate { + problems = append(problems, "it does not turn Betterbird's updater off (DisableAppUpdate)") + } + if !p.Policies.DisableTelemetry { + problems = append(problems, "it does not turn telemetry off (DisableTelemetry)") + } + if p.Policies.ExtensionUpdate == nil || *p.Policies.ExtensionUpdate { + problems = append(problems, "it leaves add-ons updating themselves (ExtensionUpdate)") + } owl, ok := p.Policies.ExtensionSettings[owlID] switch { - case !p.Policies.DisableAppUpdate: - return "it does not turn Betterbird's updater off (DisableAppUpdate)" case !ok || owl.Mode != "force_installed": - return "it does not install Owl into every profile (force_installed)" + problems = append(problems, "it does not install Owl into every profile (force_installed)") case owl.URL != "file://"+owlFile: - return "it installs Owl from " + owl.URL + ", not from the mesh's xpi" + problems = append(problems, "it installs Owl from "+owl.URL+", not from the mesh's xpi") case !owl.Disabled: - return "it leaves Owl's updates on" + problems = append(problems, "it leaves Owl's updates on") } - if pr, ok := p.Policies.Preferences[defaultClientPref]; !ok || pr.Value != false || pr.Status != "locked" { - return "it does not lock Betterbird's ask to be the default mail client off (Preferences " + defaultClientPref + ": false, locked)" + prefs := policyPrefsOf(raw) + for _, l := range lockedOff { + if pr, ok := prefs[l.Name]; !ok || pr.Value != "false" || !pr.Locked { + problems = append(problems, "it does not lock "+l.What+" off (Preferences "+l.Name+": false, locked)") + } } - return "" + return strings.Join(problems, "; ") } // defaultClientAsk says why Betterbird asks at its start to be the default mail client, or "" when it does -// not: the module's policy locks the ask off, else the profile's user.js (which wins at the start) or prefs.js -// turns it off. Betterbird's own default is to ask. +// not: the placed policy's own Preferences entry locks the ask off, else the profile's user.js (which wins at +// the start) or prefs.js turns it off. Betterbird's own default is to ask. Decided from the entry itself, never +// from what else the policy lacks. func (m *Machine) defaultClientAsk(profile string) string { - raw, err := readBounded(m.path(policyFile)) - if err == nil && !strings.Contains(policyProblem(raw), defaultClientPref) { + if v, ok := m.locks()[defaultClientPref]; ok && v == "false" { return "" } if profile != "" { @@ -225,7 +242,7 @@ func (m *Machine) defaultClientAsk(profile string) string { if err != nil { continue } - if v, ok := readPrefs(b)[defaultClientPref]; ok { + if v, ok := statementsOf(b)[defaultClientPref]; ok { if v == "false" { return "" } diff --git a/modules/betterbird/cmd/betterbird-tools/main.go b/modules/betterbird/cmd/betterbird-tools/main.go index 27778fe6..ff2331a1 100644 --- a/modules/betterbird/cmd/betterbird-tools/main.go +++ b/modules/betterbird/cmd/betterbird-tools/main.go @@ -49,10 +49,12 @@ func tools() []stdio.Tool { }, { Name: "betterbird_prefs", - Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (user.js, " + - "which wins at the start; prefs.js; or Betterbird's default). A name ending in . or * is a prefix " + - "(at most 200 answered). A preference whose name can hold a credential, or that names an account, a " + - "server, a calendar or a person, is answered without its value. (r)", + Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (the " + + "module's policy, which locks it; user.js, which wins at the start; prefs.js; or the default), and the " + + "default itself with the file that gives it: Betterbird's own, read from omni.ja and the installation's " + + "defaults/pref, or the module's defaults (mesh-defaults.js). A name ending in . or * is a prefix (at most " + + "200 answered). A preference whose name can hold a credential, or that names an account, a server, a " + + "calendar or a person, is answered without its value. (r)", Input: map[string]any{ "names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "preference names or prefixes, e.g. [\"calendar.alarms.\", \"extensions.activeThemeID\"]"}, @@ -65,6 +67,45 @@ func tools() []stdio.Tool { return machine.Prefs(names) }, }, + { + Name: "betterbird_config", + Description: "The configuration the module ships, preference by preference: the value its policy locks, " + + "its default (mesh-defaults.js, or Betterbird's own where the module keeps that), the node layer (the " + + "setting prefs on this machine), the profile's own saved value and the value in effect. It flags drift: " + + "a value the profile saved that beats the module's default without a node layer saying so. With keep " + + "(names), it proposes a node layer of the setting prefs for this machine holding the profile's values of " + + "those preferences, through the controller's settings verb, for the operator's Approve; it never writes " + + "user.js. (r; a with keep)", + Input: map[string]any{ + "keep": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "managed preferences whose profile value this machine keeps: proposed as a node layer, e.g. [\"mail.uifontsize\"]"}, + }, + Run: func(args map[string]any) (any, error) { + keep, err := texts(args, "keep") + if err != nil { + return nil, err + } + return machine.Config(keep) + }, + }, + { + Name: "betterbird_config_apply", + Description: "Bring the profile back to the module's configuration: takes the drifting values " + + "(betterbird_config) out of the profile's prefs.js, so the module's defaults hold at Betterbird's next " + + "start, and says what it cleared, each with the value it had and the one that now holds. Without apply " + + "it lists them only. With apply it acts only while Betterbird is not running, because Betterbird " + + "rewrites prefs.js when it quits; every other line of prefs.js is kept. (r; a with apply)", + Input: map[string]any{ + "apply": map[string]any{"type": "boolean", "description": "clear the drifting values (default false: a dry run)"}, + }, + Run: func(args map[string]any) (any, error) { + apply, err := flag(args, "apply", false) + if err != nil { + return nil, err + } + return machine.ConfigApply(apply) + }, + }, { Name: "betterbird_user_js", Description: "The module's user.js — the preferences the module declares (setting prefs), then the declared " + @@ -135,7 +176,8 @@ func tools() []stdio.Tool { { Name: "betterbird_check", Description: "Check what the module promises: Betterbird installed from the mesh's pinned release and not " + - "behind the newest published build; the enterprise policy placed; Owl at its pin and active; one " + + "behind the newest published build; the enterprise policy placed and the module's defaults with it; no " + + "managed preference drifting from them; Owl at its pin and active; one " + "Betterbird runs; its profile is found; the module's user.js on the machine, valid, placed and in " + "effect; the declared accounts in the profile's list, none refused, each signed in once; its output " + "read. Answers ok and each finding with what to do, never an address, host or name. (r)", diff --git a/modules/betterbird/install/defaults-pref/mesh-defaults.js b/modules/betterbird/install/defaults-pref/mesh-defaults.js new file mode 100644 index 00000000..fee2b32b --- /dev/null +++ b/modules/betterbird/install/defaults-pref/mesh-defaults.js @@ -0,0 +1,83 @@ +// Written by the mesh (module betterbird, novox/hq issue 345): the operator's setup of Betterbird, taken from +// the laptop's profile and approved by the operator on 2026-10-12. Betterbird reads this file at its start as +// a default: a value the profile saved in its prefs.js (a choice made in Betterbird's settings window) wins +// over it, a node's layer of the setting prefs (user.js) wins over both, and the policy's locks +// (distribution/policies.json) win over everything. betterbird_config shows the layers and the drift; +// betterbird_config_apply brings a profile back to these values. Change it in the catalogue, never here. +// +// A line "keeps Betterbird's default" names a preference the module manages without writing a value: the +// operator's setup is Betterbird's own default there, and a value the profile saved is drift. + +// --- Theme and display --- + +// keeps Betterbird's default: extensions.activeThemeID +// The default theme follows the desktop's dark mode; compactness comes from mail.uidensity, not a theme. +// keeps Betterbird's default: browser.display.use_system_colors +// Betterbird's own colours, which the default theme is drawn for. +// keeps Betterbird's default: layout.css.always_underline_links +// Links are underlined only where the message's own style says so. + +// Compact density: more of the message list fits on a laptop screen (Betterbird's default is 1, normal). +pref("mail.uidensity", 0); +// 14-point interface text, the operator's choice; a larger desk screen sets its own size in a node layer. +pref("mail.uifontsize", 14); +// Message bodies are shown as their sender wrote them, not darkened. +pref("mail.dark-reader.enabled", false); + +// --- Layout --- + +// Vertical view: folders, the message list and the message side by side (Betterbird's default is 0, classic). +pref("mail.pane_config.dynamic", 2); +// Each row of the message list on several lines, so sender, subject and date all show in the narrow column. +pref("mail.pane_config.multiline_all", true); +// The message list as cards (Betterbird's default is 1, the table). +pref("mail.threadpane.listview", 0); +// Threads grouped in every folder by default (Betterbird's default is 0, unthreaded). +pref("mailnews.default_view_flags", 1); + +// --- Mail handling and compose --- + +// No confirmation before Shift+Delete removes a message for good. +pref("mail.warn_on_shift_delete", false); +// keeps Betterbird's default: mail.spam.manualMark +// Marking a message as junk by hand does nothing more than mark it. +// keeps Betterbird's default: mail.compose.show_attachment_pane +// The composer shows its attachment pane only when a message has attachments. + +// --- Notifications --- + +// No pop-up for new mail: the operator reads mail when they choose to. +pref("mail.biff.show_alert", false); +// No sound for new mail. +pref("mail.biff.play_sound", false); +// No tray icon for new mail. +pref("mail.biff.show_tray_icon", false); +// If a node's layer turns alerts back on, they go through the desktop's notification daemon. +pref("mail.biff.use_system_alert", true); + +// --- Calendar --- + +// The calendar's working day starts at 09:00 (Betterbird's default is 08:00). +pref("calendar.view.daystarthour", 9); +// and ends at 20:00 (Betterbird's default is 17:00). +pref("calendar.view.dayendhour", 20); +// A calendar item is deleted without asking. +pref("calendar.item.promptDelete", false); +// The calendar follows the node's time zone, because the laptop travels. +pref("calendar.timezone.useSystemTimezone", true); +// Reminders show in their window (Betterbird's default, made explicit: the reminder is why this module exists). +pref("calendar.alarms.show", true); +// Reminders missed while the laptop slept show at the next start (Betterbird's default is false). +pref("calendar.alarms.showmissed", true); + +// --- Chat --- + +// No chat button: the operator uses no chat account in Betterbird (Betterbird's default is true). +pref("mail.chat.enabled", false); + +// --- Privacy --- + +// No remote content in a message unless its sender is allowed (Betterbird's default, made explicit). +pref("mailnews.message_display.disable_remote_image", true); +// Ask sites not to track. A default only: the policy cannot lock a privacy. preference. +pref("privacy.donottrackheader.enabled", true); diff --git a/modules/betterbird/install/distribution/policies.json b/modules/betterbird/install/distribution/policies.json index e75b4c84..4edb6abe 100644 --- a/modules/betterbird/install/distribution/policies.json +++ b/modules/betterbird/install/distribution/policies.json @@ -1,6 +1,8 @@ { "policies": { "DisableAppUpdate": true, + "DisableTelemetry": true, + "ExtensionUpdate": false, "ExtensionSettings": { "owl@beonex.com": { "installation_mode": "force_installed", @@ -12,6 +14,14 @@ "mail.shell.checkDefaultClient": { "Value": false, "Status": "locked" + }, + "mail.inappnotifications.enabled": { + "Value": false, + "Status": "locked" + }, + "mailnews.start_page.enabled": { + "Value": false, + "Status": "locked" } } } diff --git a/modules/betterbird/module.json b/modules/betterbird/module.json index 049fc8e4..e46053c8 100644 --- a/modules/betterbird/module.json +++ b/modules/betterbird/module.json @@ -21,6 +21,8 @@ "tools": [ "betterbird_status", "betterbird_prefs", + "betterbird_config", + "betterbird_config_apply", "betterbird_user_js", "betterbird_log", "betterbird_restart",