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.
679 lines
22 KiB
Go
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
|
|
}
|