Stage 5: das-update and das-welcome (0.3.0)

das-tools (Go, GTK 3 via gotk3):
- das-update: update window, tray icon when updates are available,
  --cli mode; xbps-install -Su between a snapper pre/post snapshot pair,
  privileged part through pkexec, rollback and reboot hints
- das-welcome: first-login window with install (live), language,
  home folder renaming, updates, settings and help

das-settings 0.3.0: create home folders with translated names.
xdg-user-dirs 0.20 only looks for translations in the first
XDG_DATA_DIRS entry with a locale directory (/usr/local/share/locale).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
das
2026-10-19 18:24:39 +03:00
co-authored by Claude Opus 5.5
parent 3b4527990e
commit d1d76c2bef
40 changed files with 2529 additions and 17 deletions
@@ -0,0 +1,122 @@
// Package xbps finds pending system updates with xbps.
package xbps
import (
"context"
"fmt"
"os/exec"
"sort"
"strings"
)
// Update is one package that a full system update would change.
type Update struct {
Name string
OldVersion string // empty for a new dependency
NewVersion string
Action string // "update", "install", "remove" ...
}
// Check lists pending updates without root: -M syncs the repository index
// into memory only, -n makes it a dry run.
func Check(ctx context.Context) ([]Update, error) {
out, err := exec.CommandContext(ctx, "xbps-install", "-SMun").CombinedOutput()
if err != nil {
// xbps refuses to plan anything while xbps itself is outdated
if strings.Contains(string(out), "must be updated") {
return []Update{{Name: "xbps", Action: "update"}}, nil
}
if msg := strings.TrimSpace(string(out)); msg != "" {
return nil, fmt.Errorf("xbps-install: %w: %s", err, msg)
}
return nil, fmt.Errorf("xbps-install: %w", err)
}
updates := ParseDryRun(string(out))
if len(updates) == 0 {
return nil, nil
}
listed, err := exec.CommandContext(ctx, "xbps-query", "-l").Output()
if err != nil {
return nil, fmt.Errorf("xbps-query: %w", err)
}
installed := ParseInstalled(string(listed))
for i := range updates {
updates[i].OldVersion = installed[updates[i].Name]
}
return updates, nil
}
// ParseDryRun parses "xbps-install -un" output: one transaction entry per
// line, "<pkgver> <action> <arch> <repository> [sizes]". Other lines are skipped.
func ParseDryRun(out string) []Update {
var updates []Update
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
name, version, ok := SplitPkgver(f[0])
if !ok {
continue
}
switch f[1] {
case "update", "install", "remove", "reinstall", "downgrade":
updates = append(updates, Update{Name: name, NewVersion: version, Action: f[1]})
}
}
sort.Slice(updates, func(i, j int) bool { return updates[i].Name < updates[j].Name })
return updates
}
// ParseInstalled parses "xbps-query -l" ("ii <pkgver> <description>") into
// a name → version map.
func ParseInstalled(out string) map[string]string {
m := make(map[string]string)
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
if len(f) < 2 {
continue
}
if name, version, ok := SplitPkgver(f[1]); ok {
m[name] = version
}
}
return m
}
// SplitPkgver splits "name-version_revision" at the last dash. The version
// part always contains "_revision", which tells pkgvers from other words.
func SplitPkgver(pkgver string) (name, version string, ok bool) {
i := strings.LastIndexByte(pkgver, '-')
if i <= 0 || i == len(pkgver)-1 || !strings.Contains(pkgver[i+1:], "_") {
return "", "", false
}
return pkgver[:i], pkgver[i+1:], true
}
// NeedsReboot reports whether updating these packages replaces something
// that a running system keeps loaded: the kernel, the C library or the
// init system.
func NeedsReboot(updates []Update) bool {
for _, u := range updates {
switch {
case u.Name == "glibc", u.Name == "runit", u.Name == "runit-void", u.Name == "dbus",
u.Name == "elogind", u.Name == "eudev", u.Name == "linux-firmware":
return true
case strings.HasPrefix(u.Name, "linux") && strings.Trim(u.Name[len("linux"):], "0123456789.") == "":
return true
}
}
return false
}
// SelfUpdate reports whether xbps itself is in the list. xbps must be
// updated on its own before the rest of the system.
func SelfUpdate(updates []Update) bool {
for _, u := range updates {
if u.Name == "xbps" {
return true
}
}
return false
}
@@ -0,0 +1,83 @@
package xbps
import (
"reflect"
"testing"
)
func TestParseDryRun(t *testing.T) {
out := `[*] Updating repository ` + "`https://repo-default.voidlinux.org/current/x86_64-repodata'" + ` ...
firefox-144.0_1 update x86_64 https://repo-default.voidlinux.org/current 245MB 70MB
libfoo-2.1_3 install x86_64 https://repo-default.voidlinux.org/current 1024KB 300KB
linux6.12-6.12.50_1 update x86_64 https://repo-default.voidlinux.org/current
gtk+3-3.24.50_1 update x86_64 https://repo-default.voidlinux.org/current
oldpkg-1.0_1 remove x86_64 https://repo-default.voidlinux.org/current
bash-5.3_2 configure x86_64 https://repo-default.voidlinux.org/current
`
got := ParseDryRun(out)
want := []Update{
{Name: "firefox", NewVersion: "144.0_1", Action: "update"},
{Name: "gtk+3", NewVersion: "3.24.50_1", Action: "update"},
{Name: "libfoo", NewVersion: "2.1_3", Action: "install"},
{Name: "linux6.12", NewVersion: "6.12.50_1", Action: "update"},
{Name: "oldpkg", NewVersion: "1.0_1", Action: "remove"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("ParseDryRun:\n got %+v\nwant %+v", got, want)
}
if got := ParseDryRun(""); len(got) != 0 {
t.Errorf("ParseDryRun(empty) = %+v, want none", got)
}
}
func TestParseInstalled(t *testing.T) {
out := "ii acl-2.4.0_1 Access Control List filesystem support\n" +
"ii base-container-0.3_3 Void Linux base system meta\n" +
"\n"
want := map[string]string{"acl": "2.4.0_1", "base-container": "0.3_3"}
if got := ParseInstalled(out); !reflect.DeepEqual(got, want) {
t.Errorf("ParseInstalled = %v, want %v", got, want)
}
}
func TestSplitPkgver(t *testing.T) {
tests := []struct {
in, name, version string
ok bool
}{
{"xbps-0.60.5_1", "xbps", "0.60.5_1", true},
{"font-jetbrains-mono-2.304_1", "font-jetbrains-mono", "2.304_1", true},
{"[*]", "", "", false},
{"Updating", "", "", false},
{"name-", "", "", false},
{"no-revision-1.0", "", "", false},
}
for _, tt := range tests {
name, version, ok := SplitPkgver(tt.in)
if name != tt.name || version != tt.version || ok != tt.ok {
t.Errorf("SplitPkgver(%q) = %q, %q, %v; want %q, %q, %v", tt.in, name, version, ok, tt.name, tt.version, tt.ok)
}
}
}
func TestNeedsReboot(t *testing.T) {
tests := []struct {
names []string
want bool
}{
{[]string{"firefox", "mousepad"}, false},
{[]string{"linux6.12"}, true},
{[]string{"linux"}, true},
{[]string{"glibc"}, true},
{[]string{"linux-headers", "linux-firmware-network"}, false},
}
for _, tt := range tests {
var updates []Update
for _, n := range tt.names {
updates = append(updates, Update{Name: n})
}
if got := NeedsReboot(updates); got != tt.want {
t.Errorf("NeedsReboot(%v) = %v, want %v", tt.names, got, tt.want)
}
}
}