diff --git a/core/internal/server/sysupdate/backend_flatpak.go b/core/internal/server/sysupdate/backend_flatpak.go index 3371898b..2494fed3 100644 --- a/core/internal/server/sysupdate/backend_flatpak.go +++ b/core/internal/server/sysupdate/backend_flatpak.go @@ -2,6 +2,7 @@ package sysupdate import ( "context" + "errors" "os/exec" "strings" ) @@ -20,17 +21,44 @@ func (flatpakBackend) RunsInTerminal() bool { return false } func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") } func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) { - cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name") + // Run `flatpak update` + cmd := exec.CommandContext(ctx, "flatpak", "update") + cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts out, err := cmd.Output() if err != nil { - return nil, err + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(out) > 0 { + } else if len(out) == 0 { + return nil, err + } } installed := flatpakInstalled(ctx) - return parseFlatpakUpdates(string(out), installed), nil + return parseFlatpakUpdateOutput(string(out), installed), nil +} + +type flatpakInstalledEntry struct { + version string + branch string } func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry { - out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").Output() + m := flatpakListInstalled(ctx, false) + if m == nil { + m = make(map[string]flatpakInstalledEntry) + } + for k, v := range flatpakListInstalled(ctx, true) { + if _, exists := m[k]; !exists { + m[k] = v + } + } + return m +} + +func flatpakListInstalled(ctx context.Context, system bool) map[string]flatpakInstalledEntry { + args := []string{"flatpak", "list", "--columns=application,version,branch"} + if system { + args = append(args, "--system") + } + out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output() if err != nil { return nil } @@ -51,9 +79,6 @@ func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry { if len(fields) > 2 { entry.branch = fields[2] } - if len(fields) > 3 { - entry.commit = fields[3] - } key := appID if entry.branch != "" { key = appID + "//" + entry.branch @@ -63,12 +88,6 @@ func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry { return m } -type flatpakInstalledEntry struct { - version string - branch string - commit string -} - func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { if opts.DryRun { return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine}) @@ -83,74 +102,71 @@ func flatpakUpgradeArgv() []string { return []string{"flatpak", "update", "-y", "--noninteractive"} } -func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package { - if text == "" { - return nil - } +func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package { var pkgs []Package + seen := make(map[string]bool) for line := range strings.SplitSeq(text, "\n") { - if line == "" { + p := parseFlatpakUpdateRow(strings.TrimRight(line, "\r"), installed) + if p == nil || seen[p.Ref] { continue } - fields := strings.Split(line, "\t") - if len(fields) == 0 || fields[0] == "" { - continue - } - appID := fields[0] - version, branch, commit := "", "", "" - if len(fields) > 1 { - version = fields[1] - } - if len(fields) > 2 { - branch = fields[2] - } - if len(fields) > 3 { - commit = fields[3] - } - display := appID - if len(fields) > 4 && fields[4] != "" { - display = fields[4] - } - - key := appID - if branch != "" { - key = appID + "//" + branch - } - inst := installed[key] - - if inst.commit != "" && commit != "" && strings.HasPrefix(commit, inst.commit) { - continue - } - - from, to := flatpakVersionPair(inst.version, inst.commit, version, commit) - - ref := appID - if branch != "" { - ref = appID + "//" + branch - } - - pkgs = append(pkgs, Package{ - Name: display, - Repo: RepoFlatpak, - Backend: "flatpak", - FromVersion: from, - ToVersion: to, - Ref: ref, - }) + seen[p.Ref] = true + pkgs = append(pkgs, *p) } return pkgs } -func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) { - if remoteVer != "" { - return installedVer, remoteVer +func parseFlatpakUpdateRow(line string, installed map[string]flatpakInstalledEntry) *Package { + // Row format: " N.\t\t\t\t\t\t" + fields := strings.Split(line, "\t") + if len(fields) < 5 { + return nil + } + // First field must look like " N." (optional whitespace, digits, period) + rowField := strings.TrimSpace(fields[0]) + if len(rowField) < 2 || rowField[len(rowField)-1] != '.' { + return nil + } + for _, c := range rowField[:len(rowField)-1] { + if c < '0' || c > '9' { + return nil + } } - return shortCommit(installedCommit), shortCommit(remoteCommit) -} -func shortCommit(c string) string { - if len(c) > 8 { - return c[:8] + appID := strings.TrimSpace(fields[2]) + branch := strings.TrimSpace(fields[3]) + op := strings.TrimSpace(fields[4]) + if appID == "" || op == "" { + return nil + } + switch op { + case "i", "u", "r": // install, update, reinstall + default: + return nil + } + + ref := appID + if branch != "" { + ref = appID + "//" + branch + } + + name := strings.TrimSpace(fields[1]) + if name == "" { + name = appID + } + + var from string + if op != "i" { + if inst, ok := installed[ref]; ok { + from = inst.version + } + } + + return &Package{ + Name: name, + Repo: RepoFlatpak, + Backend: "flatpak", + FromVersion: from, + Ref: ref, } - return c } diff --git a/core/internal/server/sysupdate/backend_flatpak_test.go b/core/internal/server/sysupdate/backend_flatpak_test.go index 6705b641..9cacde24 100644 --- a/core/internal/server/sysupdate/backend_flatpak_test.go +++ b/core/internal/server/sysupdate/backend_flatpak_test.go @@ -5,7 +5,9 @@ import ( "testing" ) -func TestParseFlatpakUpdates(t *testing.T) { +func TestParseFlatpakUpdateOutput(t *testing.T) { + realOutput := "Looking for updates…\n\n\n 1.\t \torg.gtk.Gtk3theme.adw-gtk3-dark\t3.22\ti\tflathub\t< 131.4 kB\n\nProceed with these changes to the system installation? [Y/n]: n\n" + tests := []struct { name string input string @@ -13,137 +15,92 @@ func TestParseFlatpakUpdates(t *testing.T) { want []Package }{ { - name: "empty", + name: "empty output", input: "", want: nil, }, { - name: "real flathub-style row with empty version, falls back to commit", - // columns: application,version,branch,commit,name - input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord", - installed: map[string]flatpakInstalledEntry{ - "com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"}, - }, - want: []Package{ - { - Name: "Discord", - Repo: RepoFlatpak, - Backend: "flatpak", - FromVersion: "8b16fa1a", - ToVersion: "43a1e5d2", - Ref: "com.discordapp.Discord//stable", - }, - }, + name: "nothing to do", + input: "Looking for updates…\n\nNothing to do.\n", + want: nil, }, { - name: "remote provides version, installed version known", - input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App", - installed: map[string]flatpakInstalledEntry{ - "com.example.App//stable": {version: "1.4.2"}, - }, + name: "real flatpak update output — new install", + input: realOutput, want: []Package{ { - Name: "Example App", - Repo: RepoFlatpak, - Backend: "flatpak", - FromVersion: "1.4.2", - ToVersion: "1.5.0", - Ref: "com.example.App//stable", - }, - }, - }, - { - name: "no installed entry, remote has no version, falls back to commit on both sides", - input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform", - installed: nil, - want: []Package{ - { - Name: "gnome platform", + Name: "org.gtk.Gtk3theme.adw-gtk3-dark", Repo: RepoFlatpak, Backend: "flatpak", FromVersion: "", - ToVersion: "badcd4af", - Ref: "org.gnome.Platform//49", + Ref: "org.gtk.Gtk3theme.adw-gtk3-dark//3.22", }, }, }, { - name: "missing display name falls back to application id", - input: "com.example.NoName\t2.0\tstable\tabcdef123456\t", - want: []Package{ - { - Name: "com.example.NoName", - Repo: RepoFlatpak, - Backend: "flatpak", - FromVersion: "", - ToVersion: "2.0", - Ref: "com.example.NoName//stable", - }, - }, - }, - { - name: "skips blank lines and rows with empty application id", - input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App", - want: []Package{ - { - Name: "Real App", - Repo: RepoFlatpak, - Backend: "flatpak", - FromVersion: "", - ToVersion: "1.0", - Ref: "org.real.App//stable", - }, - }, - }, - { - name: "skips phantom updates where remote commit matches installed", - input: "com.phantom.App\t\tstable\tabc12345deadbeef\tPhantom", + name: "update with installed version", + input: "Looking for updates…\n\n 1.\tSlack\tcom.slack.Slack\tstable\tu\tflathub\t< 5.2 MB\n\nProceed? [Y/n]: n\n", installed: map[string]flatpakInstalledEntry{ - "com.phantom.App//stable": {commit: "abc12345"}, + "com.slack.Slack//stable": {version: "4.40.0"}, + }, + want: []Package{ + { + Name: "Slack", + Repo: RepoFlatpak, + Backend: "flatpak", + FromVersion: "4.40.0", + Ref: "com.slack.Slack//stable", + }, + }, + }, + { + name: "reinstall op included", + input: " 1.\t\torg.freedesktop.Platform\t25.08\tr\tflathub\t< 100 MB\n", + want: []Package{ + { + Name: "org.freedesktop.Platform", + Repo: RepoFlatpak, + Backend: "flatpak", + Ref: "org.freedesktop.Platform//25.08", + }, + }, + }, + { + name: "unknown op excluded", + input: " 1.\t\torg.freedesktop.Platform\t25.08\te\tflathub\t0\n", + want: nil, + }, + { + name: "deduplicates same ref", + input: " 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n 2.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\n", + want: []Package{ + { + Name: "com.example.App", + Repo: RepoFlatpak, + Backend: "flatpak", + Ref: "com.example.App//stable", + }, + }, + }, + { + name: "non-table lines ignored", + input: "Looking for updates…\nSome warning line\nID\tBranch\tOp\n 1.\t\tcom.example.App\tstable\ti\tflathub\t< 1 MB\nProceed? [Y/n]: n\n", + want: []Package{ + { + Name: "com.example.App", + Repo: RepoFlatpak, + Backend: "flatpak", + Ref: "com.example.App//stable", + }, }, - want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := parseFlatpakUpdates(tt.input, tt.installed) + got := parseFlatpakUpdateOutput(tt.input, tt.installed) if !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want) - } - }) - } -} - -func TestFlatpakVersionPair(t *testing.T) { - tests := []struct { - name string - installedVer, installedCommit, remoteVer, remoteCommit string - wantFrom, wantTo string - }{ - { - name: "remote has version - prefer versions", - installedVer: "1.0.0", remoteVer: "1.1.0", - wantFrom: "1.0.0", wantTo: "1.1.0", - }, - { - name: "remote has no version - both sides fall to short commit", - installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855", - remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586", - wantFrom: "8b16fa1a", wantTo: "43a1e5d2", - }, - { - name: "short commits left as-is", - installedCommit: "abc123", remoteCommit: "def456", - wantFrom: "abc123", wantTo: "def456", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit) - if from != tt.wantFrom || to != tt.wantTo { - t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo) + t.Errorf("parseFlatpakUpdateOutput() = %#v\nwant %#v", got, tt.want) } }) } diff --git a/core/internal/server/sysupdate/executor.go b/core/internal/server/sysupdate/executor.go index 792cb5f3..0453bd96 100644 --- a/core/internal/server/sysupdate/executor.go +++ b/core/internal/server/sysupdate/executor.go @@ -35,7 +35,6 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error { } return cmd.Process.Kill() } - cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run()