package main import ( "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" ) // refs are an application or runtime id, optionally with arch and branch: org.gimp.GIMP, // org.freedesktop.Platform/x86_64/23.08, app/org.gimp.GIMP/x86_64/stable. var refs = regexp.MustCompile(`^((app|runtime)/)?[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+(/[A-Za-z0-9_]*(/[A-Za-z0-9._-]*)?)?$`) func checkRef(r string) error { if !refs.MatchString(r) { return fmt.Errorf("%q is not a flatpak id or ref", r) } return nil } var remoteName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) // Ref is one installed application or runtime. type Ref struct { ID string `json:"id"` Name string `json:"name,omitempty"` Version string `json:"version,omitempty"` Branch string `json:"branch"` Arch string `json:"arch"` Ref string `json:"ref"` Origin string `json:"origin"` Installation string `json:"installation"` Size string `json:"size"` Bytes int64 `json:"bytes"` } // sizeBytes reads flatpak's human size ("275.8 MB", with a no-break space): decimal units. func sizeBytes(s string) int64 { f := strings.Fields(strings.ReplaceAll(s, " ", " ")) if len(f) == 0 { return 0 } n, err := strconv.ParseFloat(f[0], 64) if err != nil { return 0 } unit := map[string]float64{"bytes": 1, "byte": 1, "B": 1, "kB": 1e3, "KB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12} if len(f) > 1 { if m, ok := unit[f[1]]; ok { n *= m } } return int64(n) } func installFlag(inst string) []string { if inst == "" { return nil } return []string{"--" + inst} } // listRefs runs flatpak list for apps or runtimes. func listRefs(kind, inst string) ([]Ref, error) { args := append([]string{"list", "--" + kind, "--columns=application,name,version,branch,arch,origin,installation,size,ref"}, installFlag(inst)...) r, err := call(Cmd{Name: "flatpak", Args: args}) if err != nil { return nil, err } out := []Ref{} for _, l := range lines(r.Stdout) { f := strings.Split(l, "\t") if len(f) < 9 { continue } size := strings.ReplaceAll(f[7], " ", " ") out = append(out, Ref{ID: f[0], Name: f[1], Version: f[2], Branch: f[3], Arch: f[4], Origin: f[5], Installation: f[6], Size: size, Bytes: sizeBytes(size), Ref: f[8]}) } return out, nil } // ListAnswer is what flatpak_list and flatpak_runtimes answer. type ListAnswer struct { Count int `json:"count"` Bytes int64 `json:"bytes"` Refs []Ref `json:"refs"` } // List answers the applications or runtimes. func List(kind, inst string) (ListAnswer, error) { got, err := listRefs(kind, inst) if err != nil { return ListAnswer{}, err } out := ListAnswer{Count: len(got), Refs: got} for _, r := range got { out.Bytes += r.Bytes } return out, nil } // Remote is one remote of one installation. type Remote struct { Name string `json:"name"` URL string `json:"url"` Installation string `json:"installation"` Priority string `json:"priority"` Disabled bool `json:"disabled"` Installed int `json:"installed_from"` } // RemotesAnswer is what flatpak_remotes answers. type RemotesAnswer struct { Remotes []Remote `json:"remotes"` Flathub bool `json:"flathub_system"` Findings []string `json:"findings"` } // Remotes answers both installations' remotes and what is wrong with them. func Remotes() (RemotesAnswer, error) { r, err := call(Cmd{Name: "flatpak", Args: []string{"remotes", "--show-disabled", "--columns=name,url,options,priority"}}) if err != nil { return RemotesAnswer{}, err } all, err := listRefs("app", "") if err != nil { return RemotesAnswer{}, err } rt, err := listRefs("runtime", "") if err != nil { return RemotesAnswer{}, err } from := map[string]int{} for _, x := range append(all, rt...) { from[x.Origin+"\x00"+x.Installation]++ } out := RemotesAnswer{Remotes: []Remote{}, Findings: []string{}} byURL := map[string][]string{} for _, l := range lines(r.Stdout) { f := strings.Split(l, "\t") if len(f) < 4 { continue } inst := "system" disabled := false for _, o := range strings.Split(f[2], ",") { switch strings.TrimSpace(o) { case "user": inst = "user" case "disabled": disabled = true } if strings.HasPrefix(strings.TrimSpace(o), "system") { inst = strings.TrimSpace(o) } } rem := Remote{Name: f[0], URL: f[1], Installation: inst, Priority: f[3], Disabled: disabled, Installed: from[f[0]+"\x00"+inst]} out.Remotes = append(out.Remotes, rem) byURL[strings.TrimRight(rem.URL, "/")] = append(byURL[strings.TrimRight(rem.URL, "/")], rem.Name+" ("+inst+")") if inst == "system" && rem.Name == "flathub" && !disabled { out.Flathub = true } if rem.Installed == 0 { out.Findings = append(out.Findings, fmt.Sprintf("remote %s in the %s installation has nothing installed from it", rem.Name, inst)) } } if !out.Flathub { out.Findings = append(out.Findings, "the system installation has no enabled flathub remote: the flatpak package ships one in /usr/share/flatpak/remotes.d, so it was removed or disabled by hand") } for url, names := range byURL { if len(names) > 1 { sort.Strings(names) out.Findings = append(out.Findings, fmt.Sprintf("%s is configured %d times: %s", url, len(names), strings.Join(names, ", "))) } } sort.Strings(out.Findings) return out, nil } // Updates answers what an update would change. func Updates(inst string) (map[string]any, error) { args := append([]string{"remote-ls", "--updates", "--columns=application,branch,version,origin,ref"}, installFlag(inst)...) r, err := call(Cmd{Name: "flatpak", Args: args}) if err != nil { return nil, err } out := []map[string]string{} for _, l := range lines(r.Stdout) { f := strings.Split(l, "\t") if len(f) >= 5 { out = append(out, map[string]string{"id": f[0], "branch": f[1], "version": f[2], "origin": f[3], "ref": f[4]}) } } return map[string]any{"count": len(out), "updates": out}, nil } // Extension is one extension point a ref's metadata declares. type Extension struct { ID string Versions []string Subdirs bool } // extensionsOf reads [Extension …] groups from a ref's metadata, with the versions each accepts: // versions, else version, else the branch of the ref declaring it. func extensionsOf(metadata, branch string) []Extension { out := []Extension{} var cur *Extension flush := func() { if cur != nil { if len(cur.Versions) == 0 { cur.Versions = []string{branch} } out = append(out, *cur) } cur = nil } var version, versions string for _, l := range strings.Split(metadata, "\n") { l = strings.TrimSpace(l) if strings.HasPrefix(l, "[") { if cur != nil { cur.Versions = pickVersions(versions, version) } flush() version, versions = "", "" if strings.HasPrefix(l, "[Extension ") && strings.HasSuffix(l, "]") { cur = &Extension{ID: strings.TrimSuffix(strings.TrimPrefix(l, "[Extension "), "]")} } continue } if cur == nil { continue } k, v, found := strings.Cut(l, "=") if !found { continue } switch strings.TrimSpace(k) { case "version": version = strings.TrimSpace(v) case "versions": versions = strings.TrimSpace(v) case "subdirectories": cur.Subdirs = strings.TrimSpace(v) == "true" } } if cur != nil { cur.Versions = pickVersions(versions, version) } flush() return out } func pickVersions(versions, version string) []string { if versions != "" { out := []string{} for _, v := range strings.Split(versions, ";") { if v = strings.TrimSpace(v); v != "" { out = append(out, v) } } return out } if version != "" { return []string{version} } return nil } // fills says whether a runtime fills an extension point. func (e Extension) fills(r Ref) bool { if !(r.ID == e.ID || (e.Subdirs && strings.HasPrefix(r.ID, e.ID+"."))) { return false } for _, v := range e.Versions { if v == r.Branch { return true } } return false } // UnusedAnswer is what flatpak_unused answers. type UnusedAnswer struct { Count int `json:"count"` Bytes int64 `json:"bytes"` Unused []Ref `json:"unused"` Pinned []string `json:"pinned"` Note string `json:"note"` } func info(inst string, extra ...string) (string, error) { r, err := call(Cmd{Name: "flatpak", Args: append(append([]string{"info"}, installFlag(inst)...), extra...)}) return strings.TrimSpace(r.Stdout), err } // Unused computes what no installed application needs. func Unused() (UnusedAnswer, error) { apps, err := listRefs("app", "") if err != nil { return UnusedAnswer{}, err } runtimes, err := listRefs("runtime", "") if err != nil { return UnusedAnswer{}, err } key := func(inst, ref string) string { return inst + "\x00" + strings.TrimPrefix(strings.TrimPrefix(ref, "runtime/"), "app/") } installed := map[string]Ref{} for _, r := range runtimes { installed[key(r.Installation, r.Ref)] = r } used := map[string]bool{} type item struct { inst, ref, branch string } queue := []item{} // A user installation's application may use a system runtime; a system one only system runtimes. mark := func(inst, ref string) { for _, where := range []string{inst, "system"} { k := key(where, ref) if r, ok := installed[k]; ok && !used[k] { used[k] = true queue = append(queue, item{where, r.Ref, r.Branch}) return } } } for _, a := range apps { for _, flag := range []string{"--show-runtime", "--show-sdk"} { ref, err := info(a.Installation, flag, a.Ref) if err != nil { return UnusedAnswer{}, err } if ref != "" { mark(a.Installation, ref) } } queue = append(queue, item{a.Installation, a.Ref, a.Branch}) } pinned := []string{} for _, inst := range []string{"system", "user"} { r := run(Cmd{Name: "flatpak", Args: []string{"pin", "--" + inst}}) if r.Status != 0 || r.Error != "" { continue } for _, l := range lines(r.Stdout) { p := strings.TrimSpace(l) if strings.HasPrefix(p, "runtime/") { pinned = append(pinned, p) mark(inst, p) } } } for len(queue) > 0 { it := queue[0] queue = queue[1:] meta, err := info(it.inst, "--show-metadata", it.ref) if err != nil { return UnusedAnswer{}, err } for _, e := range extensionsOf(meta, it.branch) { for k, r := range installed { if !used[k] && e.fills(r) && (r.Installation == it.inst || r.Installation == "system") { used[k] = true queue = append(queue, item{r.Installation, r.Ref, r.Branch}) } } } } out := UnusedAnswer{Unused: []Ref{}, Pinned: pinned, Note: "Computed without changing anything. flatpak_remove_unused lets flatpak decide, which may differ in detail (a locale or debug extension listed here by nothing)."} for k, r := range installed { if !used[k] { out.Unused = append(out.Unused, r) out.Bytes += r.Bytes } } sort.Slice(out.Unused, func(i, k int) bool { return out.Unused[i].Ref < out.Unused[k].Ref }) out.Count = len(out.Unused) return out, nil } // Where each installation lives. var installDirs = map[string]string{"system": "/var/lib/flatpak", "user": ".local/share/flatpak"} // DiskAnswer is what flatpak_disk_usage answers. type DiskAnswer struct { Installations map[string]int64 `json:"installation_bytes"` Largest []Ref `json:"largest"` Note string `json:"note,omitempty"` } // DiskUsage measures each installation's directory and lists the largest refs. func DiskUsage() (DiskAnswer, error) { out := DiskAnswer{Installations: map[string]int64{}, Largest: []Ref{}} for inst, dir := range installDirs { if !filepath.IsAbs(dir) { dir = filepath.Join(accountHome(), dir) } // du exits 1 when a file is unreadable and still prints the total of what it could read. r := run(Cmd{Name: "du", Args: []string{"-sb", dir}}) if r.Error != "" { return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r) } f := strings.Fields(r.Stdout) if len(f) == 0 { if strings.Contains(r.Stderr, "No such file") { out.Installations[inst] = 0 continue } return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r) } n, _ := strconv.ParseInt(f[0], 10, 64) out.Installations[inst] = n if r.Status != 0 { out.Note = "some files were unreadable to the account, so a total is a lower bound" } } apps, err := listRefs("app", "") if err != nil { return DiskAnswer{}, err } rts, err := listRefs("runtime", "") if err != nil { return DiskAnswer{}, err } all := append(apps, rts...) sort.Slice(all, func(i, k int) bool { return all[i].Bytes > all[k].Bytes }) if len(all) > 50 { all = all[:50] } out.Largest = all return out, nil } func accountHome() string { return homeFrom(getenv) } // ActAnswer is what an act answers. type ActAnswer struct { Act string `json:"act"` Ref string `json:"ref,omitempty"` Installation string `json:"installation"` Job Job `json:"job"` } // act runs flatpak as a job; the system installation needs root, the account's does not. func act(verb, ref, inst string, args ...string) (ActAnswer, error) { c := Cmd{Name: "flatpak", Args: append([]string{verb, "--" + inst, "--noninteractive", "-y"}, args...), Root: inst == "system"} j, err := actAsJob(c) if err != nil { return ActAnswer{}, err } return ActAnswer{Act: verb, Ref: ref, Installation: inst, Job: j}, nil } // Install installs a ref from a remote. func Install(ref, remote, inst string) (ActAnswer, error) { if !remoteName.MatchString(remote) { return ActAnswer{}, fmt.Errorf("%q is not a remote's name", remote) } return act("install", ref, inst, remote, ref) } // Remove uninstalls a ref. func Remove(ref, inst string, deleteData bool) (ActAnswer, error) { if deleteData { return act("uninstall", ref, inst, "--delete-data", ref) } return act("uninstall", ref, inst, ref) } // Update updates one ref, or everything. func Update(ref, inst string) (ActAnswer, error) { if ref == "" { return act("update", "", inst) } return act("update", ref, inst, ref) } // RemoveUnused lets flatpak uninstall what it finds unused. func RemoveUnused(inst string) (ActAnswer, error) { return act("uninstall", "", inst, "--unused") } // getenv and homeFrom read the account's home the way the runtime gives it. var getenv = func(k string) string { return strings.TrimSpace(os.Getenv(k)) } func homeFrom(env func(string) string) string { if h := env("MESH_OPERATOR_HOME"); h != "" { return h } return env("HOME") }