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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/gotk3/gotk3/glib"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/system"
|
||||
"dasos/tools/internal/tray"
|
||||
"dasos/tools/internal/xbps"
|
||||
)
|
||||
|
||||
const (
|
||||
// Give the network time to come up after login.
|
||||
firstCheck = 2 * time.Minute
|
||||
checkInterval = 6 * time.Hour
|
||||
checkTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
type trayApp struct {
|
||||
icon *tray.Icon
|
||||
checking bool
|
||||
notified []string // the updates the last notification was about
|
||||
window *exec.Cmd
|
||||
}
|
||||
|
||||
func runTray() int {
|
||||
if system.IsLive() {
|
||||
// Updating the live session only fills its RAM overlay.
|
||||
return 0
|
||||
}
|
||||
gtk.Init(nil)
|
||||
t := &trayApp{}
|
||||
|
||||
menu, err := gtk.MenuNew()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return 1
|
||||
}
|
||||
for _, item := range []struct {
|
||||
label string
|
||||
action func()
|
||||
}{
|
||||
{i18n.T("update.tray.menu"), t.openWindow},
|
||||
{i18n.T("update.tray.check"), t.check},
|
||||
} {
|
||||
mi, err := gtk.MenuItemNewWithLabel(item.label)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return 1
|
||||
}
|
||||
mi.Connect("activate", item.action)
|
||||
menu.Append(mi)
|
||||
}
|
||||
t.icon = tray.New(iconName, t.openWindow, menu)
|
||||
|
||||
glib.TimeoutSecondsAdd(uint(firstCheck.Seconds()), func() bool {
|
||||
t.check()
|
||||
glib.TimeoutSecondsAdd(uint(checkInterval.Seconds()), func() bool {
|
||||
t.check()
|
||||
return true
|
||||
})
|
||||
return false
|
||||
})
|
||||
gtk.Main()
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t *trayApp) check() {
|
||||
if t.checking {
|
||||
return
|
||||
}
|
||||
t.checking = true
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), checkTimeout)
|
||||
defer cancel()
|
||||
updates, err := xbps.Check(ctx)
|
||||
glib.IdleAdd(func() { t.show(updates, err) })
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *trayApp) show(updates []xbps.Update, err error) {
|
||||
t.checking = false
|
||||
if err != nil {
|
||||
// Offline or a mirror problem: keep the current state, try again later.
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
t.icon.Hide()
|
||||
t.notified = nil
|
||||
return
|
||||
}
|
||||
summary := i18n.Plural("update.count", len(updates))
|
||||
t.icon.Show(summary)
|
||||
|
||||
names := make([]string, len(updates))
|
||||
for i, u := range updates {
|
||||
names[i] = u.Name + "-" + u.NewVersion
|
||||
}
|
||||
// Notify once per set of updates, not at every check.
|
||||
if !slices.Equal(names, t.notified) {
|
||||
t.notified = names
|
||||
exec.Command("notify-send", "--app-name=das-update", "--icon="+iconName,
|
||||
summary, i18n.T("update.notify")).Start()
|
||||
}
|
||||
}
|
||||
|
||||
// openWindow starts the update window and checks again once it is closed.
|
||||
func (t *trayApp) openWindow() {
|
||||
if t.window != nil {
|
||||
// das-update is a single-instance application: this raises it.
|
||||
exec.Command("/usr/bin/das-update").Start()
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("/usr/bin/das-update")
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-update:", err)
|
||||
return
|
||||
}
|
||||
t.window = cmd
|
||||
go func() {
|
||||
cmd.Wait()
|
||||
glib.IdleAdd(func() {
|
||||
t.window = nil
|
||||
t.check()
|
||||
})
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
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/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, markStep):
|
||||
line = "\n==> " + strings.TrimPrefix(line, markStep)
|
||||
case strings.HasPrefix(line, markSnapshot):
|
||||
n, _ := strconv.Atoi(strings.TrimPrefix(line, markSnapshot))
|
||||
glib.IdleAdd(func() { w.snapshot = n })
|
||||
line = i18n.T("update.snapshot", n)
|
||||
case line == markReboot:
|
||||
reboot = true
|
||||
continue
|
||||
case line == markUpToDate:
|
||||
upToDate = true
|
||||
continue
|
||||
case strings.HasPrefix(line, markError):
|
||||
failure = strings.TrimPrefix(line, 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"))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var localeNames = map[string]string{
|
||||
"uk_UA": "Українська",
|
||||
"en_US": "English (US)",
|
||||
"en_GB": "English (UK)",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
}
|
||||
|
||||
// availableLocales lists the built UTF-8 locales as LANG values, e.g. uk_UA.UTF-8.
|
||||
func availableLocales() []string {
|
||||
out, err := exec.Command("locale", "-a").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseLocales(string(out))
|
||||
}
|
||||
|
||||
func parseLocales(out string) []string {
|
||||
var locales []string
|
||||
for _, l := range strings.Fields(out) {
|
||||
base, codeset, ok := strings.Cut(l, ".")
|
||||
if !ok || !strings.EqualFold(strings.ReplaceAll(codeset, "-", ""), "utf8") || !strings.Contains(base, "_") {
|
||||
continue
|
||||
}
|
||||
locales = append(locales, base+".UTF-8")
|
||||
}
|
||||
sort.Strings(locales)
|
||||
return locales
|
||||
}
|
||||
|
||||
func currentLocale() string {
|
||||
lang := os.Getenv("LANG")
|
||||
base, _, _ := strings.Cut(lang, ".")
|
||||
return base + ".UTF-8"
|
||||
}
|
||||
|
||||
func localeName(locale string) string {
|
||||
base, _, _ := strings.Cut(locale, ".")
|
||||
if name, ok := localeNames[base]; ok {
|
||||
return name
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
var objectPath = regexp.MustCompile(`object path "([^"]+)"`)
|
||||
|
||||
// setLanguage makes LightDM start the next session with the locale. LightDM
|
||||
// takes it from AccountsService when that runs, and from ~/.dmrc otherwise,
|
||||
// so both are set.
|
||||
func setLanguage(locale string) error {
|
||||
errDmrc := writeDmrc(locale)
|
||||
errAccounts := setAccountsLanguage(locale)
|
||||
if errDmrc != nil && errAccounts != nil {
|
||||
return errors.Join(errDmrc, errAccounts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setAccountsLanguage(locale string) error {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := exec.Command("dbus-send", "--system", "--print-reply", "--dest=org.freedesktop.Accounts",
|
||||
"/org/freedesktop/Accounts", "org.freedesktop.Accounts.FindUserByName", "string:"+u.Username).Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("AccountsService: %w", err)
|
||||
}
|
||||
m := objectPath.FindSubmatch(out)
|
||||
if m == nil {
|
||||
return fmt.Errorf("AccountsService: no user object in %q", out)
|
||||
}
|
||||
if out, err := exec.Command("dbus-send", "--system", "--print-reply", "--dest=org.freedesktop.Accounts",
|
||||
string(m[1]), "org.freedesktop.Accounts.User.SetLanguage", "string:"+locale).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("AccountsService: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeDmrc sets Language in the [Desktop] section of ~/.dmrc.
|
||||
func writeDmrc(locale string) error {
|
||||
path := filepath.Join(os.Getenv("HOME"), ".dmrc")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(setDmrcLanguage(string(data), locale)), 0o644)
|
||||
}
|
||||
|
||||
func setDmrcLanguage(data, locale string) string {
|
||||
lines := strings.Split(strings.TrimRight(data, "\n"), "\n")
|
||||
if data == "" {
|
||||
lines = nil
|
||||
}
|
||||
section, inserted := -1, false
|
||||
for i, l := range lines {
|
||||
t := strings.TrimSpace(l)
|
||||
switch {
|
||||
case t == "[Desktop]":
|
||||
section = i
|
||||
case strings.HasPrefix(t, "["):
|
||||
if section >= 0 && !inserted {
|
||||
lines = append(lines[:i], append([]string{"Language=" + locale}, lines[i:]...)...)
|
||||
inserted = true
|
||||
}
|
||||
section = -1
|
||||
case section >= 0 && strings.HasPrefix(t, "Language="):
|
||||
lines[i] = "Language=" + locale
|
||||
inserted = true
|
||||
}
|
||||
if inserted {
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case inserted:
|
||||
case section >= 0:
|
||||
lines = append(lines, "Language="+locale)
|
||||
default:
|
||||
lines = append(lines, "[Desktop]", "Language="+locale)
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLocales(t *testing.T) {
|
||||
got := parseLocales("C\nC.utf8\nPOSIX\nuk_UA.utf8\nen_US.UTF-8\nde_DE.iso88591\n")
|
||||
want := []string{"en_US.UTF-8", "uk_UA.UTF-8"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("parseLocales = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDmrcLanguage(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"", "[Desktop]\nLanguage=en_US.UTF-8\n"},
|
||||
{"[Desktop]\nSession=xfce\n", "[Desktop]\nSession=xfce\nLanguage=en_US.UTF-8\n"},
|
||||
{"[Desktop]\nLanguage=uk_UA.UTF-8\nSession=xfce\n", "[Desktop]\nLanguage=en_US.UTF-8\nSession=xfce\n"},
|
||||
{"[Desktop]\nSession=xfce\n[Other]\nLanguage=x\n", "[Desktop]\nSession=xfce\nLanguage=en_US.UTF-8\n[Other]\nLanguage=x\n"},
|
||||
{"[Other]\nKey=1\n", "[Other]\nKey=1\n[Desktop]\nLanguage=en_US.UTF-8\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := setDmrcLanguage(tt.in, "en_US.UTF-8"); got != tt.want {
|
||||
t.Errorf("setDmrcLanguage(%q) =\n%q\nwant\n%q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Command das-welcome is the Das OS first-login window: interface language,
|
||||
// home folder names, updates, settings and help.
|
||||
//
|
||||
// das-welcome show the window
|
||||
// das-welcome --autostart show it only if the user did not turn that off
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gotk3/gotk3/glib"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/system"
|
||||
"dasos/tools/internal/userdirs"
|
||||
)
|
||||
|
||||
const (
|
||||
appID = "ua.org.das.Welcome"
|
||||
helpURL = "https://git.das.org.ua/das/Das-OS"
|
||||
)
|
||||
|
||||
func main() {
|
||||
autostart := flag.Bool("autostart", false, "show the window only if it is enabled at login")
|
||||
flag.Parse()
|
||||
|
||||
if *autostart && !showAtLogin() {
|
||||
return
|
||||
}
|
||||
app, err := gtk.ApplicationNew(appID, glib.APPLICATION_FLAGS_NONE)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var win *gtk.ApplicationWindow
|
||||
app.Connect("activate", func() {
|
||||
if win != nil {
|
||||
win.Present()
|
||||
return
|
||||
}
|
||||
win, err = newWindow(app)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
app.Quit()
|
||||
return
|
||||
}
|
||||
win.ShowAll()
|
||||
})
|
||||
os.Exit(app.Run(nil))
|
||||
}
|
||||
|
||||
// settingsFile records whether the window opens at login. Without it the
|
||||
// window opens once: at the first login of a user (or every live session).
|
||||
func settingsFile() string {
|
||||
dir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if dir == "" {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(dir, "das", "welcome")
|
||||
}
|
||||
|
||||
func showAtLogin() bool {
|
||||
data, err := os.ReadFile(settingsFile())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "autostart=true"
|
||||
}
|
||||
|
||||
func setShowAtLogin(on bool) error {
|
||||
path := settingsFile()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(fmt.Sprintf("autostart=%v\n", on)), 0o644)
|
||||
}
|
||||
|
||||
// builder collects the first error of a chain of gotk3 constructors.
|
||||
type builder struct{ err error }
|
||||
|
||||
func (b *builder) check(err error) {
|
||||
if b.err == nil {
|
||||
b.err = err
|
||||
}
|
||||
}
|
||||
|
||||
func (b *builder) label(text string, wrap bool) *gtk.Label {
|
||||
l, err := gtk.LabelNew(text)
|
||||
b.check(err)
|
||||
if err == nil {
|
||||
l.SetXAlign(0)
|
||||
l.SetLineWrap(wrap)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (b *builder) button(text string, onClick func()) *gtk.Button {
|
||||
btn, err := gtk.ButtonNewWithLabel(text)
|
||||
b.check(err)
|
||||
if err == nil {
|
||||
btn.SetVAlign(gtk.ALIGN_CENTER)
|
||||
btn.Connect("clicked", onClick)
|
||||
}
|
||||
return btn
|
||||
}
|
||||
|
||||
// row is one entry of the list: icon, title, description and controls.
|
||||
func (b *builder) row(icon, title, desc string, controls ...gtk.IWidget) (*gtk.Box, *gtk.Label) {
|
||||
box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 14)
|
||||
b.check(err)
|
||||
img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_DND)
|
||||
b.check(err)
|
||||
text, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return box, nil
|
||||
}
|
||||
img.SetPixelSize(32)
|
||||
t := b.label("", false)
|
||||
t.SetMarkup("<b>" + html.EscapeString(title) + "</b>")
|
||||
d := b.label(desc, true)
|
||||
if style, err := d.GetStyleContext(); err == nil {
|
||||
style.AddClass("dim-label")
|
||||
}
|
||||
text.PackStart(t, false, false, 0)
|
||||
text.PackStart(d, false, false, 0)
|
||||
box.PackStart(img, false, false, 0)
|
||||
box.PackStart(text, true, true, 0)
|
||||
for _, c := range controls {
|
||||
box.PackEnd(c, false, false, 0)
|
||||
}
|
||||
return box, d
|
||||
}
|
||||
|
||||
func newWindow(app *gtk.Application) (*gtk.ApplicationWindow, error) {
|
||||
b := &builder{}
|
||||
win, err := gtk.ApplicationWindowNew(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
win.SetTitle(i18n.T("welcome.title"))
|
||||
win.SetIconName("das-logo")
|
||||
win.SetDefaultSize(640, -1)
|
||||
win.SetPosition(gtk.WIN_POS_CENTER)
|
||||
|
||||
outer, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 16)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
outer.SetMarginStart(24)
|
||||
outer.SetMarginEnd(24)
|
||||
outer.SetMarginTop(24)
|
||||
outer.SetMarginBottom(18)
|
||||
|
||||
header, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 16)
|
||||
b.check(err)
|
||||
logo, err := gtk.ImageNewFromIconName("das-logo", gtk.ICON_SIZE_DIALOG)
|
||||
b.check(err)
|
||||
titles, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 4)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
logo.SetPixelSize(72)
|
||||
title := b.label("", false)
|
||||
title.SetMarkup(`<span size="xx-large" weight="bold">` + html.EscapeString(i18n.T("welcome.title")) + "</span>")
|
||||
titles.SetVAlign(gtk.ALIGN_CENTER)
|
||||
titles.PackStart(title, false, false, 0)
|
||||
titles.PackStart(b.label(i18n.T("welcome.subtitle"), true), false, false, 0)
|
||||
header.PackStart(logo, false, false, 0)
|
||||
header.PackStart(titles, true, true, 0)
|
||||
outer.PackStart(header, false, false, 0)
|
||||
|
||||
list, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 14)
|
||||
b.check(err)
|
||||
add := func(row *gtk.Box) {
|
||||
if sep, err := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL); err == nil {
|
||||
list.PackStart(sep, false, false, 0)
|
||||
}
|
||||
list.PackStart(row, false, false, 0)
|
||||
}
|
||||
|
||||
if system.IsLive() {
|
||||
install := b.button(i18n.T("welcome.install"), func() { launch("das-install") })
|
||||
if style, err := install.GetStyleContext(); err == nil {
|
||||
style.AddClass("suggested-action")
|
||||
}
|
||||
row, _ := b.row("das-logo", i18n.T("welcome.install"), i18n.T("welcome.install.desc"), install)
|
||||
add(row)
|
||||
}
|
||||
if row := languageRow(b); row != nil {
|
||||
add(row)
|
||||
}
|
||||
if row := foldersRow(b); row != nil {
|
||||
add(row)
|
||||
}
|
||||
if !system.IsLive() {
|
||||
row, _ := b.row("software-update-available", i18n.T("welcome.update"), i18n.T("welcome.update.desc"),
|
||||
b.button(i18n.T("welcome.update.open"), func() { launch("das-update") }))
|
||||
add(row)
|
||||
}
|
||||
row, _ := b.row("preferences-desktop", i18n.T("welcome.settings"), i18n.T("welcome.settings.desc"),
|
||||
b.button(i18n.T("welcome.open"), func() { launch("xfce4-settings-manager") }))
|
||||
add(row)
|
||||
row, _ = b.row("help-browser", i18n.T("welcome.help"), i18n.T("welcome.help.desc"),
|
||||
b.button(i18n.T("welcome.open"), func() { launch("xdg-open", helpURL) }))
|
||||
add(row)
|
||||
outer.PackStart(list, false, false, 0)
|
||||
|
||||
footer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 12)
|
||||
b.check(err)
|
||||
autostart, err := gtk.CheckButtonNewWithLabel(i18n.T("welcome.autostart"))
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
// The window opens by itself only once unless the user asks for more.
|
||||
autostart.SetActive(false)
|
||||
if _, err := os.Stat(settingsFile()); err == nil {
|
||||
autostart.SetActive(showAtLogin())
|
||||
} else if err := setShowAtLogin(false); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
autostart.Connect("toggled", func() {
|
||||
if err := setShowAtLogin(autostart.GetActive()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
})
|
||||
footer.PackStart(autostart, false, false, 0)
|
||||
footer.PackEnd(b.button(i18n.T("welcome.close"), func() { win.Close() }), false, false, 0)
|
||||
outer.PackEnd(footer, false, false, 0)
|
||||
|
||||
win.Add(outer)
|
||||
return win, b.err
|
||||
}
|
||||
|
||||
func launch(name string, args ...string) {
|
||||
if err := exec.Command(name, args...).Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// languageRow offers the UTF-8 locales the system has built. It returns nil
|
||||
// when there is nothing to choose from.
|
||||
func languageRow(b *builder) *gtk.Box {
|
||||
locales := availableLocales()
|
||||
if len(locales) < 2 {
|
||||
return nil
|
||||
}
|
||||
combo, err := gtk.ComboBoxTextNew()
|
||||
b.check(err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
current := currentLocale()
|
||||
for _, l := range locales {
|
||||
combo.Append(l, localeName(l))
|
||||
}
|
||||
if !combo.SetActiveID(current) {
|
||||
combo.SetActive(0)
|
||||
}
|
||||
combo.SetVAlign(gtk.ALIGN_CENTER)
|
||||
|
||||
var desc *gtk.Label
|
||||
apply := b.button(i18n.T("welcome.apply"), func() {
|
||||
if err := setLanguage(combo.GetActiveID()); err != nil {
|
||||
desc.SetText(i18n.T("welcome.error", err))
|
||||
return
|
||||
}
|
||||
desc.SetText(i18n.T("welcome.lang.done"))
|
||||
})
|
||||
row, d := b.row("preferences-desktop-locale", i18n.T("welcome.lang"), i18n.T("welcome.lang.desc"), apply, combo)
|
||||
desc = d
|
||||
return row
|
||||
}
|
||||
|
||||
// foldersRow offers to rename home folders that kept English names.
|
||||
func foldersRow(b *builder) *gtk.Box {
|
||||
home := os.Getenv("HOME")
|
||||
renames, err := pendingRenames(home)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
return nil
|
||||
}
|
||||
if len(renames) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, len(renames))
|
||||
for i, r := range renames {
|
||||
names[i] = filepath.Base(r.From)
|
||||
}
|
||||
var desc *gtk.Label
|
||||
var rename *gtk.Button
|
||||
rename = b.button(i18n.T("welcome.dirs.rename"), func() {
|
||||
if err := userdirs.Apply(home, renames); err != nil {
|
||||
desc.SetText(i18n.T("welcome.error", err))
|
||||
return
|
||||
}
|
||||
desc.SetText(i18n.T("welcome.dirs.done"))
|
||||
rename.SetSensitive(false)
|
||||
})
|
||||
row, d := b.row("folder", i18n.T("welcome.dirs"), i18n.T("welcome.dirs.desc", strings.Join(names, ", ")), rename)
|
||||
desc = d
|
||||
return row
|
||||
}
|
||||
|
||||
func pendingRenames(home string) ([]userdirs.Rename, error) {
|
||||
data, err := os.ReadFile(userdirs.ConfigFile(home))
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := context.Background()
|
||||
english, err := userdirs.Defaults(ctx, "C.UTF-8")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localized, err := userdirs.Defaults(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return userdirs.Plan(home, userdirs.Parse(string(data)), english, localized), nil
|
||||
}
|
||||
Reference in New Issue
Block a user