mesh/delivery-group group feat/a-secret-given-at-the-desk rejected: a member's own check failed
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose (0.82s)
mesh/delivery superseded: a newer head of the same pull request
243 lines
12 KiB
Go
243 lines
12 KiB
Go
package catalogue
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
|
||
// role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for
|
||
// a role rather than each inventing one — and a machine running two of one role is refused at
|
||
// assignment instead of found by two bars on one screen.
|
||
const (
|
||
LoginManagerSeat = "node-login-manager"
|
||
DisplayServerSeat = "node-display-server"
|
||
DisplaySessionSeat = "node-display-session"
|
||
TerminalEmulatorSeat = "node-terminal-emulator"
|
||
LauncherSeat = "node-launcher"
|
||
NotifierSeat = "node-notifier"
|
||
LockScreenSeat = "node-lock-screen"
|
||
ClipboardSeat = "node-clipboard"
|
||
BarSeat = "node-bar"
|
||
CompositorSeat = "node-compositor"
|
||
SecretServiceSeat = "node-secret-service"
|
||
)
|
||
|
||
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
|
||
// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead,
|
||
// ADR 0255), the compositor and the secret
|
||
// service are roles a second holder competes for, and nothing has needed to ask them anything.
|
||
func graphicalSessionSeats() []Seat {
|
||
const decided = "novox/hq ADR 0208"
|
||
return []Seat{
|
||
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
|
||
"one the operator account starts by default.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}},
|
||
{Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
|
||
"where it is placed; and the layout profile in force, if one matches.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
|
||
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
|
||
"identities: list them, save the current arrangement under a name, or apply one.",
|
||
Input: withEnum(schema(map[string]string{
|
||
"action": "list, save or apply",
|
||
"name": "the profile to save or apply (save and apply only)",
|
||
}, []string{"action"}), "action", "list", "save", "apply")},
|
||
}},
|
||
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
|
||
// bindings, start-up commands, rules — placed by the session's holder, never written into
|
||
// its directory by the contributor.
|
||
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided,
|
||
Receives: []Receivable{{Kind: "config", Comment: "#"}}, Serves: []Verb{
|
||
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
|
||
"it is visible or focused.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
|
||
"whether it has focus; narrowed to one workspace when named.",
|
||
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
|
||
}},
|
||
{Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
|
||
"or the login shell, in a directory or the account's home.",
|
||
Input: schema(map[string]string{
|
||
"command": "what to run in it (optional; the login shell when absent)",
|
||
"directory": "where it starts (optional; the account's home when absent)",
|
||
}, nil)},
|
||
}},
|
||
{Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
|
||
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
|
||
Input: map[string]any{"type": "object", "required": []string{"choices"},
|
||
"properties": map[string]any{
|
||
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||
"description": "the lines to choose between, in order"},
|
||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||
}}},
|
||
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
|
||
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
|
||
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
|
||
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
|
||
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
|
||
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
|
||
Input: schema(map[string]string{
|
||
"prompt": "what the prompt asks",
|
||
"message": "a line saying who asks and for what (optional)",
|
||
"seal_to": "the asker's public sealing key: the answer is sealed to it",
|
||
"timeout_seconds": "give up after this long (optional)",
|
||
}, []string{"prompt", "seal_to"})},
|
||
}},
|
||
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||
{Name: "send", Description: "Show the operator a notification.",
|
||
Input: withEnum(schema(map[string]string{
|
||
"title": "the notification's summary",
|
||
"body": "its text (optional)",
|
||
"urgency": "low, normal (the default) or critical",
|
||
}, []string{"title"}), "urgency", "low", "normal", "critical")},
|
||
{Name: "history", Description: "The notifications shown lately, newest first.",
|
||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||
}},
|
||
{Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||
{Name: "lock", Description: "Lock the operator's session now.",
|
||
Input: schema(map[string]string{}, nil)},
|
||
}},
|
||
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||
{Name: "history", Description: "What the clipboard held lately, newest first.",
|
||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
||
{Name: "copy", Description: "Put text on the operator's clipboard.",
|
||
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
|
||
}},
|
||
// It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which
|
||
// the holder renders in its own grammar. A module that knows something about the machine — the
|
||
// power module its battery — gives the block; the bar knows no machine's hardware.
|
||
{Name: BarSeat, Scope: ScopeNode, Decision: decided,
|
||
Receives: []Receivable{{Kind: BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
|
||
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
|
||
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
|
||
}
|
||
}
|
||
|
||
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
|
||
// the choices by the schema rather than by a refusal.
|
||
func withEnum(s map[string]any, property string, values ...string) map[string]any {
|
||
props := s["properties"].(map[string]any)
|
||
p := props[property].(map[string]any)
|
||
p["enum"] = values
|
||
return s
|
||
}
|
||
|
||
// BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255).
|
||
const BarKindBlock = "block"
|
||
|
||
// barShows is what a contributed block may show, and the options each takes — the bar-neutral
|
||
// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template
|
||
// renders it (the shape's examples) before it is accepted.
|
||
var barShows = map[string]map[string]barOption{
|
||
// The machine's battery: its charge, and how long it lasts or takes to fill.
|
||
"battery": {"device": {kind: "text"}},
|
||
// One line a command prints, run every interval seconds: what the bar cannot know itself.
|
||
"command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}},
|
||
// A value the contributor keeps on the bus (novox/hq ADR 0255): its state `<module>.<name>` and the
|
||
// key, this machine's name when absent. The value is an object whose `text` is what is shown. The
|
||
// holder is granted to read it and shows it however its bar shows a changing value; no file and
|
||
// no command passes between the two modules.
|
||
"state": {"state": {kind: "text", required: true}, "key": {kind: "text"}},
|
||
}
|
||
|
||
// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own.
|
||
func barBlockReads(data map[string]any) []StateRead {
|
||
if data["shows"] != "state" {
|
||
return nil
|
||
}
|
||
options, _ := data["options"].(map[string]any)
|
||
s, ok := options["state"].(string)
|
||
if !ok || s == "" {
|
||
return nil
|
||
}
|
||
key, _ := options["key"].(string)
|
||
return []StateRead{{Read: s, Key: key}}
|
||
}
|
||
|
||
type barOption struct {
|
||
kind string // text, or seconds (a whole number from 1 to 86400)
|
||
required bool
|
||
}
|
||
|
||
// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order,
|
||
// what it shows, and that kind's options.
|
||
//
|
||
// - bar: top or bottom;
|
||
// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the
|
||
// clock) — the two places every holder keeps for contributed blocks;
|
||
// - order: 0–99 within the place, 50 when absent;
|
||
// - shows: battery or command; options: what that kind takes.
|
||
func barBlockShape() *Shape {
|
||
return &Shape{
|
||
Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
|
||
{Field: "place", Values: []string{"resources", "status"}}},
|
||
Order: "order",
|
||
Check: barBlockProblems,
|
||
Reads: barBlockReads,
|
||
Examples: []map[string]any{
|
||
{"bar": "bottom", "place": "status", "shows": "battery"},
|
||
{"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}},
|
||
{"bar": "bottom", "place": "status", "shows": "command",
|
||
"options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}},
|
||
{"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}},
|
||
{"bar": "bottom", "place": "status", "shows": "state",
|
||
"options": map[string]any{"state": "power.draw", "key": "a-machine"}},
|
||
},
|
||
}
|
||
}
|
||
|
||
func barBlockProblems(data map[string]any) []string {
|
||
var problems []string
|
||
for field := range data {
|
||
if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) {
|
||
problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field))
|
||
}
|
||
}
|
||
shows, _ := data["shows"].(string)
|
||
takes, known := barShows[shows]
|
||
if !known {
|
||
return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"],
|
||
strings.Join(sortedKeys(barShows), ", ")))
|
||
}
|
||
options := map[string]any{}
|
||
if raw, has := data["options"]; has {
|
||
o, ok := raw.(map[string]any)
|
||
if !ok {
|
||
return append(problems, "a block's options are an object")
|
||
}
|
||
options = o
|
||
}
|
||
for name, v := range options {
|
||
opt, ok := takes[name]
|
||
if !ok {
|
||
problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name,
|
||
orNothing(sortedKeys(takes))))
|
||
continue
|
||
}
|
||
switch opt.kind {
|
||
case "text":
|
||
if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" {
|
||
problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name))
|
||
}
|
||
case "seconds":
|
||
if _, whole := wholeIn(v, 1, 86400); !whole {
|
||
problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name))
|
||
}
|
||
}
|
||
}
|
||
for _, name := range sortedKeys(takes) {
|
||
if _, has := options[name]; takes[name].required && !has {
|
||
problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name))
|
||
}
|
||
}
|
||
return problems
|
||
}
|