Files
mesh-catalog/modules/betterbird/cmd/betterbird-tools/reminder.go
T
jschoubben 3629089ced
mesh/merge-gate pass: builds new: modules/betterbird, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
betterbird: never lose an unsent message, never answer an address, and keep the test apart (review of #155)
A restart ended Betterbird with whatever a composer held; now it refuses while a Msgcompose window is
open unless discard_unsent says the operator agreed, and answers within the runtime's 30 s. A
preference's value could carry an address, a user%40domain@host URI, a host or the home; those are
masked, recent-folder, account and cloud-file preferences and every add-on preference but the theme's
id are withheld, and a request for everything is refused. A log kept the token after Bearer or Basic.
The reminder test's Betterbird counted as the operator's and left its profile behind when it ended on
its own; it is now left out everywhere and its unit removes the profile when it stops.
2026-10-09 12:22:47 +02:00

153 lines
6.5 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 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"`
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:Mesh reminder test", "BEGIN:VALARM", "ACTION:DISPLAY",
"DESCRIPTION:Mesh reminder test", "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
}
// testPrefs are the test profile's own preferences: its one calendar, and no first-run questions.
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("mail.rights.version", 1);`,
"",
}, "\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
}
user := testPrefs(strings.TrimPrefix(calendar, m.Root))
if mine, err := readBounded(m.home(declared)); err == nil && validUserJS(string(mine)) == nil {
user = string(mine) + "\n" + user
}
user += strings.Join(carried, "\n") + "\n"
if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 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 = "Mesh reminder test, in a calendar file of the test profile only"
a.Due = now.Add(after).UTC().Format(time.RFC3339)
return a, nil
}