Files
mesh-catalog/modules/slack/cmd/slack-tools/slack.go
T
jochen 73bb597c16
mesh/merge-gate pass: builds docker, gitea, lab, nftables, slack, systemd → ace, g14, novox, shanks; no bus step; 4 wait(s) for a person; every machine com…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Hold what the tools say to the glossary's retired words (hq ADR 0244)
An agent meets the mesh's words most often in tool descriptions, and
nothing compared them with the glossary: several still said "the host"
for the node-engine and the forge's pull request comment was headed
"Change plan", a word retired twice over. retired-words is the copy of
the words the glossary retires for the tools, and checks/words fails the
repository check when any string a module's code can show, or any
manifest description, uses one. Those found are reworded here.
2026-10-07 20:02:09 +02:00

679 lines
22 KiB
Go

package main
// Slack as the tools see it. Slack is one Electron process tree: the main process (comm slack, no
// --type=) is both the window and the tray icon, and its helpers (--type=zygote, gpu-process,
// renderer, utility) are its children. The tools read only:
// - the account's processes, and of Slack's main process where its output goes (fd 1 and 2);
// - Slack's settings file, ~/.config/Slack/storage/root-state.json, of which only switches are
// answered (launch on login, start hidden, run from the tray, notification method, hardware
// acceleration), and local-settings.json for the Electron version;
// - Slack's own log, ~/.config/Slack/logs/default/browser*.log, from which the signed-in accounts and
// workspaces are counted (counted only: no name, no id) and which slack_log answers masked;
// - the X window tree (xwininfo), for the tray icon; the session bus, for who shows notifications.
//
// Never read: Cookies, Local Storage, IndexedDB, the os_crypt key in Local State. Those are the
// sessions' credentials, and no tool needs them.
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// Where Slack keeps things, under the operator's home, and how it is started.
const (
slackComm = "slack"
slackBin = "/usr/bin/slack"
packageFor = "slack-desktop"
restartAs = "mesh-slack"
entryName = "slack.desktop"
stateFile = ".config/Slack/storage/root-state.json"
localFile = ".config/Slack/local-settings.json"
logDir = ".config/Slack/logs/default"
i3Main = ".config/i3/config"
i3DropIns = ".config/i3/config.d"
notifyName = "org.freedesktop.Notifications"
)
// startArgs are the package's own desktop entry's arguments: GTK 3, and -s, started to the tray.
var startArgs = []string{"--gtk-version=3", "-s"}
// ruleMark is how the module's window rules are recognised wherever they are found.
const ruleMark = `for_window [class="(?i)^slack$"`
// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link.
var readLink = os.Readlink
// isLink says whether a path is a symbolic link itself; tests replace it for the same reason.
var isLink = func(p string) bool {
fi, err := os.Lstat(p)
return err == nil && fi.Mode()&os.ModeSymlink != 0
}
// mainProcs are Slack's main processes: comm slack without --type=.
func (m *Machine) mainProcs() []Proc {
var out []Proc
for _, p := range m.procs(slackComm) {
if !strings.Contains(p.Command, "--type=") {
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 with EPIPE.
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 the session's output is sent there)"
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
}
// Settings are the switches of Slack's settings the tools answer. Every other key stays unread.
type Settings struct {
Found bool `json:"found"`
LaunchOnLogin *bool `json:"launch_on_login,omitempty"`
HideOnStartup *bool `json:"hide_on_startup,omitempty"`
RunFromTray *bool `json:"run_from_tray,omitempty"`
NotificationMethod string `json:"notification_method,omitempty"`
HardwareAccel *bool `json:"hardware_acceleration,omitempty"`
Electron string `json:"electron,omitempty"`
}
func (m *Machine) settings() Settings {
var s Settings
raw, err := readBounded(m.home(stateFile))
if err == nil {
var doc struct {
Settings struct {
LaunchOnStartup *bool `json:"launchOnStartup"`
HideOnStartup *bool `json:"hideOnStartup"`
RunFromTray *bool `json:"runFromTray"`
NotificationMethod *string `json:"notificationMethod"`
UseHwAcceleration *bool `json:"useHwAcceleration"`
} `json:"settings"`
}
if json.Unmarshal(raw, &doc) == nil {
s.Found = true
s.LaunchOnLogin, s.HideOnStartup, s.RunFromTray = doc.Settings.LaunchOnStartup, doc.Settings.HideOnStartup, doc.Settings.RunFromTray
s.HardwareAccel = doc.Settings.UseHwAcceleration
s.NotificationMethod = "Slack's default"
if doc.Settings.NotificationMethod != nil && *doc.Settings.NotificationMethod != "" {
s.NotificationMethod = *doc.Settings.NotificationMethod
}
}
}
if raw, err := readBounded(m.home(localFile)); err == nil {
var doc struct {
Electron string `json:"lastElectronVersionLaunched"`
}
if json.Unmarshal(raw, &doc) == nil {
s.Electron = doc.Electron
}
}
return s
}
// Workspaces is how many accounts Slack is signed in to, and how many workspaces they open: counted
// from the last report Slack logged of each account, since its last start. Never a name or an id.
type Workspaces struct {
Accounts int `json:"accounts"`
Workspaces int `json:"workspaces"`
AsOf string `json:"as_of,omitempty"`
Note string `json:"note,omitempty"`
}
var logStamp = regexp.MustCompile(`^\[(\d\d/\d\d/\d\d, \d\d:\d\d:\d\d:\d+)\] (\w+): `)
// browserLogs are Slack's main-process logs, oldest first (browser2.log, browser1.log, browser.log).
func (m *Machine) browserLogs() []string {
files, _ := filepath.Glob(m.home(logDir, "browser*.log"))
sort.Slice(files, func(i, j int) bool {
a, _ := os.Stat(files[i])
b, _ := os.Stat(files[j])
if a == nil || b == nil {
return files[i] < files[j]
}
return a.ModTime().Before(b.ModTime())
})
if len(files) > 3 {
files = files[len(files)-3:]
}
return files
}
func (m *Machine) workspaces() Workspaces {
var lines []string
for _, f := range m.browserLogs() {
l, err := tailLines(f, 1<<20)
if err == nil {
lines = append(lines, l...)
}
}
start := 0
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], "] info: Store: INITIALIZE") {
start = i
break
}
}
accounts := map[string][]string{}
var asOf string
for i := start; i < len(lines); i++ {
if !strings.Contains(lines[i], "Store: STORE_USER_WORKSPACES") {
continue
}
var block strings.Builder
j := i + 1
for ; j < len(lines) && !logStamp.MatchString(lines[j]); j++ {
block.WriteString(lines[j])
}
var report struct {
User string `json:"userTeamId"`
Workspaces []string `json:"workspaceIds"`
}
if json.Unmarshal([]byte(block.String()), &report) == nil && report.User != "" {
accounts[report.User] = report.Workspaces
if mm := logStamp.FindStringSubmatch(lines[i]); mm != nil {
asOf = mm[1]
}
}
i = j - 1
}
w := Workspaces{Accounts: len(accounts), AsOf: asOf}
all := map[string]bool{}
for _, ws := range accounts {
for _, id := range ws {
all[id] = true
}
}
w.Workspaces = len(all)
if len(accounts) == 0 {
w.Note = "Slack has logged no account since its last start: signed out, or not started yet"
}
return w
}
// Tray is whether Slack's icon sits in a tray: a window of class "Slack" reparented into another
// program's window (i3bar's tray), found in the X window tree.
type Tray struct {
Present bool `json:"present"`
In string `json:"in,omitempty"`
Unknown string `json:"unknown,omitempty"`
}
var treeLine = regexp.MustCompile(`^(\s*)0x[0-9a-f]+ (?:"[^"]*"|\(has no name\)): \("([^"]*)" "([^"]*)"\)`)
// trayIn reads `xwininfo -root -tree`: each window is indented under its parent.
func trayIn(tree string) (bool, string) {
type frame struct {
indent int
class string
}
var stack []frame
for _, l := range strings.Split(tree, "\n") {
trimmed := strings.TrimLeft(l, " ")
if !strings.HasPrefix(trimmed, "0x") {
continue
}
indent := len(l) - len(trimmed)
for len(stack) > 0 && stack[len(stack)-1].indent >= indent {
stack = stack[:len(stack)-1]
}
mm := treeLine.FindStringSubmatch(l)
class := ""
if mm != nil {
class = mm[3]
if mm[2] == "slack" && class == "Slack" {
for k := len(stack) - 1; k >= 0; k-- {
if c := stack[k].class; c != "" && !strings.EqualFold(c, "slack") {
return true, c
}
}
}
}
stack = append(stack, frame{indent, class})
}
return false, ""
}
func (m *Machine) tray(s Session) Tray {
o := m.cmd(5*time.Second, s.Env(), "xwininfo", "-root", "-tree")
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
if o.Err == ErrNotInstalled {
return Tray{Unknown: "xwininfo (package xorg-xwininfo) is not installed, so the window tree cannot be read"}
}
return Tray{Unknown: err.Error()}
}
ok, in := trayIn(o.Stdout)
return Tray{Present: ok, In: in}
}
// Notifier is who shows Slack's notifications: the owner of the session bus's notification name.
type Notifier struct {
Owner string `json:"owner,omitempty"`
PID int `json:"pid,omitempty"`
// Activatable is a notifier that is not running but that the bus starts on the first notification.
Activatable bool `json:"activatable,omitempty"`
Unknown string `json:"unknown,omitempty"`
}
func (m *Machine) notifier() Notifier {
o := m.cmd(5*time.Second, m.bus(), "busctl", "--user", "list", "--no-legend", "--no-pager")
if err := failed(o, "busctl", "--user", "list"); err != nil {
return Notifier{Unknown: err.Error()}
}
for _, l := range strings.Split(o.Stdout, "\n") {
f := strings.Fields(l)
if len(f) < 3 || f[0] != notifyName {
continue
}
if pid, err := strconv.Atoi(f[1]); err == nil {
return Notifier{Owner: f[2], PID: pid}
}
return Notifier{Activatable: true}
}
return Notifier{}
}
// Status is what slack_status answers.
type Status struct {
Installed string `json:"installed,omitempty"`
Foreign bool `json:"outside_the_official_repositories,omitempty"`
Running []Proc `json:"running"`
Helpers int `json:"helper_processes"`
Output []Stream `json:"output,omitempty"`
Tray *Tray `json:"tray,omitempty"`
Notifier Notifier `json:"notifications_to"`
Settings Settings `json:"settings"`
Workspaces Workspaces `json:"signed_in"`
Starts []string `json:"starts"`
}
// foreign says whether pacman counts the package as foreign: installed, but in no sync repository.
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
}
func (m *Machine) Status() (Status, error) {
s := Status{Running: m.mainProcs(), Settings: m.settings(), Workspaces: m.workspaces(), Starts: []string{}}
if s.Running == nil {
s.Running = []Proc{}
}
s.Helpers = len(m.procs(slackComm)) - len(s.Running)
if v, err := m.installed(packageFor); err == nil && v != "" {
s.Installed, s.Foreign = v, m.foreign(packageFor)
}
if len(s.Running) > 0 {
s.Output = m.streams(s.Running[0].PID)
if sess, err := m.session(); err == nil {
t := m.tray(sess)
s.Tray = &t
}
}
s.Notifier = m.notifier()
for _, st := range m.starts() {
s.Starts = append(s.Starts, st.What)
}
return s, nil
}
// Start is one thing that starts Slack at login; Mine is the module's own.
type Start struct {
What string
Mine bool
Do string
}
// starts are every start of Slack at login the tools can see: window-manager exec lines (the module's
// in i3's main file, any other in the operator's drop-ins) and XDG autostart entries.
func (m *Machine) starts() []Start {
var out []Start
main := m.tilde(filepath.Join(m.Home, i3Main)) + ":"
for _, l := range m.i3Starts(slackComm) {
if strings.HasPrefix(l, main) {
out = append(out, Start{What: "window manager (this module's line): " + l, Mine: true})
} else {
out = append(out, Start{What: "window manager: " + l, Do: "remove the line: the module's line in i3's configuration is Slack's one start"})
}
}
if a := m.autostart(entryName); a.Starts {
st := Start{What: "XDG autostart: " + a.From + " (" + a.Exec + ")"}
user := m.home(".config", "autostart", entryName)
if isLink(user) {
st.Do = "untick 'Launch app on login' in Slack's preferences: Slack made this link for that setting and removes it when unticked"
} else if strings.HasPrefix(a.From, "~") {
st.Do = "delete " + a.From + " (the predecessor's): Slack then reads 'Launch app on login' as off, and the module's line is the one start"
} else {
st.Do = "hide it with an entry of the account (Hidden=true): the module's line is Slack's one start"
}
out = append(out, st)
}
return out
}
// LogAnswer is what slack_log answers.
type LogAnswer struct {
Source string `json:"source"`
Files []string `json:"files"`
Lines []string `json:"lines"`
Cut bool `json:"cut,omitempty"`
Note string `json:"note,omitempty"`
}
const mostAnswer = 256 << 10
// The masks. A token or a cookie is hidden wherever it appears; a notification's or a message's text,
// and the names of people, channels and workspaces, are hidden by their key.
var (
slackToken = regexp.MustCompile(`xox[a-z]-[A-Za-z0-9-]+|xapp-[A-Za-z0-9-]+`)
credential = regexp.MustCompile(`(?i)\b(authorization|bearer|token|password|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:bearer\s+)?[^\s"',;&]+`)
cookieD = regexp.MustCompile(`\bd=[A-Za-z0-9%/+=._-]{20,}`)
privateKey = regexp.MustCompile(`"(title|subtitle|content|body|text|authorName|userName|channelName|workspaceName|teamName|name|email|realName|displayName)": "(?:[^"\\]|\\.)*"`)
)
func mask(s string) string {
s = slackToken.ReplaceAllString(s, "<hidden>")
s = cookieD.ReplaceAllString(s, "d=<hidden>")
s = credential.ReplaceAllString(s, "${1}${2}<hidden>")
return privateKey.ReplaceAllString(s, `"${1}": "<hidden>"`)
}
// Log answers the last n lines of Slack's main-process log (source browser) or of its web app's
// console log (source webapp), masked; problems keeps the error and warning entries.
func (m *Machine) Log(source string, n int, problems bool) (LogAnswer, error) {
a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}}
var files []string
switch source {
case "browser":
files = m.browserLogs()
case "webapp":
files, _ = filepath.Glob(m.home(logDir, "webapp-console*.log"))
sort.Slice(files, func(i, j int) bool {
a, _ := os.Stat(files[i])
b, _ := os.Stat(files[j])
return a != nil && b != nil && a.ModTime().Before(b.ModTime())
})
default:
return a, fmt.Errorf("source is browser or webapp, not %q", source)
}
if len(files) == 0 {
a.Note = "Slack keeps no such log in ~/" + logDir
return a, nil
}
var got []string
for i := len(files) - 1; i >= 0 && len(got) < n && len(a.Files) < 3; i-- {
lines, err := tailLines(files[i], 1<<20)
if err != nil {
return a, err
}
if problems {
var keep []string
for _, l := range lines {
if mm := logStamp.FindStringSubmatch(l); mm != nil && (mm[2] == "error" || mm[2] == "warn") {
keep = append(keep, l)
}
}
lines = keep
}
if len(lines) > n-len(got) {
lines = lines[len(lines)-(n-len(got)):]
}
got = append(lines, got...)
a.Files = append(a.Files, m.tilde(strings.TrimPrefix(files[i], m.Root)))
}
size := 0
for i := len(got) - 1; i >= 0; i-- {
l := mask(got[i])
if size+len(l) > mostAnswer {
a.Cut = true
got = got[i+1:]
break
}
size += len(l) + 1
got[i] = l
}
if got == nil {
got = []string{}
}
a.Lines = got
return a, nil
}
// tailLines answers the last n lines of a file, read from its end to at most MostRead.
func tailLines(path string, n int) ([]string, error) {
h, err := os.Open(path)
if err != nil {
return nil, err
}
defer h.Close()
if st, err := h.Stat(); err == nil && st.Size() > MostRead {
if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil {
return nil, err
}
}
var all []string
s := bufio.NewScanner(io.LimitReader(h, MostRead))
s.Buffer(make([]byte, 64<<10), 4<<20)
for s.Scan() {
all = append(all, string(bytes.TrimRight(s.Bytes(), "\r")))
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, s.Err()
}
// RestartAnswer is what slack_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends Slack (the main process and its helpers) and starts it again to the tray, in the
// operator's session, under the account's service manager: its output then goes to the journal.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(8*time.Second, slackComm)
if err := m.detach(s, restartAs, append([]string{slackBin}, startArgs...)...); err != nil {
return a, err
}
m.waitFor(slackComm, 6*time.Second)
a.Running = m.mainProcs()
if a.Running == nil {
a.Running = []Proc{}
}
if len(a.Running) == 0 {
return a, fmt.Errorf("Slack was started as %s but no main process appeared within 6 s: "+
"see `journalctl --user -u %s`", a.Unit, a.Unit)
}
return a, nil
}
// CheckAnswer is what slack_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises: Slack installed, one start (the module's line), the window
// rules once, one Slack running with its output read, its icon in the tray, and a notifier.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
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("Slack ("+packageFor+") is not installed", "install it from the AUR: the module does not install it, because the node-engine installs packages from the official repositories only")
}
mine := 0
for _, st := range m.starts() {
a.Starts = append(a.Starts, st.What)
if st.Mine {
mine++
} else {
add("a second start: "+st.What, st.Do)
}
}
if mine == 0 {
add("i3's configuration has not got the module's start of Slack", "assign the i3 module, which places this module's lines, and push the node")
} else if mine > 1 {
add(fmt.Sprintf("i3's configuration starts Slack %d times", mine), "push the node: the module's lines are placed once")
}
rules, operators := m.windowRules()
if rules == 0 {
add("i3's configuration has not got the module's window rules for Slack", "assign the i3 module and push the node")
}
for _, f := range operators {
add("the operator's "+f+" repeats Slack's window rules, which are this module's now", "delete "+f+": i3 reads it after the module's lines")
}
running := m.mainProcs()
sess, serr := m.session()
if serr == nil {
switch {
case len(running) == 0:
add("Slack does not run in the desktop session", "slack_restart")
case len(running) > 1:
add(fmt.Sprintf("%d Slack main processes run", len(running)), "slack_restart ends them all and starts one")
}
}
if len(running) > 0 {
for _, st := range m.streams(running[0].PID) {
if st.Dead {
add(fmt.Sprintf("Slack's fd %d is a pipe nobody reads (a session started before its output went to the journal): "+
"its logger silences the EPIPE it gets there, any other write it makes fails", st.FD),
"slack_restart: its output then goes to the journal. Every later login sends the session's output there (the i3 module's login entry)")
}
}
if serr == nil {
if t := m.tray(sess); !t.Present && t.Unknown == "" {
add("Slack's icon is in no tray: closing its window leaves it unreachable", "give the bar a tray (i3bar's tray_output), then slack_restart")
}
}
}
if n := m.notifier(); n.Unknown == "" && n.Owner == "" && !n.Activatable {
add("nobody shows notifications on the session bus ("+notifyName+"): Slack's go nowhere", "assign dunst, the node's notifier")
}
a.OK = len(a.Findings) == 0
return a, nil
}
// windowRules counts the module's rule lines in i3's main file, and names the operator's drop-ins that
// carry Slack's rules too.
func (m *Machine) windowRules() (int, []string) {
n := 0
if raw, err := readBounded(m.home(i3Main)); err == nil {
for _, l := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(strings.TrimSpace(l), ruleMark) {
n++
}
}
}
var files []string
more, _ := filepath.Glob(m.home(i3DropIns, "*.conf"))
for _, f := range more {
raw, err := readBounded(f)
if err != nil {
continue
}
for _, l := range strings.Split(string(raw), "\n") {
t := strings.ToLower(strings.TrimSpace(l))
if strings.HasPrefix(t, "for_window") && strings.Contains(t, "slack") {
files = append(files, m.tilde(strings.TrimPrefix(f, m.Root)))
break
}
}
}
return n, files
}