Files
mesh-catalog/modules/flatpak/cmd/flatpak-tools/flatpak.go
T
jochen 152ef9621d 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.
2026-10-04 13:02:23 +02:00

502 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}