Both are official packages already on both workstations, started once by dex from an XDG autostart entry (the client's own, the package's). The modules declare the package, add no second start, own none of the apps' files, and give the mesh status, log, restart and check.
668 lines
19 KiB
Go
668 lines
19 KiB
Go
package main
|
|
|
|
// The Nextcloud desktop client as the tools see it, read from the client's own files only:
|
|
// - ~/.config/Nextcloud/nextcloud.cfg, the client's settings (Qt's INI form), which the client
|
|
// rewrites and the module never writes. Accounts and sync folders are read from it. The server's
|
|
// address, the account's user ids and every credential-like key are never answered: they are
|
|
// read only to be hidden in what the log tools answer;
|
|
// - the sync-run log of each folder (<appdata>/Nextcloud/*_sync.log), whose first line is the
|
|
// folder's local path, and whose run markers and per-file lines give the last sync's state and
|
|
// errors;
|
|
// - the client's log directory, ~/.config/Nextcloud/logs, rotated by the client every two hours
|
|
// (the newest file plain, the older ones gzipped).
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Where the client keeps things, under the operator's home.
|
|
const (
|
|
configFile = ".config/Nextcloud/nextcloud.cfg"
|
|
logDir = ".config/Nextcloud/logs"
|
|
entryName = "Nextcloud.desktop"
|
|
clientComm = "nextcloud"
|
|
clientBin = "/usr/bin/nextcloud"
|
|
restartAs = "mesh-nextcloud-client"
|
|
packageFor = "nextcloud-client"
|
|
userUnit = "com.nextcloud.desktopclient.nextcloud.service"
|
|
)
|
|
|
|
// syncLogDirs are where the client has kept its sync-run logs, newest convention first.
|
|
var syncLogDirs = []string{".local/share/Nextcloud", ".config/Nextcloud"}
|
|
|
|
// ini is a Qt INI file: section → key → value. Keys keep Qt's backslash-separated groups.
|
|
type ini map[string]map[string]string
|
|
|
|
func parseINI(raw []byte) ini {
|
|
out := ini{}
|
|
section := "General"
|
|
s := bufio.NewScanner(bytes.NewReader(raw))
|
|
s.Buffer(make([]byte, 64<<10), 4<<20) // a certificate is one long line
|
|
for s.Scan() {
|
|
l := strings.TrimSpace(s.Text())
|
|
if l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") {
|
|
section = l[1 : len(l)-1]
|
|
continue
|
|
}
|
|
i := strings.IndexByte(l, '=')
|
|
if i <= 0 {
|
|
continue
|
|
}
|
|
v := strings.TrimSpace(l[i+1:])
|
|
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
|
|
v = v[1 : len(v)-1]
|
|
}
|
|
if out[section] == nil {
|
|
out[section] = map[string]string{}
|
|
}
|
|
out[section][strings.TrimSpace(l[:i])] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Folder is one sync folder as the client is configured.
|
|
type Folder struct {
|
|
LocalPath string `json:"-"`
|
|
Local string `json:"local_path"`
|
|
RemotePath string `json:"remote_path"`
|
|
Paused bool `json:"paused"`
|
|
VirtualFiles string `json:"virtual_files,omitempty"`
|
|
Journal string `json:"-"`
|
|
JournalFound bool `json:"journal_found"`
|
|
LastSync *Run `json:"last_sync,omitempty"`
|
|
}
|
|
|
|
// Account is one account, by name only.
|
|
type Account struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Auth string `json:"auth,omitempty"`
|
|
Folders []Folder `json:"folders"`
|
|
// hidden are the values never answered: the server's host and the account's user ids.
|
|
hosts []string
|
|
users []string
|
|
}
|
|
|
|
// Config is what the tools read from nextcloud.cfg.
|
|
type Config struct {
|
|
Found bool `json:"found"`
|
|
ClientVersion string `json:"client_version,omitempty"`
|
|
LaunchAtStartup *bool `json:"launch_on_system_startup,omitempty"`
|
|
Accounts []Account `json:"accounts"`
|
|
}
|
|
|
|
func (m *Machine) config() (Config, error) {
|
|
raw, err := readBounded(m.home(configFile))
|
|
if os.IsNotExist(err) {
|
|
return Config{Accounts: []Account{}}, nil
|
|
}
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("reading the client's settings: %w", err)
|
|
}
|
|
f := parseINI(raw)
|
|
c := Config{Found: true, ClientVersion: f["General"]["clientVersion"], Accounts: []Account{}}
|
|
if v, ok := f["General"]["launchOnSystemStartup"]; ok {
|
|
b := v == "true"
|
|
c.LaunchAtStartup = &b
|
|
}
|
|
byID := map[string]*Account{}
|
|
folders := map[string]map[string]*Folder{}
|
|
var ids []string
|
|
for k, v := range f["Accounts"] {
|
|
parts := strings.Split(k, `\`)
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
id := parts[0]
|
|
if _, err := strconv.Atoi(id); err != nil {
|
|
continue
|
|
}
|
|
a := byID[id]
|
|
if a == nil {
|
|
a = &Account{ID: id, Folders: []Folder{}}
|
|
byID[id] = a
|
|
folders[id] = map[string]*Folder{}
|
|
ids = append(ids, id)
|
|
}
|
|
if len(parts) == 2 {
|
|
switch parts[1] {
|
|
case "displayName":
|
|
a.Name = v
|
|
case "authType":
|
|
a.Auth = v
|
|
case "url":
|
|
if u, err := url.Parse(v); err == nil && u.Host != "" {
|
|
a.hosts = append(a.hosts, u.Host)
|
|
if u.Hostname() != u.Host {
|
|
a.hosts = append(a.hosts, u.Hostname())
|
|
}
|
|
}
|
|
case "user", "dav_user", "webflow_user", "http_user":
|
|
if v != "" {
|
|
a.users = append(a.users, v)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
// <id>\Folders\<n>\<key>, and the other folder groups (FoldersWithPlaceholders, Multifolders).
|
|
if len(parts) == 4 && strings.HasPrefix(parts[1], "Folders") || len(parts) == 4 && parts[1] == "Multifolders" {
|
|
key := parts[1] + `\` + parts[2]
|
|
fo := folders[id][key]
|
|
if fo == nil {
|
|
fo = &Folder{}
|
|
folders[id][key] = fo
|
|
}
|
|
switch parts[3] {
|
|
case "localPath":
|
|
fo.LocalPath = v
|
|
case "targetPath":
|
|
fo.RemotePath = v
|
|
case "paused":
|
|
fo.Paused = v == "true"
|
|
case "virtualFilesMode":
|
|
fo.VirtualFiles = v
|
|
case "journalPath":
|
|
fo.Journal = v
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(ids)
|
|
for _, id := range ids {
|
|
a := byID[id]
|
|
if a.Name == "" {
|
|
a.Name = "account " + id
|
|
}
|
|
var keys []string
|
|
for k := range folders[id] {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
fo := *folders[id][k]
|
|
if fo.LocalPath == "" {
|
|
continue
|
|
}
|
|
fo.Local = m.tilde(fo.LocalPath)
|
|
if fo.Journal != "" {
|
|
fo.JournalFound = exists(filepath.Join(m.Root, fo.LocalPath, fo.Journal))
|
|
}
|
|
a.Folders = append(a.Folders, fo)
|
|
}
|
|
c.Accounts = append(c.Accounts, *a)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// redactor hides what an answer must never carry: the servers' hosts, the accounts' user ids, and
|
|
// anything shaped like a credential.
|
|
type redactor struct{ hosts, users []string }
|
|
|
|
var credential = regexp.MustCompile(`(?i)\b(authorization|bearer|basic|token|apppassword|app_password|password|passwd|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:(?:bearer|basic)\s+)?[^\s"',;&]+`)
|
|
|
|
func (c Config) redactor() redactor {
|
|
var r redactor
|
|
for _, a := range c.Accounts {
|
|
r.hosts = append(r.hosts, a.hosts...)
|
|
r.users = append(r.users, a.users...)
|
|
}
|
|
// Longest first, so a host's port form is replaced before its bare name.
|
|
sort.Slice(r.hosts, func(i, j int) bool { return len(r.hosts[i]) > len(r.hosts[j]) })
|
|
sort.Slice(r.users, func(i, j int) bool { return len(r.users[i]) > len(r.users[j]) })
|
|
return r
|
|
}
|
|
|
|
func (r redactor) clean(s string) string {
|
|
s = credential.ReplaceAllString(s, "${1}${2}<hidden>")
|
|
for _, u := range r.users {
|
|
s = strings.ReplaceAll(s, u, "<account>")
|
|
}
|
|
for _, h := range r.hosts {
|
|
s = strings.ReplaceAll(s, h, "<server>")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Run is one folder's last sync run, from its sync-run log.
|
|
type Run struct {
|
|
Started string `json:"started,omitempty"`
|
|
Finished string `json:"finished,omitempty"`
|
|
// State is "finished", "running", or "never" when the log has no run.
|
|
State string `json:"state"`
|
|
Changes int `json:"items"`
|
|
Errors int `json:"errors"`
|
|
Problems []Problem `json:"problems,omitempty"`
|
|
Log string `json:"log"`
|
|
}
|
|
|
|
// Problem is one item of a run that ended with an error.
|
|
type Problem struct {
|
|
File string `json:"file"`
|
|
Error string `json:"error"`
|
|
HTTP string `json:"http,omitempty"`
|
|
}
|
|
|
|
const mostProblems = 10
|
|
|
|
// syncLogFor finds the sync-run log whose first line is the folder's local path; the newest wins.
|
|
func (m *Machine) syncLogFor(local string) string {
|
|
want := strings.TrimSuffix(local, "/")
|
|
best, bestAt := "", time.Time{}
|
|
for _, d := range syncLogDirs {
|
|
files, _ := filepath.Glob(m.home(d, "*_sync.log"))
|
|
for _, f := range files {
|
|
h, err := os.Open(f)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
first, _ := bufio.NewReader(io.LimitReader(h, 4096)).ReadString('\n')
|
|
h.Close()
|
|
if strings.TrimSuffix(strings.TrimSpace(first), "/") != want {
|
|
continue
|
|
}
|
|
if st, err := os.Stat(f); err == nil && st.ModTime().After(bestAt) {
|
|
best, bestAt = f, st.ModTime()
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// lastRun reads the end of a sync-run log: the last run's markers and its items.
|
|
func (m *Machine) lastRun(path string, r redactor) (*Run, error) {
|
|
lines, err := tailLines(path, 4000)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
run := &Run{State: "never", Log: m.tilde(strings.TrimPrefix(path, m.Root))}
|
|
start := -1
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if strings.HasPrefix(lines[i], "#=#=#=# Syncrun started ") {
|
|
start = i
|
|
break
|
|
}
|
|
}
|
|
if start < 0 {
|
|
return run, nil
|
|
}
|
|
run.Started = marker(lines[start], "#=#=#=# Syncrun started ")
|
|
run.State = "running"
|
|
for _, l := range lines[start+1:] {
|
|
switch {
|
|
case strings.HasPrefix(l, "#=#=#=# Syncrun finished "):
|
|
run.Finished = marker(l, "#=#=#=# Syncrun finished ")
|
|
run.State = "finished"
|
|
case strings.HasPrefix(l, "#"):
|
|
default:
|
|
f := strings.Split(l, "|")
|
|
if len(f) < 12 {
|
|
continue
|
|
}
|
|
run.Changes++
|
|
if e := strings.TrimSpace(f[10]); e != "" {
|
|
run.Errors++
|
|
if len(run.Problems) < mostProblems {
|
|
run.Problems = append(run.Problems, Problem{File: r.clean(f[2]), Error: r.clean(e), HTTP: strings.TrimSpace(f[11])})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return run, nil
|
|
}
|
|
|
|
func marker(line, prefix string) string {
|
|
f := strings.Fields(strings.TrimPrefix(line, prefix))
|
|
if len(f) == 0 {
|
|
return ""
|
|
}
|
|
return f[0]
|
|
}
|
|
|
|
// tailLines answers the last n lines of a file, plain or gzipped, read 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()
|
|
var r io.Reader = h
|
|
if strings.HasSuffix(path, ".gz") {
|
|
z, err := gzip.NewReader(h)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", filepath.Base(path), err)
|
|
}
|
|
defer z.Close()
|
|
r = z
|
|
} else if st, err := h.Stat(); err == nil && st.Size() > MostRead {
|
|
// A plain file is read from its end.
|
|
if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(r, MostRead))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
|
|
if len(all) == 1 && all[0] == "" {
|
|
all = nil
|
|
}
|
|
if len(all) > n {
|
|
all = all[len(all)-n:]
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// clientLogs are the client's log files, newest first.
|
|
func (m *Machine) clientLogs() []string {
|
|
entries, err := os.ReadDir(m.home(logDir))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
type file struct {
|
|
path string
|
|
at time.Time
|
|
}
|
|
var files []file
|
|
for _, e := range entries {
|
|
// The client's own log, not its permanent-delete records beside it.
|
|
if e.IsDir() || !strings.Contains(e.Name(), "_nextcloud.log") {
|
|
continue
|
|
}
|
|
if info, err := e.Info(); err == nil {
|
|
files = append(files, file{m.home(logDir, e.Name()), info.ModTime()})
|
|
}
|
|
}
|
|
sort.Slice(files, func(i, j int) bool { return files[i].at.After(files[j].at) })
|
|
out := make([]string, len(files))
|
|
for i, f := range files {
|
|
out[i] = f.path
|
|
}
|
|
return out
|
|
}
|
|
|
|
// level is a client log line's level: "[ warning category file:line ]:" → warning.
|
|
func level(line string) string {
|
|
i := strings.Index(line, "[ ")
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
f := strings.Fields(line[i+2:])
|
|
if len(f) == 0 {
|
|
return ""
|
|
}
|
|
return f[0]
|
|
}
|
|
|
|
func isProblem(line string) bool {
|
|
switch level(line) {
|
|
case "warning", "critical", "fatal":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// LogAnswer is what nextcloud_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
|
|
|
|
// Log answers the last n lines of the client's log (or only its warnings and worse), or of the sync
|
|
// runs' log, the server's address and the account's ids hidden.
|
|
func (m *Machine) Log(source string, n int, problemsOnly bool) (LogAnswer, error) {
|
|
c, err := m.config()
|
|
if err != nil {
|
|
return LogAnswer{}, err
|
|
}
|
|
r := c.redactor()
|
|
a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}}
|
|
var files []string
|
|
switch source {
|
|
case "client":
|
|
files = m.clientLogs()
|
|
if len(files) == 0 {
|
|
a.Note = "the client keeps no log files in ~/" + logDir + ": it writes them there only while its logging is switched on"
|
|
return a, nil
|
|
}
|
|
case "sync":
|
|
for _, acc := range c.Accounts {
|
|
for _, f := range acc.Folders {
|
|
if p := m.syncLogFor(f.LocalPath); p != "" {
|
|
files = append(files, p)
|
|
}
|
|
}
|
|
}
|
|
if len(files) == 0 {
|
|
a.Note = "no sync-run log found for any configured folder"
|
|
return a, nil
|
|
}
|
|
default:
|
|
return a, fmt.Errorf("source is client or sync, not %q", source)
|
|
}
|
|
// The newest file first; older ones until n lines are found, at most four files.
|
|
var got []string
|
|
for i, f := range files {
|
|
if i == 4 || len(got) >= n {
|
|
break
|
|
}
|
|
lines, err := tailLines(f, 1<<20)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
if problemsOnly {
|
|
var keep []string
|
|
for _, l := range lines {
|
|
if isProblem(l) {
|
|
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(f, m.Root)))
|
|
if source == "sync" {
|
|
// Each folder's log is its own story: the newest folder's only, unless asked again.
|
|
break
|
|
}
|
|
}
|
|
size := 0
|
|
for i := len(got) - 1; i >= 0; i-- {
|
|
l := r.clean(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
|
|
}
|
|
|
|
// Status is what nextcloud_status answers.
|
|
type Status struct {
|
|
Installed string `json:"installed,omitempty"`
|
|
Running []Proc `json:"running"`
|
|
Config Config `json:"settings"`
|
|
Problems []string `json:"recent_client_problems"`
|
|
StartedBy Autostart `json:"started_by"`
|
|
}
|
|
|
|
// Status reads the client: whether it runs, its accounts and folders with their last sync, and the
|
|
// warnings and worse at the end of its log.
|
|
func (m *Machine) Status() (Status, error) {
|
|
c, err := m.config()
|
|
if err != nil {
|
|
return Status{}, err
|
|
}
|
|
r := c.redactor()
|
|
s := Status{Running: m.procs(clientComm), Config: c, Problems: []string{}, StartedBy: m.autostart(entryName)}
|
|
if s.Running == nil {
|
|
s.Running = []Proc{}
|
|
}
|
|
if v, err := m.installed(packageFor); err == nil {
|
|
s.Installed = v
|
|
}
|
|
for ai := range s.Config.Accounts {
|
|
for fi := range s.Config.Accounts[ai].Folders {
|
|
f := &s.Config.Accounts[ai].Folders[fi]
|
|
if p := m.syncLogFor(f.LocalPath); p != "" {
|
|
if run, err := m.lastRun(p, r); err == nil {
|
|
f.LastSync = run
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The two newest files: the log rotates every two hours, and may just have.
|
|
logs := m.clientLogs()
|
|
if len(logs) > 2 {
|
|
logs = logs[:2]
|
|
}
|
|
for i := len(logs) - 1; i >= 0; i-- {
|
|
if lines, err := tailLines(logs[i], 1<<20); err == nil {
|
|
for _, l := range lines {
|
|
if isProblem(l) {
|
|
s.Problems = append(s.Problems, r.clean(l))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(s.Problems) > 10 {
|
|
s.Problems = s.Problems[len(s.Problems)-10:]
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// RestartAnswer is what nextcloud_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 running client and starts it again in the operator's session, under the account's
|
|
// service manager, as its autostart entry does (--background: to the tray, no window).
|
|
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(6*time.Second, clientComm)
|
|
if err := m.detach(s, restartAs, clientBin, "--background"); err != nil {
|
|
return a, err
|
|
}
|
|
a.Running = m.waitFor(clientComm, 4*time.Second)
|
|
if len(a.Running) == 0 {
|
|
return a, fmt.Errorf("the client was started as %s but no %s process appeared within 4 s: "+
|
|
"see `journalctl --user -u %s`", a.Unit, clientComm, a.Unit)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// CheckAnswer is what nextcloud_check answers.
|
|
type CheckAnswer struct {
|
|
OK bool `json:"ok"`
|
|
Findings []Finding `json:"findings"`
|
|
Starts []string `json:"starts"`
|
|
}
|
|
|
|
// Check verifies the module's promises: the package, one start (the client's own autostart entry,
|
|
// which the session's dex runs), the client running once in a session, and its folders present.
|
|
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 "+packageFor+" is not installed", "push the module to the node")
|
|
}
|
|
c, err := m.config()
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
entry := m.autostart(entryName)
|
|
if entry.Starts {
|
|
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
|
if !strings.Contains(entry.Exec, clientComm) {
|
|
add("the autostart entry runs "+entry.Exec+", not the client", "untick and tick again 'Launch on system startup' in the client's settings")
|
|
}
|
|
} else {
|
|
add("the client does not start with the session ("+entry.Because+")",
|
|
"tick 'Launch on system startup' in the client's General settings: the client writes its own entry")
|
|
}
|
|
if c.LaunchAtStartup != nil && !*c.LaunchAtStartup && entry.Starts {
|
|
add("the client's setting says not to launch at startup, but its autostart entry is there", "tick and untick the setting, or remove ~/.config/autostart/"+entryName)
|
|
}
|
|
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(clientComm) {
|
|
a.Starts = append(a.Starts, "window manager: "+l)
|
|
add("a second start: "+l, "remove the line; the autostart entry is the client's one start")
|
|
}
|
|
if o := m.cmd(0, m.bus(), "systemctl", "--user", "is-enabled", userUnit); o.Err == nil && strings.TrimSpace(o.Stdout) == "enabled" {
|
|
a.Starts = append(a.Starts, "user unit: "+userUnit)
|
|
add("a second start: the packaged user unit "+userUnit+" is enabled", "systemctl --user disable "+userUnit)
|
|
}
|
|
running := m.procs(clientComm)
|
|
if _, err := m.session(); err == nil {
|
|
switch {
|
|
case len(running) == 0:
|
|
add("no client runs in the desktop session", "nextcloud_restart")
|
|
case len(running) > 1:
|
|
add(fmt.Sprintf("%d clients run", len(running)), "nextcloud_restart ends them all and starts one")
|
|
}
|
|
}
|
|
if !c.Found {
|
|
add("the client has no settings yet (~/"+configFile+")", "open the client and add the account: its configuration is the operator's")
|
|
} else if len(c.Accounts) == 0 {
|
|
add("the client has no account", "add the account in the client")
|
|
}
|
|
for _, acc := range c.Accounts {
|
|
for _, f := range acc.Folders {
|
|
if !exists(filepath.Join(m.Root, f.LocalPath)) {
|
|
add("the sync folder "+m.tilde(f.LocalPath)+" of "+acc.Name+" does not exist", "recreate it, or remove the folder from the client")
|
|
} else if f.Journal != "" && !f.JournalFound {
|
|
add("the sync folder "+m.tilde(f.LocalPath)+" has no sync journal yet", "it is written at the first sync")
|
|
}
|
|
if f.Paused {
|
|
add("the sync folder "+m.tilde(f.LocalPath)+" is paused", "resume it in the client")
|
|
}
|
|
}
|
|
}
|
|
a.OK = len(a.Findings) == 0
|
|
return a, nil
|
|
}
|