package main // Polychromatic's tray as the tools see it: its processes, its one start (the module's line in the // window manager's configuration), the package's login helper and the one setting of the application's // that makes the helper start the tray too, and whether the daemon it drives answers. The daemon is // the openrazer module's; it is asked with --auto-start=no, so a question never starts it. import ( "encoding/json" "fmt" "strings" "time" ) const ( trayComm = "polychromatic-t" // the kernel keeps 15 characters of polychromatic-tray-applet trayWord = "polychromatic-tray-applet" trayBin = "/usr/bin/polychromatic-tray-applet" helperEntry = "polychromatic-autostart.desktop" restartAs = "mesh-polychromatic-tray" packageFor = "polychromatic" prefsFile = ".config/polychromatic/preferences.json" settingName = "Preferences → Tray → \"Start the tray applet when I log on\"" ) // trays are the tray's processes. Its cut command name is this bundle's own too, so the program is // matched by its full name. func (m *Machine) trays() []Proc { return m.procsOf(trayComm, trayWord) } // TraySetting is the application's own setting that makes its login helper start the tray. The // module never writes it: the application rewrites its preferences file itself. type TraySetting struct { // Autostart is the setting; Polychromatic's default is on. Autostart bool `json:"autostart"` Delay int `json:"autostart_delay_seconds"` From string `json:"from"` } // setting reads the tray keys of the preferences file and nothing else of it. func (m *Machine) setting() TraySetting { s := TraySetting{Autostart: true, From: "Polychromatic's default (no preferences file)"} raw, err := readBounded(m.home(prefsFile)) if err != nil { return s } var doc struct { Tray struct { Autostart *bool `json:"autostart"` Delay int `json:"autostart_delay"` } `json:"tray"` } if err := json.Unmarshal(raw, &doc); err != nil { s.From = "Polychromatic's default (the preferences file does not parse)" return s } s.From = "~/" + prefsFile if doc.Tray.Autostart != nil { s.Autostart = *doc.Tray.Autostart } s.Delay = doc.Tray.Delay return s } // Backend is whether the openrazer daemon answers, and how many devices it has. type Backend struct { Answers bool `json:"answers"` Devices int `json:"devices"` Unanswered string `json:"unanswered,omitempty"` } func (m *Machine) backend() Backend { args := []string{"--user", "--auto-start=no", "--json=short", "call", "org.razer", "/org/razer", "razer.devices", "getDevices"} o := m.cmd(5*time.Second, m.bus(), "busctl", args...) if err := failed(o, "busctl", args...); err != nil { return Backend{Unanswered: "the openrazer daemon does not answer (asked without starting it): " + err.Error()} } var doc struct { Data [][]string `json:"data"` } if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 { return Backend{Unanswered: "the daemon's device list is not busctl's JSON"} } return Backend{Answers: true, Devices: len(doc.Data[0])} } // foreign says whether the package came from outside the official repositories (pacman -Qm). func (m *Machine) foreign() bool { o := m.cmd(0, nil, "pacman", "-Qqm", packageFor) return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor } // StatusAnswer is what polychromatic_status answers. type StatusAnswer struct { Installed string `json:"installed,omitempty"` Foreign bool `json:"outside_official_repositories"` Tray []Proc `json:"tray"` StartedBy []string `json:"started_by"` Helper Autostart `json:"login_helper"` Setting TraySetting `json:"tray_setting"` Backend Backend `json:"backend"` } // Status reads the tray, what starts it, and whether the daemon behind it answers. func (m *Machine) Status() (StatusAnswer, error) { s := StatusAnswer{Tray: m.trays(), StartedBy: []string{}, Helper: m.autostart(helperEntry), Setting: m.setting(), Backend: m.backend()} if s.Tray == nil { s.Tray = []Proc{} } v, err := m.installed(packageFor) if err != nil { return s, err } s.Installed = v if v != "" { s.Foreign = m.foreign() } for _, l := range m.i3Starts(trayWord) { s.StartedBy = append(s.StartedBy, "window manager: "+l) } if s.Helper.Starts && s.Setting.Autostart { s.StartedBy = append(s.StartedBy, "the login helper ("+s.Helper.From+"), as the tray setting is on") } return s, nil } // RestartAnswer is what polychromatic_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 the tray and starts it again in the operator's session, under the account's service // manager. 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.stopProcs(5*time.Second, m.trays()) if err := m.detach(s, restartAs, trayBin); err != nil { return a, err } a.Running = m.waitForOf(trayComm, trayWord, 4*time.Second) if len(a.Running) == 0 { return a, fmt.Errorf("the tray was started as %s but no %s process appeared within 4 s: "+ "see `journalctl --user -u %s`", a.Unit, trayWord, a.Unit) } return a, nil } // CheckAnswer is what polychromatic_check answers. type CheckAnswer struct { OK bool `json:"ok"` Findings []Finding `json:"findings"` Starts []string `json:"starts"` } // Check verifies what the module promises and relies on: the package (found, not installed by the // mesh); one start, the module's window-manager line; the tray running once; and the daemon answering. 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("polychromatic is not installed. It is outside the official repositories, so the mesh does not install it", "install it from the AUR by hand; it brings the openrazer packages with it") } lines := m.i3Starts(trayWord) for _, l := range lines { a.Starts = append(a.Starts, "window manager: "+l) } switch { case len(lines) == 0: add("the window manager does not start the tray", "push the module: its line in the window manager's configuration is the tray's start") case len(lines) > 1: for _, l := range lines[1:] { add("a second start: "+l, "remove the line; the module's line is the tray's one start") } } helper := m.autostart(helperEntry) if set := m.setting(); helper.Starts && set.Autostart { a.Starts = append(a.Starts, "login helper: "+helper.From+" (the tray setting is on)") add("a second start: the package's login helper starts the tray too, as the tray setting is on ("+set.From+")", "in Polychromatic, untick "+settingName+". The helper keeps its other work: it resumes the devices' effects") } if own := m.autostart(trayWord + ".desktop"); own.Starts { a.Starts = append(a.Starts, "XDG autostart: "+own.From) add("a second start: "+own.From, "remove it; the module's line is the tray's one start") } if _, err := m.session(); err == nil { switch running := m.trays(); { case len(running) == 0: add("no tray runs in the desktop session", "polychromatic_restart") case len(running) > 1: add(fmt.Sprintf("%d trays run", len(running)), "polychromatic_restart ends them all and starts one") } } if b := m.backend(); !b.Answers { add(b.Unanswered+": the tray has no devices to show", "assign the openrazer module; openrazer_check says why the daemon is not running") } a.OK = len(a.Findings) == 0 return a, nil }