package main // blueman's applet as the tools see it: its processes, its XDG autostart entry (the package's), the // applet's own answers on the session bus, and its settings in gsettings. Every bus call is made with // --auto-start=no: the applet is D-Bus activatable, and a question must never start it. import ( "encoding/json" "fmt" "sort" "strings" "time" ) const ( appletComm = "blueman-applet" trayComm = "blueman-tray" appletBin = "/usr/bin/blueman-applet" entryName = "blueman.desktop" restartAs = "mesh-blueman-applet" packageFor = "blueman" busName = "org.blueman.Applet" busPath = "/org/blueman/Applet" stackUnit = "bluetooth.service" ) // appletCall asks the running applet one question over the session bus and answers busctl's JSON. func (m *Machine) appletCall(method string) (json.RawMessage, error) { args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, busPath, busName, method} o := m.cmd(5*time.Second, m.bus(), "busctl", args...) if err := failed(o, "busctl", args...); err != nil { return nil, err } var doc struct { Data []json.RawMessage `json:"data"` } if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 { return nil, fmt.Errorf("the applet's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200)) } return doc.Data[0], nil } func (m *Machine) appletStrings(method string) ([]string, error) { raw, err := m.appletCall(method) if err != nil { return nil, err } var out []string if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("the applet's %s is not a list of names: %w", method, err) } sort.Strings(out) return out, nil } // gvariantStrings reads gsettings' printed array of strings: "@as []" or "['a', '!b']". func gvariantStrings(s string) []string { s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "@as")) s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]") out := []string{} for _, item := range strings.Split(s, ",") { item = strings.Trim(strings.TrimSpace(item), `'"`) if item != "" { out = append(out, item) } } return out } // Plugins is which applet plugins run, and what the operator's settings say about them. type Plugins struct { // Loaded are the plugins the running applet answers it has loaded. Loaded []string `json:"loaded"` // NotLoaded are the plugins it knows but did not load: switched off, or not fit for this machine. NotLoaded []string `json:"not_loaded"` // Switched is the operator's plugin-list setting: a name to load it, !name to keep it off. Empty // is blueman's defaults. Switched []string `json:"switched"` } // AppletStatus is what blueman_status answers. type AppletStatus struct { Installed string `json:"installed,omitempty"` Applet []Proc `json:"applet"` Tray []Proc `json:"tray"` StartedBy Autostart `json:"started_by"` // Bluetooth is the applet's own view of the adapter's power, when it runs. Bluetooth *bool `json:"bluetooth_on,omitempty"` Plugins Plugins `json:"plugins"` // Unanswered says why the running applet's view is missing. Unanswered string `json:"unanswered,omitempty"` } // Status reads the applet: running or not, how it starts, and its plugins. func (m *Machine) Status() (AppletStatus, error) { s := AppletStatus{Applet: m.procs(appletComm), Tray: m.procs(trayComm), StartedBy: m.autostart(entryName), Plugins: Plugins{Loaded: []string{}, NotLoaded: []string{}, Switched: []string{}}} if s.Applet == nil { s.Applet = []Proc{} } if s.Tray == nil { s.Tray = []Proc{} } if v, err := m.installed(packageFor); err == nil { s.Installed = v } o := m.cmd(5*time.Second, m.bus(), "gsettings", "get", "org.blueman.general", "plugin-list") if o.Err == nil && o.Code == 0 { s.Plugins.Switched = gvariantStrings(o.Stdout) } if len(s.Applet) == 0 { s.Unanswered = "the applet is not running" return s, nil } loaded, err := m.appletStrings("QueryPlugins") if err != nil { s.Unanswered = err.Error() return s, nil } s.Plugins.Loaded = loaded if all, err := m.appletStrings("QueryAvailablePlugins"); err == nil { in := map[string]bool{} for _, p := range loaded { in[p] = true } for _, p := range all { if !in[p] { s.Plugins.NotLoaded = append(s.Plugins.NotLoaded, p) } } } if raw, err := m.appletCall("GetBluetoothStatus"); err == nil { var on bool if json.Unmarshal(raw, &on) == nil { s.Bluetooth = &on } } return s, nil } // RestartAnswer is what blueman_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 applet and its tray icon, and starts the applet again in the operator's session // under the account's service manager. The applet starts its tray icon itself. 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(5*time.Second, appletComm, trayComm) if err := m.detach(s, restartAs, appletBin); err != nil { return a, err } a.Running = m.waitFor(appletComm, 4*time.Second) if len(a.Running) == 0 { return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+ "see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit) } return a, nil } // CheckAnswer is what blueman_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; one start (the package's // autostart entry, which the session's dex runs); the applet running once in a session; and the // Bluetooth daemon it manages, which is the bluetooth module's. 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("the package blueman is not installed", "push the module to the node") } entry := m.autostart(entryName) if entry.Starts { a.Starts = append(a.Starts, "XDG autostart: "+entry.From) if entry.From != "/etc/xdg/autostart/"+entryName { add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start") } } else { add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry") } if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil { add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it") } for _, l := range m.i3Starts(appletComm) { a.Starts = append(a.Starts, "window manager: "+l) add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start") } if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" { add("the Bluetooth daemon ("+stackUnit+") is not running: the applet has nothing to manage", "assign the bluetooth module, which owns bluez and its daemon") } running := m.procs(appletComm) if _, err := m.session(); err == nil { switch { case len(running) == 0: add("no applet runs in the desktop session", "blueman_restart") case len(running) > 1: add(fmt.Sprintf("%d applets run", len(running)), "blueman_restart ends them all and starts one") } } a.OK = len(a.Findings) == 0 return a, nil }