betterbird: ship the operator's approved configuration, and show and fix drift from it (hq issue 345)
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
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
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.
This commit is contained in:
@@ -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: <name>` 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
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"tools": [
|
||||
"betterbird_status",
|
||||
"betterbird_prefs",
|
||||
"betterbird_config",
|
||||
"betterbird_config_apply",
|
||||
"betterbird_user_js",
|
||||
"betterbird_log",
|
||||
"betterbird_restart",
|
||||
|
||||
Reference in New Issue
Block a user