A calendar reminder showed as a transparent, empty window on shanks and no tool could say which window it was or which preferences Betterbird ran with. The module owns one file, the user.js its setting prefs fills, which its tools place whole into the profile Betterbird uses and never over an operator's own; it reads named preferences without credentials or accounts, restarts Betterbird under the user manager so its output reaches the journal, checks what it promises, and shows a reminder on demand from a throw-away profile so a fix can be proved without the operator's calendar. No package: betterbird-bin is the AUR's.
720 lines
25 KiB
Go
720 lines
25 KiB
Go
package main
|
|
|
|
// Betterbird as the tools see it. Betterbird is the mail client's one process tree: the main process
|
|
// (comm betterbird-bin, no -contentproc) owns every window — the mail window, the calendar's reminder
|
|
// dialog, the composer — and its children (-contentproc) render add-ons and media. The tools read only:
|
|
// - the account's processes, and of the main process where its output goes (fd 1 and 2) and which
|
|
// profile it holds open;
|
|
// - profiles.ini, to find the profile, and the profile's prefs.js and user.js, of which only the
|
|
// preferences asked for are answered and never one that can hold a credential or names an account
|
|
// (passwordSecret, accountPrefix);
|
|
// - the profile's extensions.json, for the add-ons' ids and whether each is on;
|
|
// - the module's declared user.js (~/.config/betterbird/user.js), which the node-engine writes from
|
|
// the module's setting `prefs`.
|
|
//
|
|
// Never read: the mail store, the address books, logins.json, key4.db, cert9.db, cookies, the
|
|
// calendar's caches. Nothing of the operator's mail, calendar or accounts is answered.
|
|
//
|
|
// The one file a tool writes in the profile is user.js, and only the module's own: the declared file,
|
|
// copied whole (betterbird_user_js with apply, betterbird_restart). A user.js that is not the module's
|
|
// is never overwritten.
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
mainComm = "betterbird-bin"
|
|
startBin = "/usr/bin/betterbird"
|
|
packageFor = "betterbird-bin"
|
|
appIni = "/opt/betterbird/application.ini"
|
|
restartAs = "mesh-betterbird"
|
|
testUnit = "mesh-betterbird-reminder-test"
|
|
// profilesDir is where Betterbird keeps its profiles: Thunderbird's place, which Betterbird shares.
|
|
profilesDir = ".thunderbird"
|
|
// declared is the module's user.js, written by the node-engine from the setting prefs.
|
|
declared = ".config/betterbird/user.js"
|
|
// mark is the first line of the module's user.js: how a user.js is known to be the module's.
|
|
mark = "// Betterbird's user.js (module betterbird"
|
|
)
|
|
|
|
// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link.
|
|
var readLink = os.Readlink
|
|
|
|
// mainProcs are Betterbird's main processes: comm betterbird-bin without -contentproc.
|
|
func (m *Machine) mainProcs() []Proc {
|
|
var out []Proc
|
|
for _, p := range m.procs(mainComm) {
|
|
if !strings.Contains(p.Command, "-contentproc") {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Stream is where a process's standard output or error goes.
|
|
type Stream struct {
|
|
FD int `json:"fd"`
|
|
Goes string `json:"goes"`
|
|
// Dead is a pipe no process of the account reads: a write there fails.
|
|
Dead bool `json:"dead,omitempty"`
|
|
}
|
|
|
|
// streams says where fd 1 and 2 of a process go.
|
|
func (m *Machine) streams(pid int) []Stream {
|
|
var out []Stream
|
|
for _, fd := range []int{1, 2} {
|
|
target, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%d", pid, fd)))
|
|
s := Stream{FD: fd}
|
|
switch {
|
|
case err != nil:
|
|
s.Goes = "unreadable"
|
|
case target == "/dev/null":
|
|
s.Goes = "discarded (/dev/null)"
|
|
case strings.HasPrefix(target, "socket:"):
|
|
s.Goes = "a socket (the journal, when started by the module)"
|
|
case strings.HasPrefix(target, "pipe:"):
|
|
if r := m.pipeReaders(target, pid); len(r) > 0 {
|
|
s.Goes = "a pipe read by " + strings.Join(r, ", ")
|
|
} else {
|
|
s.Goes, s.Dead = "a pipe nobody reads", true
|
|
}
|
|
default:
|
|
s.Goes = m.tilde(target)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pipeReaders are the account's processes holding the pipe open for reading (O_RDONLY in fdinfo).
|
|
func (m *Machine) pipeReaders(pipe string, except int) []string {
|
|
entries, _ := os.ReadDir(m.path("/proc"))
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, e := range entries {
|
|
pid, err := strconv.Atoi(e.Name())
|
|
if err != nil || pid == except {
|
|
continue
|
|
}
|
|
dir := m.path(filepath.Join("/proc", e.Name()))
|
|
if m.uidOf(dir) != m.UID {
|
|
continue
|
|
}
|
|
fds, _ := os.ReadDir(filepath.Join(dir, "fd"))
|
|
for _, fd := range fds {
|
|
if t, err := readLink(filepath.Join(dir, "fd", fd.Name())); err != nil || t != pipe {
|
|
continue
|
|
}
|
|
if readOnly(readTrimmed(filepath.Join(dir, "fdinfo", fd.Name()))) {
|
|
name := fmt.Sprintf("%s (pid %d)", readTrimmed(filepath.Join(dir, "comm")), pid)
|
|
if !seen[name] {
|
|
seen[name] = true
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// readOnly reads an fdinfo's flags (octal): the access mode is its low two bits, 0 for reading.
|
|
func readOnly(fdinfo string) bool {
|
|
for _, l := range strings.Split(fdinfo, "\n") {
|
|
if f := strings.Fields(l); len(f) == 2 && f[0] == "flags:" {
|
|
n, err := strconv.ParseInt(f[1], 8, 64)
|
|
return err == nil && n&3 == 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// iniSections reads an INI file into its sections, in order.
|
|
func iniSections(raw []byte) (names []string, sections map[string]map[string]string) {
|
|
sections = map[string]map[string]string{}
|
|
cur := ""
|
|
s := bufio.NewScanner(bytes.NewReader(raw))
|
|
for s.Scan() {
|
|
l := strings.TrimSpace(s.Text())
|
|
switch {
|
|
case l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#"):
|
|
case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"):
|
|
cur = l[1 : len(l)-1]
|
|
if _, ok := sections[cur]; !ok {
|
|
names = append(names, cur)
|
|
sections[cur] = map[string]string{}
|
|
}
|
|
default:
|
|
if i := strings.IndexByte(l, '='); i > 0 && cur != "" {
|
|
sections[cur][strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
|
}
|
|
}
|
|
}
|
|
return names, sections
|
|
}
|
|
|
|
// Profile is the profile the tools act on, and how it was found.
|
|
type Profile struct {
|
|
Dir string `json:"dir"`
|
|
FoundBy string `json:"found_by"`
|
|
path string
|
|
}
|
|
|
|
// profile finds the profile: the one the running Betterbird holds open, else the installation's
|
|
// default in profiles.ini ([Install…] Default), else the profile marked Default=1.
|
|
func (m *Machine) profile() (Profile, error) {
|
|
base := m.home(profilesDir)
|
|
if ps := m.mainProcs(); len(ps) > 0 {
|
|
counts := map[string]int{}
|
|
fds, _ := os.ReadDir(m.path(fmt.Sprintf("/proc/%d/fd", ps[0].PID)))
|
|
for _, fd := range fds {
|
|
t, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%s", ps[0].PID, fd.Name())))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rel, err := filepath.Rel(filepath.Join(m.Home, profilesDir), t)
|
|
if err != nil || strings.HasPrefix(rel, "..") || !strings.Contains(rel, "/") {
|
|
continue
|
|
}
|
|
counts[strings.SplitN(rel, "/", 2)[0]]++
|
|
}
|
|
best, n := "", 0
|
|
for d, c := range counts {
|
|
if c > n || (c == n && d < best) {
|
|
best, n = d, c
|
|
}
|
|
}
|
|
if best != "" {
|
|
return Profile{Dir: m.tilde(filepath.Join(m.Home, profilesDir, best)), path: filepath.Join(base, best),
|
|
FoundBy: fmt.Sprintf("held open by the running Betterbird (pid %d, %d files)", ps[0].PID, n)}, nil
|
|
}
|
|
}
|
|
raw, err := readBounded(filepath.Join(base, "profiles.ini"))
|
|
if err != nil {
|
|
return Profile{}, fmt.Errorf("no profile: Betterbird is not running and ~/%s/profiles.ini is unreadable (%v)", profilesDir, err)
|
|
}
|
|
names, sections := iniSections(raw)
|
|
pick := func(rel string, by string) (Profile, error) {
|
|
dir := filepath.Join(base, filepath.FromSlash(rel))
|
|
if filepath.IsAbs(rel) {
|
|
dir = m.path(rel)
|
|
}
|
|
if !strings.HasPrefix(filepath.Clean(dir), filepath.Clean(base)+string(filepath.Separator)) {
|
|
return Profile{}, fmt.Errorf("profiles.ini names a profile outside ~/%s: %s", profilesDir, rel)
|
|
}
|
|
return Profile{Dir: m.tilde(strings.TrimPrefix(dir, m.Root)), path: dir, FoundBy: by}, nil
|
|
}
|
|
var installs []string
|
|
for _, n := range names {
|
|
if strings.HasPrefix(n, "Install") && sections[n]["Default"] != "" {
|
|
installs = append(installs, n)
|
|
}
|
|
}
|
|
if len(installs) == 1 {
|
|
return pick(sections[installs[0]]["Default"], "profiles.ini: the installation's default ["+installs[0]+"]")
|
|
}
|
|
for _, n := range names {
|
|
if strings.HasPrefix(n, "Profile") && sections[n]["Default"] == "1" && sections[n]["Path"] != "" {
|
|
return pick(sections[n]["Path"], "profiles.ini: the profile marked Default=1 ["+n+"]")
|
|
}
|
|
}
|
|
return Profile{}, fmt.Errorf("profiles.ini names %d installation defaults and no profile marked default: start Betterbird once, then ask again", len(installs))
|
|
}
|
|
|
|
// prefLine is one user_pref(…) or pref(…) line of prefs.js or user.js.
|
|
var prefLine = regexp.MustCompile(`^\s*(?:user_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;\s*$`)
|
|
|
|
// readPrefs reads the preferences of a prefs.js or user.js: name to its value, as written.
|
|
func readPrefs(raw []byte) map[string]string {
|
|
out := map[string]string{}
|
|
s := bufio.NewScanner(bytes.NewReader(raw))
|
|
s.Buffer(make([]byte, 64<<10), 4<<20)
|
|
for s.Scan() {
|
|
if mm := prefLine.FindStringSubmatch(s.Text()); mm != nil {
|
|
out[mm[1]] = mm[2]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// passwordSecret and accountPrefix say which preferences are never answered with their value: one
|
|
// whose name can hold a credential, and one that names an account, a server or a person.
|
|
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.",
|
|
"services.sync.", "mail.displayname", "mail.default_"}
|
|
)
|
|
|
|
// withheld says why a preference's value is never answered, or "" when it may be.
|
|
func withheld(name string) string {
|
|
if passwordSecret.MatchString(name) {
|
|
return "its name says it can hold a credential"
|
|
}
|
|
for _, p := range accountPrefix {
|
|
if strings.HasPrefix(name, p) {
|
|
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"`
|
|
}
|
|
|
|
// mostPrefs bounds how many preferences one answer carries.
|
|
const mostPrefs = 200
|
|
|
|
// Prefs answers the named preferences (a name, or a prefix ending in . or *) from the profile.
|
|
func (m *Machine) Prefs(names []string) (map[string]any, error) {
|
|
if len(names) == 0 {
|
|
return nil, errors.New("names is required: preferences by name, or a prefix ending in . or *, e.g. calendar.alarms.")
|
|
}
|
|
p, err := m.profile()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
|
userJS, _ := readBounded(filepath.Join(p.path, "user.js"))
|
|
fromPrefs, fromUser := readPrefs(prefsJS), readPrefs(userJS)
|
|
all := map[string]bool{}
|
|
for n := range fromPrefs {
|
|
all[n] = true
|
|
}
|
|
for n := range fromUser {
|
|
all[n] = true
|
|
}
|
|
var out []Pref
|
|
cut := false
|
|
seen := map[string]bool{}
|
|
for _, want := range names {
|
|
prefix := strings.HasSuffix(want, ".") || strings.HasSuffix(want, "*")
|
|
want = strings.TrimSuffix(want, "*")
|
|
var matched []string
|
|
if prefix {
|
|
for n := range all {
|
|
if strings.HasPrefix(n, want) {
|
|
matched = append(matched, n)
|
|
}
|
|
}
|
|
sort.Strings(matched)
|
|
} else {
|
|
matched = []string{want}
|
|
}
|
|
for _, n := range matched {
|
|
if seen[n] {
|
|
continue
|
|
}
|
|
seen[n] = true
|
|
if len(out) == mostPrefs {
|
|
cut = true
|
|
break
|
|
}
|
|
pr := Pref{Name: n}
|
|
v, from := fromPrefs[n], "prefs.js"
|
|
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 = ""
|
|
}
|
|
pr.From = from
|
|
if why := withheld(n); why != "" {
|
|
pr.Withheld = why
|
|
} else {
|
|
pr.Value = v
|
|
}
|
|
out = append(out, pr)
|
|
}
|
|
}
|
|
answer := map[string]any{"profile": p, "prefs": out}
|
|
if cut {
|
|
answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs)
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// statement is the one thing a line of the module's user.js may be, beside a comment.
|
|
var statement = regexp.MustCompile(`^user_pref\("[A-Za-z0-9_.\-\[\]]+",\s*(true|false|-?[0-9]+|"(?:[^"\\]|\\.)*")\);$`)
|
|
|
|
// validUserJS says what is wrong with a user.js the module would place, or nil. It holds comments and
|
|
// user_pref(…) statements only — the setting is one line, so several statements may share one — and
|
|
// sets no preference whose name could hold a credential.
|
|
func validUserJS(text string) error {
|
|
if !strings.HasPrefix(text, mark) {
|
|
return fmt.Errorf("it does not begin with the module's mark (%q)", mark)
|
|
}
|
|
for i, line := range strings.Split(text, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "//") {
|
|
continue
|
|
}
|
|
for _, st := range splitStatements(line) {
|
|
if st == "" || strings.HasPrefix(st, "//") {
|
|
continue
|
|
}
|
|
if !statement.MatchString(st) {
|
|
return fmt.Errorf("line %d holds %q, which is not user_pref(\"name\", value);", i+1, st)
|
|
}
|
|
name := st[len(`user_pref("`):strings.Index(st, `",`)]
|
|
if passwordSecret.MatchString(name) {
|
|
return fmt.Errorf("line %d sets %s, a preference that can hold a credential: not through the mesh", i+1, name)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitStatements cuts a line after each `);`, outside a string.
|
|
func splitStatements(line string) []string {
|
|
var out []string
|
|
in, esc, start := false, false, 0
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
switch {
|
|
case esc:
|
|
esc = false
|
|
case c == '\\' && in:
|
|
esc = true
|
|
case c == '"':
|
|
in = !in
|
|
case c == ';' && !in:
|
|
out = append(out, strings.TrimSpace(line[start:i+1]))
|
|
start = i + 1
|
|
}
|
|
}
|
|
if rest := strings.TrimSpace(line[start:]); rest != "" {
|
|
out = append(out, rest)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UserJS is the module's user.js against the profile's.
|
|
type UserJS struct {
|
|
Declared string `json:"declared"`
|
|
Present bool `json:"declared_present"`
|
|
Valid string `json:"declared_problem,omitempty"`
|
|
Profile string `json:"profile_user_js"`
|
|
Placed string `json:"placed"`
|
|
Statements int `json:"statements"`
|
|
// Effective says whether the running Betterbird started after the file was placed.
|
|
Effective string `json:"effective,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Applied bool `json:"applied,omitempty"`
|
|
}
|
|
|
|
// userJS compares the declared user.js with the profile's; apply places the declared one when the
|
|
// profile has none or the module's.
|
|
func (m *Machine) userJS(apply bool) (UserJS, error) {
|
|
a := UserJS{Declared: "~/" + declared}
|
|
want, err := readBounded(m.home(declared))
|
|
a.Present = err == nil
|
|
p, perr := m.profile()
|
|
if perr != nil {
|
|
return a, perr
|
|
}
|
|
target := filepath.Join(p.path, "user.js")
|
|
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
|
|
if a.Present {
|
|
if verr := validUserJS(string(want)); verr != nil {
|
|
a.Valid = verr.Error()
|
|
}
|
|
a.Content = string(want)
|
|
for _, line := range strings.Split(string(want), "\n") {
|
|
for _, st := range splitStatements(strings.TrimSpace(line)) {
|
|
if statement.MatchString(st) {
|
|
a.Statements++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
have, herr := readBounded(target)
|
|
switch {
|
|
case herr != nil:
|
|
a.Placed = "none: the profile has no user.js"
|
|
case a.Present && bytes.Equal(have, want):
|
|
a.Placed = "the module's, as declared"
|
|
case bytes.HasPrefix(have, []byte(mark)):
|
|
a.Placed = "the module's, from an earlier declaration"
|
|
default:
|
|
a.Placed = "not the module's: a user.js of the operator's, which the module never overwrites"
|
|
}
|
|
if apply {
|
|
switch {
|
|
case !a.Present:
|
|
return a, fmt.Errorf("the module's user.js is not on this machine (~/%s): the module is not pushed here yet", declared)
|
|
case a.Valid != "":
|
|
return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid)
|
|
case herr == nil && !bytes.HasPrefix(have, []byte(mark)):
|
|
return a, fmt.Errorf("%s is not the module's: move it aside (its preferences belong in the setting prefs), then apply again", a.Profile)
|
|
}
|
|
if herr != nil || !bytes.Equal(have, want) {
|
|
tmp := target + ".mesh-new"
|
|
if err := os.WriteFile(tmp, want, 0o644); err != nil {
|
|
return a, err
|
|
}
|
|
if err := os.Rename(tmp, target); err != nil {
|
|
os.Remove(tmp)
|
|
return a, err
|
|
}
|
|
}
|
|
a.Applied, a.Placed = true, "the module's, as declared"
|
|
}
|
|
if ps := m.mainProcs(); len(ps) > 0 {
|
|
if st, err := os.Stat(target); err == nil {
|
|
if t, err := time.Parse(time.RFC3339, ps[0].Since); err == nil && st.ModTime().After(t) {
|
|
a.Effective = "not yet: Betterbird reads user.js when it starts, and it started before the file was placed (betterbird_restart)"
|
|
} else if err == nil {
|
|
a.Effective = "yes: the running Betterbird started after the file was placed"
|
|
}
|
|
}
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// Status is what betterbird_status answers.
|
|
type Status struct {
|
|
Running []Proc `json:"running"`
|
|
Children int `json:"children"`
|
|
Installed string `json:"installed,omitempty"`
|
|
Foreign bool `json:"from_the_aur,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
Build string `json:"build,omitempty"`
|
|
Profile *Profile `json:"profile,omitempty"`
|
|
Problem string `json:"profile_problem,omitempty"`
|
|
Output []Stream `json:"output,omitempty"`
|
|
UserJS *UserJS `json:"user_js,omitempty"`
|
|
AddOns []AddOn `json:"add_ons,omitempty"`
|
|
// Display are the preferences that decide how its windows and notifications look.
|
|
Display []Pref `json:"display_prefs,omitempty"`
|
|
}
|
|
|
|
// AddOn is one add-on the operator installed: its id, and whether it is on.
|
|
type AddOn struct {
|
|
ID string `json:"id"`
|
|
Active bool `json:"active"`
|
|
}
|
|
|
|
// displayPrefs are the preferences betterbird_status answers: the reminder dialog, the theme, the
|
|
// renderer, and whether notifications go to the desktop's notifier.
|
|
var displayPrefs = []string{"calendar.alarms.playsound", "calendar.alarms.show", "calendar.alarms.showmissed",
|
|
"calendar.alarms.indicator.show", "alerts.useSystemBackend", "mail.biff.use_system_alert",
|
|
"extensions.activeThemeID", "browser.display.use_system_colors", "ui.systemUsesDarkTheme",
|
|
"gfx.webrender.software", "gfx.webrender.all", "layers.acceleration.disabled", "widget.gtk.overlay-scrollbars.enabled",
|
|
"mozilla.widget.use-argb-visuals"}
|
|
|
|
func (m *Machine) foreign(pkg string) bool {
|
|
o := m.cmd(0, nil, "pacman", "-Qmq", pkg)
|
|
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == pkg
|
|
}
|
|
|
|
// Status answers whether Betterbird runs, what is installed, its profile and the module's user.js.
|
|
func (m *Machine) Status() (Status, error) {
|
|
s := Status{Running: m.mainProcs()}
|
|
if s.Running == nil {
|
|
s.Running = []Proc{}
|
|
}
|
|
s.Children = len(m.procs(mainComm)) - len(s.Running)
|
|
if v, err := m.installed(packageFor); err == nil && v != "" {
|
|
s.Installed, s.Foreign = v, m.foreign(packageFor)
|
|
}
|
|
if raw, err := readBounded(m.path(appIni)); err == nil {
|
|
_, sec := iniSections(raw)
|
|
s.Version, s.Build = sec["App"]["Version"], sec["App"]["BuildID"]
|
|
}
|
|
if len(s.Running) > 0 {
|
|
s.Output = m.streams(s.Running[0].PID)
|
|
}
|
|
p, err := m.profile()
|
|
if err != nil {
|
|
s.Problem = err.Error()
|
|
return s, nil
|
|
}
|
|
s.Profile = &p
|
|
if u, err := m.userJS(false); err == nil {
|
|
u.Content = ""
|
|
s.UserJS = &u
|
|
}
|
|
if raw, err := readBounded(filepath.Join(p.path, "extensions.json")); err == nil {
|
|
var ext struct {
|
|
Addons []struct {
|
|
ID string `json:"id"`
|
|
Active bool `json:"active"`
|
|
Location string `json:"location"`
|
|
} `json:"addons"`
|
|
}
|
|
if json.Unmarshal(raw, &ext) == nil {
|
|
for _, a := range ext.Addons {
|
|
if a.Location == "app-profile" {
|
|
s.AddOns = append(s.AddOns, AddOn{a.ID, a.Active})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if got, err := m.Prefs(displayPrefs); err == nil {
|
|
s.Display = got["prefs"].([]Pref)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// emailAddress and credentialWord are masked in what Betterbird printed.
|
|
var (
|
|
emailAddress = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
|
credentialWord = regexp.MustCompile(`(?i)((?:password|passwd|token|secret|authorization|cookie)\S*\s*[=:]\s*)\S+`)
|
|
)
|
|
|
|
func mask(s string) string {
|
|
s = emailAddress.ReplaceAllString(s, "<hidden>")
|
|
return credentialWord.ReplaceAllString(s, "${1}<hidden>")
|
|
}
|
|
|
|
// mostAnswer bounds a log answer.
|
|
const mostAnswer = 128 << 10
|
|
|
|
// Log answers the last n lines Betterbird printed, from the journal of the unit the module starts it
|
|
// as; problems keeps the lines that say error, warning, crash or assertion. Addresses and credentials
|
|
// are masked.
|
|
func (m *Machine) Log(n int, problems bool, unit string) (map[string]any, error) {
|
|
if unit == "" {
|
|
unit = restartAs
|
|
}
|
|
if unit != restartAs && unit != testUnit {
|
|
return nil, fmt.Errorf("unit is %s or %s", restartAs, testUnit)
|
|
}
|
|
o := m.cmd(0, m.bus(), "journalctl", "--user", "--unit", unit+".service", "--no-pager", "--output", "short-iso", "-n", strconv.Itoa(n))
|
|
if err := failed(o, "journalctl"); err != nil {
|
|
return nil, err
|
|
}
|
|
var lines []string
|
|
size := 0
|
|
keep := regexp.MustCompile(`(?i)(error|warn|crash|assert|fail|exception)`)
|
|
for _, l := range strings.Split(strings.TrimRight(o.Stdout, "\n"), "\n") {
|
|
if l == "" || (problems && !keep.MatchString(l)) {
|
|
continue
|
|
}
|
|
l = mask(l)
|
|
if size+len(l) > mostAnswer {
|
|
break
|
|
}
|
|
size += len(l) + 1
|
|
lines = append(lines, l)
|
|
}
|
|
if lines == nil {
|
|
lines = []string{}
|
|
}
|
|
a := map[string]any{"unit": unit + ".service", "lines": lines}
|
|
if ps := m.mainProcs(); len(ps) > 0 && !strings.HasPrefix(ps[0].StartedIn, restartAs) && unit == restartAs {
|
|
a["note"] = fmt.Sprintf("the running Betterbird (pid %d) was not started by the module: it runs in %s, "+
|
|
"and its output goes where status says (output), not to this journal; betterbird_restart starts it as %s",
|
|
ps[0].PID, ps[0].StartedIn, restartAs)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// RestartAnswer is what betterbird_restart answers.
|
|
type RestartAnswer struct {
|
|
Ended []int `json:"ended"`
|
|
Killed []int `json:"killed,omitempty"`
|
|
UserJS string `json:"user_js"`
|
|
Running []Proc `json:"running"`
|
|
Session Session `json:"session"`
|
|
Unit string `json:"unit"`
|
|
}
|
|
|
|
// Restart ends Betterbird (asked to quit, forced after 20 s), places the module's user.js when it can,
|
|
// and starts Betterbird again in the operator's session under the account's service manager, its output
|
|
// in the journal.
|
|
func (m *Machine) Restart() (RestartAnswer, error) {
|
|
s, err := m.session()
|
|
if err != nil {
|
|
return RestartAnswer{}, err
|
|
}
|
|
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
|
a.Ended, a.Killed = m.stopProcs(20*time.Second, m.mainProcs())
|
|
if u, err := m.userJS(true); err != nil {
|
|
a.UserJS = "not placed: " + err.Error()
|
|
} else {
|
|
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s", u.Statements, declared)
|
|
}
|
|
if err := m.detach(s, restartAs, startBin); err != nil {
|
|
return a, err
|
|
}
|
|
m.waitFor(mainComm, 8*time.Second)
|
|
a.Running = m.mainProcs()
|
|
if a.Running == nil {
|
|
a.Running = []Proc{}
|
|
}
|
|
if len(a.Running) == 0 {
|
|
return a, fmt.Errorf("Betterbird was started as %s but no main process appeared within 8 s: betterbird_log says why", a.Unit)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// CheckAnswer is what betterbird_check answers.
|
|
type CheckAnswer struct {
|
|
OK bool `json:"ok"`
|
|
Findings []Finding `json:"findings"`
|
|
}
|
|
|
|
// Check verifies what the module promises: Betterbird installed, one running in the session, its
|
|
// profile found, the module's user.js declared, valid, placed and in effect, and its output read.
|
|
func (m *Machine) Check() (CheckAnswer, error) {
|
|
a := CheckAnswer{Findings: []Finding{}}
|
|
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
|
v, err := m.installed(packageFor)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
if v == "" {
|
|
add("Betterbird ("+packageFor+") is not installed", "install it from the AUR: the node-engine installs packages from the official repositories only, so the module does not")
|
|
}
|
|
ps := m.mainProcs()
|
|
switch {
|
|
case len(ps) == 0:
|
|
add("Betterbird is not running", "start it from the launcher, or betterbird_restart")
|
|
case len(ps) > 1:
|
|
add(fmt.Sprintf("%d Betterbird main processes run", len(ps)), "one profile is held by one process: betterbird_restart")
|
|
}
|
|
u, err := m.userJS(false)
|
|
switch {
|
|
case err != nil:
|
|
add("no profile: "+err.Error(), "start Betterbird once")
|
|
case !u.Present:
|
|
add("the module's user.js is not on this machine (~/"+declared+")", "the module is not pushed here: the controller's push")
|
|
case u.Valid != "":
|
|
add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs")
|
|
case !strings.HasPrefix(u.Placed, "the module's, as declared"):
|
|
add("the profile's user.js is "+u.Placed, "betterbird_user_js with apply, then betterbird_restart")
|
|
case strings.HasPrefix(u.Effective, "not yet"):
|
|
add("the module's user.js is placed but not in effect: "+u.Effective, "betterbird_restart")
|
|
}
|
|
if len(ps) > 0 {
|
|
for _, s := range m.streams(ps[0].PID) {
|
|
if s.Dead {
|
|
add(fmt.Sprintf("Betterbird's fd %d is a pipe nobody reads (a session started before its output went to the journal): what it prints is lost", s.FD),
|
|
"betterbird_restart, which starts it with its output in the journal")
|
|
}
|
|
}
|
|
}
|
|
a.OK = len(a.Findings) == 0
|
|
return a, nil
|
|
}
|