flatpak: the package, Flathub with it, and the installations as tools (hq to-be 42 phase 2.9)
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.
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user