Files
mesh-catalog/modules/betterbird/cmd/betterbird-tools/main.go
T
jochen 1835f0efad
mesh/merge-gate head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
betterbird: own Betterbird's whole configuration as settings, shown without a credential (hq issue 586, ADR 0329)
The module owned only the accounts' server fields, so identities, signatures,
calendars, address books, filters and tags could differ between desktop nodes
unseen, and the operator's own preferences were shipped in the catalogue.

prefs becomes one entry per preference (a node's layer overrides one key) and the
defaults file keeps no value of the operator's; identities, calendars,
address-books, filters and tags become settings, adopted like accounts, filters
owned whole per account, and what Betterbird rewrites written only while it is
closed. New tools list each kind, derive the layers from every desktop node and
propose one at a time, and show drift; every answer passes a credential check.
2026-10-13 02:03:04 +02:00

340 lines
18 KiB
Go

// The betterbird module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): Betterbird, the
// mail and calendar client, in the operator's session, served by the node's runtime as the operator
// account. The module holds no seat, so every tool is its own.
//
// The operator's mail, calendar events, contacts and credentials are never read: a tool reads processes,
// profiles.ini, and of the profile its prefs.js, user.js, extensions.json, each account's msgFilterRules.dat and
// of logins.json only the server each kept sign-in is for. The tools show the operator's own configuration, an
// account's, an identity's, a calendar's, an address book's, a filter's and a tag's values, and never a credential
// (novox/hq ADR 0329 §1, §2): every answer passes credentialChecked.
package main
import (
"fmt"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// The module's start runs this before Betterbird (novox/hq ADR 0316 §11, §12): the user.js placed and the
// declared accounts written into the account lists, while no Betterbird of this account runs.
if len(os.Args) == 2 && os.Args[1] == "prepare" {
if err := machine.Prepare(); err != nil {
fmt.Fprintln(os.Stderr, "betterbird: "+err.Error())
os.Exit(1)
}
return
}
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
list := toolList()
for i := range list {
run := list[i].Run
list[i].Run = func(args map[string]any) (any, error) {
out, err := run(args)
return credentialChecked(out), err
}
}
return list
}
// listTool is the listing tool of one of the five kinds (novox/hq ADR 0329 §12).
func listTool(kind, what string) stdio.Tool {
return stdio.Tool{
Name: "betterbird_" + strings.ReplaceAll(kind, "-", "_"),
Description: "The " + what + " of the profile Betterbird uses beside the ones the module's setting " + kind +
" declares (novox/hq ADR 0329): each of the profile's with its key, its values and the declared entry it is " +
"(none: the person's own), Owl's marked; each declared one with its value and how it resolved on this node " +
"(adopted, created or refused with why, and its key). The operator's own values, never a password, token " +
"or cookie. betterbird_drift says how they differ. (r)",
Run: func(map[string]any) (any, error) { return machine.List(kind) },
}
}
func toolList() []stdio.Tool {
return []stdio.Tool{
{
Name: "betterbird_status",
Description: "Betterbird: whether it runs (the main process's pid, since, and the scope it runs in; how " +
"many child processes); what is installed in /opt/betterbird and from where (the mesh's pinned release, " +
"or the community package before the switch), its version and build beside the newest published build; " +
"whether the enterprise policy is placed; Owl's version against the mesh's pin and whether it is active; " +
"where its output goes; the profile it uses and how that was found; the module's user.js (declared, " +
"placed, in effect, with every declared identity, calendar, address book and tag as it resolved); the " +
"declared accounts by count (in the profile's list, other accounts, not signed in yet); the add-ons in the " +
"profile; and the preferences that decide how its windows and notifications look. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "betterbird_prefs",
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\"]"},
},
Run: func(args map[string]any) (any, error) {
names, err := texts(args, "names")
if err != nil {
return nil, err
}
return machine.Prefs(names)
},
},
{
Name: "betterbird_config",
Description: "Betterbird's preferences through the module's layers, preference by preference: the value its " +
"policy locks, the setting prefs (the operator's setup, mesh-wide with this node's layer over it, one entry " +
"per preference), Betterbird's own default where the module keeps that, the profile's own saved value and " +
"the value in effect. It flags drift: a declared value the profile's user.js does not hold yet, or a value " +
"the profile saved where the module keeps Betterbird's default. With keep (names), it proposes this node's " +
"layer of the setting prefs holding the profile's values of those preferences beside what the layer holds, " +
"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 setting prefs, then the declared accounts' preferences (setting " +
"accounts), then the declared identities, calendars, address books and tags — against the profile's " +
"user.js: whether it is placed, and whether the running Betterbird started after it was. With apply, place " +
"it in the profile (only where the profile has no user.js or the module's own; an operator's file is never " +
"overwritten). A declared entry the profile already holds is adopted under its own keys; one it does not " +
"hold is created under the module's keys; an ambiguous one is refused alone, with the remedy (novox/hq ADR " +
"0317, ADR 0329 §7). What Betterbird rewrites itself — the account and identity lists, the calendars' " +
"order, the removals and each declared account's msgFilterRules.dat — is written only while Betterbird is " +
"closed, and the answer counts what waits (§8, §9). Accounts' values are withheld in the user.js shown. " +
"Betterbird reads it at its next start: betterbird_restart. (r; a with apply)",
Input: map[string]any{
"apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"},
},
Run: func(args map[string]any) (any, error) {
apply, err := flag(args, "apply", false)
if err != nil {
return nil, err
}
return machine.userJS(apply)
},
},
{
Name: "betterbird_log",
Description: "The last lines Betterbird printed, from the journal of the unit the module starts it as " +
"(mesh-betterbird; or mesh-betterbird-reminder-test). With problems, only lines that say error, " +
"warning, assertion, crash or failure. Mail addresses and credentials are replaced by <hidden>. A " +
"Betterbird the module did not start prints elsewhere, and the answer says so. (r)",
Input: map[string]any{
"lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"},
"problems": map[string]any{"type": "boolean", "description": "only problem lines (default false)"},
"unit": map[string]any{"type": "string", "enum": []string{restartAs, testUnit}, "description": "which start's journal (default " + restartAs + ")"},
},
Run: func(args map[string]any) (any, error) {
n, err := whole(args, "lines", 100, 1, 2000)
if err != nil {
return nil, err
}
problems, err := flag(args, "problems", false)
if err != nil {
return nil, err
}
unit, err := text(args, "unit", false)
if err != nil {
return nil, err
}
return machine.Log(n, problems, unit)
},
},
{
Name: "betterbird_restart",
Description: "End Betterbird (asked to quit, forced after 8 s), place the module's user.js when the profile " +
"allows it, and start Betterbird again in the operator's desktop session under the account's service " +
"manager (unit mesh-betterbird), its output in the journal. Refused while a message is being written " +
"(a composer window is open), because the unsent message would be lost, unless discard_unsent is true — " +
"ask the operator first. Answers the pids ended and the new main process. (a)",
Input: map[string]any{
"discard_unsent": map[string]any{"type": "boolean", "description": "restart even with a composer open, losing what it holds (default false)"},
},
Run: func(args map[string]any) (any, error) {
discard, err := flag(args, "discard_unsent", false)
if err != nil {
return nil, err
}
return machine.Restart(discard)
},
},
{
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 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; per kind, no " +
"declared identity, calendar, address book, filter or tag drifting or refused, and no change waiting for " +
"Betterbird to close; its output read. Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
{
Name: "betterbird_accounts",
Description: "The accounts of the profile Betterbird uses with their values (name, address, user, servers' " +
"hosts and ports) and the declared account each is, Owl's marked by name and address (never declared), " +
"with counts by kind. Never a password, a token or anything of the login store (novox/hq ADR 0329 §1, §2). " +
"With propose, it builds the module's setting accounts from them (every account but Owl's) and proposes " +
"it in the mesh-wide layer as it stands, through the controller's settings verb, for the operator's " +
"Approve on a channel that proves who answers (novox/hq ADR 0317, ADR 0277); the answer shows the value it " +
"proposed. (r; a with propose)",
Input: map[string]any{
"propose": map[string]any{"type": "boolean", "description": "build the setting accounts from the profile and propose it to the operator (default false)"},
},
Run: func(args map[string]any) (any, error) {
propose, err := flag(args, "propose", false)
if err != nil {
return nil, err
}
return machine.ProfileAccounts(propose)
},
},
listTool(kindIdentities, "identities (each account's default and its other identities, with name, address, reply-to, organisation and signature)"),
listTool(kindCalendars, "calendars (CalDAV with name, address, colour and options; an ICS one by its name only)"),
listTool(kindAddressBooks, "address books (CardDAV with name, address and user; a local one by its name)"),
listTool(kindFilters, "filters (per declared account: its msgFilterRules.dat as filters, folders by their path within the account; where the module keeps a file it replaced)"),
listTool(kindTags, "tags (key, name, colour, place)"),
{
Name: "betterbird_derive",
Description: "Derive the module's settings identities, calendars, address-books, filters and tags (or the kinds " +
"given) from the profiles of every desktop node holding Betterbird, laid out per layer: an entry on every " +
"such node goes into the mesh-wide layer with this node's value, and the answer lists each field another " +
"node holds differently; one on some nodes only goes into those nodes' own layers. Owl's, ICS calendars, " +
"local address books and any address carrying a password are left out and named. prefs puts values of the " +
"setting prefs into the mesh-wide layer. With propose, the next layer (the mesh-wide first, then each " +
"node's) is proposed for the operator's Approve through the controller's settings verb, carrying the layer " +
"as it stands beside the derived kinds; refused while a proposal of the module's waits, or while a desktop " +
"node does not answer (novox/hq ADR 0329 §10, ADR 0277). here answers this node's profile only, as the " +
"other nodes are asked. The values are the operator's own; never a credential. (r; a with propose)",
Input: map[string]any{
"kinds": map[string]any{"type": "array", "items": map[string]any{"type": "string", "enum": allKinds},
"description": "the kinds to derive (default all five)"},
"here": map[string]any{"type": "boolean", "description": "this node's profile only (default false)"},
"propose": map[string]any{"type": "boolean", "description": "propose the next layer to the operator (default false)"},
"prefs": map[string]any{"type": "object", "description": "values of the setting prefs for the mesh-wide " +
"layer, by preference name, e.g. {\"mail.uifontsize\": 14}"},
"layer": map[string]any{"type": "string", "description": "the layer to propose instead of the next: mesh-wide or a node's name"},
},
Run: func(args map[string]any) (any, error) {
kinds, err := texts(args, "kinds")
if err != nil {
return nil, err
}
here, err := flag(args, "here", false)
if err != nil {
return nil, err
}
propose, err := flag(args, "propose", false)
if err != nil {
return nil, err
}
layer, err := text(args, "layer", false)
if err != nil {
return nil, err
}
var prefs map[string]any
if v, ok := args["prefs"]; ok && v != nil {
if prefs, ok = v.(map[string]any); !ok {
return nil, fmt.Errorf("prefs is an object of preference names and values, not %T", v)
}
}
return machine.Derive(kinds, here, propose, prefs, layer)
},
},
{
Name: "betterbird_drift",
Description: "How this node's profile differs from the declared identities, calendars, address books, " +
"filters and tags (novox/hq ADR 0329 §11): per declared entry whether the profile holds it, holds another " +
"value (each differing field with the declared value and the profile's), lacks it, or it is refused; and " +
"the profile's entries nothing declares, which stay the person's. The profile as Betterbird reads it at " +
"its next start. (r)",
Input: map[string]any{
"kind": map[string]any{"type": "string", "enum": allKinds, "description": "one kind (default every kind)"},
},
Run: func(args map[string]any) (any, error) {
kind, err := text(args, "kind", false)
if err != nil {
return nil, err
}
return machine.Drift(kind)
},
},
{
Name: "betterbird_reminder_test",
Description: "OPENS WINDOWS ON THE OPERATOR'S SCREEN: tell the operator before calling it. Shows a calendar " +
"reminder without touching the operator's calendar: a second Betterbird in a throw-away profile under " +
"the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most 10 minutes), its main " +
"window marked MESH TEST in red, with the module's user.js (no accounts) and one local calendar file " +
"holding one event, \"MESH TEST reminder\", whose reminder is due after `after` seconds. carry copies " +
"named preferences of the operator's profile (a theme, a colour switch) into it, never a credential or " +
"an account. Then xorg_windows (class Calendar) and xorg_screenshot show the reminder window. stop ends " +
"it and removes the profile. (a)",
Input: map[string]any{
"after": map[string]any{"type": "integer", "description": "seconds until the reminder is due (default 40, 15 to 300)"},
"carry": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "preference names to copy from the operator's profile, e.g. [\"extensions.activeThemeID\"]"},
"stop": map[string]any{"type": "boolean", "description": "end the test Betterbird and remove its profile (default false)"},
},
Run: func(args map[string]any) (any, error) {
after, err := whole(args, "after", 40, 15, 300)
if err != nil {
return nil, err
}
carry, err := texts(args, "carry")
if err != nil {
return nil, err
}
stop, err := flag(args, "stop", false)
if err != nil {
return nil, err
}
return machine.ReminderTest(time.Duration(after)*time.Second, carry, stop)
},
},
}
}