Merge remote-tracking branch 'origin/main' into fix/the-forge-tools-never-weaken-protection
mesh/merge-gate pass: builds gitea → novox; 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
mesh/merge-gate pass: builds gitea → novox; 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
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# betterbird
|
||||
|
||||
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
|
||||
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, ADR 0208). It requires
|
||||
`x11-display`, so it is assigned only where a display server is held on the same machine.
|
||||
|
||||
It exists because a symptom had no tools: on a workstation, a calendar reminder appeared as a
|
||||
transparent window with nothing in it (2026-10-09). Nothing in the mesh could say which window that
|
||||
was, how the compositor treats it, or which preferences Betterbird ran with. The diagnosis is in hq
|
||||
issue 345.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the module's directory | `~/.config/betterbird/` |
|
||||
| the module's `user.js`, filled from the setting `prefs` | `~/.config/betterbird/user.js` |
|
||||
|
||||
Nothing else. It holds no seat and adds no start.
|
||||
|
||||
- **No package.** g14 and shanks both run `betterbird-bin` 153.1.0esr-1, installed explicitly from the
|
||||
AUR: `pacman` counts it as foreign. The node-engine installs packages from the official
|
||||
repositories only, so the module does not declare it, and **installing and upgrading Betterbird stay
|
||||
the operator's** (an AUR helper). `betterbird_status` says the version and that it is from the AUR;
|
||||
`betterbird_check` says when it is missing.
|
||||
- **No start.** Betterbird is started by the operator from the launcher (on the workstation measured,
|
||||
it runs in the login session's scope, started by hand, with no autostart entry and no line in i3's
|
||||
configuration). `betterbird_restart` starts it as the transient user unit `mesh-betterbird`, so its
|
||||
output then goes to the journal.
|
||||
- **The profile is Betterbird's** (ADR 0182): `~/.thunderbird/` holds the mail, the address books,
|
||||
the calendars' caches, the logins and the keys. The module declares none of it.
|
||||
|
||||
## The user.js: how a preference is declared
|
||||
|
||||
Betterbird reads `user.js` in its profile when it starts, and a preference set there wins over the
|
||||
one it saved in `prefs.js`. The profile's directory has a random name per machine
|
||||
(`<random>.default-default`), which a manifest cannot name. So:
|
||||
|
||||
1. the node-engine writes `~/.config/betterbird/user.js` from the module's setting `prefs`, one line of
|
||||
`user_pref("name", value);` statements (default: a comment, no preference);
|
||||
2. the module's tools copy that file whole into the profile: `betterbird_user_js` with `apply`, or
|
||||
`betterbird_restart`, which places it before it starts Betterbird again;
|
||||
3. Betterbird reads it at that start.
|
||||
|
||||
**The profile is found** as the one the running Betterbird holds open; with none running, as the
|
||||
installation's default in `profiles.ini` (`[Install…] Default`), else the profile marked `Default=1`.
|
||||
On the workstation measured these differ: `Profile1` is marked `Default=1`, and the installation's
|
||||
default, the one in use, is the other.
|
||||
|
||||
**A user.js the module did not write is never overwritten.** The module's begins with the line
|
||||
`// Betterbird's user.js (module betterbird`; any other is the operator's, and `apply` refuses with
|
||||
what to do. The tools refuse a declared file that holds anything but comments and `user_pref`
|
||||
statements, or that sets a preference whose name can hold a credential.
|
||||
|
||||
The file is **trusted** (it carries no `"trusted": false`): a preference can weaken how the mail client
|
||||
checks a server, so the setting is changed only at the controller's terminal, never through a verb
|
||||
(novox/hq issue 339).
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175). **No answer carries mail, a
|
||||
calendar entry, an account, an address or a credential.**
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `betterbird_status` (r) | whether it runs (pid, since, scope, children), the package and version and that it is from the AUR, where its output goes, the profile and how it was found, the user.js (declared, placed, in effect), the add-ons the operator installed (ids), and the preferences that decide how its windows and notifications look |
|
||||
| `betterbird_prefs` (r) | named preferences or prefixes (ending in `.` or `*`, at most 200; an empty name, `*` or `.` is refused): value and source (user.js, prefs.js or the default). Withheld without its value: a name that can hold a credential (`password`, `token`, `oauth`, `signon`, …); one that names an account, server, calendar, recent folder or person (`mail.server.`, `mail.identity.`, `mail.last_msg…`, `messenger.account.`, `mail.cloud_files.`, `calendar.registry.`, …); and every `extensions.*` but the theme's id (`extensions.activeThemeID`). In every value answered, the home is `~`, and a mail address, a `user%40domain@host` URI and a host name are masked |
|
||||
| `betterbird_user_js` (r; a with `apply`) | the declared user.js against the profile's; `apply` places it |
|
||||
| `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked (the value after `password=`, `token:` and the like, and the token after `Bearer`/`Basic`). A Betterbird the module did not start prints elsewhere, and the answer says where |
|
||||
| `betterbird_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js where allowed, starts it as `mesh-betterbird`. **Refused while a composer window is open** (X instance `Msgcompose`, whatever its class), because the unsent message would be lost, unless `discard_unsent` is true, which only the operator's yes justifies. It answers within the runtime's 30 s: a start not yet seen after 4 s is said in `note`, and `betterbird_status` shows it |
|
||||
| `betterbird_check` (r) | installed; one running; profile found; the user.js on the machine, valid, placed, in effect; its output read |
|
||||
| `betterbird_reminder_test` (a) | a reminder on demand without the operator's calendar (below) |
|
||||
|
||||
### A reminder on demand
|
||||
|
||||
`betterbird_reminder_test` starts a **second** Betterbird with `--no-remote` in a throw-away profile
|
||||
under `/run/user/<uid>/mesh-betterbird-reminder-test/`, as the user unit
|
||||
`mesh-betterbird-reminder-test` (ended after 10 minutes at most). The profile holds the module's
|
||||
user.js and one calendar: a local `.ics` file with one event, "Mesh reminder test", whose reminder is
|
||||
due `after` seconds from the call (default 40). `carry` copies named preferences of the operator's
|
||||
profile into it (a theme, a colour switch), under the same limits as `betterbird_prefs`. Then:
|
||||
|
||||
- `xorg_windows` with class `Calendar` answers the reminder window: role `AlarmWindow`, type `Dialog`,
|
||||
its depth (32: a visual with alpha, which the compositor blends), its map state;
|
||||
- `xorg_screenshot` with that window shows it as the screen shows it.
|
||||
|
||||
The unit removes its profile when it ends, however it ends (`ExecStopPost`): at its 10 minutes, on
|
||||
`stop`, or when the test window is closed. The test Betterbird is never counted as the operator's: its
|
||||
command line names its own profile, and `status`, `check`, `restart` and the user.js leave it out.
|
||||
|
||||
The running Betterbird, its profile and its calendars are not touched: `--no-remote` keeps the two
|
||||
apart. **`profiles.ini` and `installs.ini` are not rewritten either**, measured on the workstation
|
||||
(2026-10-09): their content and modification time (June 2023) were the same before a `--profile` start,
|
||||
while it ran, and after it ended.
|
||||
|
||||
## The reminder window, as measured (2026-10-09, workstation, i3 and picom with glx)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| X class / instance | `eu.betterbird.Betterbird` / `Calendar` |
|
||||
| role, type | `AlarmWindow`, `_NET_WM_WINDOW_TYPE_DIALOG` (i3 floats it) |
|
||||
| depth | 32 (ARGB visual), like every Betterbird window there |
|
||||
| size | 600×300, kept in the profile's `xulstore.json` |
|
||||
|
||||
A window of depth 32 that its program has not drawn is transparent under a compositor, not black:
|
||||
that is why the symptom looks the way it does. Why Betterbird did not draw it is hq issue 345's
|
||||
question.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| files | `~/.config/betterbird/` and its `user.js` (no preference) | the same |
|
||||
| the profile | untouched until `betterbird_user_js` with `apply` or `betterbird_restart` | the same; the profile has no user.js today, so `apply` may place one |
|
||||
| Betterbird | keeps running as started | keeps running as started (since 2026-10-04, its output on the session's pipe nobody reads: `betterbird_check` names it until `betterbird_restart`) |
|
||||
|
||||
## Relies on
|
||||
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
- **`xorg`** for `xorg_windows` and `xorg_screenshot`, which look at Betterbird's windows. This module
|
||||
carries no window code of its own.
|
||||
- `systemd-run` and `journalctl` of the account's user manager.
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
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) {
|
||||
// The reminder test's Betterbird is never the operator's: its command line names its own profile.
|
||||
if !strings.Contains(p.Command, "-contentproc") && !strings.Contains(p.Command, testUnit) {
|
||||
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_", "mail.last_msg", "messenger.account.", "mail.cloud_files."}
|
||||
// allowedExtensions are the only extensions.* preferences answered: the add-ons' own preferences can
|
||||
// hold anything, and a theme's id is what a window's look depends on.
|
||||
allowedExtensions = map[string]bool{"extensions.activeThemeID": true}
|
||||
)
|
||||
|
||||
// withheld says why a preference's value is never answered, or "" when it may be.
|
||||
func withheld(name string) string {
|
||||
if strings.HasPrefix(name, "extensions.") && !allowedExtensions[name] {
|
||||
return "an add-on's own preference: only the theme's id (extensions.activeThemeID) is answered"
|
||||
}
|
||||
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.")
|
||||
}
|
||||
for _, n := range names {
|
||||
if strings.Trim(strings.TrimSpace(n), ".*") == "" {
|
||||
return nil, fmt.Errorf("%q asks for every preference: name one, or a prefix such as calendar.alarms.", n)
|
||||
}
|
||||
}
|
||||
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 if allowedExtensions[n] {
|
||||
pr.Value = v
|
||||
} else {
|
||||
pr.Value = m.maskValue(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+`)
|
||||
)
|
||||
|
||||
// userAtHost and hostName are what a preference's value may name a server by.
|
||||
var (
|
||||
userAtHost = regexp.MustCompile(`[A-Za-z0-9._%+\-]+%40[A-Za-z0-9.\-]+@[A-Za-z0-9.\-]+`)
|
||||
// A host is a name of three labels or more, or of two whose last is a common top-level domain: a
|
||||
// file's name (a.pdf) is not one.
|
||||
hostName = regexp.MustCompile(`(?i)\b(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.){2,}[a-z]{2,}|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.(?:com|org|net|be|nl|de|fr|eu|io|uk|us|info|biz|dev|app|cloud|local|lan|internal|home|corp))\b`)
|
||||
bearerOrBasic = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=\-]+`)
|
||||
)
|
||||
|
||||
func mask(s string) string {
|
||||
s = userAtHost.ReplaceAllString(s, "<hidden>")
|
||||
s = emailAddress.ReplaceAllString(s, "<hidden>")
|
||||
s = bearerOrBasic.ReplaceAllString(s, "$1 <hidden>")
|
||||
return credentialWord.ReplaceAllString(s, "${1}<hidden>")
|
||||
}
|
||||
|
||||
// maskValue is a preference's value as answered: the home as ~, and no address, user or host.
|
||||
func (m *Machine) maskValue(v string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
v = strings.ReplaceAll(v, strings.TrimSuffix(m.Home, "/")+"/", "~/")
|
||||
}
|
||||
v = mask(v)
|
||||
return hostName.ReplaceAllString(v, "<host>")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Composers are the message windows that were open: what was unsent and is gone.
|
||||
Composers int `json:"composers_discarded,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// composerClass is the X instance of a message being written, whatever the class beside it.
|
||||
var composerClass = regexp.MustCompile(`\("Msgcompose" "[^"]*"\)`)
|
||||
|
||||
// composers counts the open message windows, from the X window tree.
|
||||
func (m *Machine) composers(s Session) (int, error) {
|
||||
o := m.cmd(3*time.Second, s.Env(), "xwininfo", "-root", "-tree")
|
||||
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(composerClass.FindAllString(o.Stdout, -1)), nil
|
||||
}
|
||||
|
||||
// Restart ends Betterbird (asked to quit, forced after 10 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. While a message is being written it refuses, unless discard says the unsent may go.
|
||||
// It answers within the runtime's 30 s: a start that has not shown after 4 s is said, and
|
||||
// betterbird_status sees it come.
|
||||
func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return RestartAnswer{}, err
|
||||
}
|
||||
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||
n, err := m.composers(s)
|
||||
switch {
|
||||
case err != nil && !discard:
|
||||
return a, fmt.Errorf("whether a message is being written cannot be told (%v): restart with discard_unsent only if none is", err)
|
||||
case n > 0 && !discard:
|
||||
return a, fmt.Errorf("%d composer window(s) are open: an unsent message would be lost. Send or save it, or ask the operator, then call again with discard_unsent", n)
|
||||
}
|
||||
a.Composers = n
|
||||
a.Ended, a.Killed = m.stopProcs(8*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)
|
||||
}
|
||||
// As detach does, with shorter bounds: the worst case of the whole call (3 s for the window tree,
|
||||
// 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s.
|
||||
_ = m.cmd(3*time.Second, s.Env(), "systemctl", "--user", "stop", restartAs+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + restartAs}
|
||||
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)
|
||||
if err := failed(m.cmd(5*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
|
||||
return a, err
|
||||
}
|
||||
for waited := time.Duration(0); waited < 4*time.Second && len(m.mainProcs()) == 0; waited += 250 * time.Millisecond {
|
||||
m.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
a.Running = m.mainProcs()
|
||||
if a.Running == nil {
|
||||
a.Running = []Proc{}
|
||||
}
|
||||
if len(a.Running) == 0 {
|
||||
a.Note = "started as " + a.Unit + "; no main process yet after 4 s: betterbird_status shows it once it runs, betterbird_log says why if it does not"
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const profileDir = testHome + "/.thunderbird/60016jsp.default-default"
|
||||
|
||||
// links stands in for the kernel's fd links: path → target.
|
||||
func links(t *testing.T, m map[string]string) {
|
||||
t.Helper()
|
||||
old := readLink
|
||||
readLink = func(p string) (string, error) {
|
||||
for k, v := range m {
|
||||
if strings.HasSuffix(p, k) {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
t.Cleanup(func() { readLink = old })
|
||||
}
|
||||
|
||||
// betterbird is a running Betterbird with one child, its profile and its prefs.
|
||||
func (f *fake) betterbird() {
|
||||
f.proc(4000, 1000, "betterbird-bin", []string{"betterbird"}, "session-4.scope", "DISPLAY=:1")
|
||||
f.proc(4001, 1000, "betterbird-bin", []string{"/opt/betterbird/betterbird-bin", "-contentproc", "-isForBrowser"}, "session-4.scope")
|
||||
f.write("/proc/4000/fd/40", "")
|
||||
f.write("/proc/4000/fd/41", "")
|
||||
f.write("/proc/4000/fd/1", "")
|
||||
f.write(testHome+"/.thunderbird/profiles.ini", "[Profile0]\nName=default-default\nIsRelative=1\nPath=60016jsp.default-default\n\n"+
|
||||
"[Profile1]\nName=default\nIsRelative=1\nPath=au2i98w4.default\nDefault=1\n\n[InstallF72D9E66FA3C07ED]\nDefault=60016jsp.default-default\nLocked=1\n")
|
||||
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
|
||||
`user_pref("browser.display.use_system_colors", true);`+"\n"+
|
||||
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+
|
||||
`user_pref("calendar.alarms.showmissed", true);`+"\n"+
|
||||
`user_pref("mail.identity.id1.useremail", "someone@example.org");`+"\n"+
|
||||
`user_pref("extensions.owl.token", "abc");`+"\n"+
|
||||
`user_pref("signon.rememberSignons", true);`+"\n")
|
||||
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","active":true,"location":"app-profile"},{"id":"x@mozilla.org","active":true,"location":"app-builtin"}]}`)
|
||||
}
|
||||
|
||||
func (f *fake) declare(prefs string) {
|
||||
f.write(testHome+"/.config/betterbird/user.js", mark+", novox/hq issue 345). Owned by the mesh.\n//\n"+prefs+"\n")
|
||||
}
|
||||
|
||||
func TestTheProfileIsTheOneTheRunningBetterbirdHoldsElseTheInstallationsDefault(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, map[string]string{"/proc/4000/fd/40": profileDir + "/prefs.js", "/proc/4000/fd/41": profileDir + "/places.sqlite"})
|
||||
p, err := f.profile()
|
||||
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "pid 4000, 2 files") {
|
||||
t.Fatalf("%+v %v", p, err)
|
||||
}
|
||||
links(t, map[string]string{})
|
||||
p, err = f.profile()
|
||||
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "InstallF72D9E66FA3C07ED") {
|
||||
t.Fatalf("the installation's default, not Profile1's Default=1: %+v %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefsAnswerValuesButNeverACredentialOrAnAccount(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.write(profileDir+"/user.js", mark+")\n"+`user_pref("browser.display.use_system_colors", false);`+"\n")
|
||||
got, err := f.Prefs([]string{"browser.display.", "mail.identity.*", "extensions.owl.token", "signon.rememberSignons", "gfx.webrender.software"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := got["prefs"].([]Pref)
|
||||
by := map[string]Pref{}
|
||||
for _, p := range ps {
|
||||
by[p.Name] = p
|
||||
}
|
||||
if p := by["browser.display.use_system_colors"]; p.Value != "false" || !strings.HasPrefix(p.From, "user.js") {
|
||||
t.Fatalf("user.js wins: %+v", p)
|
||||
}
|
||||
for _, n := range []string{"mail.identity.id1.useremail", "extensions.owl.token", "signon.rememberSignons"} {
|
||||
if p := by[n]; p.Value != "" || p.Withheld == "" {
|
||||
t.Errorf("%s: %+v", n, p)
|
||||
}
|
||||
}
|
||||
if p := by["gfx.webrender.software"]; p.Value != "" || !strings.Contains(p.From, "default") {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
if _, err := f.Prefs(nil); err == nil {
|
||||
t.Fatal("names are required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheModulesUserJSIsPlacedOnlyWhereNoOperatorsFileIs(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not pushed") {
|
||||
t.Fatalf("nothing declared: %v", err)
|
||||
}
|
||||
f.declare(`user_pref("calendar.alarms.showmissed", true); user_pref("ui.key.x", "a;b");`)
|
||||
u, err := f.userJS(true)
|
||||
if err != nil || !u.Applied || u.Statements != 2 {
|
||||
t.Fatalf("%+v %v", u, err)
|
||||
}
|
||||
placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js"))
|
||||
declared, _ := os.ReadFile(filepath.Join(f.Root, testHome, ".config/betterbird/user.js"))
|
||||
if string(placed) != string(declared) {
|
||||
t.Fatal("placed whole, as declared")
|
||||
}
|
||||
if !strings.HasPrefix(u.Effective, "yes") {
|
||||
t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective)
|
||||
}
|
||||
later := time.Unix(1_900_000_000, 0)
|
||||
os.Chtimes(filepath.Join(f.Root, profileDir, "user.js"), later, later)
|
||||
if u, _ := f.userJS(false); !strings.HasPrefix(u.Effective, "not yet") {
|
||||
t.Fatalf("placed after Betterbird started: %q", u.Effective)
|
||||
}
|
||||
f.write(profileDir+"/user.js", `user_pref("a.b", 1);`+"\n")
|
||||
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not the module's") {
|
||||
t.Fatalf("an operator's user.js is never overwritten: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); string(b) != `user_pref("a.b", 1);`+"\n" {
|
||||
t.Fatal("and it is left as it was")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUserJSHoldsOnlyPreferencesAndNoCredential(t *testing.T) {
|
||||
for text, ok := range map[string]bool{
|
||||
mark + ")\n// none\n": true,
|
||||
mark + ")\n" + `user_pref("a.b", true); user_pref("c", -3); user_pref("d", "x\"y;z");`: true,
|
||||
mark + ")\n" + `pref("a.b", true);`: false,
|
||||
mark + ")\n" + `user_pref("a.b", true); Services.foo();`: false,
|
||||
mark + ")\n" + `user_pref("mail.smtpserver.s1.password", "x");`: false,
|
||||
`user_pref("a.b", true);`: false,
|
||||
} {
|
||||
if err := validUserJS(text); (err == nil) != ok {
|
||||
t.Errorf("%q: %v", text, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSaysWhatRunsTheProfileTheAddOnsAndTheDisplayPrefs(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[36802]"})
|
||||
f.write("/opt/betterbird/application.ini", "[App]\nVendor=Betterbird Project\nName=Betterbird\nVersion=153.1.0\nBuildID=20260820183427\n")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "pacman" && args[0] == "-Q" {
|
||||
return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"}
|
||||
}
|
||||
if name == "pacman" && args[0] == "-Qmq" {
|
||||
return Output{Stdout: "betterbird-bin\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Running) != 1 || s.Running[0].PID != 4000 || s.Children != 1 || s.Installed != "153.1.0esr-1" || !s.Foreign ||
|
||||
s.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if !s.Output[0].Dead {
|
||||
t.Fatalf("the session's dead pipe: %+v", s.Output)
|
||||
}
|
||||
found := false
|
||||
for _, p := range s.Display {
|
||||
if p.Name == "extensions.activeThemeID" && p.Value == `"thunderbird-compact-dark@mozilla.org"` {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%+v", s.Display)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNamesWhatIsMissingAndWhatToDo(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[1]", "/proc/4000/fd/2": "/dev/null"})
|
||||
f.answer = func(name string, args []string) Output { return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"} }
|
||||
a, err := f.Check()
|
||||
if err != nil || a.OK {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
all := ""
|
||||
for _, x := range a.Findings {
|
||||
all += x.What + " | " + x.Do + "\n"
|
||||
}
|
||||
if !strings.Contains(all, "not on this machine") || !strings.Contains(all, "pipe nobody reads") {
|
||||
t.Fatal(all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartEndsItPlacesTheUserJSAndStartsItUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.onStart = func(argv []string) {
|
||||
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
|
||||
}
|
||||
a, err := f.Restart(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Ended) != 1 || a.Ended[0] != 4000 || len(a.Running) != 1 || a.Running[0].PID != 5000 || !strings.HasPrefix(a.UserJS, "placed") {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=mesh-betterbird") {
|
||||
t.Fatalf("%v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReminderTestTouchesNothingOfTheOperatorsProfile(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.declare(`user_pref("calendar.alarms.show", true);`)
|
||||
before, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
|
||||
a, err := f.ReminderTest(40*time.Second, []string{"extensions.activeThemeID", "mail.identity.id1.useremail", "nope.nope"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(a.Carried, ",") != "extensions.activeThemeID" || len(a.Withheld) != 2 {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
dir := filepath.Join(f.Root, "/run/user/1000", testUnit)
|
||||
user, _ := os.ReadFile(filepath.Join(dir, "profile", "user.js"))
|
||||
cal, _ := os.ReadFile(filepath.Join(dir, "test.ics"))
|
||||
if !strings.Contains(string(user), `"calendar.alarms.show", true`) || !strings.Contains(string(user), `"extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org"`) ||
|
||||
!strings.Contains(string(user), "file:///run/user/1000/"+testUnit+"/test.ics") || strings.Contains(string(user), "someone@") {
|
||||
t.Fatalf("%s", user)
|
||||
}
|
||||
// The event starts 10 minutes after the reminder, which is due 40 s from now.
|
||||
if !strings.Contains(string(cal), "DTSTART:20270115T081040Z") || !strings.Contains(string(cal), "TRIGGER:-PT10M") ||
|
||||
a.Due != "2027-01-15T08:00:40Z" {
|
||||
t.Fatalf("%s %s", cal, a.Due)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit + " --setenv=MOZ_NO_REMOTE=1") {
|
||||
t.Fatalf("%v", f.calls)
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
|
||||
if string(before) != string(after) || exists(filepath.Join(f.Root, profileDir, "user.js")) {
|
||||
t.Fatal("the operator's profile is untouched")
|
||||
}
|
||||
if _, err := f.ReminderTest(0, nil, true); err != nil || exists(dir) {
|
||||
t.Fatalf("stop removes the test profile: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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, calendars, accounts and credentials are never read: a tool reads processes,
|
||||
// profiles.ini, and of the profile only prefs.js, user.js and extensions.json, and answers no
|
||||
// preference that can hold a credential or names an account.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []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), the installed package and version and whether it is from the AUR, where its " +
|
||||
"output goes, the profile it uses and how that was found, the module's user.js (declared, placed, in " +
|
||||
"effect), the add-ons the operator installed, and the preferences that decide how its windows and " +
|
||||
"notifications look (reminder dialog, theme, renderer, system notifications). (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 (user.js, " +
|
||||
"which wins at the start; prefs.js; or Betterbird's default). 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_user_js",
|
||||
Description: "The module's user.js — the preferences the module declares, from its setting prefs, written by " +
|
||||
"the mesh to ~/.config/betterbird/user.js — 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). 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 is installed; one runs; its profile is found; the " +
|
||||
"module's user.js is on the machine, valid, placed in the profile and in effect; its output is read " +
|
||||
"(a pipe nobody reads loses what it prints). Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
{
|
||||
Name: "betterbird_reminder_test",
|
||||
Description: "Show 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), with the module's user.js and one local calendar file holding one event 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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// betterbird's shape (novox/hq issue 345, ADR 0208): no package (Betterbird comes from the AUR, and the
|
||||
// node-engine installs from the official repositories only), no seat, no start of its own (the operator
|
||||
// starts it from the launcher); the X display on its own machine; one directory and one file of its own,
|
||||
// the user.js the setting prefs fills; and the Go bundle serving exactly the listed betterbird_ tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Requires []string `json:"requires"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestItOwnsTheUserJSAndNothingOfTheProfile(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "betterbird" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
var ids []string
|
||||
for _, r := range m.Resources {
|
||||
ids = append(ids, r["id"].(string))
|
||||
if r["type"] == "package" {
|
||||
t.Errorf("betterbird-bin is the AUR's: a package resource asks the node-engine for what it cannot install")
|
||||
}
|
||||
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${machine:account-home}/.config/betterbird") {
|
||||
t.Errorf("a path outside the module's directory: %s", p)
|
||||
}
|
||||
}
|
||||
if strings.Join(ids, ",") != "configuration-dir,user-js" {
|
||||
t.Fatalf("%v", ids)
|
||||
}
|
||||
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
file := m.Resources[1]
|
||||
content := file["content"].(string)
|
||||
if !strings.HasPrefix(content, mark) || !strings.Contains(content, "${setting:prefs}") || file["trusted"] != nil {
|
||||
t.Fatalf("the user.js begins with the mark, holds the setting, and stays trusted (only the terminal sets it): %v", file)
|
||||
}
|
||||
// The default is a comment, so a machine without the setting places a valid, empty user.js.
|
||||
def := m.Settings["prefs"].(map[string]any)["default"].(string)
|
||||
if err := validUserJS(strings.ReplaceAll(content, "${setting:prefs}", def)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "betterbird_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed betterbird_ and described", tool.Name)
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The review of pull request 155 (hq issue 345): a restart never loses an unsent message unasked, a
|
||||
// preference's value never carries an address, a log never a credential, the reminder test never
|
||||
// counts as the operator's Betterbird and leaves nothing behind, and a restart answers within the
|
||||
// runtime's call limit.
|
||||
|
||||
const composerTree = `
|
||||
0x2a00003 "Inbox - Betterbird": ("Mail" "eu.betterbird.Betterbird") 2560x1388+0+0 +0+26
|
||||
0x2a01234 "Write: hello - Betterbird": ("Msgcompose" "eu.betterbird.Betterbird") 900x700+10+10 +10+10
|
||||
`
|
||||
|
||||
func TestARestartRefusesWhileAComposerIsOpenUnlessTheUnsentMayGo(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.declare("// none")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "xwininfo" {
|
||||
return Output{Stdout: composerTree}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
|
||||
t.Fatalf("refused while a message is being written: %v", err)
|
||||
}
|
||||
if len(f.signals) != 0 {
|
||||
t.Fatalf("nothing was ended: %v", f.signals)
|
||||
}
|
||||
f.onStart = func([]string) {
|
||||
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
|
||||
}
|
||||
a, err := f.Restart(true)
|
||||
if err != nil || len(a.Ended) != 1 || a.Composers != 1 {
|
||||
t.Fatalf("with discard_unsent it goes ahead and says what was open: %+v %v", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARestartAnswersWithinTheCallLimitEvenWhenNothingCooperates(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.stubborn[4000] = true
|
||||
slept := time.Duration(0)
|
||||
f.Sleep = func(d time.Duration) { slept += d }
|
||||
a, err := f.Restart(false)
|
||||
if err != nil {
|
||||
t.Fatalf("a start that has not shown yet is said, not failed: %v", err)
|
||||
}
|
||||
if slept > 18*time.Second || len(a.Killed) != 1 || a.Note == "" {
|
||||
t.Fatalf("waited %v of the runtime's 30 s: %+v", slept, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPreferencesValueCarriesNoAddressHostOrHome(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.write(profileDir+"/prefs.js", strings.Join([]string{
|
||||
`user_pref("mail.last_msg_movecopy_target_uri", "imap://someone%40example.org@mail.example.org/INBOX");`,
|
||||
`user_pref("messenger.account.a1.server", "x");`,
|
||||
`user_pref("mail.cloud_files.accounts.a", "y");`,
|
||||
`user_pref("editor.lastFileLocation", "/home/operator/Documents/a.pdf");`,
|
||||
`user_pref("network.proxy.http", "proxy.corp.example.com");`,
|
||||
`user_pref("mail.compose.default_to", "imap://someone%40example.org@mail.example.org/x and bob@example.org");`,
|
||||
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`,
|
||||
`user_pref("extensions.ui.lastCategory", "addons://list/theme");`,
|
||||
`user_pref("font.size.variable.x-western", 12);`,
|
||||
}, "\n")+"\n")
|
||||
got, err := f.Prefs([]string{"mail.last_msg*", "messenger.account.", "mail.cloud_files.", "editor.", "network.proxy.",
|
||||
"mail.compose.default_to", "extensions.", "font.size."})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]Pref{}
|
||||
for _, p := range got["prefs"].([]Pref) {
|
||||
by[p.Name] = p
|
||||
}
|
||||
for _, n := range []string{"mail.last_msg_movecopy_target_uri", "messenger.account.a1.server", "mail.cloud_files.accounts.a", "extensions.ui.lastCategory"} {
|
||||
if p := by[n]; p.Value != "" || p.Withheld == "" {
|
||||
t.Errorf("withheld: %s %+v", n, p)
|
||||
}
|
||||
}
|
||||
if v := by["editor.lastFileLocation"].Value; v != `"~/Documents/a.pdf"` {
|
||||
t.Errorf("home as ~: %s", v)
|
||||
}
|
||||
if v := by["network.proxy.http"].Value; strings.Contains(v, "example") {
|
||||
t.Errorf("a host: %s", v)
|
||||
}
|
||||
if v := by["mail.compose.default_to"].Value; strings.Contains(v, "example") || strings.Contains(v, "someone") {
|
||||
t.Errorf("an address: %s", v)
|
||||
}
|
||||
if v := by["extensions.activeThemeID"].Value; v != `"thunderbird-compact-dark@mozilla.org"` {
|
||||
t.Errorf("a theme's id is answered as it is: %s", v)
|
||||
}
|
||||
if v := by["font.size.variable.x-western"].Value; v != "12" {
|
||||
t.Errorf("%s", v)
|
||||
}
|
||||
for _, bad := range [][]string{{""}, {"*"}, {"."}, {" "}} {
|
||||
if _, err := f.Prefs(bad); err == nil {
|
||||
t.Errorf("%q is refused: it asks for everything", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestALogLineKeepsNoBearerOrBasicCredential(t *testing.T) {
|
||||
for _, l := range []string{"Authorization: Bearer eyJhbGciOi.abc.def", "auth header basic dXNlcjpwYXNz sent", "token=abc123"} {
|
||||
got := mask(l)
|
||||
for _, secret := range []string{"eyJhbGciOi", "dXNlcjpwYXNz", "abc123"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Errorf("%q → %q", l, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReminderTestIsNeverTheOperatorsBetterbirdAndCleansUpWhenItEnds(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.proc(6000, 1000, "betterbird-bin", []string{"/usr/bin/betterbird", "--no-remote", "--profile", "/run/user/1000/" + testUnit + "/profile"}, testUnit+".service")
|
||||
if ps := f.mainProcs(); len(ps) != 1 || ps[0].PID != 4000 {
|
||||
t.Fatalf("only the operator's: %+v", ps)
|
||||
}
|
||||
f.declare("// none")
|
||||
if _, err := f.ReminderTest(40*time.Second, nil, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit) {
|
||||
t.Fatalf("the unit removes its profile when it ends: %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAComposerIsKnownByItsInstanceWhateverItsClass(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "xwininfo" {
|
||||
return Output{Stdout: ` 0x2a01234 "Write: x": ("Msgcompose" "Betterbird") 900x700+10+10 +10+10` + "\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
|
||||
t.Fatalf("a composer of another class is still a composer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARestartsWorstCaseStaysUnderTheRuntimesThirtySeconds(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.betterbird()
|
||||
links(t, nil)
|
||||
f.stubborn[4000] = true
|
||||
total := time.Duration(0)
|
||||
run := f.Run
|
||||
f.Run = func(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
// Every command takes its whole timeout: the worst case.
|
||||
if d, ok := ctx.Deadline(); ok {
|
||||
total += time.Until(d).Round(time.Second)
|
||||
}
|
||||
return run(ctx, env, name, args...)
|
||||
}
|
||||
f.Sleep = func(d time.Duration) { total += d }
|
||||
if _, err := f.Restart(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total > 26*time.Second {
|
||||
t.Fatalf("the worst case is %v, over the runtime's 30 s less a margin", total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module betterbird
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"module": "betterbird",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"settings": {
|
||||
"prefs": {
|
||||
"kind": "preference",
|
||||
"default": "// none",
|
||||
"why": "Betterbird runs with its own defaults until a machine's assignment names a preference: one line of user_pref(\"name\", value); statements, which the module's user.js holds and Betterbird reads at its start (novox/hq issue 345)"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
"betterbird_status",
|
||||
"betterbird_prefs",
|
||||
"betterbird_user_js",
|
||||
"betterbird_log",
|
||||
"betterbird_restart",
|
||||
"betterbird_check",
|
||||
"betterbird_reminder_test"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "configuration-dir",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.config/betterbird",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "user-js",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/betterbird/user.js",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "// Betterbird's user.js (module betterbird, novox/hq issue 345). Owned by the mesh: the node-engine\n// writes it here at every push, and the module's tools copy it whole into Betterbird's profile\n// (betterbird_user_js with apply, betterbird_restart), where Betterbird reads it at its start and lets it\n// win over the preferences Betterbird saves itself. Change the module's setting prefs, never this file or the profile's copy.\n//\n// Only comments and user_pref(\"name\", value); statements; the tools refuse anything else, and any\n// preference whose name can hold a credential.\n${setting:prefs}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/betterbird-tools",
|
||||
"binary": "betterbird-tools",
|
||||
"loads": [
|
||||
"betterbird-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,24 @@ The X compositor as a module (novox/hq ADR 0208, research 026).
|
||||
a process has one starter. Its package's autostart entry is that starter where there is one, and
|
||||
the `xinitrc` slot where there is none.
|
||||
|
||||
## Settings
|
||||
|
||||
Two preferences, each a machine's own (the per-node layer of `settings`), because the right values follow
|
||||
the machine's graphics driver (novox/hq research 037):
|
||||
|
||||
| setting | default | what it changes |
|
||||
|---|---|---|
|
||||
| `backend` | `glx` | how picom draws: `glx` on the graphics card, `xrender` on the CPU |
|
||||
| `use-damage` | `true` | `true` repaints only what changed (picom's own default); `false` repaints the whole screen each frame |
|
||||
|
||||
The defaults are what every machine ran before, so the file filled with them changes nothing. They exist
|
||||
for hq issue 345: a window that is mapped but shows nothing (a calendar reminder) or stays stale after a
|
||||
workspace switch (a chat window) is a known failure of glx with damage tracking on some drivers, and
|
||||
`xrender` or `use-damage` false is how a machine is set to test that. The file stays trusted, so both are set at the
|
||||
controller's terminal alone (issue 339): a value is pasted into picom.conf as it is, and a backend of
|
||||
`xrender"; write-pid-path = "…` would add an option of the caller's (hq issue 346). A changed file is read at
|
||||
the next `picom_restart` or login: the module has no unit to reload.
|
||||
|
||||
## Tools
|
||||
|
||||
Served by the node's runtime as the operator account (ADR 0175). The tools find the operator's X
|
||||
|
||||
@@ -12,21 +12,29 @@ import (
|
||||
// every desktop module that carries it.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Settings map[string]setting `json:"settings"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
// setting is one key's default, as the definition gives it (novox/hq ADR 0262).
|
||||
type setting struct {
|
||||
Kind string `json:"kind"`
|
||||
Default any `json:"default"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
type claim struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
|
||||
@@ -59,3 +59,36 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
|
||||
// The backend and damage tracking are each machine's (novox/hq research 037: the backend depends on the
|
||||
// machine's graphics driver; issue 345: a window left unpainted under glx is the first suspect). Both
|
||||
// are settings whose defaults are what every machine ran before, so the file filled with them is today's.
|
||||
func TestTheBackendAndDamageTrackingAreSettingsWithTodaysDefaults(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
b, d := m.Settings["backend"], m.Settings["use-damage"]
|
||||
if b.Kind != "preference" || b.Default != "glx" || b.Why == "" || d.Kind != "preference" || d.Default != true || d.Why == "" {
|
||||
t.Fatalf("settings: %+v", m.Settings)
|
||||
}
|
||||
r := m.resource(t, "configuration")
|
||||
c := r["content"].(string)
|
||||
for _, want := range []string{`backend = "${setting:backend}";`, "use-damage = ${setting:use-damage};"} {
|
||||
if !strings.Contains(c, want) {
|
||||
t.Errorf("the file asks for %q", want)
|
||||
}
|
||||
}
|
||||
// Trusted (unmarked): a value is pasted into the file as it is, so `xrender"; write-pid-path = "…`
|
||||
// would add an option of the caller's. Until settings are typed and quoted (hq issue 346), only the
|
||||
// controller's terminal sets them.
|
||||
if _, marked := r["trusted"]; marked {
|
||||
t.Fatal("the file stays trusted: its settings are set at the terminal alone (hq issue 346)")
|
||||
}
|
||||
got := parseRules("picom.conf", withDefaults(c), nil)
|
||||
if got.Global["backend"] != "glx" || got.Global["use-damage"] != "true" || got.Global["vsync"] != "true" {
|
||||
t.Fatalf("filled with the defaults: %v", got.Global)
|
||||
}
|
||||
}
|
||||
|
||||
// withDefaults fills the file's settings with their defaults, as a machine without its own values gets it.
|
||||
func withDefaults(c string) string {
|
||||
return strings.NewReplacer("${setting:backend}", "glx", "${setting:use-damage}", "true").Replace(c)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestTheModulesOwnConfigurationReadsAsRulesInOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := parseRules("picom.conf", string(raw), nil)
|
||||
r := parseRules("picom.conf", withDefaults(string(raw)), nil)
|
||||
if r.Global["backend"] != "glx" || r.Global["vsync"] != "true" || r.Global["shadow"] != "false" {
|
||||
t.Fatalf("global options: %v", r.Global)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to
|
||||
# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.
|
||||
|
||||
backend = "glx";
|
||||
# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research
|
||||
# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every
|
||||
# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or
|
||||
# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is
|
||||
# left unpainted or stale.
|
||||
backend = "${setting:backend}";
|
||||
use-damage = ${setting:use-damage};
|
||||
vsync = true;
|
||||
|
||||
fading = true;
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"backend": {
|
||||
"kind": "preference",
|
||||
"default": "glx",
|
||||
"why": "glx draws with the graphics card and has served every machine so far; xrender draws on the CPU and is what to try where glx leaves a window unpainted or stale on the machine's driver (novox/hq research 037, issue 345)"
|
||||
},
|
||||
"use-damage": {
|
||||
"kind": "preference",
|
||||
"default": true,
|
||||
"why": "true repaints only what changed, picom's own default; false repaints the whole screen each frame, which costs work and cures a window left stale or unpainted on some drivers (novox/hq issue 345)"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
"picom_restart",
|
||||
"picom_rules",
|
||||
@@ -43,7 +55,7 @@
|
||||
"path": "${machine:account-home}/.config/picom/picom.conf",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is\n# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to\n# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.\n\nbackend = \"glx\";\nvsync = true;\n\nfading = true;\nfade-in-step = 0.05;\nfade-out-step = 0.05;\n\n# Tiled windows hide their shadows and corners, so neither is drawn.\nshadow = false;\ncorner-radius = 0;\nblur-method = \"none\";\n\n# \"Focused\" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach\n# the toplevel picom tracks, and a terminal then never lifts to its focused opacity.\nuse-ewmh-active-win = true;\n\n# Window rules are applied in order, and a later match wins. Only terminals are translucent:\n# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class;\n# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is\n# used wherever no rule sets one, which is what the window-opacity tool sets.\nrules = (\n { match = \"window_type = 'tooltip'\"; fade = false; opacity = 0.95; },\n { match = \"window_type = 'dock' || window_type = 'desktop'\"; fade = false; },\n { match = \"class_g = 'XTerm' && focused\"; opacity = 0.95; },\n { match = \"class_g = 'XTerm' && !focused\"; opacity = 0.75; },\n # A full-screen window is opaque, a terminal included: a video or a game is never see-through.\n { match = \"fullscreen\"; opacity = 1; }\n);\n"
|
||||
"content": "# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is\n# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to\n# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.\n\n# The backend and damage tracking are each machine's settings (backend, use-damage; novox/hq research\n# 037, issue 345): the right ones depend on the machine's graphics driver. The defaults are what every\n# machine ran before: glx, and picom's own default of repainting only what changed. xrender, or\n# use-damage false (repaint the whole screen each frame), is what to try on a machine where a window is\n# left unpainted or stale.\nbackend = \"${setting:backend}\";\nuse-damage = ${setting:use-damage};\nvsync = true;\n\nfading = true;\nfade-in-step = 0.05;\nfade-out-step = 0.05;\n\n# Tiled windows hide their shadows and corners, so neither is drawn.\nshadow = false;\ncorner-radius = 0;\nblur-method = \"none\";\n\n# \"Focused\" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach\n# the toplevel picom tracks, and a terminal then never lifts to its focused opacity.\nuse-ewmh-active-win = true;\n\n# Window rules are applied in order, and a later match wins. Only terminals are translucent:\n# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class;\n# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is\n# used wherever no rule sets one, which is what the window-opacity tool sets.\nrules = (\n { match = \"window_type = 'tooltip'\"; fade = false; opacity = 0.95; },\n { match = \"window_type = 'dock' || window_type = 'desktop'\"; fade = false; },\n { match = \"class_g = 'XTerm' && focused\"; opacity = 0.95; },\n { match = \"class_g = 'XTerm' && !focused\"; opacity = 0.75; },\n # A full-screen window is opaque, a terminal included: a video or a game is never see-through.\n { match = \"fullscreen\"; opacity = 1; }\n);\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
@@ -47,6 +47,11 @@ default, so the mesh's layer is set before the first assignment (as `power`'s ar
|
||||
one file: the module has no merged file and no contribution, so issue 168 cannot carry them anywhere
|
||||
else.
|
||||
|
||||
**They are set at the controller's terminal alone** (the file is trusted, unmarked; issue 339). A value is
|
||||
pasted into the file as it is, and the format is shell assignments meant to be sourced by a shell, so a
|
||||
value of `$HOME/x"; <command>; X="` would add a command of whoever set it (hq issue 346). Until settings
|
||||
are typed and quoted when written, no verb may set them.
|
||||
|
||||
**Why `xdg-user-dirs-update` is disabled.** It runs at every login, from its XDG autostart entry, which
|
||||
`dex` starts. Measured on xdg-user-dirs 0.20 against a copy of the folders:
|
||||
|
||||
|
||||
@@ -194,3 +194,21 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The folders file stays trusted (unmarked), so its three settings are set at the controller's terminal
|
||||
// alone. A value is pasted into it as it is, and the format is shell assignments defined to be sourced
|
||||
// by a shell: `$HOME/x"; <command>; X="` would add a command of the caller's (hq issue 346). Until
|
||||
// settings are typed and quoted at render, no verb may set them.
|
||||
func TestTheFoldersFileStaysTrustedUntilSettingsAreQuoted(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] != "user-dirs" {
|
||||
continue
|
||||
}
|
||||
if _, marked := r["trusted"]; marked {
|
||||
t.Fatal("user-dirs.dirs carries a trusted marking: its settings are the terminal's alone (hq issue 346)")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no user-dirs resource")
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
},
|
||||
{
|
||||
"id": "user-dirs",
|
||||
"trusted": false,
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/user-dirs.dirs",
|
||||
"owner": "${machine:account}",
|
||||
|
||||
+19
-3
@@ -9,7 +9,7 @@ The X display server as a module (novox/hq ADR 0208, research 026, to-be 42 phas
|
||||
- **Gated by the capability `seat`**, which the host reports for a machine with a graphics device
|
||||
and a connected display. See *Blockers* for why not `graphical-session`.
|
||||
- **Packages:** `xorg-server`, `xorg-xinit`, `xorg-xrandr`, `xorg-xset`, `xorg-xrdb`, `xorg-xinput`,
|
||||
`xorg-setxkbmap`, `xorg-xwd` and `autorandr`. The GPU's driver is not here. It follows the machine,
|
||||
`xorg-setxkbmap`, `xorg-xwd`, `xorg-xwininfo` and `autorandr`. The GPU's driver is not here. It follows the machine,
|
||||
so it belongs to that machine model's hardware module.
|
||||
|
||||
## What it owns
|
||||
@@ -71,7 +71,8 @@ which is a cpp directive error under plain `xrdb -merge` but is skipped here.
|
||||
| `xorg_input_devices` | r | the input devices, and each pointer's libinput settings |
|
||||
| `xorg_input_set` | a | tap, tap drag, natural scroll, disable while typing, left handed, middle emulation, enabled, acceleration, by device id or name |
|
||||
| `xorg_keyboard` | r/a | the XKB map; set layout, variant, model, options |
|
||||
| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`) |
|
||||
| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`); with `inline`, the PNG in the answer as base64 (at most 700 KiB; a larger one is halved in size until it fits, and `inline_scale` says so). A window that is not viewable is refused |
|
||||
| `xorg_windows` | r | the windows as the X server knows them, managed or not: id, name, instance and class, size and place; with a class or name filter (or `detail`), each one's depth (`argb`), visual, map state, override-redirect, types, states, role and own opacity |
|
||||
| `xorg_x_log` | r | the X server's log for the running session: version, start, errors (and warnings) |
|
||||
|
||||
All answers are structured. Each tool that changes the running server says how long the change lasts.
|
||||
@@ -90,7 +91,22 @@ The bus it hands on is the user manager's (`unix:path=/run/user/<uid>/bus`). Wit
|
||||
answers `{"error": "no-graphical-session", "reason": …, "looked": […]}`.
|
||||
|
||||
The screenshot needs no screenshot program. `xwd` dumps the screen, and the module turns the dump
|
||||
into a PNG itself.
|
||||
into a PNG itself. **A window is cut from the dump of the whole screen** at the place `xwininfo` gives,
|
||||
never dumped by its id: `xwd -id` asks the server for the window's colormap entries, which a window of
|
||||
a 32-bit (alpha) visual refused with `BadColor (X_QueryColors)` on a workstation (2026-10-09), and the
|
||||
cut shows the window as the compositor shows it, which is what a question about a window that looks
|
||||
wrong needs. A window under another one shows what covers it, and a window that is not viewable (unmapped, or on a
|
||||
workspace not shown) is refused rather than photographed as whatever is at its place. `inline` answers
|
||||
the image itself, for a session that cannot read the machine's files: a tool's answer crosses the bus,
|
||||
whose largest message is NATS's default of 1 MiB (the `nats` module sets no other), so the PNG is held to
|
||||
700 KiB (about 935 KiB as base64) and halved in size until it fits.
|
||||
|
||||
`xorg_windows` exists because a window manager lists only the windows it manages, and says nothing a
|
||||
compositor goes by. A window of depth 32 has an alpha channel: the compositor blends it with what is
|
||||
behind it, so one whose program drew nothing looks transparent rather than black. Its details take
|
||||
two commands per window (`xwininfo -all`, and `xprop` for the role and the window's own opacity), so
|
||||
they are read for at most 40 windows. `xprop` is the `picom` module's package; without it the role and
|
||||
opacity are left out.
|
||||
|
||||
## What it improves
|
||||
|
||||
|
||||
@@ -143,16 +143,35 @@ func tools(d desktop.Desk) []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "xorg_screenshot",
|
||||
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id. " +
|
||||
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id " +
|
||||
"(cut from the screen as the compositor shows it, so a see-through window shows what is behind it). " +
|
||||
"Written under the account's home (default ~/Pictures/Screenshots/screenshot-<time>.png). Answers " +
|
||||
"the path, size and bytes, never the image.",
|
||||
"the path, size and bytes; with inline, also the PNG itself as base64 (at most 700 KiB, halved in size until it " +
|
||||
"fits; inline_scale says so). A window that is not viewable (unmapped, or on a workspace not shown) is refused.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"output": desktop.Str("only this output (optional)"),
|
||||
"window": desktop.Str("only this window, by its X id, e.g. 0x3a00007 (optional)"),
|
||||
"path": desktop.Str("where to write it, inside the home (optional)"),
|
||||
"inline": desktop.Flag("also answer the PNG as base64, at most 700 KiB, for a session that cannot read the file (default false)"),
|
||||
}),
|
||||
Run: call(x.screenshot),
|
||||
},
|
||||
{
|
||||
Name: "xorg_windows",
|
||||
Description: "The windows on the X screen as the server knows them, managed or not (dialogs, tooltips, " +
|
||||
"windows no window manager lists): id, name, instance and class, size and absolute place, level in " +
|
||||
"the tree. With class or name (regular expressions), or detail, also each window's depth (argb: a " +
|
||||
"32-bit visual a compositor blends with what is behind it), visual class, map state, override-redirect, " +
|
||||
"window types and states, role and own opacity — for at most 40 windows. Without a filter, only " +
|
||||
"windows with a class, unless all.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"class": desktop.Str("only windows whose class or instance matches this regular expression"),
|
||||
"name": desktop.Str("only windows whose name (title) matches this regular expression"),
|
||||
"detail": desktop.Flag("read each window's details (default false; true when class or name is given)"),
|
||||
"all": desktop.Flag("without a filter, also windows without a class (default false)"),
|
||||
}),
|
||||
Run: call(x.windows),
|
||||
},
|
||||
{
|
||||
Name: "xorg_x_log",
|
||||
Description: "The X server's log for the running session (or the newest one): its version, when it " +
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestItInstallsTheServerItsStartAndTheToolsItsVerbsRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd"
|
||||
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd,xorg-xwininfo"
|
||||
if strings.Join(got, ",") != want {
|
||||
t.Fatalf("packages: %v", got)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"os"
|
||||
@@ -494,7 +496,34 @@ func (x xorg) keyboard(ctx context.Context, a desktop.Args) (any, error) {
|
||||
|
||||
var windowID = regexp.MustCompile(`^(0x[0-9a-fA-F]{1,8}|[0-9]{1,10})$`)
|
||||
|
||||
// mostInline is the largest PNG a screenshot carries in its answer. A tool's answer crosses the bus,
|
||||
// whose largest message is 1 MiB; 700 KiB is about 935 KiB as base64, which leaves room for the rest.
|
||||
const mostInline = 700 << 10
|
||||
|
||||
// shrink averages each by×by block of an image into one pixel.
|
||||
func shrink(img image.Image, by int) *image.RGBA {
|
||||
b := img.Bounds()
|
||||
out := image.NewRGBA(image.Rect(0, 0, b.Dx()/by, b.Dy()/by))
|
||||
for y := 0; y < out.Bounds().Dy(); y++ {
|
||||
for x := 0; x < out.Bounds().Dx(); x++ {
|
||||
var r, g, bl, n uint32
|
||||
for dy := 0; dy < by; dy++ {
|
||||
for dx := 0; dx < by; dx++ {
|
||||
cr, cg, cb, _ := img.At(b.Min.X+x*by+dx, b.Min.Y+y*by+dy).RGBA()
|
||||
r, g, bl, n = r+cr, g+cg, bl+cb, n+1
|
||||
}
|
||||
}
|
||||
out.SetRGBA(x, y, color.RGBA{uint8(r / n >> 8), uint8(g / n >> 8), uint8(bl / n >> 8), 0xff})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
inline, _, err := a.Bool("inline")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stamp := time.Now().Format("20060102-150405")
|
||||
path, err := desktop.InHome(a.Opt("path", "~/Pictures/Screenshots/screenshot-"+stamp+".png"))
|
||||
if err != nil {
|
||||
@@ -514,15 +543,28 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
}
|
||||
dump.Close()
|
||||
defer os.Remove(dump.Name())
|
||||
args := []string{"-silent", "-out", dump.Name()}
|
||||
// A window is cut from the whole screen, not dumped by its id: what the screen shows is what the
|
||||
// compositor made of it (a window with an alpha channel is blended with what is behind it), and xwd
|
||||
// asks the server for a window's colormap entries, which a window of a 32-bit visual can refuse
|
||||
// with BadColor (X_QueryColors). The cut is the window's frame-less area at its absolute place.
|
||||
var cut *image4
|
||||
if w := a.Opt("window", ""); w != "" {
|
||||
if !windowID.MatchString(w) {
|
||||
return nil, fmt.Errorf("window is an X window id, e.g. 0x3a00007")
|
||||
}
|
||||
args = append(args, "-id", w)
|
||||
} else {
|
||||
args = append(args, "-root")
|
||||
g, err := x.windowGeometry(ctx, env, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A window that is not viewable is not on the screen: its rectangle would show what is there
|
||||
// instead, which is a picture of the wrong thing.
|
||||
if g.Mapped != "" && g.Mapped != "IsViewable" {
|
||||
return nil, fmt.Errorf("window %s is %s, not on the screen (IsUnMapped: not shown; IsUnviewable: "+
|
||||
"its parent is not shown, e.g. on another workspace): bring it to the screen first", w, g.Mapped)
|
||||
}
|
||||
cut = &g
|
||||
}
|
||||
args := []string{"-silent", "-out", dump.Name(), "-root"}
|
||||
if r := x.d.Run(ctx, env, nil, "xwd", args...); !r.OK() {
|
||||
if r.Code == 127 {
|
||||
return nil, fmt.Errorf("xwd is not installed here; the xorg module's package resource (xorg-xwd) installs it")
|
||||
@@ -538,7 +580,15 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
return nil, fmt.Errorf("the dump xwd wrote: %w", err)
|
||||
}
|
||||
var shot image.Image = img
|
||||
if name := a.Opt("output", ""); name != "" {
|
||||
if cut != nil {
|
||||
r := image.Rect(cut.X, cut.Y, cut.X+cut.W, cut.Y+cut.H).Intersect(img.Bounds())
|
||||
if r.Empty() {
|
||||
return nil, fmt.Errorf("the window is not on the screen (%dx%d at %d,%d)", cut.W, cut.H, cut.X, cut.Y)
|
||||
}
|
||||
crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
|
||||
draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
|
||||
shot = crop
|
||||
} else if name := a.Opt("output", ""); name != "" {
|
||||
_, outs, _, err := x.query(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -568,7 +618,30 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
b := shot.Bounds()
|
||||
return map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}, nil
|
||||
answer := map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}
|
||||
if inline {
|
||||
// For a session that cannot read the machine's files: the image itself, within the bus's bound.
|
||||
// An image larger than mostInline is halved until it fits, and the answer says by how much; the
|
||||
// file keeps the full size.
|
||||
small, scale := buf.Bytes(), 1
|
||||
for len(small) > mostInline && shot.Bounds().Dx()/(scale*2) >= 64 {
|
||||
scale *= 2
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, shrink(shot, scale)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
small = b.Bytes()
|
||||
}
|
||||
if len(small) > mostInline {
|
||||
answer["inline"] = fmt.Sprintf("not included: %d bytes even at 1/%d; take one output or one window", len(small), scale)
|
||||
} else {
|
||||
answer["png_base64"] = base64.StdEncoding.EncodeToString(small)
|
||||
if scale > 1 {
|
||||
answer["inline_scale"] = fmt.Sprintf("1/%d of the file's size, to fit the bus", scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (x xorg) xlog(ctx context.Context, a desktop.Args) (any, error) {
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
// The windows on the X screen, as the server knows them (xwininfo): what a window manager's own list
|
||||
// (node-display-session.windows) leaves out — a window it does not manage, a dialog, a tooltip, a
|
||||
// window that is mapped but drawn by nobody — and what a compositor goes by: the window's depth (32
|
||||
// bits is a visual with an alpha channel, which a compositor blends with what is behind it), its type,
|
||||
// whether it is mapped, and its own opacity.
|
||||
//
|
||||
// The tree comes from one `xwininfo -root -tree`. A window's details take one `xwininfo -all` and one
|
||||
// `xprop` each, so they are read only for the windows asked about, and at most mostDetailed of them.
|
||||
|
||||
const mostDetailed = 40
|
||||
|
||||
// Window is one window of the tree, and its details when they were read.
|
||||
type Window struct {
|
||||
ID string `json:"window"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
// Level is the window's depth in the tree: 1 is a child of the root (a frame or an unmanaged window).
|
||||
Level int `json:"level"`
|
||||
|
||||
// The details (detail true, or a class asked for).
|
||||
Depth int `json:"depth,omitempty"`
|
||||
ARGB *bool `json:"argb,omitempty"`
|
||||
Visual string `json:"visual_class,omitempty"`
|
||||
Mapped string `json:"map_state,omitempty"`
|
||||
OverrideRedir *bool `json:"override_redirect,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
States []string `json:"states,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Opacity *float64 `json:"opacity,omitempty"`
|
||||
DetailProblems string `json:"detail_problem,omitempty"`
|
||||
}
|
||||
|
||||
// treeLine is one line of `xwininfo -root -tree`: the id, the name (or "(has no name)"), the class
|
||||
// pair when the window has one, the size and position relative to the parent, and the absolute one.
|
||||
var treeLine = regexp.MustCompile(`^(\s*)(0x[0-9a-fA-F]+) (?:\(has no name\)|"(.*)"): \((?:"([^"]*)" "([^"]*)")?\)\s+(\d+)x(\d+)[+-]-?\d+[+-]-?\d+\s+\+?(-?\d+)\+?(-?\d+)\s*$`)
|
||||
|
||||
// ParseTree reads the windows of `xwininfo -root -tree`, in the server's stacking order per parent.
|
||||
func ParseTree(text string) []Window {
|
||||
var out []Window
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
m := treeLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
w := Window{ID: m[2], Name: m[3], Instance: m[4], Class: m[5]}
|
||||
w.Width, _ = strconv.Atoi(m[6])
|
||||
w.Height, _ = strconv.Atoi(m[7])
|
||||
w.X, _ = strconv.Atoi(m[8])
|
||||
w.Y, _ = strconv.Atoi(m[9])
|
||||
// xwininfo indents a child of the root by 5 spaces and each further level by 3.
|
||||
w.Level = 1 + (len(m[1])-5)/3
|
||||
out = append(out, w)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseDetails adds what `xwininfo -id <w> -all` says about one window.
|
||||
func ParseDetails(w *Window, text string) {
|
||||
section := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
indent := len(raw) - len(strings.TrimLeft(raw, " "))
|
||||
switch {
|
||||
case strings.HasPrefix(line, "Depth:"):
|
||||
w.Depth, _ = strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "Depth:")))
|
||||
argb := w.Depth == 32
|
||||
w.ARGB = &argb
|
||||
section = ""
|
||||
case strings.HasPrefix(line, "Visual Class:"):
|
||||
w.Visual = strings.TrimSpace(strings.TrimPrefix(line, "Visual Class:"))
|
||||
case strings.HasPrefix(line, "Map State:"):
|
||||
w.Mapped = strings.TrimSpace(strings.TrimPrefix(line, "Map State:"))
|
||||
case strings.HasPrefix(line, "Override Redirect State:"):
|
||||
v := strings.TrimSpace(strings.TrimPrefix(line, "Override Redirect State:")) == "yes"
|
||||
w.OverrideRedir = &v
|
||||
case line == "Window type:":
|
||||
section = "type"
|
||||
case line == "Window state:":
|
||||
section = "state"
|
||||
case line == "":
|
||||
section = ""
|
||||
case section != "" && indent >= 10:
|
||||
if section == "type" {
|
||||
w.Types = append(w.Types, line)
|
||||
} else {
|
||||
w.States = append(w.States, line)
|
||||
}
|
||||
default:
|
||||
if indent < 10 {
|
||||
section = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// opacityLine and roleLine are the two properties read with xprop.
|
||||
var (
|
||||
opacityLine = regexp.MustCompile(`^_NET_WM_WINDOW_OPACITY\(CARDINAL\) = (\d+)`)
|
||||
roleLine = regexp.MustCompile(`^WM_WINDOW_ROLE\(STRING\) = "(.*)"`)
|
||||
)
|
||||
|
||||
// ParseWindowProps adds the window's role and its own opacity (0 to 1) from xprop.
|
||||
func ParseWindowProps(w *Window, text string) {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if m := opacityLine.FindStringSubmatch(line); m != nil {
|
||||
if n, err := strconv.ParseUint(m[1], 10, 32); err == nil {
|
||||
v := float64(n) / float64(0xffffffff)
|
||||
v = float64(int(v*1000+0.5)) / 1000
|
||||
w.Opacity = &v
|
||||
}
|
||||
}
|
||||
if m := roleLine.FindStringSubmatch(line); m != nil {
|
||||
w.Role = m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x xorg) windows(ctx context.Context, a desktop.Args) (any, error) {
|
||||
classExpr := a.Opt("class", "")
|
||||
nameExpr := a.Opt("name", "")
|
||||
detail, _, err := a.Bool("detail")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, _, err := a.Bool("all")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var classRe, nameRe *regexp.Regexp
|
||||
if classExpr != "" {
|
||||
if classRe, err = regexp.Compile(classExpr); err != nil {
|
||||
return nil, fmt.Errorf("class is a regular expression: %w", err)
|
||||
}
|
||||
}
|
||||
if nameExpr != "" {
|
||||
if nameRe, err = regexp.Compile(nameExpr); err != nil {
|
||||
return nil, fmt.Errorf("name is a regular expression: %w", err)
|
||||
}
|
||||
}
|
||||
res, s, err := x.d.InSession(ctx, "xwininfo", "-root", "-tree")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.OK() {
|
||||
if res.Code == 127 {
|
||||
return nil, fmt.Errorf("xwininfo is not installed here; the xorg module's package resource (xorg-xwininfo) installs it")
|
||||
}
|
||||
return nil, res.Err()
|
||||
}
|
||||
tree := ParseTree(res.Stdout)
|
||||
var shown []Window
|
||||
for _, w := range tree {
|
||||
switch {
|
||||
case classRe != nil && !(classRe.MatchString(w.Class) || classRe.MatchString(w.Instance)):
|
||||
continue
|
||||
case nameRe != nil && !nameRe.MatchString(w.Name):
|
||||
continue
|
||||
case classRe == nil && nameRe == nil && !all && w.Class == "":
|
||||
// Without a filter, the windows a program owns: those with a class.
|
||||
continue
|
||||
}
|
||||
shown = append(shown, w)
|
||||
}
|
||||
detailed := 0
|
||||
if detail || classRe != nil || nameRe != nil {
|
||||
env := s.Env(x.d.Base)
|
||||
for i := range shown {
|
||||
if detailed == mostDetailed {
|
||||
break
|
||||
}
|
||||
detailed++
|
||||
w := &shown[i]
|
||||
info := x.d.Run(ctx, env, nil, "xwininfo", "-id", w.ID, "-all")
|
||||
if !info.OK() {
|
||||
// A window can go between the tree and its details.
|
||||
w.DetailProblems = strings.TrimSpace(info.Stderr)
|
||||
continue
|
||||
}
|
||||
ParseDetails(w, info.Stdout)
|
||||
if props := x.d.Run(ctx, env, nil, "xprop", "-id", w.ID, "WM_WINDOW_ROLE", "_NET_WM_WINDOW_OPACITY"); props.OK() {
|
||||
ParseWindowProps(w, props.Stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
answer := map[string]any{"session": s.Display, "windows": shown, "count": len(shown), "in_tree": len(tree)}
|
||||
if detailed < len(shown) && (detail || classRe != nil || nameRe != nil) {
|
||||
answer["detailed"] = fmt.Sprintf("the first %d of %d; narrow class or name for the rest", detailed, len(shown))
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// windowGeometry is a window's absolute position and size, from `xwininfo -id`.
|
||||
func (x xorg) windowGeometry(ctx context.Context, env []string, id string) (image4, error) {
|
||||
r := x.d.Run(ctx, env, nil, "xwininfo", "-id", id)
|
||||
if !r.OK() {
|
||||
if r.Code == 127 {
|
||||
return image4{}, fmt.Errorf("xwininfo is not installed here; the xorg module's package resource (xorg-xwininfo) installs it")
|
||||
}
|
||||
return image4{}, r.Err()
|
||||
}
|
||||
return ParseGeometry(r.Stdout)
|
||||
}
|
||||
|
||||
// image4 is a rectangle on the screen, and the window's map state there.
|
||||
type image4 struct {
|
||||
X, Y, W, H int
|
||||
Mapped string
|
||||
}
|
||||
|
||||
// ParseGeometry reads the absolute corner and the size from `xwininfo -id`.
|
||||
func ParseGeometry(text string) (image4, error) {
|
||||
var g image4
|
||||
found := 0
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
for prefix, into := range map[string]*int{
|
||||
"Absolute upper-left X:": &g.X, "Absolute upper-left Y:": &g.Y, "Width:": &g.W, "Height:": &g.H,
|
||||
} {
|
||||
if strings.HasPrefix(line, "Map State:") {
|
||||
g.Mapped = strings.TrimSpace(strings.TrimPrefix(line, "Map State:"))
|
||||
}
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, prefix))); err == nil {
|
||||
*into = n
|
||||
found++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found < 4 || g.W <= 0 || g.H <= 0 {
|
||||
return image4{}, fmt.Errorf("xwininfo gave no geometry for the window")
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
// The tree and the details as xwininfo printed them on a workstation (2026-10-09), with a calendar
|
||||
// reminder of the mail client open: a dialog of a 32-bit visual.
|
||||
const treeText = `
|
||||
xwininfo: Window id: 0x3e6 (the root window) "i3"
|
||||
|
||||
Root window id: 0x3e6 (the root window) "i3"
|
||||
Parent window id: 0x0 (none)
|
||||
160 children:
|
||||
0x1800002 "Dunst": ("Dunst" "Dunst") 259x39+2291+50 +2291+50
|
||||
0x2a00e04 "Betterbird": ("eu.betterbird.Betterbird" "eu.betterbird.Betterbird") 1x1+0+0 +0+0
|
||||
2 children:
|
||||
0x2a00e08 (has no name): () 1x1+-1+-1 +-1+-1
|
||||
0x600090 "[i3 con] floating_con around 0x5594": () 602x325+979+556 +979+556
|
||||
1 child:
|
||||
0x2a00221 "1 Reminder": ("Calendar" "eu.betterbird.Betterbird") 600x300+1+24 +980+581
|
||||
0x2a00222 "say "hi": there": ("Mail" "eu.betterbird.Betterbird") 10x10+-100+-100 +-100+-100
|
||||
`
|
||||
|
||||
const detailText = `
|
||||
xwininfo: Window id: 0x2a00221 "1 Reminder"
|
||||
|
||||
Absolute upper-left X: 980
|
||||
Absolute upper-left Y: 581
|
||||
Width: 600
|
||||
Height: 300
|
||||
Depth: 32
|
||||
Visual: 0x87
|
||||
Visual Class: TrueColor
|
||||
Map State: IsViewable
|
||||
Override Redirect State: no
|
||||
Someone wants these events:
|
||||
KeyPress
|
||||
Window manager hints:
|
||||
Client accepts input or input focus: Yes
|
||||
Window type:
|
||||
Dialog
|
||||
Window state:
|
||||
Focused
|
||||
Process id: 37147 on host h
|
||||
Frame extents: 0, 0, 0, 0
|
||||
|
||||
Normal window size hints:
|
||||
`
|
||||
|
||||
func TestTheTreeIsReadWithClassesPlacesAndLevels(t *testing.T) {
|
||||
got := ParseTree(treeText)
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("%d windows: %+v", len(got), got)
|
||||
}
|
||||
r := got[4]
|
||||
if r.ID != "0x2a00221" || r.Name != "1 Reminder" || r.Instance != "Calendar" || r.Class != "eu.betterbird.Betterbird" ||
|
||||
r.Width != 600 || r.Height != 300 || r.X != 980 || r.Y != 581 || r.Level != 2 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
if got[2].ID != "0x2a00e08" || got[2].Class != "" || got[2].X != -1 || got[2].Level != 2 {
|
||||
t.Fatalf("a nameless child at a negative place: %+v", got[2])
|
||||
}
|
||||
if got[5].Name != `say "hi": there` || got[5].X != -100 || got[5].Level != 1 {
|
||||
t.Fatalf("a name with quotes and a colon: %+v", got[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDetailsSayDepthTypeStateRoleAndOpacity(t *testing.T) {
|
||||
w := Window{ID: "0x2a00221"}
|
||||
ParseDetails(&w, detailText)
|
||||
ParseWindowProps(&w, "WM_WINDOW_ROLE(STRING) = \"AlarmWindow\"\n_NET_WM_WINDOW_OPACITY(CARDINAL) = 2147483647\n")
|
||||
if w.Depth != 32 || w.ARGB == nil || !*w.ARGB || w.Visual != "TrueColor" || w.Mapped != "IsViewable" ||
|
||||
w.OverrideRedir == nil || *w.OverrideRedir || strings.Join(w.Types, ",") != "Dialog" ||
|
||||
strings.Join(w.States, ",") != "Focused" || w.Role != "AlarmWindow" || w.Opacity == nil || *w.Opacity != 0.5 {
|
||||
t.Fatalf("%+v", w)
|
||||
}
|
||||
plain := Window{}
|
||||
ParseWindowProps(&plain, "WM_WINDOW_ROLE: not found.\n_NET_WM_WINDOW_OPACITY: not found.\n")
|
||||
if plain.Opacity != nil || plain.Role != "" {
|
||||
t.Fatalf("no opacity of its own is none, not 0: %+v", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsFiltersByClassAndReadsTheDetailsOfWhatItShows(t *testing.T) {
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"xwininfo -root -tree": {Stdout: treeText},
|
||||
"xwininfo -id 0x2a00e04 -all": {Stdout: " Depth: 32\n Map State: IsUnMapped\n"},
|
||||
"xwininfo -id 0x2a00221 -all": {Stdout: detailText},
|
||||
"xwininfo -id 0x2a00222 -all": {Code: 1, Stderr: "X Error: BadWindow"},
|
||||
"xprop -id 0x2a00221 WM_WINDOW_ROLE _NET_WM_WINDOW_OPACITY": {Stdout: "WM_WINDOW_ROLE(STRING) = \"AlarmWindow\"\n"},
|
||||
}}
|
||||
got, err := byName(t, f.desk(true), "xorg_windows")(map[string]any{"class": "(?i)betterbird"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ws := got.(map[string]any)["windows"].([]Window)
|
||||
if len(ws) != 3 || ws[1].Role != "AlarmWindow" || ws[1].Depth != 32 || ws[2].DetailProblems == "" {
|
||||
t.Fatalf("%+v", ws)
|
||||
}
|
||||
f2 := &fakeDesk{answers: map[string]desktop.Result{"xwininfo -root -tree": {Stdout: treeText}}}
|
||||
got, err = byName(t, f2.desk(true), "xorg_windows")(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := got.(map[string]any)["count"]; n != 4 || len(f2.ran) != 1 {
|
||||
t.Fatalf("without a filter: the windows with a class, and no details read: %v %v", n, f2.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowsScreenshotIsCutFromTheScreenNotDumpedByItsID(t *testing.T) {
|
||||
// The fake xwd writes a 4x3 screen whose pixel (x, y) is (x*10, y*10, 7).
|
||||
home := t.TempDir()
|
||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"xwininfo -id 0x2a00221": {Stdout: " Absolute upper-left X: 1\n Absolute upper-left Y: 1\n Width: 2\n Height: 2\n"},
|
||||
}}
|
||||
d := f.desk(true)
|
||||
run := d.Run
|
||||
d.Run = func(ctx context.Context, env []string, stdin []byte, name string, args ...string) desktop.Result {
|
||||
if name == "xwd" {
|
||||
for i, a := range args {
|
||||
if a == "-id" {
|
||||
t.Fatal("xwd is never asked for one window")
|
||||
}
|
||||
if a == "-out" {
|
||||
os.WriteFile(args[i+1], xwdOf(4, 3, true, func(x, y int) (uint8, uint8, uint8) { return uint8(x * 10), uint8(y * 10), 7 }), 0o644)
|
||||
}
|
||||
}
|
||||
return desktop.Result{}
|
||||
}
|
||||
return run(ctx, env, stdin, name, args...)
|
||||
}
|
||||
got, err := byName(t, d, "xorg_screenshot")(map[string]any{"window": "0x2a00221", "path": "~/shot.png", "inline": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := got.(map[string]any)
|
||||
if a["width"] != 2 || a["height"] != 2 {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(a["png_base64"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := color.RGBAModel.Convert(img.At(0, 0)).(color.RGBA); c != (color.RGBA{10, 10, 7, 0xff}) {
|
||||
t.Fatalf("the cut starts at the window's corner: %v", c)
|
||||
}
|
||||
if _, err := ParseGeometry("Width: 0\n"); err == nil {
|
||||
t.Fatal("no geometry is refused")
|
||||
}
|
||||
}
|
||||
|
||||
// screenFake is a desk whose xwd writes a w×h screen of noise and whose xwininfo
|
||||
// answers one window with the given map state.
|
||||
func screenFake(t *testing.T, w, h int, state string) desktop.Desk {
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"xwininfo -id 0x2a00221": {Stdout: " Absolute upper-left X: 1\n Absolute upper-left Y: 1\n Width: 2\n Height: 2\n Map State: " + state + "\n"},
|
||||
}}
|
||||
d := f.desk(true)
|
||||
run := d.Run
|
||||
d.Run = func(ctx context.Context, env []string, stdin []byte, name string, args ...string) desktop.Result {
|
||||
if name == "xwd" {
|
||||
for i, a := range args {
|
||||
if a == "-out" {
|
||||
// Noise, which a PNG cannot compress: the worst case for the bound.
|
||||
os.WriteFile(args[i+1], xwdOf(w, h, true, func(x, y int) (uint8, uint8, uint8) {
|
||||
n := uint32(x)*2654435761 ^ uint32(y)*2246822519
|
||||
n ^= n >> 13
|
||||
n *= 3266489917
|
||||
return uint8(n), uint8(n >> 8), uint8(n >> 16)
|
||||
}), 0o644)
|
||||
}
|
||||
}
|
||||
return desktop.Result{}
|
||||
}
|
||||
return run(ctx, env, stdin, name, args...)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestAWindowThatIsNotViewableIsNotPhotographed(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||
for _, state := range []string{"IsUnMapped", "IsUnviewable"} {
|
||||
_, err := byName(t, screenFake(t, 4, 3, state), "xorg_screenshot")(map[string]any{"window": "0x2a00221", "path": "~/s.png"})
|
||||
if err == nil || !strings.Contains(err.Error(), state) {
|
||||
t.Errorf("%s: %v", state, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInlineImageFitsTheBusAndIsScaledDownWhenItWouldNot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||
got, err := byName(t, screenFake(t, 800, 600, "IsViewable"), "xorg_screenshot")(map[string]any{"path": "~/s.png", "inline": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := got.(map[string]any)
|
||||
b64, _ := a["png_base64"].(string)
|
||||
if b64 == "" || len(b64) > 1<<20-64<<10 {
|
||||
t.Fatalf("an inline image within the bus's 1 MiB, with room for the answer: %d bytes; %v", len(b64), a["inline"])
|
||||
}
|
||||
if a["bytes"].(int) > mostInline && a["inline_scale"] == nil {
|
||||
t.Fatalf("a file larger than the bound is answered scaled down, and says so: %v", a["inline_scale"])
|
||||
}
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func TestEveryToolThatNeedsTheScreenSaysPlainlyThatThereIsNoSession(t *testing.T
|
||||
{"xorg_set_mode", map[string]any{"output": "eDP-1", "mode": "auto"}},
|
||||
{"xorg_primary", nil}, {"xorg_dpi", nil}, {"xorg_input_devices", nil},
|
||||
{"xorg_input_set", map[string]any{"device": "16", "tap": true}}, {"xorg_keyboard", nil},
|
||||
{"xorg_screenshot", nil},
|
||||
{"xorg_screenshot", nil}, {"xorg_windows", nil},
|
||||
} {
|
||||
_, err := byName(t, d, call.tool)(orEmpty(call.args))
|
||||
if !desktop.IsNoSession(err) {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"xorg_input_set",
|
||||
"xorg_keyboard",
|
||||
"xorg_screenshot",
|
||||
"xorg_windows",
|
||||
"xorg_x_log"
|
||||
],
|
||||
"resources": [
|
||||
@@ -72,6 +73,11 @@
|
||||
"type": "package",
|
||||
"package": "xorg-xwd"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-xwininfo",
|
||||
"type": "package",
|
||||
"package": "xorg-xwininfo"
|
||||
},
|
||||
{
|
||||
"id": "package-autorandr",
|
||||
"type": "package",
|
||||
|
||||
Reference in New Issue
Block a user