The package ships Flathub in /usr/share/flatpak/remotes.d, so the module declares no remote of its own and checks it instead. Eleven Go tools: list, runtimes, remotes (naming the desktop's duplicate user Flathub), updates, unused, disk usage, and install, remove, update and remove-unused, the system installation's acts through sudo -n and the account's without. uninstall --unused has no dry run, so flatpak_unused works it out from flatpak's own answers: an application's runtime and SDK, the extension points of what is used, and pins. Acts run as jobs inside the bundle, because an install outlasts a call.
502 lines
14 KiB
Go
502 lines
14 KiB
Go
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")
|
||
}
|