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.
308 lines
11 KiB
Go
308 lines
11 KiB
Go
package main
|
|
|
|
// What the mesh installs (novox/hq ADR 0316 §1–§9, to-be 56 §1–§3): Betterbird from its pinned upstream release
|
|
// in /opt/betterbird, where the community package betterbird-bin put it, so Betterbird keeps choosing the same
|
|
// profile; the enterprise policy in its distribution directory, which turns Betterbird's own updater off and
|
|
// installs Owl into every profile from the xpi the mesh placed beside it; and a defaults file naming the
|
|
// system's spelling dictionaries. The tools read these files and say what they find: the version against the
|
|
// pin and the newest published build, whether the policy is placed, Owl's version against its pin.
|
|
//
|
|
// **The pin on the machine is what the mesh placed.** The manifest pins the release and the xpi by sha256; the
|
|
// node-engine places exactly those bytes, so the version in /opt/betterbird/application.ini (when the mesh
|
|
// placed it) and the version in the placed xpi are the pins. The community package's own copy is said as
|
|
// such, and the switch from it waits for the operator (ADR 0316 §4).
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
installDir = "/opt/betterbird"
|
|
// policyFile is the enterprise policy the module places (ADR 0316 §7).
|
|
policyFile = installDir + "/distribution/policies.json"
|
|
// owlFile is Owl's xpi as the mesh placed it, and the URL the policy installs it from.
|
|
owlFile = installDir + "/owl/owl.xpi"
|
|
owlID = "owl@beonex.com"
|
|
// defaultClientPref is Betterbird's ask at its start to be the default mail, news, feed and calendar
|
|
// client (Thunderbird's all-thunderbird.js: pref("mail.shell.checkDefaultClient", true); messenger.js opens
|
|
// systemIntegrationDialog.xhtml while it is true). The module's policy locks it off (novox/hq issue 345).
|
|
defaultClientPref = "mail.shell.checkDefaultClient"
|
|
// preparation is the tools' program as the module places it for its start (betterbird-tools prepare).
|
|
preparation = "/usr/local/lib/mesh-betterbird/betterbird-tools"
|
|
// versionsURL is where Betterbird publishes its newest build per major version: what its own updater
|
|
// reads (MailGlue's BetterbirdUpdateCheck), and what the status compares the installed build with.
|
|
versionsURL = "https://www.betterbird.eu/start/versions.txt"
|
|
)
|
|
|
|
// publishedBuilds fetches Betterbird's list of newest builds: a variable so a test answers.
|
|
var publishedBuilds = func() (string, error) {
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := client.Get(versionsURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("%s answered %s", versionsURL, resp.Status)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
return string(body), err
|
|
}
|
|
|
|
// Installation is what is installed, and from where.
|
|
type Installation struct {
|
|
// From is the mesh, the community package (betterbird-bin, before the switch), or nothing.
|
|
From string `json:"from"`
|
|
Version string `json:"version,omitempty"`
|
|
Build string `json:"build,omitempty"`
|
|
// Newest is the newest build Betterbird published for this major version, and whether this one is behind.
|
|
Newest string `json:"newest_published_build,omitempty"`
|
|
Behind bool `json:"behind,omitempty"`
|
|
// Policy says whether the module's enterprise policy is placed, and what it does.
|
|
Policy string `json:"policy"`
|
|
// Owl is the add-on's version as the mesh placed it (the pin), and as the profile runs it.
|
|
Owl *OwlState `json:"owl,omitempty"`
|
|
}
|
|
|
|
// OwlState is Owl against its pin.
|
|
type OwlState struct {
|
|
Pinned string `json:"pinned,omitempty"`
|
|
InProfile string `json:"in_profile,omitempty"`
|
|
Active bool `json:"active"`
|
|
AtThePin bool `json:"at_the_pin"`
|
|
// Note says, as a fact, a profile running an Owl newer than the pin: the policy's install keeps a newer
|
|
// version, and Owl installed by policy cannot be removed in Betterbird.
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// newerVersion says whether version a is newer than b, comparing dotted numbers.
|
|
func newerVersion(a, b string) bool {
|
|
if a == "" || b == "" {
|
|
return false
|
|
}
|
|
pa, pb := strings.Split(a, "."), strings.Split(b, ".")
|
|
for i := 0; i < len(pa) || i < len(pb); i++ {
|
|
var x, y int
|
|
if i < len(pa) {
|
|
fmt.Sscanf(pa[i], "%d", &x)
|
|
}
|
|
if i < len(pb) {
|
|
fmt.Sscanf(pb[i], "%d", &y)
|
|
}
|
|
if x != y {
|
|
return x > y
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// installation reads what is installed. profile may be empty when none is found yet.
|
|
func (m *Machine) installation(profile string) Installation {
|
|
var in Installation
|
|
community, _ := m.installed(packageFor)
|
|
raw, err := readBounded(m.path(appIni))
|
|
switch {
|
|
case community != "":
|
|
in.From = "the community package " + packageFor + " " + community + ": the switch to the mesh's release waits for the operator's push, with Betterbird quit (novox/hq ADR 0316 §4)"
|
|
case err == nil:
|
|
in.From = "the mesh"
|
|
default:
|
|
in.From = "not installed"
|
|
}
|
|
if err == nil {
|
|
_, sec := iniSections(raw)
|
|
in.Version, in.Build = sec["App"]["Version"], sec["App"]["BuildID"]
|
|
in.Newest, in.Behind = newestBuild(in.Version, in.Build)
|
|
}
|
|
in.Policy = m.policyState()
|
|
pinned := xpiVersion(m.path(owlFile))
|
|
owl := &OwlState{Pinned: pinned}
|
|
if profile != "" {
|
|
for _, a := range addOns(profile) {
|
|
if a.ID == owlID {
|
|
owl.InProfile, owl.Active = a.Version, a.Active
|
|
}
|
|
}
|
|
}
|
|
owl.AtThePin = owl.Pinned != "" && owl.Pinned == owl.InProfile
|
|
if newerVersion(owl.InProfile, owl.Pinned) {
|
|
owl.Note = "the profile runs Owl " + owl.InProfile + ", newer than the mesh's pin " + owl.Pinned + ": the policy " +
|
|
"keeps a newer version and installs the pin only where Owl is missing. To run the same Owl everywhere, " +
|
|
"move the module's pin in a reviewed change"
|
|
}
|
|
if owl.Pinned != "" || owl.InProfile != "" {
|
|
in.Owl = owl
|
|
}
|
|
return in
|
|
}
|
|
|
|
// newestBuild is the newest build Betterbird published for a version's major, and whether build is older.
|
|
func newestBuild(version, build string) (string, bool) {
|
|
major, _, _ := strings.Cut(version, ".")
|
|
if major == "" || build == "" {
|
|
return "", false
|
|
}
|
|
list, err := publishedBuilds()
|
|
if err != nil {
|
|
return "unknown: " + err.Error(), false
|
|
}
|
|
for _, l := range strings.Split(list, "\n") {
|
|
f := strings.Split(strings.TrimSpace(l), ",")
|
|
if len(f) == 3 && f[0] == "Linux" && f[1] == major {
|
|
return f[2], f[2] > build
|
|
}
|
|
}
|
|
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 and issue 345 ask.
|
|
func (m *Machine) policyState() string {
|
|
raw, err := readBounded(m.path(policyFile))
|
|
if err != nil {
|
|
return "not placed: " + policyFile + " is missing"
|
|
}
|
|
if why := policyProblem(raw); why != "" {
|
|
return "placed, and " + why
|
|
}
|
|
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"
|
|
}
|
|
|
|
// 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"`
|
|
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"`
|
|
} `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 !ok || owl.Mode != "force_installed":
|
|
problems = append(problems, "it does not install Owl into every profile (force_installed)")
|
|
case owl.URL != "file://"+owlFile:
|
|
problems = append(problems, "it installs Owl from "+owl.URL+", not from the mesh's xpi")
|
|
case !owl.Disabled:
|
|
problems = append(problems, "it leaves Owl's updates on")
|
|
}
|
|
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 strings.Join(problems, "; ")
|
|
}
|
|
|
|
// defaultClientAsk says why Betterbird asks at its start to be the default mail client, or "" when it does
|
|
// 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 {
|
|
if v, ok := m.locks()[defaultClientPref]; ok && v == "false" {
|
|
return ""
|
|
}
|
|
if profile != "" {
|
|
for _, f := range []string{"user.js", "prefs.js"} {
|
|
b, err := readBounded(filepath.Join(profile, f))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if v, ok := statementsOf(b)[defaultClientPref]; ok {
|
|
if v == "false" {
|
|
return ""
|
|
}
|
|
return "the profile's " + f + " sets " + defaultClientPref + " to " + v + " and no policy locks it off"
|
|
}
|
|
}
|
|
}
|
|
return "no policy locks " + defaultClientPref + " off and Betterbird's default is to ask"
|
|
}
|
|
|
|
// xpiVersion is the version an add-on's manifest.json says, or "" when it cannot be read.
|
|
func xpiVersion(path string) string {
|
|
r, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer r.Close()
|
|
for _, f := range r.File {
|
|
if f.Name != "manifest.json" {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer rc.Close()
|
|
var mf struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if json.NewDecoder(io.LimitReader(rc, 1<<20)).Decode(&mf) != nil {
|
|
return ""
|
|
}
|
|
return mf.Version
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// addOns reads a profile's extensions.json: each add-on's id, version, whether it is on, and where from.
|
|
func addOns(profile string) []AddOn {
|
|
raw, err := readBounded(filepath.Join(profile, "extensions.json"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var ext struct {
|
|
Addons []struct {
|
|
ID string `json:"id"`
|
|
Version string `json:"version"`
|
|
Active bool `json:"active"`
|
|
Location string `json:"location"`
|
|
} `json:"addons"`
|
|
}
|
|
if json.Unmarshal(raw, &ext) != nil {
|
|
return nil
|
|
}
|
|
var out []AddOn
|
|
for _, a := range ext.Addons {
|
|
if a.Location == "app-profile" {
|
|
out = append(out, AddOn{ID: a.ID, Version: a.Version, Active: a.Active})
|
|
}
|
|
}
|
|
return out
|
|
}
|