From 4015f634c495c4b6e9b432e40de0c2bfdbddf175 Mon Sep 17 00:00:00 2001 From: Zoey Bauer <40698010+ZoeyErinBauer@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:50:02 -0400 Subject: [PATCH] add support for shelly (#3367) * add support for shelly * fixed terminal failure * updated to have it not allow adding ignore aur * updated error strings * Fixed label port 1.6 (cherry picked from commit 8fe918ff2408eccf2b26997524598e129e8fa14c) --- core/cmd/dms/commands_system.go | 2 +- .../server/sysupdate/backend_pacman.go | 7 +- .../server/sysupdate/backend_shelly.go | 168 ++++++++++ .../server/sysupdate/backend_shelly_test.go | 298 ++++++++++++++++++ core/internal/server/sysupdate/executor.go | 10 +- .../server/sysupdate/executor_test.go | 58 ++++ core/internal/server/sysupdate/manager.go | 4 +- .../internal/server/sysupdate/manager_test.go | 52 +++ .../Modules/Settings/SystemUpdaterTab.qml | 23 +- quickshell/Services/SystemUpdateService.qml | 15 +- .../translations/settings_search_index.json | 7 +- 11 files changed, 621 insertions(+), 23 deletions(-) create mode 100644 core/internal/server/sysupdate/backend_shelly.go create mode 100644 core/internal/server/sysupdate/backend_shelly_test.go create mode 100644 core/internal/server/sysupdate/executor_test.go create mode 100644 core/internal/server/sysupdate/manager_test.go diff --git a/core/cmd/dms/commands_system.go b/core/cmd/dms/commands_system.go index 296132bdf..819bf8d7e 100644 --- a/core/cmd/dms/commands_system.go +++ b/core/cmd/dms/commands_system.go @@ -58,7 +58,7 @@ func init() { systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes") systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay") - systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)") + systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates") systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)") systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)") diff --git a/core/internal/server/sysupdate/backend_pacman.go b/core/internal/server/sysupdate/backend_pacman.go index 17ddb59ef..b69e4d103 100644 --- a/core/internal/server/sysupdate/backend_pacman.go +++ b/core/internal/server/sysupdate/backend_pacman.go @@ -17,6 +17,7 @@ import ( func init() { RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} }) RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} }) + RegisterSystemBackend(func() Backend { return &shellyBackend{} }) RegisterSystemBackend(func() Backend { return &pacmanBackend{} }) } @@ -53,9 +54,7 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string { return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed") } -// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS: -// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported -// AUR packages are exempt — holding those cannot break the repo dependency graph. +// Arch repository packages must upgrade together; AUR packages may be held. func dropPacmanRepoIgnores(ignored []string, pending []Package) []string { if len(ignored) == 0 { return ignored @@ -81,7 +80,7 @@ func isPacmanFamily(b Backend) bool { return false } switch b.ID() { - case "pacman", "paru", "yay": + case "pacman", "paru", "yay", "shelly": return true default: return false diff --git a/core/internal/server/sysupdate/backend_shelly.go b/core/internal/server/sysupdate/backend_shelly.go new file mode 100644 index 000000000..53303946f --- /dev/null +++ b/core/internal/server/sysupdate/backend_shelly.go @@ -0,0 +1,168 @@ +package sysupdate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "slices" + "strings" + "time" +) + +type shellyBackend struct{} + +func (shellyBackend) ID() string { return "shelly" } +func (shellyBackend) DisplayName() string { return "Shelly (AUR)" } +func (shellyBackend) Repo() RepoKind { return RepoSystem } +func (shellyBackend) NeedsAuth() bool { return true } +func (shellyBackend) RunsInTerminal() bool { + return os.Getenv("DMS_FORCE_PKEXEC") != "1" +} + +func (shellyBackend) IsAvailable(ctx context.Context) bool { + if !commandExists("shelly") { + return false + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + out, err := Capture(ctx, []string{"shelly", "--version", "--json"}) + if err != nil { + return false + } + var version struct { + Name string `json:"name"` + SchemaVersion int `json:"schemaVersion"` + } + // Shelly 3.1.3 introduced this CLI identity response. + return json.Unmarshal([]byte(out), &version) == nil && version.Name == "shelly" && version.SchemaVersion == 1 +} + +func (b shellyBackend) CheckUpdates(ctx context.Context) ([]Package, error) { + pkgs, err := b.checkUpdates(ctx, RepoSystem) + if err != nil { + return nil, err + } + aur, err := b.checkUpdates(ctx, RepoAUR) + if err != nil { + return nil, err + } + return append(pkgs, aur...), nil +} + +func (b shellyBackend) checkUpdates(ctx context.Context, repo RepoKind) ([]Package, error) { + kind := "standard" + if repo == RepoAUR { + kind = "aur" + } + out, err := Capture(ctx, []string{"shelly", "list-updates", kind, "--json"}) + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + return nil, fmt.Errorf("shelly %s update check: %w: %s", kind, err, strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, fmt.Errorf("shelly %s update check: %w", kind, err) + } + return parseShellyUpdates(out, repo) +} + +func parseShellyUpdates(out string, repo RepoKind) ([]Package, error) { + var updates []struct { + Name string + CurrentVersion string + Version string + NewVersion string + DownloadSize int64 + PackageBase string + } + if err := json.Unmarshal([]byte(out), &updates); err != nil { + return nil, fmt.Errorf("shelly %s updates: invalid JSON: %w", repo, err) + } + if updates == nil { + return nil, fmt.Errorf("shelly %s updates: expected a JSON array", repo) + } + var pkgs []Package + for _, update := range updates { + fromVersion := update.CurrentVersion + if repo == RepoAUR { + fromVersion = update.Version + } + if update.Name == "" || fromVersion == "" || update.NewVersion == "" { + return nil, fmt.Errorf("shelly %s updates: missing package name or version", repo) + } + pkg := Package{ + Name: update.Name, + Backend: "shelly", + Repo: repo, + FromVersion: fromVersion, + ToVersion: update.NewVersion, + SizeBytes: max(0, update.DownloadSize), + } + if repo == RepoAUR { + base := update.PackageBase + if base == "" { + base = update.Name + } + pkg.ChangelogURL = "https://aur.archlinux.org/packages/" + url.PathEscape(base) + } + pkgs = append(pkgs, pkg) + } + return pkgs, nil +} + +func (b shellyBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { + if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { + return nil + } + var aur []Package + if opts.IncludeAUR && (opts.DryRun || len(opts.Ignored) > 0) { + // The manager has already removed held packages from opts.Targets. + var err error + aur, err = b.checkUpdates(ctx, RepoAUR) + if err != nil { + return err + } + for _, pkg := range aur { + if slices.Contains(opts.Ignored, pkg.Name) { + return fmt.Errorf("shelly cannot exclude held AUR package %q; disable AUR updates or remove its DMS hold before updating", pkg.Name) + } + } + } + if opts.DryRun { + pkgs, err := b.checkUpdates(ctx, RepoSystem) + if err != nil { + return err + } + pkgs = append(pkgs, aur...) + for _, pkg := range pkgs { + if onLine != nil { + onLine(fmt.Sprintf("%s %s -> %s", pkg.Name, pkg.FromVersion, pkg.ToVersion)) + } + } + return nil + } + argv := shellyUpgradeArgv(opts.IncludeAUR) + if opts.AttachStdio { + return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: true}) + } + if !b.RunsInTerminal() { + return Run(ctx, argv, RunOptions{OnLine: onLine, Env: []string{"SHELLY_ELEVATOR=pkexec"}}) + } + term := findTerminal(opts.Terminal) + if term == "" { + return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)") + } + return Run(ctx, wrapInTerminal(term, "DMS — System Update (shelly)", strings.Join(argv, " "), opts.TerminalArgs), RunOptions{OnLine: onLine}) +} + +func shellyUpgradeArgv(includeAUR bool) []string { + if !includeAUR { + return []string{"shelly", "upgrade", "standard", "--no-confirm"} + } + return []string{"shelly", "upgrade", "all", "--no-flatpak", "--no-appimage", "--no-confirm"} +} diff --git a/core/internal/server/sysupdate/backend_shelly_test.go b/core/internal/server/sysupdate/backend_shelly_test.go new file mode 100644 index 000000000..33b0a7939 --- /dev/null +++ b/core/internal/server/sysupdate/backend_shelly_test.go @@ -0,0 +1,298 @@ +package sysupdate + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +const shellyRepoFixture = `[{"Name":"linux","CurrentVersion":"2:6.18-1","NewVersion":"2:6.19-1","DownloadSize":1024,"Repository":"core"}]` +const shellyAURFixture = `[{"Name":"example-git","Version":"r1-1","NewVersion":"r2-1","DownloadSize":-1,"PackageBase":"example"}]` + +func TestParseShellyUpdates(t *testing.T) { + tests := []struct { + name string + out string + repo RepoKind + want []Package + }{ + {"repository", shellyRepoFixture, RepoSystem, []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem, FromVersion: "2:6.18-1", ToVersion: "2:6.19-1", SizeBytes: 1024}}}, + {"aur package base", shellyAURFixture, RepoAUR, []Package{{Name: "example-git", Backend: "shelly", Repo: RepoAUR, FromVersion: "r1-1", ToVersion: "r2-1", ChangelogURL: "https://aur.archlinux.org/packages/example"}}}, + {"aur name fallback", `[{"Name":"example","Version":"1","NewVersion":"2"}]`, RepoAUR, []Package{{Name: "example", Backend: "shelly", Repo: RepoAUR, FromVersion: "1", ToVersion: "2", ChangelogURL: "https://aur.archlinux.org/packages/example"}}}, + {"empty", "[]\n", RepoSystem, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseShellyUpdates(tt.out, tt.repo) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("packages = %#v, want %#v", got, tt.want) + } + }) + } + for _, out := range []string{"", "null", "{}", "[", "[] trailing", "[null]", `[{"Name":"linux"}]`, `[{"Name":"linux","CurrentVersion":"1","NewVersion":"2","DownloadSize":"large"}]`} { + t.Run(out, func(t *testing.T) { + if _, err := parseShellyUpdates(out, RepoSystem); err == nil { + t.Fatalf("expected error for %q", out) + } + }) + } +} + +func writeUpdateExecutable(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestShellySelection(t *testing.T) { + for _, tt := range []struct { + name string + bins []string + version string + want string + }{ + {"shelly only", []string{"shelly"}, `{"schemaVersion":1,"name":"shelly","version":"3.1.3"}`, "shelly"}, + {"shelly before pacman", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly"}`, "shelly"}, + {"paru preferred", []string{"paru", "yay", "shelly", "pacman"}, "", "paru"}, + {"yay preferred", []string{"yay", "shelly", "pacman"}, "", "yay"}, + {"legacy shelly", []string{"shelly", "pacman"}, "2.4.1", "pacman"}, + {"wrong binary", []string{"shelly", "pacman"}, `{"schemaVersion":1,"name":"shelly-ui"}`, "pacman"}, + {"unknown schema", []string{"shelly", "pacman"}, `{"schemaVersion":2,"name":"shelly"}`, "pacman"}, + {"pacman only", []string{"pacman"}, "", "pacman"}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + for _, bin := range tt.bins { + writeUpdateExecutable(t, dir, bin, "printf '%s\\n' '"+tt.version+"'") + } + selection := Select(t.Context()) + if selection.System == nil || selection.System.ID() != tt.want { + t.Fatalf("selection = %#v, want %s", selection.System, tt.want) + } + }) + } +} + +func fakeShelly(t *testing.T) (string, string) { + t.Helper() + dir := t.TempDir() + log := filepath.Join(dir, "calls") + t.Setenv("PATH", dir) + t.Setenv("TERMINAL", "") + t.Setenv("DMS_FORCE_PKEXEC", "") + t.Setenv("DMS_TEST_SHELLY_LOG", log) + t.Setenv("DMS_TEST_SHELLY_EXIT", "0") + t.Setenv("SHELLY_ELEVATOR", "") + writeUpdateExecutable(t, dir, "shelly", `printf '%s|%s\n' "$*" "$SHELLY_ELEVATOR" >> "$DMS_TEST_SHELLY_LOG" +case "$*" in + 'list-updates standard --json') printf '%s\n' '`+shellyRepoFixture+`' ;; + 'list-updates aur --json') printf '%s\n' '`+shellyAURFixture+`' ;; + 'upgrade standard --no-confirm'|'upgrade all --no-flatpak --no-appimage --no-confirm') exit "$DMS_TEST_SHELLY_EXIT" ;; + *) echo "unexpected arguments: $*" >&2; exit 99 ;; +esac`) + return dir, log +} + +func readUpdateCalls(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "" + } + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestShellyCheckUpdates(t *testing.T) { + _, log := fakeShelly(t) + pkgs, err := (shellyBackend{}).CheckUpdates(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(pkgs) != 2 || pkgs[0].Repo != RepoSystem || pkgs[1].Repo != RepoAUR { + t.Fatalf("unexpected packages: %#v", pkgs) + } + if got, want := readUpdateCalls(t, log), "list-updates standard --json|\nlist-updates aur --json|\n"; got != want { + t.Fatalf("calls = %q, want %q", got, want) + } +} + +func TestShellyCheckFailures(t *testing.T) { + for _, code := range []string{"1", "2"} { + t.Run(code, func(t *testing.T) { + dir, _ := fakeShelly(t) + writeUpdateExecutable(t, dir, "shelly", "echo 'database unavailable' >&2; exit "+code) + if _, err := (shellyBackend{}).CheckUpdates(t.Context()); err == nil || !strings.Contains(err.Error(), "database unavailable") { + t.Fatalf("expected query failure with stderr, got %v", err) + } + }) + } + t.Run("cancellation", func(t *testing.T) { + dir, _ := fakeShelly(t) + writeUpdateExecutable(t, dir, "shelly", "exec /bin/sleep 30") + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + if _, err := (shellyBackend{}).CheckUpdates(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline error, got %v", err) + } + }) +} + +func TestShellyUpgrade(t *testing.T) { + for _, tt := range []struct { + name string + includeAUR bool + attached bool + terminal bool + }{ + {"pkexec repository", false, false, false}, + {"pkexec aur", true, false, false}, + {"attached repository", false, true, false}, + {"attached aur", true, true, false}, + {"terminal repository", false, false, true}, + {"terminal aur", true, false, true}, + } { + t.Run(tt.name, func(t *testing.T) { + dir, log := fakeShelly(t) + opts := UpgradeOptions{IncludeAUR: tt.includeAUR, AttachStdio: tt.attached, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}} + elevator := "" + if tt.terminal { + opts.Terminal = "fake-terminal" + writeUpdateExecutable(t, dir, opts.Terminal, `while [ "$1" != sh ] && [ "$#" -gt 0 ]; do shift; done +shift +exec /bin/sh "$@"`) + } else if !tt.attached { + t.Setenv("DMS_FORCE_PKEXEC", "1") + elevator = "pkexec" + } + if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err != nil { + t.Fatal(err) + } + command := "upgrade standard --no-confirm" + if tt.includeAUR { + command = "upgrade all --no-flatpak --no-appimage --no-confirm" + } + if got, want := readUpdateCalls(t, log), command+"|"+elevator+"\n"; got != want { + t.Fatalf("calls = %q, want %q", got, want) + } + }) + } +} + +func TestShellyUpgradeHolds(t *testing.T) { + for _, tt := range []struct { + name string + includeAUR bool + ignored []string + wantError bool + dryRun bool + }{ + {"held aur", true, []string{"example-git"}, true, false}, + {"repository only", false, []string{"example-git"}, false, false}, + {"repository hold cannot exclude system upgrade", true, []string{"linux"}, false, false}, + {"unrelated hold", true, []string{"org.example.Flatpak"}, false, false}, + {"dry held aur", true, []string{"example-git"}, true, true}, + {"dry repository only", false, []string{"example-git"}, false, true}, + {"dry repository hold", true, []string{"linux"}, false, true}, + {"dry unrelated hold", true, []string{"org.example.Flatpak"}, false, true}, + } { + t.Run(tt.name, func(t *testing.T) { + _, log := fakeShelly(t) + t.Setenv("DMS_FORCE_PKEXEC", "1") + opts := UpgradeOptions{DryRun: tt.dryRun, IncludeAUR: tt.includeAUR, Ignored: tt.ignored, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}} + err := (shellyBackend{}).Upgrade(t.Context(), opts, nil) + calls := readUpdateCalls(t, log) + if strings.Count(calls, "list-updates aur --json") > 1 { + t.Fatalf("duplicate AUR check: %q", calls) + } + if tt.wantError { + if err == nil || err.Error() != `shelly cannot exclude held AUR package "example-git"; disable AUR updates or remove its DMS hold before updating` || strings.Contains(calls, "upgrade ") { + t.Fatalf("held AUR package must block upgrade, got %v, calls %q", err, readUpdateCalls(t, log)) + } + return + } + if err != nil || strings.Contains(calls, "upgrade ") == tt.dryRun { + t.Fatalf("unexpected result for dryRun=%v: %v, calls %q", tt.dryRun, err, calls) + } + }) + } + if !isPacmanFamily(shellyBackend{}) { + t.Fatal("Shelly must enforce repository hold restrictions") + } +} + +func TestShellyHoldCheckFailure(t *testing.T) { + for _, dryRun := range []bool{false, true} { + dir, log := fakeShelly(t) + writeUpdateExecutable(t, dir, "shelly", `printf '%s\n' "$*" >> "$DMS_TEST_SHELLY_LOG" +echo 'AUR unavailable' >&2 +exit 1`) + opts := UpgradeOptions{DryRun: dryRun, IncludeAUR: true, Ignored: []string{"example-git"}, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}} + err := (shellyBackend{}).Upgrade(t.Context(), opts, nil) + if err == nil || !strings.Contains(err.Error(), "AUR unavailable") { + t.Fatalf("dryRun=%v: expected AUR query error, got %v", dryRun, err) + } + if calls := readUpdateCalls(t, log); calls != "list-updates aur --json\n" { + t.Fatalf("query failure must stop the operation: %q", calls) + } + } +} + +func TestShellyDryRun(t *testing.T) { + for _, includeAUR := range []bool{false, true} { + _, log := fakeShelly(t) + var lines []string + opts := UpgradeOptions{DryRun: true, IncludeAUR: includeAUR, Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}} + if err := (shellyBackend{}).Upgrade(t.Context(), opts, func(line string) { lines = append(lines, line) }); err != nil { + t.Fatal(err) + } + wantCalls := "list-updates standard --json|\n" + wantLines := []string{"linux 2:6.18-1 -> 2:6.19-1"} + if includeAUR { + wantCalls = "list-updates aur --json|\n" + wantCalls + wantLines = append(wantLines, "example-git r1-1 -> r2-1") + } + if got := readUpdateCalls(t, log); got != wantCalls || !reflect.DeepEqual(lines, wantLines) { + t.Fatalf("calls = %q, lines = %q", got, lines) + } + } +} + +func TestShellySkipsExcludedTargets(t *testing.T) { + for _, targets := range [][]Package{ + nil, + {{Name: "example-git", Backend: "shelly", Repo: RepoAUR}}, + {{Name: "org.example.App", Backend: "flatpak", Repo: RepoFlatpak}}, + {{Name: "linux", Backend: "pacman", Repo: RepoSystem}}, + } { + _, log := fakeShelly(t) + if err := (shellyBackend{}).Upgrade(t.Context(), UpgradeOptions{Targets: targets, IncludeFlatpak: true}, nil); err != nil { + t.Fatal(err) + } + if got := readUpdateCalls(t, log); got != "" { + t.Fatalf("excluded targets executed commands: %q", got) + } + } +} + +func TestShellyUpgradeFailure(t *testing.T) { + _, _ = fakeShelly(t) + t.Setenv("DMS_FORCE_PKEXEC", "1") + t.Setenv("DMS_TEST_SHELLY_EXIT", "126") + opts := UpgradeOptions{Targets: []Package{{Name: "linux", Backend: "shelly", Repo: RepoSystem}}} + if err := (shellyBackend{}).Upgrade(t.Context(), opts, nil); err == nil { + t.Fatal("authentication or upgrade failure must propagate") + } +} diff --git a/core/internal/server/sysupdate/executor.go b/core/internal/server/sysupdate/executor.go index a4b5afd49..470b103a2 100644 --- a/core/internal/server/sysupdate/executor.go +++ b/core/internal/server/sysupdate/executor.go @@ -35,6 +35,7 @@ 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() @@ -125,7 +126,14 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string { `printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`, title, shellCmd, ) - closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read` + closer := `dms_update_status=$? +if [ "$dms_update_status" -eq 0 ]; then + printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n' +else + printf '\n\033[1;31m=== Update failed (exit %s). Press Enter to close. ===\033[0m\n' "$dms_update_status" +fi +read -r dms_update_reply +exit "$dms_update_status"` export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; ` full := export + banner + "; " + shellCmd + "; " + closer diff --git a/core/internal/server/sysupdate/executor_test.go b/core/internal/server/sysupdate/executor_test.go new file mode 100644 index 000000000..0a2c7b1c7 --- /dev/null +++ b/core/internal/server/sysupdate/executor_test.go @@ -0,0 +1,58 @@ +package sysupdate + +import ( + "errors" + "os" + "os/exec" + "strings" + "testing" +) + +func TestTerminalWrapperPreservesExitStatus(t *testing.T) { + for _, tt := range []struct { + command string + status int + message string + }{ + {"true", 0, "Done."}, + {"false", 1, "Update failed (exit 1)"}, + {"sh -c 'exit 42'", 42, "Update failed (exit 42)"}, + } { + t.Run(tt.command, func(t *testing.T) { + argv := wrapInTerminal("xterm", "Test", tt.command, nil) + cmd := exec.Command("sh", "-c", argv[len(argv)-1]) + cmd.Stdin = strings.NewReader("\n") + out, err := cmd.CombinedOutput() + if tt.status == 0 && err != nil { + t.Fatal(err) + } + if tt.status != 0 { + exitErr, ok := errors.AsType[*exec.ExitError](err) + if !ok || exitErr.ExitCode() != tt.status { + t.Fatalf("expected exit %d, got %v (%s)", tt.status, err, out) + } + } + if !strings.Contains(string(out), tt.message) { + t.Fatalf("missing status message %q: %s", tt.message, out) + } + }) + } +} + +func TestRunAttachesStdin(t *testing.T) { + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer reader.Close() + if _, err := writer.WriteString("confirmation\n"); err != nil { + t.Fatal(err) + } + writer.Close() + previous := os.Stdin + os.Stdin = reader + defer func() { os.Stdin = previous }() + if err := Run(t.Context(), []string{"sh", "-c", `read -r answer && [ "$answer" = confirmation ]`}, RunOptions{AttachStdio: true}); err != nil { + t.Fatalf("attached command did not receive stdin: %v", err) + } +} diff --git a/core/internal/server/sysupdate/manager.go b/core/internal/server/sysupdate/manager.go index 176b3a275..1468e6c8f 100644 --- a/core/internal/server/sysupdate/manager.go +++ b/core/internal/server/sysupdate/manager.go @@ -420,8 +420,8 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) { m.failCustomUpgrade(ErrCodeCancelled, err) return } - // exit status reflects the trailing `read`, not the update command - m.appendLog(fmt.Sprintf("Terminal exited early: %v", err)) + m.failCustomUpgrade(ErrCodeBackendFailed, err) + return } m.finishSuccessfulUpgrade(false) diff --git a/core/internal/server/sysupdate/manager_test.go b/core/internal/server/sysupdate/manager_test.go new file mode 100644 index 000000000..604f00d56 --- /dev/null +++ b/core/internal/server/sysupdate/manager_test.go @@ -0,0 +1,52 @@ +package sysupdate + +import ( + "context" + "slices" + "strings" + "testing" +) + +type customUpgradeTestBackend struct { + pacmanBackend + checks int +} + +func (b *customUpgradeTestBackend) CheckUpdates(context.Context) ([]Package, error) { + b.checks++ + return nil, nil +} + +func TestCustomUpgradeFailure(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + writeUpdateExecutable(t, dir, "fake-terminal", `while [ "$#" -gt 0 ] && [ "$1" != sh ]; do shift; done +[ "$#" -gt 0 ] || exit 99 +shift +exec /bin/sh "$@"`) + + backend := &customUpgradeTestBackend{} + m := &Manager{ + selection: Selection{System: backend}, + notifyDirty: make(chan struct{}, 1), + stopChan: make(chan struct{}), + } + m.runCustomUpgrade(t.Context(), UpgradeOptions{ + Terminal: "fake-terminal", + CustomCommand: "false", + }) + + state := m.GetState() + if state.Phase != PhaseError { + t.Errorf("phase = %s, want %s", state.Phase, PhaseError) + } + if state.Error == nil || state.Error.Code != ErrCodeBackendFailed || !strings.Contains(state.Error.Message, "exit status 1") { + t.Errorf("error = %#v, want backend-failed with exit status 1", state.Error) + } + if slices.Contains(state.RecentLog, "Upgrade complete.") { + t.Error("failed custom upgrade logged success") + } + if backend.checks != 0 { + t.Errorf("failed custom upgrade refreshed %d times", backend.checks) + } +} diff --git a/quickshell/Modules/Settings/SystemUpdaterTab.qml b/quickshell/Modules/Settings/SystemUpdaterTab.qml index 97d5fdad3..7ecf63bc8 100644 --- a/quickshell/Modules/Settings/SystemUpdaterTab.qml +++ b/quickshell/Modules/Settings/SystemUpdaterTab.qml @@ -179,10 +179,10 @@ Item { SettingsToggleRow { settingKey: "systemUpdaterAUR" - tags: ["aur", "paru", "yay"] + tags: ["aur", "paru", "yay", "shelly"] text: I18n.tr("Include AUR updates") - description: I18n.tr("Run paru/yay with AUR enabled when 'Update All' is clicked.") - visible: (SystemUpdateService.backends || []).some(b => b.id === "paru" || b.id === "yay") + description: I18n.tr("Apply AUR updates alongside system updates when running 'Update All'.") + visible: (SystemUpdateService.backends || []).some(b => ["paru", "yay", "shelly"].includes(b.id)) checked: SettingsData.updaterAllowAUR onToggled: checked => SettingsData.set("updaterAllowAUR", checked) } @@ -198,17 +198,21 @@ Item { settingKey: "systemUpdaterIgnoredPackages" tags: ["system", "update", "package", "ignore"] + property bool errorIsInvalidName: false + function addIgnoredPackage() { const name = newIgnoredPackageField.text.trim(); if (name === "") { return; } - if (!/^[A-Za-z0-9@._+:-]+$/.test(name)) { + errorIsInvalidName = !/^[A-Za-z0-9@._+:-]+$/.test(name); + if (errorIsInvalidName) { ignoredPackageError.visible = true; return; } - ignoredPackageError.visible = false; - SystemUpdateService.ignorePackage(name); + ignoredPackageError.visible = !SystemUpdateService.ignorePackage(name); + if (ignoredPackageError.visible) + return; newIgnoredPackageField.text = ""; } @@ -222,6 +226,9 @@ Item { if (SettingsData.updaterUseCustomCommand) { return I18n.tr("Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions."); } + if (SystemUpdateService.pkgManager === "shelly") { + return I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored."); + } return (SettingsData.updaterIgnoredPackages || []).length > 0 ? I18n.tr("Ignored packages are hidden from the updater and skipped by 'Update All'.") : I18n.tr("No packages ignored. Add one here or hover an update in the popout and click the hide button."); } font.pixelSize: Theme.fontSizeSmall @@ -258,10 +265,12 @@ Item { StyledText { id: ignoredPackageError + width: parent.width visible: false - text: I18n.tr("Invalid package name — letters, digits and @._+:- only.") + text: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.") font.pixelSize: Theme.fontSizeSmall color: Theme.error + wrapMode: Text.WordWrap } SettingsCard { diff --git a/quickshell/Services/SystemUpdateService.qml b/quickshell/Services/SystemUpdateService.qml index 9464a63f8..b0d07ccd9 100644 --- a/quickshell/Services/SystemUpdateService.qml +++ b/quickshell/Services/SystemUpdateService.qml @@ -35,14 +35,14 @@ Singleton { readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0 readonly property bool useCustomCommand: SettingsData.updaterUseCustomCommand && (SettingsData.updaterCustomCommand || "").trim().length > 0 - // Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS: - // https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported - // AUR/Flatpak packages stay ignorable — holding those cannot break the repo dependency graph. - readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay"].includes(pkgManager) + // Arch repository packages must upgrade together. + readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay", "shelly"].includes(pkgManager) function canIgnorePackage(pkg) { if (!pkg) return false; + if (pkgManager === "shelly") + return pkg.repo === "flatpak"; return systemHoldsAllowed || pkg.repo !== "system"; } @@ -181,12 +181,15 @@ Singleton { function ignorePackage(name) { if (!name) - return; + return false; + if (pkgManager === "shelly" && !_rawUpdates.some(p => p.name === name && canIgnorePackage(p))) + return false; const list = (SettingsData.updaterIgnoredPackages || []).slice(); if (list.indexOf(name) !== -1) - return; + return true; list.push(name); SettingsData.set("updaterIgnoredPackages", list); + return true; } function unignorePackage(name) { diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index bef5e9e06..13ddbfffa 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -8225,18 +8225,21 @@ "tabIndex": 20, "category": "System Updater", "keywords": [ + "alongside", + "apply", "aur", - "enabled", "include", "packages", "paru", + "running", + "shelly", "system", "updater", "updates", "upgrade", "yay" ], - "description": "Run paru/yay with AUR enabled when " + "description": "Apply AUR updates alongside system updates when running " }, { "section": "systemUpdaterFlatpak",