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("" + html.EscapeString(status) + "") 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")) }