Files
mesh-catalog/modules/betterbird/cmd/betterbird-tools/reminder.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

200 lines
9.3 KiB
Go

package main
// A reminder on demand, without the operator's calendar: a second Betterbird, in a profile of its own
// under the account's runtime directory, with one local calendar file holding one event whose reminder
// is due a few seconds after the start. The running Betterbird, its profile, its accounts and its
// calendars are not touched: the second one starts with --no-remote, so it neither hands over to the
// first nor is handed to.
//
// The test profile gets the module's user.js too, so the reminder is drawn with the preferences the
// module declares. A theme or another preference of the operator's profile can be carried over by
// name (carry), within the same limits as betterbird_prefs: never a credential, never an account.
//
// **It is visibly a test** (the operator, 2026-10-12): on 2026-10-12 at 12:05 a test Betterbird with an empty
// profile carrying the operator's theme looked like the operator's configuration lost. So the test profile
// carries a userChrome.css that marks its main window with a red band reading MESH TEST, and the event is
// called "MESH TEST reminder". The reminder window itself is left as Betterbird draws it, because how it is
// drawn is what the test looks at. The tool's description says a window opens on the operator's screen, so
// a caller tells the operator first.
//
// It lives for at most ten minutes (the unit's RuntimeMaxSec), and stop ends it early and removes the
// profile. The reminder window is then found with xorg_windows (class Calendar, role AlarmWindow) and
// looked at with xorg_screenshot.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// testDir is the test's profile and calendar, under the account's runtime directory.
func (m *Machine) testDir() string {
return m.path(fmt.Sprintf("/run/user/%d/%s", m.UID, testUnit))
}
// carryName is a preference name the test may carry over from the operator's profile.
var carryName = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`)
// ReminderAnswer is what betterbird_reminder_test answers.
type ReminderAnswer struct {
Unit string `json:"unit"`
Profile string `json:"profile"`
Event string `json:"event"`
Due string `json:"reminder_due"`
Carried []string `json:"carried,omitempty"`
Withheld []string `json:"withheld,omitempty"`
Look string `json:"look"`
OnScreen string `json:"on_screen,omitempty"`
Stopped bool `json:"stopped,omitempty"`
}
// ics is the test calendar: one event, its reminder `after` from now.
func ics(now time.Time, after time.Duration, stamp string) string {
f := func(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
start := now.Add(after + 10*time.Minute)
return strings.Join([]string{"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//novox mesh//betterbird reminder test//EN",
"BEGIN:VEVENT", "UID:mesh-reminder-test-" + stamp + "@mesh", "DTSTAMP:" + f(now), "DTSTART:" + f(start),
"DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:" + testTitle, "BEGIN:VALARM", "ACTION:DISPLAY",
"DESCRIPTION:" + testTitle, "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
}
// testTitle is the test event's title: what the reminder window shows.
const testTitle = "MESH TEST reminder (a throw-away Betterbird of the mesh, not your calendar)"
// testChrome is the test profile's userChrome.css: a red band reading MESH TEST across the top of every
// Betterbird window but the reminder (windowtype Calendar:AlarmWindow), and a red frame around it, so the test
// Betterbird is never taken for the operator's.
const testChrome = `/* The mesh's reminder test (module betterbird, novox/hq issue 345): a throw-away Betterbird, not yours. */
:root:not([windowtype="Calendar:AlarmWindow"]) {
outline: 6px solid #d00000 !important;
outline-offset: -6px !important;
}
:root:not([windowtype="Calendar:AlarmWindow"]) > body::before {
content: "MESH TEST \2014 a throw-away Betterbird started by the mesh for a reminder test; your mail and calendar are not here. It ends by itself within 10 minutes.";
display: block !important;
background: #d00000 !important;
color: #ffffff !important;
font: bold 15px sans-serif !important;
padding: 6px 12px !important;
text-align: center !important;
}
`
// testPrefs are the test profile's own preferences: its one calendar, and no first-run questions.
//
// betterbird.update.checkIntervalDays 0 is Betterbird's own switch for its update check (MailGlue): a
// fresh profile has never checked, so Betterbird checks at its start and, when betterbird.eu publishes a
// newer build than the installed one, opens the About window on the operator's screen and gives it the
// focus (seen on the workstation 2026-10-12, novox/hq issue 345). The test is about the reminder only.
func testPrefs(calendar string) string {
return strings.Join([]string{
`user_pref("calendar.registry.meshtest.type", "ics");`,
`user_pref("calendar.registry.meshtest.uri", "file://` + calendar + `");`,
`user_pref("calendar.registry.meshtest.name", "Mesh reminder test");`,
`user_pref("calendar.registry.meshtest.calendar-main-in-composite", true);`,
`user_pref("calendar.registry.meshtest.suppressAlarms", false);`,
`user_pref("calendar.list.sortOrder", "meshtest");`,
`user_pref("mail.provider.suppress_dialog_on_startup", true);`,
`user_pref("mail.shell.checkDefaultClient", false);`,
`user_pref("datareporting.policy.dataSubmissionEnabled", false);`,
`user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);`,
`user_pref("app.update.auto", false);`,
`user_pref("betterbird.update.checkIntervalDays", 0);`,
`user_pref("mail.rights.version", 1);`,
`user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`,
"",
}, "\n")
}
// ReminderTest starts the test Betterbird, or with stop ends it and removes its profile.
func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (ReminderAnswer, error) {
dir := m.testDir()
a := ReminderAnswer{Unit: testUnit + ".service", Profile: strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root),
Look: "xorg_windows with class Calendar answers the reminder window (role AlarmWindow, its depth and map state); " +
"xorg_screenshot with that window shows it as the screen does"}
if stop {
_ = m.cmd(5*time.Second, m.bus(), "systemctl", "--user", "stop", testUnit+".service")
if err := os.RemoveAll(dir); err != nil {
return a, err
}
a.Stopped, a.Look = true, ""
return a, nil
}
s, err := m.session()
if err != nil {
return a, err
}
var carried []string
if len(carry) > 0 {
p, err := m.profile()
if err != nil {
return a, err
}
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
have := readPrefs(prefsJS)
for _, n := range carry {
switch {
case !carryName.MatchString(n):
return a, fmt.Errorf("carry holds preference names, not %q", n)
case withheld(n) != "":
a.Withheld = append(a.Withheld, n+": "+withheld(n))
case have[n] == "":
a.Withheld = append(a.Withheld, n+": not set in the operator's profile")
default:
carried = append(carried, fmt.Sprintf("user_pref(%s, %s);", strconv.Quote(n), have[n]))
a.Carried = append(a.Carried, n)
}
}
}
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", testUnit+".service")
if err := os.RemoveAll(dir); err != nil {
return a, err
}
if err := os.MkdirAll(filepath.Join(dir, "profile"), 0o700); err != nil {
return a, err
}
now := m.Now()
calendar := filepath.Join(dir, "test.ics")
if err := os.WriteFile(calendar, []byte(ics(now, after, strconv.FormatInt(now.Unix(), 10))), 0o600); err != nil {
return a, err
}
// The module's user.js first, then what is carried, then the test's own preferences: the last
// statement wins, so neither can take the test's calendar or its quiet start away.
user := ""
if mine, present, err := m.declaredText(); err == nil && present && validUserJS(mine) == nil {
user = mine + "\n"
}
user += strings.Join(carried, "\n") + "\n" + testPrefs(strings.TrimPrefix(calendar, m.Root))
if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 0o600); err != nil {
return a, err
}
if err := os.MkdirAll(filepath.Join(dir, "profile", "chrome"), 0o700); err != nil {
return a, err
}
if err := os.WriteFile(filepath.Join(dir, "profile", "chrome", "userChrome.css"), []byte(testChrome), 0o600); err != nil {
return a, err
}
// The unit removes the profile when it ends, however it ends: at its 10 minutes, on stop, or when the
// test Betterbird is closed.
call := []string{"--user", "--collect", "--quiet", "--unit=" + testUnit, "-p", "RuntimeMaxSec=600",
"-p", "ExecStopPost=/usr/bin/rm -rf " + strings.TrimPrefix(dir, m.Root), "--setenv=MOZ_NO_REMOTE=1"}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--", startBin, "--no-remote", "--profile", strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root))
if err := failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
return a, err
}
a.Event = testTitle + ", in a calendar file of the test profile only"
a.OnScreen = "a second Betterbird's window is now on the operator's screen, marked MESH TEST in red; its reminder " +
"window follows when the reminder is due. Both go when the test ends (stop, or 10 minutes)"
a.Due = now.Add(after).UTC().Format(time.RFC3339)
return a, nil
}