Files
mesh-catalog/modules/slack/cmd/slack-tools/slack.go
T
jochen 75f25fca21 Add slack and jetbrains-toolbox: one start each, the apps kept as found
Slack comes from the AUR and Toolbox from JetBrains' self-updating tarball,
so neither is declared: the host installs official packages only, and a
pinned archive would fight Toolbox's own updates. Slack's start and the
operator's i3 window rules become one node-display-session contribution,
because Slack's own launch-on-login is a symlink in ~/.config/autostart.
Toolbox keeps its own autostart entry as its one start. The shared
desktop.go header now names all four bundles.
2026-10-05 15:05:55 +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 host 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
}