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, "") s = cookieD.ReplaceAllString(s, "d=") s = credential.ReplaceAllString(s, "${1}${2}") return privateKey.ReplaceAllString(s, `"${1}": ""`) } // 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 }