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:
jochen
2026-10-04 13:02:23 +02:00
parent db297e8bdd
commit 152ef9621d
12 changed files with 1900 additions and 0 deletions
@@ -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")
}