xterm: the terminal holds node-terminal-emulator; its resources through xorg's slot (hq ADR 0208)
It requires x11-display, names itself in TERMINAL, and contributes its X resources to the xresources slot normal: today's palette and clipboard keys, JetBrainsMono Nerd Font, 10000 lines of scrollback, all scoped to XTerm* instead of every Xt program. It owns no file. The tools are the seat's open, which starts a terminal through the account's service manager so it outlives the runtime's restarts, and font (in force, what fontconfig resolves it to, set for new terminals) and colours.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# xterm
|
||||
|
||||
The terminal as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 5).
|
||||
|
||||
- **Claims `node-terminal-emulator`** and serves its verb `open`.
|
||||
- **Requires `x11-display`.** In a Wayland session, `xwayland` would provide it.
|
||||
- **Package `xterm`.**
|
||||
- **Environment** (ADR 0203): `TERMINAL=xterm`. i3's `$mod+Return` runs `i3-sensible-terminal`, which
|
||||
starts whatever `$TERMINAL` names, so the window manager no longer names the terminal.
|
||||
- **X resources**, contributed to the `xresources` slot `normal` and placed by `xorg` in its resources
|
||||
file. It owns no file of its own.
|
||||
|
||||
## The resources
|
||||
|
||||
They are those both workstations ran, with three changes:
|
||||
|
||||
- **face:** `JetBrainsMono Nerd Font` 12, replacing Hack (research 026/04);
|
||||
- **scope:** every resource is under `XTerm*`. Today they were `*faceName`, `*background` and so on,
|
||||
which reached every Xt program. `XTerm*` also outranks a `*name` resource of yours, so the old
|
||||
`~/.Xresources.d/xterm` cannot win over them while it is still merged;
|
||||
- **scrollback:** `saveLines` 10000 instead of 2000.
|
||||
|
||||
The palette (background, foreground and the sixteen colours) is today's, unchanged. So are the
|
||||
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it. The bundle's
|
||||
`Resources()` is the one source of these values. The manifest test holds the manifest to it, and
|
||||
`xterm_colours` compares against it.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-terminal-emulator.open` | d | a terminal in the session: the login shell, or a command run through it (`$SHELL -lc`), in a directory (the home by default), with an optional title, and `hold` to keep it open. It starts as a transient unit of the account's service manager (`systemd-run --user`), so it outlives the call and every runtime restart |
|
||||
| `xterm_font` | r/a | the face and size in force and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
|
||||
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's default |
|
||||
|
||||
## What it leaves found
|
||||
|
||||
`~/.Xresources.d/xterm`, and the `#include` of it in `~/.Xresources`.
|
||||
|
||||
## The one-off migration
|
||||
|
||||
**Delete `~/.Xresources.d/xterm` and its `#include` line in `~/.Xresources`** once `xterm` is
|
||||
assigned (see `xorg`'s README for the rest of that file). Until then they are merged after the
|
||||
mesh's. Being `*`-scoped, they lose to this module's `XTerm*` values, and still reach every other Xt
|
||||
program.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | none (`xterm` present) | the same |
|
||||
| `xorg`'s resources file | gains this module's resources | the same |
|
||||
| environment | gains `TERMINAL=xterm` | the same |
|
||||
| running terminals | **nothing**. The resources are merged at the next login | the same |
|
||||
| next login | new terminals in JetBrains Mono, with 10000 lines of scrollback | the same. The desktop has a hand-copied build of the face until `fonts` installs the package |
|
||||
|
||||
## Blockers
|
||||
|
||||
None. It needs `xorg` assigned for `x11-display`, and `fonts` for the face. Without `fonts`,
|
||||
fontconfig picks the nearest installed face, and `xterm_font` says which one in `resolves_to`.
|
||||
@@ -0,0 +1,68 @@
|
||||
// xterm's tools (novox/hq ADR 0208, research 026/05): node-terminal-emulator's verb `open`, and the
|
||||
// module's own `font` and `colours`.
|
||||
//
|
||||
// A terminal opened by `open` is handed to the account's own service manager (internal/desktop's
|
||||
// Launch), never left a child of the runtime, which the service manager empties at every restart.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway")})); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
return run(ctx, desktop.Args(args))
|
||||
}
|
||||
}
|
||||
|
||||
func tools(x xterm) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-terminal-emulator.open",
|
||||
Description: "Open a terminal window in the operator's session: the account's login shell, or a command " +
|
||||
"run through it, in a directory (the home when none is named). hold keeps the window open after " +
|
||||
"the command ends. Answers the unit it runs as.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"command": desktop.Str("what to run in it (optional; the login shell when absent)"),
|
||||
"directory": desktop.Str("where it starts (optional; the account's home)"),
|
||||
"title": desktop.Str("the window's title (optional)"),
|
||||
"hold": desktop.Flag("keep the window open after the command ends (default false)"),
|
||||
}),
|
||||
Run: call(x.open),
|
||||
},
|
||||
{
|
||||
Name: "xterm_font",
|
||||
Description: "The face and size xterm uses, and the installed font that face resolves to. With face " +
|
||||
"and/or size, set them for terminals opened from now on (an installed face only); the module's " +
|
||||
"resources set the default again at the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"face": desktop.Str("a font family, as fc-list names it"),
|
||||
"size": desktop.Num("points, 6 to 48"),
|
||||
}),
|
||||
Run: call(x.font),
|
||||
},
|
||||
{
|
||||
Name: "xterm_colours",
|
||||
Description: "xterm's colours in force: background, foreground, cursor and the sixteen palette entries, " +
|
||||
"as #rrggbb, each beside the module's default and whether it differs.",
|
||||
Input: desktop.Schema(map[string]any{}),
|
||||
Run: call(x.colours),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type xterm struct{ d desktop.Desk }
|
||||
|
||||
// The module's defaults: the face research 026/04 chose and both workstations' palette.
|
||||
const (
|
||||
Face = "JetBrainsMono Nerd Font"
|
||||
Size = 12
|
||||
)
|
||||
|
||||
// Palette is the colours both workstations ran, by resource name, in the order they are written.
|
||||
var Palette = [][2]string{
|
||||
{"background", "#000000"}, {"foreground", "#a8a8a8"},
|
||||
{"color0", "#000000"}, {"color8", "#666666"},
|
||||
{"color1", "#9e1828"}, {"color9", "#bc5766"},
|
||||
{"color2", "#008800"}, {"color10", "#61a171"},
|
||||
{"color3", "#d2bb4b"}, {"color11", "#e7db52"},
|
||||
{"color4", "#414171"}, {"color12", "#5085af"},
|
||||
{"color5", "#963c59"}, {"color13", "#a97a99"},
|
||||
{"color6", "#418179"}, {"color14", "#6ba4a4"},
|
||||
{"color7", "#bebebe"}, {"color15", "#ffffff"},
|
||||
}
|
||||
|
||||
// Resources is the module's contribution to the X resources (the xresources slot `normal`), as the
|
||||
// manifest carries it; the manifest test holds the two to one text.
|
||||
func Resources() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n")
|
||||
b.WriteString("! outrank any `*name` resource of yours and touch no other X program.\n")
|
||||
for _, kv := range [][2]string{
|
||||
{"termName", "xterm-256color"}, {"saveLines", "10000"}, {"renderFont", "true"},
|
||||
{"faceName", Face}, {"faceSize", strconv.Itoa(Size)},
|
||||
{"boldMode", "false"}, {"alwaysBoldMode", "false"}, {"allowBoldFonts", "true"},
|
||||
} {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
}
|
||||
for _, kv := range Palette {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
}
|
||||
b.WriteString("XTerm*VT100.Translations: #override \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// openScript runs in the new terminal: into the directory, then the login shell, or a command
|
||||
// through it so the account's environment and aliases are there.
|
||||
const openScript = `cd -- "$1" || exit 1
|
||||
if [ -n "$2" ]; then exec "${SHELL:-/bin/sh}" -lc "$2"; fi
|
||||
exec "${SHELL:-/bin/sh}" -l`
|
||||
|
||||
// OpenArgs is the xterm command line open starts.
|
||||
func OpenArgs(dir, command, title string, hold bool) []string {
|
||||
args := []string{"xterm"}
|
||||
if title != "" {
|
||||
args = append(args, "-title", title)
|
||||
}
|
||||
if hold {
|
||||
args = append(args, "-hold")
|
||||
}
|
||||
return append(args, "-e", "/bin/sh", "-c", openScript, "sh", dir, command)
|
||||
}
|
||||
|
||||
func (x xterm) open(ctx context.Context, a desktop.Args) (any, error) {
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := a.Opt("directory", desktop.Home())
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
if dir, err = desktop.InHome(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory here", dir)
|
||||
}
|
||||
hold, _, err := a.Bool("hold")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := OpenArgs(dir, a.Opt("command", ""), a.Opt("title", ""), hold)
|
||||
launched, err := x.d.Launch(ctx, s, "xterm", argv...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"session": s.Display, "directory": dir, "command": a.Opt("command", "(the login shell)"), "launched": launched}, nil
|
||||
}
|
||||
|
||||
// Effective is a resource's value as an XTerm sees it in `xrdb -query`: its XTerm*-scoped value, else
|
||||
// the global *-scoped one.
|
||||
func Effective(query, name string) string {
|
||||
var global string
|
||||
for _, l := range strings.Split(query, "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "XTerm*" + name, "XTerm." + name, "XTerm*vt100." + name, "XTerm*VT100." + name:
|
||||
return strings.TrimSpace(v)
|
||||
case "*" + name:
|
||||
global = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return global
|
||||
}
|
||||
|
||||
var faceName = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,64}$`)
|
||||
|
||||
func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
face := a.Opt("face", "")
|
||||
size, sized, err := a.Number("size")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if face != "" || sized {
|
||||
var res []string
|
||||
if face != "" {
|
||||
if !faceName.MatchString(face) {
|
||||
return nil, fmt.Errorf("a face is a family name, as fc-list names it")
|
||||
}
|
||||
if fam := x.d.Run(ctx, env, nil, "fc-list", face, "family"); strings.TrimSpace(fam.Stdout) == "" {
|
||||
return nil, fmt.Errorf("no installed font is %q: the fonts module installs the mesh's faces", face)
|
||||
}
|
||||
res = append(res, "XTerm*faceName: "+face)
|
||||
}
|
||||
if sized {
|
||||
if size < 6 || size > 48 {
|
||||
return nil, fmt.Errorf("size is from 6 to 48 points")
|
||||
}
|
||||
res = append(res, "XTerm*faceSize: "+strconv.FormatFloat(size, 'f', -1, 64))
|
||||
}
|
||||
if r := x.d.Run(ctx, env, []byte(strings.Join(res, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
|
||||
return nil, r.Err()
|
||||
}
|
||||
}
|
||||
q := x.d.Run(ctx, env, nil, "xrdb", "-query")
|
||||
if !q.OK() {
|
||||
return nil, q.Err()
|
||||
}
|
||||
inForce := Effective(q.Stdout, "faceName")
|
||||
answer := map[string]any{
|
||||
"session": s.Display, "face": inForce, "size": Effective(q.Stdout, "faceSize"),
|
||||
"default": map[string]any{"face": Face, "size": Size},
|
||||
}
|
||||
if inForce != "" {
|
||||
m := x.d.Run(ctx, env, nil, "fc-match", "-f", "%{family[0]}|%{style[0]}|%{file}", inForce)
|
||||
if parts := strings.SplitN(m.Stdout, "|", 3); m.OK() && len(parts) == 3 {
|
||||
answer["resolves_to"] = map[string]any{"family": parts[0], "style": parts[1], "file": parts[2],
|
||||
"exact": strings.EqualFold(parts[0], inForce)}
|
||||
}
|
||||
}
|
||||
if face != "" || sized {
|
||||
answer["lasts"] = "for terminals opened from now on, until the next login"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
var rgbSpec = regexp.MustCompile(`^rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})$`)
|
||||
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#9e1828`) as #rrggbb; anything else (a colour name) as it is.
|
||||
func Hex(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if m := rgbSpec.FindStringSubmatch(v); m != nil {
|
||||
out := "#"
|
||||
for _, c := range m[1:] {
|
||||
n, _ := strconv.ParseUint(c, 16, 32)
|
||||
max := uint64(1)<<(4*len(c)) - 1
|
||||
out += fmt.Sprintf("%02x", (n*255+max/2)/max)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return strings.ToLower(v)
|
||||
}
|
||||
|
||||
func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
res, s, err := x.d.InSession(ctx, "xrdb", "-query")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.OK() {
|
||||
return nil, res.Err()
|
||||
}
|
||||
type colour struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Default string `json:"default"`
|
||||
Differs bool `json:"differs,omitempty"`
|
||||
}
|
||||
var out []colour
|
||||
for _, kv := range Palette {
|
||||
v := Hex(Effective(res.Stdout, kv[0]))
|
||||
out = append(out, colour{kv[0], v, kv[1], v != kv[1]})
|
||||
}
|
||||
cursor := Hex(Effective(res.Stdout, "cursorColor"))
|
||||
return map[string]any{"session": s.Display, "colours": out, "cursor": cursor}, nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
Shell []struct {
|
||||
For, Slot, Code string
|
||||
} `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestItHoldsTheTerminalSeatOnAnXDisplayAndNamesItselfTheTerminal(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != "node-terminal-emulator" || strings.Join(m.Claims[0].Serves, ",") != "open" {
|
||||
t.Fatalf("%+v", m.Claims)
|
||||
}
|
||||
if strings.Join(m.Requires, ",") != "x11-display" || m.Environment.Variables["TERMINAL"] != "xterm" || len(m.Environment.Variables) != 1 {
|
||||
t.Fatalf("%v %v", m.Requires, m.Environment)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools(xterm{}) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
if !served["node-terminal-emulator.open"] || len(served) != len(m.Tools)+1 {
|
||||
t.Fatalf("%v %v", served, m.Tools)
|
||||
}
|
||||
for _, n := range m.Tools {
|
||||
if !served[n] {
|
||||
t.Errorf("%s", n)
|
||||
}
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["package"] != "xterm" {
|
||||
t.Fatalf("%v", m.Resources)
|
||||
}
|
||||
if a := m.Build.Artifacts[0]; a["binary"] != "xterm-tools" || a["from"] != "cmd/xterm-tools" {
|
||||
t.Fatalf("a binary not named xterm, so nothing looking for xterm by name finds the tools: %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xresources" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
if m.Shell[0].Code != Resources() {
|
||||
t.Fatal("the manifest's resources are not the ones the bundle describes: regenerate the manifest from Resources()")
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(Resources()), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "!"), strings.HasPrefix(l, " "):
|
||||
case strings.HasPrefix(l, "#"):
|
||||
t.Errorf("merged with -nocpp, a # line is skipped: %q", l)
|
||||
case !strings.HasPrefix(l, "XTerm*"):
|
||||
t.Errorf("a resource of another program, or of every one: %q", l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(Resources(), "XTerm*faceName: JetBrainsMono Nerd Font") || !strings.Contains(Resources(), "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the chosen face and the clipboard keys")
|
||||
}
|
||||
}
|
||||
|
||||
type fake struct {
|
||||
ran []string
|
||||
stdin []string
|
||||
query string
|
||||
}
|
||||
|
||||
func (f *fake) desk(session bool) desktop.Desk {
|
||||
return desktop.Desk{
|
||||
Find: func() (*desktop.Session, error) {
|
||||
if !session {
|
||||
return nil, &desktop.NoSession{Reason: "none"}
|
||||
}
|
||||
return &desktop.Session{Display: ":1", XAuthority: "/h/.Xauthority", Type: "x11", RuntimeDir: "/run/user/1000", Bus: "unix:path=/run/user/1000/bus"}, nil
|
||||
},
|
||||
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
|
||||
f.ran = append(f.ran, strings.Join(append([]string{name}, args...), " "))
|
||||
f.stdin = append(f.stdin, string(stdin))
|
||||
switch name {
|
||||
case "xrdb":
|
||||
return desktop.Result{Stdout: f.query}
|
||||
case "fc-list":
|
||||
if strings.Contains(args[0], "Missing") {
|
||||
return desktop.Result{}
|
||||
}
|
||||
return desktop.Result{Stdout: args[0] + "\n"}
|
||||
case "fc-match":
|
||||
return desktop.Result{Stdout: "JetBrainsMono Nerd Font|Regular|/usr/share/fonts/TTF/J.ttf"}
|
||||
}
|
||||
return desktop.Result{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenHandsTheTerminalToTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
f := &fake{}
|
||||
dir := t.TempDir()
|
||||
got, err := xterm{d: f.desk(true)}.open(context.Background(), desktop.Args{"directory": dir, "command": "htop", "hold": true, "title": "top"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.ran) != 1 || !strings.HasPrefix(f.ran[0], "systemd-run --user --collect --quiet --unit=mesh-xterm-") {
|
||||
t.Fatalf("%q", f.ran)
|
||||
}
|
||||
for _, want := range []string{"--setenv=DISPLAY=:1", "--setenv=XAUTHORITY=/h/.Xauthority", "-- xterm -title top -hold -e /bin/sh -c", "sh " + dir + " htop"} {
|
||||
if !strings.Contains(f.ran[0], want) {
|
||||
t.Errorf("lacks %q: %s", want, f.ran[0])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(asJSON(got), `"unit":"mesh-xterm-`) {
|
||||
t.Fatalf("%s", asJSON(got))
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).open(context.Background(), desktop.Args{"directory": "/no/such/dir"}); err == nil {
|
||||
t.Fatal("a directory that is not there")
|
||||
}
|
||||
if _, err := (xterm{d: (&fake{}).desk(false)}).open(context.Background(), desktop.Args{}); !desktop.IsNoSession(err) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTerminalsScriptStartsInTheDirectoryAndRunsTheCommandThroughTheShell(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
args := OpenArgs(dir, "pwd > out", "", false)
|
||||
sh := args[len(args)-6:] // /bin/sh -c script sh dir command
|
||||
cmd := exec.Command(sh[0], sh[1:]...)
|
||||
cmd.Env = []string{"SHELL=/bin/sh", "PATH=/usr/bin:/bin", "HOME=" + dir}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "out"))
|
||||
if strings.TrimSpace(string(b)) != dir {
|
||||
t.Fatalf("%q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
q := "*faceName:\tHack Nerd Font\nXTerm*faceName:\tJetBrainsMono Nerd Font\nXTerm*faceSize:\t12\n" +
|
||||
"XTerm*background:\t#000000\n*foreground:\trgb:a8/a8/a8\nXTerm*color1:\trgb:ff/00/00\n"
|
||||
if Effective(q, "faceName") != "JetBrainsMono Nerd Font" || Effective(q, "foreground") != "rgb:a8/a8/a8" {
|
||||
t.Fatal("XTerm's own value outranks the global one; the global one stands alone")
|
||||
}
|
||||
for in, want := range map[string]string{"rgb:9e/18/28": "#9e1828", "rgb:f/0/8": "#ff0088", "rgb:ffff/0000/8080": "#ff0080", "#ABCDEF": "#abcdef", "red": "red"} {
|
||||
if got := Hex(in); got != want {
|
||||
t.Errorf("%s: %s", in, got)
|
||||
}
|
||||
}
|
||||
f := &fake{query: q}
|
||||
got, err := xterm{d: f.desk(true)}.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := asJSON(got)
|
||||
if !strings.Contains(j, `{"name":"color1","value":"#ff0000","default":"#9e1828","differs":true}`) || !strings.Contains(j, `{"name":"foreground","value":"#a8a8a8","default":"#a8a8a8"}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
t.Fatal("a face that is not installed")
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
t.Fatal("size out of range")
|
||||
}
|
||||
f.ran, f.stdin = nil, nil
|
||||
got, err = xterm{d: f.desk(true)}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.stdin[1] != "XTerm*faceName: Inter\nXTerm*faceSize: 13\n" || !strings.Contains(asJSON(got), `"lasts"`) {
|
||||
t.Fatalf("%q %s", f.stdin, asJSON(got))
|
||||
}
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module xterm
|
||||
|
||||
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,160 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans.
|
||||
type Args map[string]any
|
||||
|
||||
// Text is a required string, trimmed.
|
||||
func (a Args) Text(name string) (string, error) {
|
||||
v, ok := a[name].(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return "", fmt.Errorf("%s is required, as text", name)
|
||||
}
|
||||
return strings.TrimSpace(v), nil
|
||||
}
|
||||
|
||||
// Opt is an optional string, trimmed, or def.
|
||||
func (a Args) Opt(name, def string) string {
|
||||
if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Has is whether the caller gave the argument at all.
|
||||
func (a Args) Has(name string) bool {
|
||||
v, ok := a[name]
|
||||
return ok && v != nil
|
||||
}
|
||||
|
||||
// Bool is an optional boolean: its value, and whether it was given.
|
||||
func (a Args) Bool(name string) (bool, bool, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
b, isBool := v.(bool)
|
||||
if !isBool {
|
||||
return false, false, fmt.Errorf("%s is true or false", name)
|
||||
}
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// Number is an optional number: its value, and whether it was given.
|
||||
func (a Args) Number(name string) (float64, bool, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
f, isNum := v.(float64)
|
||||
if !isNum || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return 0, false, fmt.Errorf("%s is a number", name)
|
||||
}
|
||||
return f, true, nil
|
||||
}
|
||||
|
||||
// Whole is an optional whole number within [lo, hi], or def.
|
||||
func (a Args) Whole(name string, def, lo, hi int) (int, error) {
|
||||
f, given, err := a.Number(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !given {
|
||||
return def, nil
|
||||
}
|
||||
if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
|
||||
return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi)
|
||||
}
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
// OneOf is an optional string that must be one of choices, or def.
|
||||
func (a Args) OneOf(name, def string, choices ...string) (string, error) {
|
||||
v := a.Opt(name, def)
|
||||
for _, c := range choices {
|
||||
if v == c {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", "))
|
||||
}
|
||||
|
||||
// Strings is an optional list of strings.
|
||||
func (a Args) Strings(name string) ([]string, error) {
|
||||
v, ok := a[name]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, isList := v.([]any)
|
||||
if !isList {
|
||||
return nil, fmt.Errorf("%s is a list of text", name)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, x := range list {
|
||||
s, isText := x.(string)
|
||||
if !isText {
|
||||
return nil, fmt.Errorf("%s is a list of text", name)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Home is the operator account's home: the runtime's word for it, else this process's.
|
||||
func Home() string {
|
||||
if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" {
|
||||
return h
|
||||
}
|
||||
if h, err := os.UserHomeDir(); err == nil {
|
||||
return h
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path
|
||||
// that leaves the home through `..` is refused, so a tool that writes never writes outside it.
|
||||
func InHome(path string) (string, error) {
|
||||
home := Home()
|
||||
switch {
|
||||
case path == "~":
|
||||
path = home
|
||||
case strings.HasPrefix(path, "~/"):
|
||||
path = filepath.Join(home, path[2:])
|
||||
case !filepath.IsAbs(path):
|
||||
path = filepath.Join(home, path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%s is outside the account's home", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Schema builds a tool's input schema from property descriptions; required names those that must
|
||||
// be given. A property is a string unless its description object says otherwise.
|
||||
func Schema(props map[string]any, required ...string) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Str, Num, Flag, List and Enum describe one property.
|
||||
func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
||||
func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
|
||||
func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} }
|
||||
func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} }
|
||||
func List(desc string) map[string]any {
|
||||
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
|
||||
}
|
||||
func Enum(desc string, values ...string) map[string]any {
|
||||
return map[string]any{"type": "string", "enum": values, "description": desc}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The desktop modules that carry this package. 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.
|
||||
var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"}
|
||||
|
||||
func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) {
|
||||
mine, err := filepath.Glob("*.go")
|
||||
if err != nil || len(mine) == 0 {
|
||||
t.Fatal("no files of this package found", err)
|
||||
}
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "internal", "desktop")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
theirs, _ := filepath.Glob(filepath.Join(dir, "*.go"))
|
||||
if len(theirs) != len(mine) {
|
||||
t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine))
|
||||
continue
|
||||
}
|
||||
for _, f := range mine {
|
||||
a, _ := os.ReadFile(f)
|
||||
b, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(a, b) {
|
||||
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,232 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
|
||||
// fits in a tool's reply.
|
||||
const (
|
||||
DefaultTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Result is what one command did.
|
||||
type Result struct {
|
||||
Command []string `json:"command"`
|
||||
Code int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TimedOut bool `json:"timed_out,omitempty"`
|
||||
}
|
||||
|
||||
// OK is whether the command ran and exited 0.
|
||||
func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut }
|
||||
|
||||
// Err is the command's failure as an error naming it and what it said, or nil.
|
||||
func (r Result) Err() error {
|
||||
if r.OK() {
|
||||
return nil
|
||||
}
|
||||
said := strings.TrimSpace(r.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(r.Stdout)
|
||||
}
|
||||
if r.TimedOut {
|
||||
return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " "))
|
||||
}
|
||||
return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said)
|
||||
}
|
||||
|
||||
// Runner runs a command with an environment and answers what it did. Tools take one, so their
|
||||
// tests replace the machine with a table of answers.
|
||||
type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result
|
||||
|
||||
// Exec is the machine's Runner: the command in its own process group, ended with everything it
|
||||
// started at the deadline, each stream cut at MostOutput.
|
||||
func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result {
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
res := Result{Command: append([]string{name}, args...)}
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Env = env
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
out, errb := &capped{}, &capped{}
|
||||
cmd.Stdout, cmd.Stderr = out, errb
|
||||
if err := cmd.Start(); err != nil {
|
||||
res.Code = 127
|
||||
res.Stderr = err.Error()
|
||||
return res
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
var err error
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-ctx.Done():
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
err = <-done
|
||||
res.TimedOut = true
|
||||
}
|
||||
res.Stdout, res.Stderr = out.String(), errb.String()
|
||||
res.Truncated = out.cut || errb.cut
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.As(err, &exit):
|
||||
res.Code = exit.ExitCode()
|
||||
if res.Code < 0 {
|
||||
res.Code = 128
|
||||
}
|
||||
default:
|
||||
res.Code = 1
|
||||
if res.Stderr == "" {
|
||||
res.Stderr = err.Error()
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an
|
||||
// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write.
|
||||
type capped struct {
|
||||
buf bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.buf.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.buf.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.buf.Write(p)
|
||||
}
|
||||
|
||||
func (c *capped) String() string { return c.buf.String() }
|
||||
|
||||
// Desk is what a desktop tool needs: how to find the session, and how to run a command.
|
||||
type Desk struct {
|
||||
Find func() (*Session, error)
|
||||
Run Runner
|
||||
// Base is the environment a command starts from, before the session's words.
|
||||
Base []string
|
||||
}
|
||||
|
||||
// Machine is the real Desk, preferring the named processes as the session's.
|
||||
func Machine(prefer ...string) Desk {
|
||||
return Desk{
|
||||
Find: func() (*Session, error) { return Find(prefer...) },
|
||||
Run: Exec,
|
||||
Base: os.Environ(),
|
||||
}
|
||||
}
|
||||
|
||||
// InSession runs a command in the operator's session, or answers NoSession.
|
||||
func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) {
|
||||
s, err := d.Find()
|
||||
if err != nil {
|
||||
return Result{}, nil, err
|
||||
}
|
||||
return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil
|
||||
}
|
||||
|
||||
// InSessionWith is InSession with standard input.
|
||||
func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) {
|
||||
s, err := d.Find()
|
||||
if err != nil {
|
||||
return Result{}, nil, err
|
||||
}
|
||||
return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil
|
||||
}
|
||||
|
||||
// Plain runs a command with the base environment: for what needs no session.
|
||||
func (d Desk) Plain(ctx context.Context, name string, args ...string) Result {
|
||||
return d.Run(ctx, d.Base, nil, name, args...)
|
||||
}
|
||||
|
||||
// AsUser runs a command with the account's own runtime directory and bus, and no display.
|
||||
func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result {
|
||||
return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...)
|
||||
}
|
||||
|
||||
// Launched is how a program was started in the session.
|
||||
type Launched struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
How string `json:"how"`
|
||||
}
|
||||
|
||||
// Launch starts a program in the operator's session that outlives the call and the runtime.
|
||||
//
|
||||
// **Not as a child of this process.** The runtime is a system service; everything it starts is in
|
||||
// its control group, and the service manager ends that group whenever the runtime restarts — which
|
||||
// is every push that changes it. So the program is handed to the account's own service manager as a
|
||||
// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the
|
||||
// operator's user manager does. Without a user manager it is started detached as a last resort, and
|
||||
// the answer says it will end with the runtime.
|
||||
func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) {
|
||||
if len(argv) == 0 {
|
||||
return Launched{}, errors.New("nothing to launch")
|
||||
}
|
||||
env := s.Env(d.Base)
|
||||
unit := "mesh-" + name + "-" + token()
|
||||
args := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} {
|
||||
if v := lookup(env, w); v != "" {
|
||||
args = append(args, "--setenv="+w+"="+v)
|
||||
}
|
||||
}
|
||||
args = append(args, "--")
|
||||
args = append(args, argv...)
|
||||
res := d.Run(ctx, env, nil, "systemd-run", args...)
|
||||
if res.OK() {
|
||||
return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil
|
||||
}
|
||||
if s.Bus != "" {
|
||||
return Launched{}, res.Err()
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Env = env
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Launched{}, err
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil
|
||||
}
|
||||
|
||||
func lookup(env []string, name string) string {
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
if k, v, ok := strings.Cut(env[i], "="); ok && k == name {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func token() string {
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// Package desktop is how a desktop module's tools act in the operator's graphical session
|
||||
// (novox/hq ADR 0208, research 026/05).
|
||||
//
|
||||
// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a
|
||||
// process of node-tools.service, started by the system's service manager as the operator account,
|
||||
// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in
|
||||
// was started elsewhere, by the login manager, and the only place its values are written down is
|
||||
// the environment of the processes it started. So this package finds the session the way a person
|
||||
// would: it looks at the operator account's own processes, takes the one that is plainly the
|
||||
// session's (the window manager, or the oldest process carrying a display), confirms with logind
|
||||
// that its session is a live local one, and checks that the display's socket is really there.
|
||||
//
|
||||
// **Only the session's own words are read.** A session's processes also carry whatever its start
|
||||
// script exported — on the workstations that was a file of secrets — so the environment is filtered
|
||||
// to a fixed list of names while it is read, and nothing else ever leaves /proc.
|
||||
//
|
||||
// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`,
|
||||
// whenever it exists, because that is where the portal, the notifier and every user service
|
||||
// listen. A session started on a private bus (a stale session, measured on one workstation) is
|
||||
// reported as `session_bus` beside it, so the difference is visible rather than guessed at.
|
||||
//
|
||||
// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm,
|
||||
// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change
|
||||
// every copy together — the modules' tests compare them.
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionWords are the only environment words read from a session's process: the ones that say
|
||||
// where the session is. Everything else in that environment is the operator's, and is never read.
|
||||
var SessionWords = []string{
|
||||
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY",
|
||||
"XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP",
|
||||
"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR",
|
||||
"I3SOCK", "SWAYSOCK",
|
||||
}
|
||||
|
||||
// Session is the operator's running graphical session, as a tool needs it.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
ID string `json:"session_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Display string `json:"display,omitempty"`
|
||||
WaylandDisplay string `json:"wayland_display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
SessionBus string `json:"session_bus,omitempty"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
// FoundIn is the process whose environment named the session.
|
||||
FoundIn Process `json:"found_in"`
|
||||
// Active is logind's word on the session, when logind answered.
|
||||
Active *bool `json:"active,omitempty"`
|
||||
|
||||
words map[string]string
|
||||
}
|
||||
|
||||
// Process is one process the search looked at.
|
||||
type Process struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
start uint64
|
||||
}
|
||||
|
||||
// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool
|
||||
// that returns it as its error still answers structured data.
|
||||
type NoSession struct {
|
||||
Reason string `json:"reason"`
|
||||
Looked []string `json:"looked"`
|
||||
}
|
||||
|
||||
func (e *NoSession) Error() string {
|
||||
b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// IsNoSession is whether err says there is no session.
|
||||
func IsNoSession(err error) bool {
|
||||
var n *NoSession
|
||||
return errors.As(err, &n)
|
||||
}
|
||||
|
||||
// Finder holds where the search looks, so a test can point it at a tree of its own.
|
||||
type Finder struct {
|
||||
Proc string // the process table: /proc
|
||||
X11Sockets string // where X servers listen: /tmp/.X11-unix
|
||||
RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user
|
||||
UID int // whose session
|
||||
// Prefer names the processes that are the session's own, best first: the session's holder.
|
||||
Prefer []string
|
||||
// Logind answers `loginctl show-session` for one id; nil skips the check.
|
||||
Logind func(id string) (map[string]string, error)
|
||||
}
|
||||
|
||||
// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the
|
||||
// operator account the runtime names (MESH_OPERATOR_ACCOUNT).
|
||||
func DefaultFinder(prefer ...string) Finder {
|
||||
uid := os.Getuid()
|
||||
if uid == 0 {
|
||||
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
|
||||
if u, err := user.Lookup(name); err == nil {
|
||||
if n, err := strconv.Atoi(u.Uid); err == nil {
|
||||
uid = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Finder{
|
||||
Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user",
|
||||
UID: uid, Prefer: prefer, Logind: loginctl,
|
||||
}
|
||||
}
|
||||
|
||||
// Find is the operator's session on this machine, preferring a process named in prefer.
|
||||
func Find(prefer ...string) (*Session, error) {
|
||||
return DefaultFinder(prefer...).Find()
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
proc Process
|
||||
words map[string]string
|
||||
rank int
|
||||
logind map[string]string
|
||||
}
|
||||
|
||||
// Find looks for the session.
|
||||
func (f Finder) Find() (*Session, error) {
|
||||
entries, err := os.ReadDir(f.Proc)
|
||||
if err != nil {
|
||||
return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}}
|
||||
}
|
||||
looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)}
|
||||
var found []candidate
|
||||
stale := 0
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(f.Proc, e.Name())
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID {
|
||||
continue
|
||||
}
|
||||
words := readWords(filepath.Join(dir, "environ"))
|
||||
if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
if !f.reachable(words) {
|
||||
stale++
|
||||
continue
|
||||
}
|
||||
found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
reason := fmt.Sprintf("no process of uid %d carries a display", f.UID)
|
||||
if stale > 0 {
|
||||
reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID)
|
||||
}
|
||||
return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)}
|
||||
}
|
||||
|
||||
// logind's word on each session the candidates name, asked once per session.
|
||||
asked := map[string]map[string]string{}
|
||||
for i := range found {
|
||||
id := found[i].words["XDG_SESSION_ID"]
|
||||
if f.Logind == nil || id == "" {
|
||||
found[i].rank = 1
|
||||
continue
|
||||
}
|
||||
props, done := asked[id]
|
||||
if !done {
|
||||
props, _ = f.Logind(id)
|
||||
asked[id] = props
|
||||
}
|
||||
found[i].logind = props
|
||||
switch {
|
||||
case props == nil:
|
||||
found[i].rank = 1
|
||||
case props["Remote"] == "yes":
|
||||
found[i].rank = 3
|
||||
case props["Active"] == "yes" && props["State"] != "closing":
|
||||
found[i].rank = 0
|
||||
case props["State"] == "closing":
|
||||
found[i].rank = 3
|
||||
default:
|
||||
found[i].rank = 2
|
||||
}
|
||||
}
|
||||
if f.Logind != nil {
|
||||
looked = append(looked, "logind's sessions")
|
||||
}
|
||||
preferred := func(c candidate) int {
|
||||
for i, p := range f.Prefer {
|
||||
if c.proc.Command == p {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(f.Prefer)
|
||||
}
|
||||
sort.SliceStable(found, func(i, j int) bool {
|
||||
a, b := found[i], found[j]
|
||||
if a.rank != b.rank {
|
||||
return a.rank < b.rank
|
||||
}
|
||||
if pa, pb := preferred(a), preferred(b); pa != pb {
|
||||
return pa < pb
|
||||
}
|
||||
if a.proc.start != b.proc.start {
|
||||
return a.proc.start < b.proc.start
|
||||
}
|
||||
return a.proc.PID < b.proc.PID
|
||||
})
|
||||
best := found[0]
|
||||
if best.rank == 3 {
|
||||
return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked}
|
||||
}
|
||||
return f.session(best), nil
|
||||
}
|
||||
|
||||
func (f Finder) session(c candidate) *Session {
|
||||
w := c.words
|
||||
s := &Session{
|
||||
UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"],
|
||||
XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w,
|
||||
}
|
||||
s.Desktop = w["XDG_CURRENT_DESKTOP"]
|
||||
if s.Desktop == "" {
|
||||
s.Desktop = w["XDG_SESSION_DESKTOP"]
|
||||
}
|
||||
switch {
|
||||
case w["XDG_SESSION_TYPE"] != "":
|
||||
s.Type = w["XDG_SESSION_TYPE"]
|
||||
case s.WaylandDisplay != "":
|
||||
s.Type = "wayland"
|
||||
default:
|
||||
s.Type = "x11"
|
||||
}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
|
||||
}
|
||||
if isSocket(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus {
|
||||
s.SessionBus = own
|
||||
}
|
||||
if c.logind != nil {
|
||||
active := c.logind["Active"] == "yes"
|
||||
s.Active = &active
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// reachable is whether the display a process names is still served: the X server's socket, or the
|
||||
// Wayland compositor's. A process outliving its session still carries the session's words.
|
||||
func (f Finder) reachable(w map[string]string) bool {
|
||||
if d := w["WAYLAND_DISPLAY"]; d != "" {
|
||||
path := d
|
||||
if !filepath.IsAbs(d) {
|
||||
dir := w["XDG_RUNTIME_DIR"]
|
||||
if dir == "" {
|
||||
dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
|
||||
}
|
||||
path = filepath.Join(dir, d)
|
||||
}
|
||||
if isSocket(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
n, ok := DisplayNumber(w["DISPLAY"])
|
||||
return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n)))
|
||||
}
|
||||
|
||||
// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on
|
||||
// another host — an ssh session's forwarded one — is not the local session and answers false.
|
||||
func DisplayNumber(display string) (int, bool) {
|
||||
host, rest, ok := strings.Cut(display, ":")
|
||||
if !ok || (host != "" && host != "unix") {
|
||||
return 0, false
|
||||
}
|
||||
num, _, _ := strings.Cut(rest, ".")
|
||||
n, err := strconv.Atoi(num)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// Word is one of the session's words as its process had it ("" when it had none).
|
||||
func (s *Session) Word(name string) string { return s.words[name] }
|
||||
|
||||
// Env is base with the session's words in place of whatever base said for them.
|
||||
func (s *Session) Env(base []string) []string {
|
||||
drop := map[string]bool{}
|
||||
for _, w := range SessionWords {
|
||||
drop[w] = true
|
||||
}
|
||||
out := make([]string, 0, len(base)+8)
|
||||
for _, kv := range base {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if !drop[k] {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
bus := s.Bus
|
||||
if bus == "" {
|
||||
bus = s.SessionBus
|
||||
}
|
||||
for _, kv := range [][2]string{
|
||||
{"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority},
|
||||
{"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus},
|
||||
{"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID},
|
||||
{"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]},
|
||||
{"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]},
|
||||
{"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]},
|
||||
} {
|
||||
if kv[1] != "" {
|
||||
out = append(out, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the
|
||||
// user manager or the session bus and needs no display — it works with no session at all.
|
||||
func UserEnv(base []string, uid int) []string {
|
||||
dir := filepath.Join("/run/user", strconv.Itoa(uid))
|
||||
out := make([]string, 0, len(base)+2)
|
||||
for _, kv := range base {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus"))
|
||||
}
|
||||
|
||||
// readWords reads a process's environment and keeps only SessionWords.
|
||||
func readWords(path string) map[string]string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, w := range SessionWords {
|
||||
keep[w] = true
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
k, v, ok := bytes.Cut(kv, []byte{'='})
|
||||
if ok && keep[string(k)] {
|
||||
out[string(k)] = string(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func comm(dir string) string {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "comm"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// startTime is field 22 of /proc/<pid>/stat: when the process started, in clock ticks since boot.
|
||||
// Read after the command's closing parenthesis, because the command may hold spaces.
|
||||
func startTime(dir string) uint64 {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "stat"))
|
||||
if err != nil {
|
||||
return ^uint64(0)
|
||||
}
|
||||
i := bytes.LastIndexByte(b, ')')
|
||||
if i < 0 {
|
||||
return ^uint64(0)
|
||||
}
|
||||
fields := strings.Fields(string(b[i+1:]))
|
||||
// fields[0] is the state, field 3 of the line; start time is field 22.
|
||||
if len(fields) < 20 {
|
||||
return ^uint64(0)
|
||||
}
|
||||
n, err := strconv.ParseUint(fields[19], 10, 64)
|
||||
if err != nil {
|
||||
return ^uint64(0)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func isSocket(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Mode()&os.ModeSocket != 0
|
||||
}
|
||||
|
||||
// loginctl asks logind about one session, by its property lines.
|
||||
func loginctl(id string) (map[string]string, error) {
|
||||
cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
done := make(chan error, 1)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
return nil, errors.New("loginctl did not answer in 3s")
|
||||
}
|
||||
return ParseProperties(out.String()), nil
|
||||
}
|
||||
|
||||
// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them.
|
||||
func ParseProperties(text string) map[string]string {
|
||||
out := map[string]string{}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
if k, v, ok := strings.Cut(sc.Text(), "="); ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
|
||||
type fakeMachine struct {
|
||||
t *testing.T
|
||||
proc, x11, runtime string
|
||||
uid int
|
||||
}
|
||||
|
||||
func newMachine(t *testing.T) *fakeMachine {
|
||||
root, err := os.MkdirTemp("", "desk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(root) })
|
||||
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
|
||||
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *fakeMachine) socket(path string) {
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
m.t.Fatal(err)
|
||||
}
|
||||
m.t.Cleanup(func() { l.Close() })
|
||||
}
|
||||
|
||||
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
|
||||
dir := filepath.Join(m.proc, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
m.t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
|
||||
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
|
||||
// cutime cstime priority nice threads itrealvalue starttime ...
|
||||
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
|
||||
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
|
||||
}
|
||||
|
||||
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
|
||||
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
|
||||
}
|
||||
|
||||
func active(id string) (map[string]string, error) {
|
||||
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
|
||||
}
|
||||
|
||||
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X1"))
|
||||
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
||||
m.socket(filepath.Join(run, "bus"))
|
||||
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
|
||||
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
|
||||
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
|
||||
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
|
||||
|
||||
s, err := m.finder(active, "i3").Find()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
|
||||
t.Fatalf("session: %+v", s)
|
||||
}
|
||||
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
|
||||
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
|
||||
}
|
||||
if s.Active == nil || !*s.Active {
|
||||
t.Fatal("logind's word is carried")
|
||||
}
|
||||
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
|
||||
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("env lacks %s:\n%s", want, env)
|
||||
}
|
||||
}
|
||||
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
|
||||
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
if strings.Contains(string(b), "secret") {
|
||||
t.Fatal("the answer carries a word outside the session's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X0"))
|
||||
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
||||
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
||||
s, err := m.finder(nil).Find()
|
||||
if err != nil || s.FoundIn.PID != 400 {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
|
||||
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
|
||||
_, err := m.finder(active, "i3").Find()
|
||||
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
var answer map[string]any
|
||||
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
|
||||
t.Fatalf("the refusal is structured: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
|
||||
_, err := m.finder(active).Find()
|
||||
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
m.socket(filepath.Join(m.x11, "X0"))
|
||||
m.socket(filepath.Join(m.x11, "X1"))
|
||||
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
|
||||
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
|
||||
logind := func(id string) (map[string]string, error) {
|
||||
if id == "a" {
|
||||
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
|
||||
}
|
||||
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
|
||||
}
|
||||
s, err := m.finder(logind, "i3").Find()
|
||||
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
|
||||
t.Fatalf("the active session: %+v %v", s, err)
|
||||
}
|
||||
remote := func(string) (map[string]string, error) {
|
||||
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
|
||||
}
|
||||
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
|
||||
t.Fatalf("a remote session is not the operator's desktop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
|
||||
m := newMachine(t)
|
||||
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
||||
m.socket(filepath.Join(run, "wayland-1"))
|
||||
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
|
||||
s, err := m.finder(nil, "sway").Find()
|
||||
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
|
||||
t.Fatal("the compositor's socket word passes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
|
||||
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
|
||||
if _, ok := DisplayNumber(d); ok != want {
|
||||
t.Errorf("%q: %v", d, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
|
||||
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
|
||||
if !r.OK() || r.Stdout != "hello" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
|
||||
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
|
||||
if r.OK() || r.Code != 127 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
|
||||
if !r.Truncated || len(r.Stdout) != MostOutput {
|
||||
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgumentsAreReadStrictly(t *testing.T) {
|
||||
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if v, err := a.Text("name"); err != nil || v != "x" {
|
||||
t.Fatal(v, err)
|
||||
}
|
||||
if _, err := a.Text("missing"); err == nil {
|
||||
t.Fatal("a missing required text")
|
||||
}
|
||||
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
|
||||
t.Fatal(n, err)
|
||||
}
|
||||
if _, err := a.Whole("f", 0, 0, 5); err == nil {
|
||||
t.Fatal("1.5 is not whole")
|
||||
}
|
||||
if _, err := a.Whole("n", 0, 4, 5); err == nil {
|
||||
t.Fatal("out of range")
|
||||
}
|
||||
if b, given, err := a.Bool("b"); !b || !given || err != nil {
|
||||
t.Fatal("bool")
|
||||
}
|
||||
if _, _, err := a.Bool("name"); err == nil {
|
||||
t.Fatal("text is not a bool")
|
||||
}
|
||||
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
|
||||
t.Fatal(l, err)
|
||||
}
|
||||
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
|
||||
t.Fatal("not one of")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathIsKeptInsideTheHome(t *testing.T) {
|
||||
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
|
||||
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
|
||||
if got, err := InHome(in); err != nil || got != want {
|
||||
t.Errorf("%s: %s %v", in, got, err)
|
||||
}
|
||||
}
|
||||
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
|
||||
if _, err := InHome(out); err == nil {
|
||||
t.Errorf("%s was accepted", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropertiesAreParsed(t *testing.T) {
|
||||
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
|
||||
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
|
||||
t.Fatal(p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"module": "xterm",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-terminal-emulator",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"open"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"xterm_font",
|
||||
"xterm_colours"
|
||||
],
|
||||
"environment": {
|
||||
"variables": {
|
||||
"TERMINAL": "xterm"
|
||||
}
|
||||
},
|
||||
"shell": [
|
||||
{
|
||||
"for": "xresources",
|
||||
"slot": "normal",
|
||||
"code": "! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n! outrank any `*name` resource of yours and touch no other X program.\nXTerm*termName: xterm-256color\nXTerm*saveLines: 10000\nXTerm*renderFont: true\nXTerm*faceName: JetBrainsMono Nerd Font\nXTerm*faceSize: 12\nXTerm*boldMode: false\nXTerm*alwaysBoldMode: false\nXTerm*allowBoldFonts: true\nXTerm*background: #000000\nXTerm*foreground: #a8a8a8\nXTerm*color0: #000000\nXTerm*color8: #666666\nXTerm*color1: #9e1828\nXTerm*color9: #bc5766\nXTerm*color2: #008800\nXTerm*color10: #61a171\nXTerm*color3: #d2bb4b\nXTerm*color11: #e7db52\nXTerm*color4: #414171\nXTerm*color12: #5085af\nXTerm*color5: #963c59\nXTerm*color13: #a97a99\nXTerm*color6: #418179\nXTerm*color14: #6ba4a4\nXTerm*color7: #bebebe\nXTerm*color15: #ffffff\nXTerm*VT100.Translations: #override \\n\\\n Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "xterm"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/xterm-tools",
|
||||
"binary": "xterm-tools",
|
||||
"loads": [
|
||||
"xterm-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user