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,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Das
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Das OS update check
|
||||
Comment=Shows a tray icon when updates are available
|
||||
Exec=das-update --tray
|
||||
OnlyShowIn=XFCE;
|
||||
NoDisplay=true
|
||||
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=System Update
|
||||
Name[uk]=Оновлення системи
|
||||
Comment=Install updates with a snapshot to roll back to
|
||||
Comment[uk]=Встановити оновлення зі знімком для відкату
|
||||
Exec=das-update
|
||||
Icon=software-update-available
|
||||
Terminal=false
|
||||
Categories=System;Settings;
|
||||
Keywords=update;upgrade;xbps;snapshot;
|
||||
Keywords[uk]=оновлення;xbps;знімок;
|
||||
@@ -0,0 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Das OS welcome
|
||||
Exec=das-welcome --autostart
|
||||
OnlyShowIn=XFCE;
|
||||
NoDisplay=true
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Welcome to Das OS
|
||||
Name[uk]=Ласкаво просимо до Das OS
|
||||
Comment=Language, home folders, updates and help
|
||||
Comment[uk]=Мова, теки, оновлення та довідка
|
||||
Exec=das-welcome
|
||||
Icon=das-logo
|
||||
Terminal=false
|
||||
Categories=System;
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>Das OS</vendor>
|
||||
<vendor_url>https://git.das.org.ua/das/Das-OS</vendor_url>
|
||||
<action id="ua.org.das.update">
|
||||
<description>Update the system</description>
|
||||
<description xml:lang="uk">Оновлення системи</description>
|
||||
<message>Authentication is required to install system updates</message>
|
||||
<message xml:lang="uk">Для встановлення оновлень потрібна автентифікація</message>
|
||||
<icon_name>software-update-available</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin_keep</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/das-update</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module dasos/tools
|
||||
|
||||
go 1.24
|
||||
|
||||
require github.com/gotk3/gotk3 v0.6.5-0.20251124190141-e7a9e823ca35
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/gotk3/gotk3 v0.6.5-0.20251124190141-e7a9e823ca35 h1:BelWQzAzJfSMA1qbuzoV9Tp57+NCvYouEA5cWVpYcSk=
|
||||
github.com/gotk3/gotk3 v0.6.5-0.20251124190141-e7a9e823ca35/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package i18n holds the user-visible strings of the Das OS tools in
|
||||
// Ukrainian and English. Ukrainian is used when LC_ALL, LC_MESSAGES or LANG
|
||||
// (first one set wins, as in gettext) starts with "uk"; English otherwise.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ukrainian = detect(os.Getenv)
|
||||
|
||||
func detect(getenv func(string) string) bool {
|
||||
for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
if v := getenv(name); v != "" {
|
||||
return strings.HasPrefix(v, "uk")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetLang overrides the detected language, e.g. for a helper started by
|
||||
// pkexec, which clears the environment.
|
||||
func SetLang(lang string) {
|
||||
ukrainian = strings.HasPrefix(lang, "uk")
|
||||
}
|
||||
|
||||
// T returns the message for key, formatted with args like fmt.Sprintf.
|
||||
// An unknown key is returned as is, so a missing translation stays visible.
|
||||
func T(key string, args ...any) string {
|
||||
m, ok := messages[key]
|
||||
if !ok {
|
||||
return key
|
||||
}
|
||||
s := m.en
|
||||
if ukrainian {
|
||||
s = m.uk
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return fmt.Sprintf(s, args...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Plural picks the Ukrainian form for n (one, few, many); English uses the
|
||||
// "one" form for 1 and "many" otherwise. The keys are key+".one", ".few", ".many".
|
||||
func Plural(key string, n int) string {
|
||||
form := "many"
|
||||
if ukrainian {
|
||||
switch {
|
||||
case n%10 == 1 && n%100 != 11:
|
||||
form = "one"
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 12 || n%100 > 14):
|
||||
form = "few"
|
||||
}
|
||||
} else if n == 1 {
|
||||
form = "one"
|
||||
}
|
||||
return T(key+"."+form, n)
|
||||
}
|
||||
|
||||
type message struct{ uk, en string }
|
||||
|
||||
var messages = map[string]message{
|
||||
// das-update
|
||||
"update.title": {"Оновлення системи", "System update"},
|
||||
"update.checking": {"Перевірка оновлень…", "Checking for updates…"},
|
||||
"update.uptodate": {"Система оновлена.", "The system is up to date."},
|
||||
"update.available": {"Доступні оновлення:", "Updates available:"},
|
||||
"update.count.one": {"Доступне %d оновлення", "%d update available"},
|
||||
"update.count.few": {"Доступні %d оновлення", "%d updates available"},
|
||||
"update.count.many": {"Доступно %d оновлень", "%d updates available"},
|
||||
"update.check": {"Перевірити", "Check"},
|
||||
"update.install": {"Оновити", "Update"},
|
||||
"update.close": {"Закрити", "Close"},
|
||||
"update.details": {"Подробиці", "Details"},
|
||||
"update.running": {"Оновлення… Не вимикайте комп'ютер.", "Updating… Do not turn off the computer."},
|
||||
"update.done": {"Систему оновлено.", "The system has been updated."},
|
||||
"update.reboot": {"Оновлено ядро або системні бібліотеки: перезавантажте комп'ютер.", "The kernel or system libraries were updated: restart the computer."},
|
||||
"update.failed": {"Оновлення не вдалося.", "The update failed."},
|
||||
"update.cancelled": {"Оновлення скасовано.", "The update was cancelled."},
|
||||
"update.checkfailed": {"Не вдалося перевірити оновлення: %v", "Could not check for updates: %v"},
|
||||
"update.snapshot": {"Знімок перед оновленням: №%d.", "Snapshot before the update: #%d."},
|
||||
"update.nosnapshots": {"Знімки вимкнені (корінь не на Btrfs), оновлення без знімка.", "Snapshots are off (root is not on Btrfs), updating without one."},
|
||||
"update.rollback": {"Якщо після оновлення щось не працює, перезавантажтеся, у меню завантаження виберіть «Das OS snapshots» → знімок №%d, перевірте систему й виконайте «sudo snapper-rollback %d».", "If something does not work after the update, restart, choose \"Das OS snapshots\" → snapshot #%d in the boot menu, check the system and run \"sudo snapper-rollback %d\"."},
|
||||
"update.step.sync": {"Оновлення списку пакетів", "Refreshing the package index"},
|
||||
"update.step.pre": {"Створення знімка перед оновленням", "Creating a snapshot before the update"},
|
||||
"update.step.xbps": {"Оновлення менеджера пакетів xbps", "Updating the xbps package manager"},
|
||||
"update.step.install": {"Встановлення оновлень", "Installing updates"},
|
||||
"update.step.post": {"Створення знімка після оновлення", "Creating a snapshot after the update"},
|
||||
"update.confirm": {"Встановити оновлення? [Т/н] ", "Install the updates? [Y/n] "},
|
||||
"update.needroot": {"для оновлення потрібні права root", "updating needs root privileges"},
|
||||
"update.notify": {"Натисніть, щоб переглянути й встановити.", "Click to review and install them."},
|
||||
"update.tray.menu": {"Оновити систему…", "Update the system…"},
|
||||
"update.tray.check": {"Перевірити зараз", "Check now"},
|
||||
|
||||
// das-welcome
|
||||
"welcome.title": {"Ласкаво просимо до Das OS", "Welcome to Das OS"},
|
||||
"welcome.subtitle": {"Кілька кроків, щоб налаштувати систему під себе.", "A few steps to make the system your own."},
|
||||
"welcome.install": {"Встановити Das OS", "Install Das OS"},
|
||||
"welcome.install.desc": {"Ви в live-сесії. Встановіть систему на диск, щоб зберігати зміни.", "This is a live session. Install the system to a disk to keep your changes."},
|
||||
"welcome.lang": {"Мова інтерфейсу", "Interface language"},
|
||||
"welcome.lang.desc": {"Зміниться після наступного входу.", "Takes effect at the next login."},
|
||||
"welcome.lang.done": {"Мову змінено. Вийдіть із сеансу й увійдіть знову.", "Language changed. Log out and back in."},
|
||||
"welcome.apply": {"Застосувати", "Apply"},
|
||||
"welcome.dirs": {"Теки домашнього каталогу", "Home folders"},
|
||||
"welcome.dirs.desc": {"Теки «%s» мають англійські назви. Перейменувати їх мовою системи?", "The folders \"%s\" have English names. Rename them to the system language?"},
|
||||
"welcome.dirs.rename": {"Перейменувати", "Rename"},
|
||||
"welcome.dirs.done": {"Теки перейменовано.", "The folders have been renamed."},
|
||||
"welcome.update": {"Оновлення", "Updates"},
|
||||
"welcome.update.desc": {"Перед кожним оновленням створюється знімок системи для відкату.", "A system snapshot is taken before every update so it can be rolled back."},
|
||||
"welcome.update.open": {"Перевірити", "Check"},
|
||||
"welcome.settings": {"Налаштування", "Settings"},
|
||||
"welcome.settings.desc": {"Тема, екран, клавіатура, принтери та інше.", "Theme, display, keyboard, printers and more."},
|
||||
"welcome.open": {"Відкрити", "Open"},
|
||||
"welcome.help": {"Довідка", "Help"},
|
||||
"welcome.help.desc": {"Опис системи, знімки й відкат, відомі проблеми.", "About the system, snapshots and rollback, known issues."},
|
||||
"welcome.autostart": {"Показувати під час входу", "Show at login"},
|
||||
"welcome.close": {"Закрити", "Close"},
|
||||
"welcome.error": {"Помилка: %v", "Error: %v"},
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDetect(t *testing.T) {
|
||||
tests := []struct {
|
||||
env map[string]string
|
||||
want bool
|
||||
}{
|
||||
{map[string]string{"LANG": "uk_UA.UTF-8"}, true},
|
||||
{map[string]string{"LANG": "en_US.UTF-8"}, false},
|
||||
{map[string]string{"LANG": "uk_UA.UTF-8", "LC_MESSAGES": "en_US.UTF-8"}, false},
|
||||
{map[string]string{"LANG": "en_US.UTF-8", "LC_ALL": "uk_UA.UTF-8"}, true},
|
||||
{map[string]string{}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := detect(func(k string) string { return tt.env[k] }); got != tt.want {
|
||||
t.Errorf("detect(%v) = %v, want %v", tt.env, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlural(t *testing.T) {
|
||||
SetLang("uk")
|
||||
defer SetLang("")
|
||||
for n, want := range map[int]string{
|
||||
1: "Доступне 1 оновлення", 3: "Доступні 3 оновлення", 5: "Доступно 5 оновлень",
|
||||
11: "Доступно 11 оновлень", 21: "Доступне 21 оновлення", 112: "Доступно 112 оновлень",
|
||||
} {
|
||||
if got := Plural("update.count", n); got != want {
|
||||
t.Errorf("Plural(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
SetLang("en")
|
||||
if got := Plural("update.count", 1); got != "1 update available" {
|
||||
t.Errorf("English Plural(1) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package snapper creates the pre/post snapshot pair around a system update.
|
||||
package snapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config is the snapper configuration of the root file system, created by
|
||||
// das-snapper-setup when the system was installed on Btrfs.
|
||||
const Config = "root"
|
||||
|
||||
// Available reports whether snapshots of the root file system are set up.
|
||||
func Available() bool {
|
||||
if _, err := exec.LookPath("snapper"); err != nil {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat("/etc/snapper/configs/" + Config)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Pre creates a "pre" snapshot and returns its number.
|
||||
func Pre(ctx context.Context, description string) (int, error) {
|
||||
out, err := exec.CommandContext(ctx, "snapper", "-c", Config, "create",
|
||||
"--type", "pre", "--print-number", "--cleanup-algorithm", "number",
|
||||
"--description", description).CombinedOutput()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("snapper create: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return ParseNumber(string(out))
|
||||
}
|
||||
|
||||
// Post creates the "post" snapshot paired with pre.
|
||||
func Post(ctx context.Context, pre int, description string) error {
|
||||
out, err := exec.CommandContext(ctx, "snapper", "-c", Config, "create",
|
||||
"--type", "post", "--pre-number", strconv.Itoa(pre), "--cleanup-algorithm", "number",
|
||||
"--description", description).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("snapper create: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseNumber reads the snapshot number printed by "snapper create --print-number".
|
||||
func ParseNumber(out string) (int, error) {
|
||||
s := strings.TrimSpace(out)
|
||||
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("snapper printed %q, expected a snapshot number", out)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package snapper
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{"42\n", 42, false},
|
||||
{"warning: something\n7\n", 7, false},
|
||||
{"", 0, true},
|
||||
{"0\n", 0, true},
|
||||
{"Creating snapshot failed.\n", 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := ParseNumber(tt.in)
|
||||
if got != tt.want || (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseNumber(%q) = %d, %v; want %d, error %v", tt.in, got, err, tt.want, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package system answers questions about the running Das OS system.
|
||||
package system
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsLive reports whether this is the live ISO session: mklive boots it
|
||||
// with root=live:CDLABEL=... on the kernel command line.
|
||||
func IsLive() bool {
|
||||
data, err := os.ReadFile("/proc/cmdline")
|
||||
return err == nil && liveRoot(string(data))
|
||||
}
|
||||
|
||||
func liveRoot(cmdline string) bool {
|
||||
for _, p := range strings.Fields(cmdline) {
|
||||
if strings.HasPrefix(p, "root=live:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Lang is the session language, as passed to helpers that run with a
|
||||
// cleared environment.
|
||||
func Lang() string {
|
||||
for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "C"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package system
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLiveRoot(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"initrd=/boot/initrd root=live:CDLABEL=VOID_LIVE init=/sbin/init ro live.user=live\n": true,
|
||||
"BOOT_IMAGE=/@/boot/vmlinuz-6.12.50_1 root=UUID=1234 ro rootflags=subvol=@ quiet\n": false,
|
||||
"": false,
|
||||
}
|
||||
for cmdline, want := range tests {
|
||||
if got := liveRoot(cmdline); got != want {
|
||||
t.Errorf("liveRoot(%q) = %v, want %v", cmdline, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// GtkStatusIcon is deprecated in GTK 3 but is the XEmbed tray icon that the
|
||||
// Xfce panel's status tray shows, and gotk3 does not wrap it.
|
||||
#define GDK_DISABLE_DEPRECATION_WARNINGS
|
||||
#include <gtk/gtk.h>
|
||||
#include "tray.h"
|
||||
|
||||
static void on_activate(GtkStatusIcon *icon, gpointer data) {
|
||||
dasTrayActivate();
|
||||
}
|
||||
|
||||
static void on_popup(GtkStatusIcon *icon, guint button, guint time, gpointer data) {
|
||||
dasTrayPopup();
|
||||
}
|
||||
|
||||
GtkStatusIcon *das_tray_new(const char *icon_name) {
|
||||
GtkStatusIcon *icon = gtk_status_icon_new_from_icon_name(icon_name);
|
||||
gtk_status_icon_set_visible(icon, FALSE);
|
||||
g_signal_connect(icon, "activate", G_CALLBACK(on_activate), NULL);
|
||||
g_signal_connect(icon, "popup-menu", G_CALLBACK(on_popup), NULL);
|
||||
return icon;
|
||||
}
|
||||
|
||||
void das_tray_set(GtkStatusIcon *icon, gboolean visible, const char *tooltip) {
|
||||
gtk_status_icon_set_tooltip_text(icon, tooltip);
|
||||
gtk_status_icon_set_visible(icon, visible);
|
||||
}
|
||||
|
||||
void das_tray_popup(uintptr_t menu) {
|
||||
gtk_menu_popup_at_pointer(GTK_MENU((gpointer)menu), NULL);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package tray shows a notification area icon with a click action and a
|
||||
// right-click menu. Only one icon per process is supported.
|
||||
package tray
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk+-3.0
|
||||
#include <stdlib.h>
|
||||
#include "tray.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
)
|
||||
|
||||
// Icon is the tray icon. Call its methods on the GTK main thread only.
|
||||
type Icon struct {
|
||||
icon *C.GtkStatusIcon
|
||||
}
|
||||
|
||||
var (
|
||||
onActivate func()
|
||||
menu *gtk.Menu
|
||||
)
|
||||
|
||||
// New creates a hidden icon. activate runs on a left click; m pops up on a
|
||||
// right click.
|
||||
func New(iconName string, activate func(), m *gtk.Menu) *Icon {
|
||||
name := C.CString(iconName)
|
||||
defer C.free(unsafe.Pointer(name))
|
||||
onActivate, menu = activate, m
|
||||
return &Icon{icon: C.das_tray_new(name)}
|
||||
}
|
||||
|
||||
// Show shows the icon with a tooltip.
|
||||
func (i *Icon) Show(tooltip string) { i.set(true, tooltip) }
|
||||
|
||||
// Hide removes the icon from the tray.
|
||||
func (i *Icon) Hide() { i.set(false, "") }
|
||||
|
||||
func (i *Icon) set(visible bool, tooltip string) {
|
||||
t := C.CString(tooltip)
|
||||
defer C.free(unsafe.Pointer(t))
|
||||
v := C.gboolean(0)
|
||||
if visible {
|
||||
v = 1
|
||||
}
|
||||
C.das_tray_set(i.icon, v, t)
|
||||
}
|
||||
|
||||
//export dasTrayActivate
|
||||
func dasTrayActivate() {
|
||||
if onActivate != nil {
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
|
||||
//export dasTrayPopup
|
||||
func dasTrayPopup() {
|
||||
if menu != nil {
|
||||
menu.ShowAll()
|
||||
C.das_tray_popup(C.uintptr_t(menu.Native()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#include <stdint.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
GtkStatusIcon *das_tray_new(const char *icon_name);
|
||||
void das_tray_set(GtkStatusIcon *icon, gboolean visible, const char *tooltip);
|
||||
void das_tray_popup(uintptr_t menu);
|
||||
|
||||
// Implemented in Go (tray.go).
|
||||
extern void dasTrayActivate(void);
|
||||
extern void dasTrayPopup(void);
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package update runs a full system update wrapped in a snapper pre/post
|
||||
// snapshot pair, so a broken update can be rolled back from the boot menu.
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/snapper"
|
||||
"dasos/tools/internal/xbps"
|
||||
)
|
||||
|
||||
// Description marks the snapshots in "snapper list".
|
||||
const Description = "das-update"
|
||||
|
||||
// Reporter receives the progress of Apply.
|
||||
type Reporter interface {
|
||||
Step(title string)
|
||||
// Output receives the output of the commands Apply runs.
|
||||
Output() io.Writer
|
||||
// Snapshot is called once the "pre" snapshot exists.
|
||||
Snapshot(n int)
|
||||
}
|
||||
|
||||
// Result describes a finished update.
|
||||
type Result struct {
|
||||
Updates []xbps.Update
|
||||
Snapshot int // number of the "pre" snapshot, 0 without snapshots
|
||||
Reboot bool // the kernel or core libraries changed
|
||||
}
|
||||
|
||||
// Updater holds the system operations, replaced in tests.
|
||||
type Updater struct {
|
||||
Run func(ctx context.Context, out io.Writer, name string, args ...string) error
|
||||
Pending func(ctx context.Context) ([]xbps.Update, error)
|
||||
Snapshots func() bool
|
||||
Pre func(ctx context.Context, description string) (int, error)
|
||||
Post func(ctx context.Context, pre int, description string) error
|
||||
}
|
||||
|
||||
// System returns the Updater for the running system. It needs root.
|
||||
func System() *Updater {
|
||||
return &Updater{
|
||||
Run: run,
|
||||
Pending: pending,
|
||||
Snapshots: snapper.Available,
|
||||
Pre: snapper.Pre,
|
||||
Post: snapper.Post,
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, out io.Writer, name string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pending lists updates against the index synced by the first step.
|
||||
func pending(ctx context.Context) ([]xbps.Update, error) {
|
||||
out, err := exec.CommandContext(ctx, "xbps-install", "-un").CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xbps-install -un: %w: %s", err, out)
|
||||
}
|
||||
return xbps.ParseDryRun(string(out)), nil
|
||||
}
|
||||
|
||||
// Apply updates the whole system. Without pending updates it does nothing
|
||||
// and returns an empty Result. When snapshots are set up, a failure to take
|
||||
// the "pre" snapshot stops the update: updating without a way back is
|
||||
// exactly what das-update exists to prevent.
|
||||
func (u *Updater) Apply(ctx context.Context, r Reporter) (Result, error) {
|
||||
var res Result
|
||||
|
||||
r.Step(i18n.T("update.step.sync"))
|
||||
if err := u.Run(ctx, r.Output(), "xbps-install", "-S"); err != nil {
|
||||
return res, err
|
||||
}
|
||||
updates, err := u.Pending(ctx)
|
||||
if err != nil {
|
||||
// An outdated xbps refuses to plan the update; it is updated first anyway.
|
||||
updates = []xbps.Update{{Name: "xbps", Action: "update"}}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
res.Updates = updates
|
||||
res.Reboot = xbps.NeedsReboot(updates)
|
||||
|
||||
if u.Snapshots() {
|
||||
r.Step(i18n.T("update.step.pre"))
|
||||
n, err := u.Pre(ctx, Description)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Snapshot = n
|
||||
r.Snapshot(n)
|
||||
} else {
|
||||
r.Step(i18n.T("update.nosnapshots"))
|
||||
}
|
||||
|
||||
err = u.install(ctx, r, updates)
|
||||
if res.Snapshot > 0 {
|
||||
// Taken after a failure too: the pair shows what the update changed.
|
||||
r.Step(i18n.T("update.step.post"))
|
||||
if perr := u.Post(ctx, res.Snapshot, Description); perr != nil && err == nil {
|
||||
err = perr
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (u *Updater) install(ctx context.Context, r Reporter, updates []xbps.Update) error {
|
||||
if xbps.SelfUpdate(updates) {
|
||||
r.Step(i18n.T("update.step.xbps"))
|
||||
if err := u.Run(ctx, r.Output(), "xbps-install", "-yu", "xbps"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.Step(i18n.T("update.step.install"))
|
||||
return u.Run(ctx, r.Output(), "xbps-install", "-yu")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dasos/tools/internal/xbps"
|
||||
)
|
||||
|
||||
type recorder struct {
|
||||
steps []string
|
||||
snapshot int
|
||||
}
|
||||
|
||||
func (r *recorder) Step(title string) { r.steps = append(r.steps, title) }
|
||||
func (r *recorder) Output() io.Writer { return io.Discard }
|
||||
func (r *recorder) Snapshot(n int) { r.snapshot = n }
|
||||
|
||||
// fake records the system operations in calls.
|
||||
type fake struct {
|
||||
calls []string
|
||||
pending []xbps.Update
|
||||
snapshots bool
|
||||
failOn string // command line that fails
|
||||
}
|
||||
|
||||
func (f *fake) updater() *Updater {
|
||||
return &Updater{
|
||||
Run: func(_ context.Context, _ io.Writer, name string, args ...string) error {
|
||||
line := strings.Join(append([]string{name}, args...), " ")
|
||||
f.calls = append(f.calls, line)
|
||||
if line == f.failOn {
|
||||
return errors.New("failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Pending: func(context.Context) ([]xbps.Update, error) { return f.pending, nil },
|
||||
Snapshots: func() bool { return f.snapshots },
|
||||
Pre: func(context.Context, string) (int, error) {
|
||||
f.calls = append(f.calls, "pre")
|
||||
return 12, nil
|
||||
},
|
||||
Post: func(_ context.Context, pre int, _ string) error {
|
||||
f.calls = append(f.calls, "post")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUpToDate(t *testing.T) {
|
||||
f := &fake{snapshots: true}
|
||||
res, err := f.updater().Apply(context.Background(), &recorder{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"xbps-install -S"}; !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q", f.calls, want)
|
||||
}
|
||||
if len(res.Updates) != 0 || res.Snapshot != 0 {
|
||||
t.Errorf("result = %+v, want empty", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithSnapshots(t *testing.T) {
|
||||
f := &fake{snapshots: true, pending: []xbps.Update{{Name: "linux6.12"}, {Name: "xbps"}}}
|
||||
r := &recorder{}
|
||||
res, err := f.updater().Apply(context.Background(), r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"xbps-install -S", "pre", "xbps-install -yu xbps", "xbps-install -yu", "post"}
|
||||
if !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q", f.calls, want)
|
||||
}
|
||||
if res.Snapshot != 12 || r.snapshot != 12 || !res.Reboot {
|
||||
t.Errorf("result = %+v, reported snapshot %d; want snapshot 12 and reboot", res, r.snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFailureStillTakesPost(t *testing.T) {
|
||||
f := &fake{snapshots: true, pending: []xbps.Update{{Name: "firefox"}}, failOn: "xbps-install -yu"}
|
||||
res, err := f.updater().Apply(context.Background(), &recorder{})
|
||||
if err == nil {
|
||||
t.Fatal("Apply succeeded, want the install error")
|
||||
}
|
||||
want := []string{"xbps-install -S", "pre", "xbps-install -yu", "post"}
|
||||
if !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q", f.calls, want)
|
||||
}
|
||||
if res.Snapshot != 12 || res.Reboot {
|
||||
t.Errorf("result = %+v, want snapshot 12, no reboot", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithoutSnapshots(t *testing.T) {
|
||||
f := &fake{pending: []xbps.Update{{Name: "firefox"}}}
|
||||
res, err := f.updater().Apply(context.Background(), &recorder{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"xbps-install -S", "xbps-install -yu"}
|
||||
if !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q", f.calls, want)
|
||||
}
|
||||
if res.Snapshot != 0 {
|
||||
t.Errorf("snapshot = %d, want 0", res.Snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStopsWhenPreSnapshotFails(t *testing.T) {
|
||||
f := &fake{snapshots: true, pending: []xbps.Update{{Name: "firefox"}}}
|
||||
u := f.updater()
|
||||
u.Pre = func(context.Context, string) (int, error) { return 0, errors.New("no space") }
|
||||
if _, err := u.Apply(context.Background(), &recorder{}); err == nil {
|
||||
t.Fatal("Apply succeeded, want the snapshot error")
|
||||
}
|
||||
if want := []string{"xbps-install -S"}; !reflect.DeepEqual(f.calls, want) {
|
||||
t.Errorf("calls = %q, want %q (nothing installed)", f.calls, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package userdirs renames the XDG user folders (Desktop, Documents...) to
|
||||
// the names of the session language.
|
||||
//
|
||||
// xdg-user-dirs 0.20 loads its translations only from the first
|
||||
// XDG_DATA_DIRS entry that has a locale directory. In a Void session that is
|
||||
// the empty /usr/local/share/locale, so folders are created with English
|
||||
// names whatever the language. das-settings works around it for new users;
|
||||
// this package fixes folders that were already created.
|
||||
package userdirs
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rename moves one user folder.
|
||||
type Rename struct {
|
||||
Key string // DESKTOP, DOCUMENTS ...
|
||||
From, To string // absolute paths
|
||||
}
|
||||
|
||||
// Dirs maps a key such as DOCUMENTS to its value in user-dirs.dirs, e.g. "$HOME/Documents".
|
||||
type Dirs map[string]string
|
||||
|
||||
var line = regexp.MustCompile(`^XDG_([A-Z]+)_DIR="(.*)"$`)
|
||||
|
||||
// Parse reads the user-dirs.dirs format.
|
||||
func Parse(data string) Dirs {
|
||||
d := Dirs{}
|
||||
for _, l := range strings.Split(data, "\n") {
|
||||
if m := line.FindStringSubmatch(strings.TrimSpace(l)); m != nil {
|
||||
d[m[1]] = m[2]
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ConfigFile returns the path of the user's user-dirs.dirs.
|
||||
func ConfigFile(home string) string {
|
||||
if c := os.Getenv("XDG_CONFIG_HOME"); c != "" {
|
||||
return filepath.Join(c, "user-dirs.dirs")
|
||||
}
|
||||
return filepath.Join(home, ".config", "user-dirs.dirs")
|
||||
}
|
||||
|
||||
// Defaults asks xdg-user-dirs-update which folder names it would create for
|
||||
// the given LC_ALL value ("" keeps the session language), with a lookup
|
||||
// path that finds the translations. It runs in a scratch home.
|
||||
func Defaults(ctx context.Context, lcAll string) (Dirs, error) {
|
||||
tmp, err := os.MkdirTemp("", "das-userdirs-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "xdg-user-dirs-update")
|
||||
cmd.Env = append(os.Environ(), "HOME="+tmp, "XDG_CONFIG_HOME="+filepath.Join(tmp, ".config"),
|
||||
"XDG_DATA_DIRS=/usr/share")
|
||||
if lcAll != "" {
|
||||
cmd.Env = append(cmd.Env, "LC_ALL="+lcAll)
|
||||
}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("xdg-user-dirs-update: %w: %s", err, out)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tmp, ".config", "user-dirs.dirs"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Parse(string(data)), nil
|
||||
}
|
||||
|
||||
// Plan lists the folders still named as in english that have a different
|
||||
// name in localized. Folders the user moved or renamed are left alone, and
|
||||
// so are those whose new name is already taken.
|
||||
func Plan(home string, current, english, localized Dirs) []Rename {
|
||||
var renames []Rename
|
||||
for key, value := range current {
|
||||
if value != english[key] || localized[key] == "" || localized[key] == value {
|
||||
continue
|
||||
}
|
||||
from, to := Expand(home, value), Expand(home, localized[key])
|
||||
if from == home || to == home {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(to); err == nil {
|
||||
continue
|
||||
}
|
||||
renames = append(renames, Rename{Key: key, From: from, To: to})
|
||||
}
|
||||
sort.Slice(renames, func(i, j int) bool { return renames[i].Key < renames[j].Key })
|
||||
return renames
|
||||
}
|
||||
|
||||
// Expand turns a user-dirs.dirs value into an absolute path.
|
||||
func Expand(home, value string) string {
|
||||
if value == "$HOME" {
|
||||
return home
|
||||
}
|
||||
if strings.HasPrefix(value, "$HOME/") {
|
||||
return filepath.Join(home, value[len("$HOME/"):])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Apply renames the folders, then points user-dirs.dirs and the GTK file
|
||||
// chooser bookmarks at the new names.
|
||||
func Apply(home string, renames []Rename) error {
|
||||
for _, r := range renames {
|
||||
if err := os.Rename(r.From, r.To); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config := ConfigFile(home)
|
||||
data, err := os.ReadFile(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(config, rewriteConfig(string(data), home, renames)); err != nil {
|
||||
return err
|
||||
}
|
||||
bookmarks := filepath.Join(filepath.Dir(config), "gtk-3.0", "bookmarks")
|
||||
if data, err := os.ReadFile(bookmarks); err == nil {
|
||||
return writeFile(bookmarks, rewriteBookmarks(string(data), renames))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rewriteConfig(data, home string, renames []Rename) string {
|
||||
lines := strings.Split(data, "\n")
|
||||
for i, l := range lines {
|
||||
m := line.FindStringSubmatch(strings.TrimSpace(l))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range renames {
|
||||
if r.Key == m[1] {
|
||||
lines[i] = fmt.Sprintf("XDG_%s_DIR=%q", r.Key, collapse(home, r.To))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// collapse is the inverse of Expand.
|
||||
func collapse(home, path string) string {
|
||||
if rel, err := filepath.Rel(home, path); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return "$HOME/" + rel
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// rewriteBookmarks updates "file:///path [label]" lines of the GTK bookmarks file.
|
||||
func rewriteBookmarks(data string, renames []Rename) string {
|
||||
var b strings.Builder
|
||||
sc := bufio.NewScanner(strings.NewReader(data))
|
||||
for sc.Scan() {
|
||||
l := sc.Text()
|
||||
for _, r := range renames {
|
||||
old := fileURI(r.From)
|
||||
if l == old || strings.HasPrefix(l, old+" ") {
|
||||
l = fileURI(r.To) + l[len(old):]
|
||||
}
|
||||
}
|
||||
b.WriteString(l + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func fileURI(path string) string {
|
||||
return (&url.URL{Scheme: "file", Path: path}).String()
|
||||
}
|
||||
|
||||
// writeFile replaces path atomically, keeping its permissions.
|
||||
func writeFile(path, data string) error {
|
||||
mode := os.FileMode(0o644)
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
mode = fi.Mode().Perm()
|
||||
}
|
||||
tmp := path + ".das-tmp"
|
||||
if err := os.WriteFile(tmp, []byte(data), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package userdirs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const englishConfig = `# This file is written by xdg-user-dirs-update
|
||||
XDG_DESKTOP_DIR="$HOME/Desktop"
|
||||
XDG_DOCUMENTS_DIR="$HOME/Documents"
|
||||
XDG_DOWNLOAD_DIR="$HOME/Downloads"
|
||||
XDG_MUSIC_DIR="$HOME/Music"
|
||||
XDG_PUBLICSHARE_DIR="$HOME/Public"
|
||||
`
|
||||
|
||||
var (
|
||||
english = Dirs{"DESKTOP": "$HOME/Desktop", "DOCUMENTS": "$HOME/Documents", "DOWNLOAD": "$HOME/Downloads",
|
||||
"MUSIC": "$HOME/Music", "PUBLICSHARE": "$HOME/Public"}
|
||||
ukrainian = Dirs{"DESKTOP": "$HOME/Стільниця", "DOCUMENTS": "$HOME/Документи", "DOWNLOAD": "$HOME/Завантажене",
|
||||
"MUSIC": "$HOME/Музика", "PUBLICSHARE": "$HOME/Загальне"}
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
if got := Parse(englishConfig); !reflect.DeepEqual(got, english) {
|
||||
t.Errorf("Parse = %v, want %v", got, english)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAndApply(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
for _, d := range []string{"Desktop", "Documents", "Music", "Загальне", ".config/gtk-3.0"} {
|
||||
if err := os.MkdirAll(filepath.Join(home, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(home, "Documents", "cv.odt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The user moved Music elsewhere: it must stay.
|
||||
config := strings.Replace(englishConfig, `"$HOME/Music"`, `"/data/Music"`, 1)
|
||||
writeTestFile(t, filepath.Join(home, ".config", "user-dirs.dirs"), config)
|
||||
writeTestFile(t, filepath.Join(home, ".config", "gtk-3.0", "bookmarks"),
|
||||
"file://"+home+"/Documents\nfile://"+home+"/Desktop Стіл\nfile:///tmp\n")
|
||||
|
||||
renames := Plan(home, Parse(config), english, ukrainian)
|
||||
want := []Rename{
|
||||
{"DESKTOP", filepath.Join(home, "Desktop"), filepath.Join(home, "Стільниця")},
|
||||
{"DOCUMENTS", filepath.Join(home, "Documents"), filepath.Join(home, "Документи")},
|
||||
// Downloads does not exist yet: only the configuration changes.
|
||||
{"DOWNLOAD", filepath.Join(home, "Downloads"), filepath.Join(home, "Завантажене")},
|
||||
// PUBLICSHARE is skipped: Загальне already exists.
|
||||
}
|
||||
if !reflect.DeepEqual(renames, want) {
|
||||
t.Fatalf("Plan =\n%v\nwant\n%v", renames, want)
|
||||
}
|
||||
|
||||
if err := Apply(home, renames); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "Документи", "cv.odt")); err != nil {
|
||||
t.Errorf("file not moved with its folder: %v", err)
|
||||
}
|
||||
got := Parse(readTestFile(t, filepath.Join(home, ".config", "user-dirs.dirs")))
|
||||
wantDirs := Dirs{"DESKTOP": "$HOME/Стільниця", "DOCUMENTS": "$HOME/Документи", "DOWNLOAD": "$HOME/Завантажене",
|
||||
"MUSIC": "/data/Music", "PUBLICSHARE": "$HOME/Public"}
|
||||
if !reflect.DeepEqual(got, wantDirs) {
|
||||
t.Errorf("user-dirs.dirs = %v, want %v", got, wantDirs)
|
||||
}
|
||||
bookmarks := readTestFile(t, filepath.Join(home, ".config", "gtk-3.0", "bookmarks"))
|
||||
wantBookmarks := fileURI(filepath.Join(home, "Документи")) + "\n" +
|
||||
fileURI(filepath.Join(home, "Стільниця")) + " Стіл\nfile:///tmp\n"
|
||||
if bookmarks != wantBookmarks {
|
||||
t.Errorf("bookmarks =\n%s\nwant\n%s", bookmarks, wantBookmarks)
|
||||
}
|
||||
|
||||
// Nothing left to do afterwards.
|
||||
if again := Plan(home, got, english, ukrainian); len(again) != 0 {
|
||||
t.Errorf("second Plan = %v, want none", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCollapse(t *testing.T) {
|
||||
for _, v := range []string{"$HOME/Документи", "/data/Music"} {
|
||||
if got := collapse("/home/u", Expand("/home/u", v)); got != v {
|
||||
t.Errorf("collapse(Expand(%q)) = %q", v, got)
|
||||
}
|
||||
}
|
||||
if got := Expand("/home/u", "$HOME"); got != "/home/u" {
|
||||
t.Errorf("Expand($HOME) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package xbps finds pending system updates with xbps.
|
||||
package xbps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Update is one package that a full system update would change.
|
||||
type Update struct {
|
||||
Name string
|
||||
OldVersion string // empty for a new dependency
|
||||
NewVersion string
|
||||
Action string // "update", "install", "remove" ...
|
||||
}
|
||||
|
||||
// Check lists pending updates without root: -M syncs the repository index
|
||||
// into memory only, -n makes it a dry run.
|
||||
func Check(ctx context.Context) ([]Update, error) {
|
||||
out, err := exec.CommandContext(ctx, "xbps-install", "-SMun").CombinedOutput()
|
||||
if err != nil {
|
||||
// xbps refuses to plan anything while xbps itself is outdated
|
||||
if strings.Contains(string(out), "must be updated") {
|
||||
return []Update{{Name: "xbps", Action: "update"}}, nil
|
||||
}
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return nil, fmt.Errorf("xbps-install: %w: %s", err, msg)
|
||||
}
|
||||
return nil, fmt.Errorf("xbps-install: %w", err)
|
||||
}
|
||||
updates := ParseDryRun(string(out))
|
||||
if len(updates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
listed, err := exec.CommandContext(ctx, "xbps-query", "-l").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xbps-query: %w", err)
|
||||
}
|
||||
installed := ParseInstalled(string(listed))
|
||||
for i := range updates {
|
||||
updates[i].OldVersion = installed[updates[i].Name]
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// ParseDryRun parses "xbps-install -un" output: one transaction entry per
|
||||
// line, "<pkgver> <action> <arch> <repository> [sizes]". Other lines are skipped.
|
||||
func ParseDryRun(out string) []Update {
|
||||
var updates []Update
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
name, version, ok := SplitPkgver(f[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch f[1] {
|
||||
case "update", "install", "remove", "reinstall", "downgrade":
|
||||
updates = append(updates, Update{Name: name, NewVersion: version, Action: f[1]})
|
||||
}
|
||||
}
|
||||
sort.Slice(updates, func(i, j int) bool { return updates[i].Name < updates[j].Name })
|
||||
return updates
|
||||
}
|
||||
|
||||
// ParseInstalled parses "xbps-query -l" ("ii <pkgver> <description>") into
|
||||
// a name → version map.
|
||||
func ParseInstalled(out string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
if name, version, ok := SplitPkgver(f[1]); ok {
|
||||
m[name] = version
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SplitPkgver splits "name-version_revision" at the last dash. The version
|
||||
// part always contains "_revision", which tells pkgvers from other words.
|
||||
func SplitPkgver(pkgver string) (name, version string, ok bool) {
|
||||
i := strings.LastIndexByte(pkgver, '-')
|
||||
if i <= 0 || i == len(pkgver)-1 || !strings.Contains(pkgver[i+1:], "_") {
|
||||
return "", "", false
|
||||
}
|
||||
return pkgver[:i], pkgver[i+1:], true
|
||||
}
|
||||
|
||||
// NeedsReboot reports whether updating these packages replaces something
|
||||
// that a running system keeps loaded: the kernel, the C library or the
|
||||
// init system.
|
||||
func NeedsReboot(updates []Update) bool {
|
||||
for _, u := range updates {
|
||||
switch {
|
||||
case u.Name == "glibc", u.Name == "runit", u.Name == "runit-void", u.Name == "dbus",
|
||||
u.Name == "elogind", u.Name == "eudev", u.Name == "linux-firmware":
|
||||
return true
|
||||
case strings.HasPrefix(u.Name, "linux") && strings.Trim(u.Name[len("linux"):], "0123456789.") == "":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SelfUpdate reports whether xbps itself is in the list. xbps must be
|
||||
// updated on its own before the rest of the system.
|
||||
func SelfUpdate(updates []Update) bool {
|
||||
for _, u := range updates {
|
||||
if u.Name == "xbps" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package xbps
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDryRun(t *testing.T) {
|
||||
out := `[*] Updating repository ` + "`https://repo-default.voidlinux.org/current/x86_64-repodata'" + ` ...
|
||||
firefox-144.0_1 update x86_64 https://repo-default.voidlinux.org/current 245MB 70MB
|
||||
libfoo-2.1_3 install x86_64 https://repo-default.voidlinux.org/current 1024KB 300KB
|
||||
linux6.12-6.12.50_1 update x86_64 https://repo-default.voidlinux.org/current
|
||||
gtk+3-3.24.50_1 update x86_64 https://repo-default.voidlinux.org/current
|
||||
oldpkg-1.0_1 remove x86_64 https://repo-default.voidlinux.org/current
|
||||
bash-5.3_2 configure x86_64 https://repo-default.voidlinux.org/current
|
||||
`
|
||||
got := ParseDryRun(out)
|
||||
want := []Update{
|
||||
{Name: "firefox", NewVersion: "144.0_1", Action: "update"},
|
||||
{Name: "gtk+3", NewVersion: "3.24.50_1", Action: "update"},
|
||||
{Name: "libfoo", NewVersion: "2.1_3", Action: "install"},
|
||||
{Name: "linux6.12", NewVersion: "6.12.50_1", Action: "update"},
|
||||
{Name: "oldpkg", NewVersion: "1.0_1", Action: "remove"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ParseDryRun:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
if got := ParseDryRun(""); len(got) != 0 {
|
||||
t.Errorf("ParseDryRun(empty) = %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInstalled(t *testing.T) {
|
||||
out := "ii acl-2.4.0_1 Access Control List filesystem support\n" +
|
||||
"ii base-container-0.3_3 Void Linux base system meta\n" +
|
||||
"\n"
|
||||
want := map[string]string{"acl": "2.4.0_1", "base-container": "0.3_3"}
|
||||
if got := ParseInstalled(out); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ParseInstalled = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPkgver(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, name, version string
|
||||
ok bool
|
||||
}{
|
||||
{"xbps-0.60.5_1", "xbps", "0.60.5_1", true},
|
||||
{"font-jetbrains-mono-2.304_1", "font-jetbrains-mono", "2.304_1", true},
|
||||
{"[*]", "", "", false},
|
||||
{"Updating", "", "", false},
|
||||
{"name-", "", "", false},
|
||||
{"no-revision-1.0", "", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
name, version, ok := SplitPkgver(tt.in)
|
||||
if name != tt.name || version != tt.version || ok != tt.ok {
|
||||
t.Errorf("SplitPkgver(%q) = %q, %q, %v; want %q, %q, %v", tt.in, name, version, ok, tt.name, tt.version, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsReboot(t *testing.T) {
|
||||
tests := []struct {
|
||||
names []string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"firefox", "mousepad"}, false},
|
||||
{[]string{"linux6.12"}, true},
|
||||
{[]string{"linux"}, true},
|
||||
{[]string{"glibc"}, true},
|
||||
{[]string{"linux-headers", "linux-firmware-network"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
var updates []Update
|
||||
for _, n := range tt.names {
|
||||
updates = append(updates, Update{Name: n})
|
||||
}
|
||||
if got := NeedsReboot(updates); got != tt.want {
|
||||
t.Errorf("NeedsReboot(%v) = %v, want %v", tt.names, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Template file for 'das-tools'
|
||||
# In-tree package: Go sources are in files/src (FILESDIR).
|
||||
pkgname=das-tools
|
||||
version=0.1.0
|
||||
revision=1
|
||||
create_wrksrc=yes
|
||||
# Like build_style=go: the Go linker decides about PIE, xbps-src must not check it
|
||||
nopie=yes
|
||||
hostmakedepends="go pkg-config"
|
||||
makedepends="gtk+3-devel"
|
||||
depends="dbus libnotify polkit sudo xdg-user-dirs xdg-utils"
|
||||
short_desc="Das OS tools: das-update (updates with snapshots) and das-welcome"
|
||||
maintainer="Das <anry.das@gmail.com>"
|
||||
license="MIT"
|
||||
homepage="https://git.das.org.ua/das/Das-OS"
|
||||
|
||||
do_build() {
|
||||
cp -rL "${FILESDIR}/src/." .
|
||||
export GOCACHE="${wrksrc}/.gocache" GOMODCACHE="${wrksrc}/.gomodcache"
|
||||
export GOTOOLCHAIN=local GOFLAGS="-trimpath -modcacherw"
|
||||
go test ./...
|
||||
go build -o bin/ ./cmd/...
|
||||
}
|
||||
|
||||
do_install() {
|
||||
vbin bin/das-update
|
||||
vbin bin/das-welcome
|
||||
vinstall "${FILESDIR}/data/das-update.desktop" 644 usr/share/applications
|
||||
vinstall "${FILESDIR}/data/das-welcome.desktop" 644 usr/share/applications
|
||||
vinstall "${FILESDIR}/data/das-update-tray.desktop" 644 etc/xdg/autostart
|
||||
vinstall "${FILESDIR}/data/das-welcome-autostart.desktop" 644 etc/xdg/autostart das-welcome.desktop
|
||||
vinstall "${FILESDIR}/data/ua.org.das.update.policy" 644 usr/share/polkit-1/actions
|
||||
vlicense "${FILESDIR}/LICENSE"
|
||||
}
|
||||
Reference in New Issue
Block a user