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
}
@@ -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
}