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,184 @@
// Command das-update updates Das OS with a snapper snapshot taken before
// the update, so a broken update can be rolled back from the boot menu.
//
// das-update window that lists and installs updates
// das-update --tray background check, shows a tray icon when updates are available
// das-update --cli the same in a terminal (servers, ssh); -y skips the question
// das-update --apply the privileged part, started by the window through pkexec
package main
import (
"bufio"
"context"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"dasos/tools/internal/i18n"
"dasos/tools/internal/system"
"dasos/tools/internal/update"
"dasos/tools/internal/xbps"
)
func main() {
cli := flag.Bool("cli", false, "update in the terminal")
yes := flag.Bool("y", false, "with --cli: do not ask for confirmation")
apply := flag.Bool("apply", false, "install the updates as root (used by the window)")
tray := flag.Bool("tray", false, "check for updates in the background")
lang := flag.String("lang", "", "message language, e.g. uk_UA.UTF-8 (pkexec clears LANG)")
flag.Parse()
if *lang != "" {
i18n.SetLang(*lang)
}
switch {
case *apply:
os.Exit(runApply())
case *cli:
os.Exit(runCLI(*yes))
case *tray:
os.Exit(runTray())
default:
os.Exit(runWindow())
}
}
// Machine-readable lines that --apply prints for the window, among the
// plain output of xbps.
const (
markStep = "@@step "
markSnapshot = "@@snapshot "
markReboot = "@@reboot"
markUpToDate = "@@uptodate"
markError = "@@error "
)
type markReporter struct{ w io.Writer }
func (r markReporter) Step(title string) { fmt.Fprintln(r.w, markStep+title) }
func (r markReporter) Output() io.Writer { return r.w }
func (r markReporter) Snapshot(n int) { fmt.Fprintf(r.w, "%s%d\n", markSnapshot, n) }
func runApply() int {
if os.Geteuid() != 0 {
fmt.Fprintln(os.Stderr, "das-update: "+i18n.T("update.needroot"))
return 1
}
// Losing the window must not stop xbps halfway through a transaction.
signal.Ignore(syscall.SIGHUP, syscall.SIGPIPE)
r := markReporter{os.Stdout}
res, err := update.System().Apply(context.Background(), r)
if err != nil {
fmt.Println(markError + err.Error())
return 1
}
if len(res.Updates) == 0 {
fmt.Println(markUpToDate)
} else if res.Reboot {
fmt.Println(markReboot)
}
return 0
}
type cliReporter struct{}
func (cliReporter) Step(title string) { fmt.Printf("\n==> %s\n", title) }
func (cliReporter) Output() io.Writer { return os.Stdout }
func (cliReporter) Snapshot(n int) { fmt.Println(i18n.T("update.snapshot", n)) }
func runCLI(yes bool) int {
if !yes {
fmt.Println(i18n.T("update.checking"))
updates, err := xbps.Check(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, i18n.T("update.checkfailed", err))
return 1
}
if len(updates) == 0 {
fmt.Println(i18n.T("update.uptodate"))
return 0
}
printUpdates(os.Stdout, updates)
if !confirm(os.Stdin) {
fmt.Println(i18n.T("update.cancelled"))
return 1
}
}
if os.Geteuid() != 0 {
return sudo()
}
signal.Ignore(syscall.SIGHUP)
res, err := update.System().Apply(context.Background(), cliReporter{})
fmt.Println()
switch {
case err != nil:
fmt.Fprintf(os.Stderr, "%s %v\n", i18n.T("update.failed"), err)
case len(res.Updates) == 0:
fmt.Println(i18n.T("update.uptodate"))
default:
fmt.Println(i18n.T("update.done"))
if res.Reboot {
fmt.Println(i18n.T("update.reboot"))
}
}
if res.Snapshot > 0 && len(res.Updates) > 0 {
fmt.Println(i18n.T("update.rollback", res.Snapshot, res.Snapshot))
}
if err != nil {
return 1
}
return 0
}
// sudo runs the confirmed update again as root.
func sudo() int {
self, err := os.Executable()
if err == nil {
var path string
path, err = exec.LookPath("sudo")
if err == nil {
err = syscall.Exec(path, []string{"sudo", self, "--cli", "-y", "--lang", system.Lang()}, os.Environ())
}
}
fmt.Fprintf(os.Stderr, "das-update: %s: %v\n", i18n.T("update.needroot"), err)
return 1
}
func printUpdates(w io.Writer, updates []xbps.Update) {
fmt.Fprintln(w, i18n.T("update.available"))
width := 0
for _, u := range updates {
width = max(width, len(u.Name))
}
for _, u := range updates {
switch {
case u.Action == "remove":
fmt.Fprintf(w, " %-*s %s → −\n", width, u.Name, u.OldVersion)
case u.OldVersion == "":
fmt.Fprintf(w, " %-*s + %s\n", width, u.Name, u.NewVersion)
default:
fmt.Fprintf(w, " %-*s %s → %s\n", width, u.Name, u.OldVersion, u.NewVersion)
}
}
fmt.Fprintln(w, i18n.Plural("update.count", len(updates)))
}
// confirm accepts Enter, y/yes and the Ukrainian т/так.
func confirm(in io.Reader) bool {
fmt.Print(i18n.T("update.confirm"))
line, err := bufio.NewReader(in).ReadString('\n')
if err != nil && line == "" {
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "", "y", "yes", "т", "так":
return true
}
return false
}