Files
Das-OS/srcpkgs/das-tools/files/src/cmd/das-update-gui/window.go
T
dasandClaude Opus 5.5 eba4516998 Stage 6: server image with a console installer (0.4.0)
- server/ profile and build-iso.sh [desktop|server]; make iso-server,
  make qemu-server, run-qemu.sh --serial; login on tty and ttyS0
- das-server: SSH, chrony, NetworkManager/dhcpcd, Docker with Compose,
  nftables (closed except SSH, own table), fail2ban reading socklog,
  sshd without root passwords, Docker log rotation, tools
- das-server-install: dialog installer in Ukrainian and English, or
  unattended with --config; GPT + Btrfs subvolumes, GRUB for UEFI and BIOS,
  serial console, SSH key with optional keys-only logins
- das-install-common: mount-source and postinstall shared with Calamares
- dsv: runit service manager (list, enable, disable, start, stop,
  restart, status, logs) with bash completion, also on the desktop
- das-update split into a subpackage without GTK; the window and tray
  icon move to /usr/libexec/das/das-update-gui
- das-snapper-setup enables dbus, which snapper needs
- TODO: ISO size review with measured numbers and options

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:28:26 +03:00

341 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"context"
"errors"
"fmt"
"html"
"os"
"os/exec"
"strconv"
"strings"
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk"
"dasos/tools/internal/i18n"
"dasos/tools/internal/system"
"dasos/tools/internal/update"
"dasos/tools/internal/xbps"
)
const (
appID = "ua.org.das.Update"
iconName = "software-update-available"
// pkexec exit codes when the password dialog was dismissed or failed
pkexecDismissed = 126
pkexecNotAuthed = 127
)
type window struct {
win *gtk.ApplicationWindow
status *gtk.Label
spinner *gtk.Spinner
store *gtk.ListStore
list *gtk.ScrolledWindow
info *gtk.Label
details *gtk.Expander
log *gtk.TextView
check *gtk.Button
install *gtk.Button
close *gtk.Button
running bool
snapshot int
}
func runWindow() int {
app, err := gtk.ApplicationNew(appID, glib.APPLICATION_FLAGS_NONE)
if err != nil {
fmt.Fprintln(os.Stderr, "das-update:", err)
return 1
}
var w *window
app.Connect("activate", func() {
// A second "das-update" only raises the open window.
if w != nil {
w.win.Present()
return
}
w, err = newWindow(app)
if err != nil {
fmt.Fprintln(os.Stderr, "das-update:", err)
app.Quit()
return
}
w.win.ShowAll()
w.details.Hide()
w.startCheck()
})
return app.Run(nil)
}
func newWindow(app *gtk.Application) (*window, error) {
w := &window{}
var err error
must := func(e error) {
if err == nil {
err = e
}
}
w.win, err = gtk.ApplicationWindowNew(app)
if err != nil {
return nil, err
}
w.win.SetTitle(i18n.T("update.title"))
w.win.SetIconName(iconName)
w.win.SetDefaultSize(620, 480)
w.win.SetPosition(gtk.WIN_POS_CENTER)
w.win.Connect("delete-event", func() bool { return w.running })
box, e := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 12)
must(e)
box.SetMarginStart(18)
box.SetMarginEnd(18)
box.SetMarginTop(18)
box.SetMarginBottom(18)
header, e := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 12)
must(e)
icon, e := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_DIALOG)
must(e)
w.status, e = gtk.LabelNew("")
must(e)
w.status.SetXAlign(0)
w.status.SetLineWrap(true)
w.spinner, e = gtk.SpinnerNew()
must(e)
header.PackStart(icon, false, false, 0)
header.PackStart(w.status, true, true, 0)
header.PackEnd(w.spinner, false, false, 0)
box.PackStart(header, false, false, 0)
w.store, e = gtk.ListStoreNew(glib.TYPE_STRING, glib.TYPE_STRING, glib.TYPE_STRING)
must(e)
tree, e := gtk.TreeViewNewWithModel(w.store)
must(e)
for i, title := range []string{"Пакет|Package", "Встановлено|Installed", "Нова версія|New version"} {
r, e := gtk.CellRendererTextNew()
must(e)
col, e := gtk.TreeViewColumnNewWithAttribute(pick(title), r, "text", i)
must(e)
col.SetResizable(true)
tree.AppendColumn(col)
}
w.list, e = gtk.ScrolledWindowNew(nil, nil)
must(e)
w.list.SetShadowType(gtk.SHADOW_IN)
w.list.SetVExpand(true)
w.list.Add(tree)
box.PackStart(w.list, true, true, 0)
w.info, e = gtk.LabelNew("")
must(e)
w.info.SetXAlign(0)
w.info.SetLineWrap(true)
w.info.SetSelectable(true)
box.PackStart(w.info, false, false, 0)
w.details, e = gtk.ExpanderNew(i18n.T("update.details"))
must(e)
w.log, e = gtk.TextViewNew()
must(e)
w.log.SetEditable(false)
w.log.SetCursorVisible(false)
w.log.SetMonospace(true)
w.log.SetWrapMode(gtk.WRAP_WORD_CHAR)
logScroll, e := gtk.ScrolledWindowNew(nil, nil)
must(e)
logScroll.SetShadowType(gtk.SHADOW_IN)
logScroll.SetSizeRequest(-1, 180)
logScroll.SetVExpand(true)
logScroll.Add(w.log)
w.details.Add(logScroll)
box.PackStart(w.details, true, true, 0)
buttons, e := gtk.ButtonBoxNew(gtk.ORIENTATION_HORIZONTAL)
must(e)
buttons.SetLayout(gtk.BUTTONBOX_END)
buttons.SetSpacing(6)
w.check, e = gtk.ButtonNewWithLabel(i18n.T("update.check"))
must(e)
w.install, e = gtk.ButtonNewWithLabel(i18n.T("update.install"))
must(e)
w.close, e = gtk.ButtonNewWithLabel(i18n.T("update.close"))
must(e)
if e == nil {
style, _ := w.install.GetStyleContext()
style.AddClass("suggested-action")
}
buttons.Add(w.check)
buttons.Add(w.install)
buttons.Add(w.close)
box.PackEnd(buttons, false, false, 0)
w.check.Connect("clicked", w.startCheck)
w.install.Connect("clicked", w.startInstall)
w.close.Connect("clicked", func() { w.win.Close() })
w.win.Add(box)
return w, err
}
// pick chooses from an inline "Ukrainian|English" pair.
func pick(pair string) string {
uk, en, _ := strings.Cut(pair, "|")
if strings.HasPrefix(system.Lang(), "uk") {
return uk
}
return en
}
func (w *window) setBusy(busy bool, status string) {
w.running = busy
w.status.SetMarkup("<b><big>" + html.EscapeString(status) + "</big></b>")
if busy {
w.spinner.Start()
} else {
w.spinner.Stop()
}
w.check.SetSensitive(!busy)
w.install.SetSensitive(!busy)
w.close.SetSensitive(!busy)
}
func (w *window) startCheck() {
w.setBusy(true, i18n.T("update.checking"))
w.info.SetText("")
go func() {
updates, err := xbps.Check(context.Background())
glib.IdleAdd(func() { w.showUpdates(updates, err) })
}()
}
func (w *window) showUpdates(updates []xbps.Update, err error) {
w.store.Clear()
switch {
case err != nil:
w.setBusy(false, i18n.T("update.checkfailed", err))
w.install.SetSensitive(false)
return
case len(updates) == 0:
w.setBusy(false, i18n.T("update.uptodate"))
w.install.SetSensitive(false)
w.list.Hide()
return
}
for _, u := range updates {
newVersion := u.NewVersion
if u.Action == "remove" {
newVersion = "−"
}
w.store.Set(w.store.Append(), []int{0, 1, 2}, []any{u.Name, u.OldVersion, newVersion})
}
w.details.Hide()
w.list.Show()
w.setBusy(false, i18n.Plural("update.count", len(updates)))
w.install.GrabFocus()
}
func (w *window) startInstall() {
w.setBusy(true, i18n.T("update.running"))
w.snapshot = 0
w.info.SetText("")
buf, _ := w.log.GetBuffer()
buf.SetText("")
// The log takes the place of the package list while xbps works.
w.list.Hide()
w.details.Show()
w.details.SetExpanded(true)
cmd := exec.Command("pkexec", "/usr/bin/das-update", "--apply", "--lang", system.Lang())
out, err := cmd.StdoutPipe()
if err == nil {
cmd.Stderr = cmd.Stdout
err = cmd.Start()
}
if err != nil {
w.finish(false, false, err.Error())
return
}
go func() {
var reboot, upToDate bool
var failure string
sc := bufio.NewScanner(out)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, update.MarkStep):
line = "\n==> " + strings.TrimPrefix(line, update.MarkStep)
case strings.HasPrefix(line, update.MarkSnapshot):
n, _ := strconv.Atoi(strings.TrimPrefix(line, update.MarkSnapshot))
glib.IdleAdd(func() { w.snapshot = n })
line = i18n.T("update.snapshot", n)
case line == update.MarkReboot:
reboot = true
continue
case line == update.MarkUpToDate:
upToDate = true
continue
case strings.HasPrefix(line, update.MarkError):
failure = strings.TrimPrefix(line, update.MarkError)
}
glib.IdleAdd(func() { w.appendLog(line + "\n") })
}
err := cmd.Wait()
var exit *exec.ExitError
if errors.As(err, &exit) && (exit.ExitCode() == pkexecDismissed || exit.ExitCode() == pkexecNotAuthed) && failure == "" {
glib.IdleAdd(func() { w.cancelled() })
return
}
if err != nil && failure == "" {
failure = err.Error()
}
glib.IdleAdd(func() {
if upToDate {
w.setBusy(false, i18n.T("update.uptodate"))
w.store.Clear()
w.install.SetSensitive(false)
return
}
w.finish(failure == "", reboot, failure)
})
}()
}
func (w *window) appendLog(text string) {
buf, _ := w.log.GetBuffer()
buf.Insert(buf.GetEndIter(), text)
buf.PlaceCursor(buf.GetEndIter())
w.log.ScrollToMark(buf.GetInsert(), 0, false, 0, 0)
}
func (w *window) cancelled() {
w.setBusy(false, i18n.T("update.cancelled"))
w.details.Hide()
w.list.Show()
}
func (w *window) finish(ok, reboot bool, failure string) {
var info []string
if ok {
w.setBusy(false, i18n.T("update.done"))
w.store.Clear()
w.install.SetSensitive(false)
if reboot {
info = append(info, i18n.T("update.reboot"))
}
} else {
w.setBusy(false, i18n.T("update.failed"))
w.details.SetExpanded(true)
if failure != "" {
w.appendLog("\n" + failure + "\n")
}
}
if w.snapshot > 0 {
info = append(info, i18n.T("update.snapshot", w.snapshot), i18n.T("update.rollback", w.snapshot, w.snapshot))
}
w.info.SetText(strings.Join(info, "\n"))
}