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,334 @@
|
||||
// Command das-welcome is the Das OS first-login window: interface language,
|
||||
// home folder names, updates, settings and help.
|
||||
//
|
||||
// das-welcome show the window
|
||||
// das-welcome --autostart show it only if the user did not turn that off
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gotk3/gotk3/glib"
|
||||
"github.com/gotk3/gotk3/gtk"
|
||||
|
||||
"dasos/tools/internal/i18n"
|
||||
"dasos/tools/internal/system"
|
||||
"dasos/tools/internal/userdirs"
|
||||
)
|
||||
|
||||
const (
|
||||
appID = "ua.org.das.Welcome"
|
||||
helpURL = "https://git.das.org.ua/das/Das-OS"
|
||||
)
|
||||
|
||||
func main() {
|
||||
autostart := flag.Bool("autostart", false, "show the window only if it is enabled at login")
|
||||
flag.Parse()
|
||||
|
||||
if *autostart && !showAtLogin() {
|
||||
return
|
||||
}
|
||||
app, err := gtk.ApplicationNew(appID, glib.APPLICATION_FLAGS_NONE)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var win *gtk.ApplicationWindow
|
||||
app.Connect("activate", func() {
|
||||
if win != nil {
|
||||
win.Present()
|
||||
return
|
||||
}
|
||||
win, err = newWindow(app)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
app.Quit()
|
||||
return
|
||||
}
|
||||
win.ShowAll()
|
||||
})
|
||||
os.Exit(app.Run(nil))
|
||||
}
|
||||
|
||||
// settingsFile records whether the window opens at login. Without it the
|
||||
// window opens once: at the first login of a user (or every live session).
|
||||
func settingsFile() string {
|
||||
dir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if dir == "" {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(dir, "das", "welcome")
|
||||
}
|
||||
|
||||
func showAtLogin() bool {
|
||||
data, err := os.ReadFile(settingsFile())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "autostart=true"
|
||||
}
|
||||
|
||||
func setShowAtLogin(on bool) error {
|
||||
path := settingsFile()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(fmt.Sprintf("autostart=%v\n", on)), 0o644)
|
||||
}
|
||||
|
||||
// builder collects the first error of a chain of gotk3 constructors.
|
||||
type builder struct{ err error }
|
||||
|
||||
func (b *builder) check(err error) {
|
||||
if b.err == nil {
|
||||
b.err = err
|
||||
}
|
||||
}
|
||||
|
||||
func (b *builder) label(text string, wrap bool) *gtk.Label {
|
||||
l, err := gtk.LabelNew(text)
|
||||
b.check(err)
|
||||
if err == nil {
|
||||
l.SetXAlign(0)
|
||||
l.SetLineWrap(wrap)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (b *builder) button(text string, onClick func()) *gtk.Button {
|
||||
btn, err := gtk.ButtonNewWithLabel(text)
|
||||
b.check(err)
|
||||
if err == nil {
|
||||
btn.SetVAlign(gtk.ALIGN_CENTER)
|
||||
btn.Connect("clicked", onClick)
|
||||
}
|
||||
return btn
|
||||
}
|
||||
|
||||
// row is one entry of the list: icon, title, description and controls.
|
||||
func (b *builder) row(icon, title, desc string, controls ...gtk.IWidget) (*gtk.Box, *gtk.Label) {
|
||||
box, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 14)
|
||||
b.check(err)
|
||||
img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_DND)
|
||||
b.check(err)
|
||||
text, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return box, nil
|
||||
}
|
||||
img.SetPixelSize(32)
|
||||
t := b.label("", false)
|
||||
t.SetMarkup("<b>" + html.EscapeString(title) + "</b>")
|
||||
d := b.label(desc, true)
|
||||
if style, err := d.GetStyleContext(); err == nil {
|
||||
style.AddClass("dim-label")
|
||||
}
|
||||
text.PackStart(t, false, false, 0)
|
||||
text.PackStart(d, false, false, 0)
|
||||
box.PackStart(img, false, false, 0)
|
||||
box.PackStart(text, true, true, 0)
|
||||
for _, c := range controls {
|
||||
box.PackEnd(c, false, false, 0)
|
||||
}
|
||||
return box, d
|
||||
}
|
||||
|
||||
func newWindow(app *gtk.Application) (*gtk.ApplicationWindow, error) {
|
||||
b := &builder{}
|
||||
win, err := gtk.ApplicationWindowNew(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
win.SetTitle(i18n.T("welcome.title"))
|
||||
win.SetIconName("das-logo")
|
||||
win.SetDefaultSize(640, -1)
|
||||
win.SetPosition(gtk.WIN_POS_CENTER)
|
||||
|
||||
outer, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 16)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
outer.SetMarginStart(24)
|
||||
outer.SetMarginEnd(24)
|
||||
outer.SetMarginTop(24)
|
||||
outer.SetMarginBottom(18)
|
||||
|
||||
header, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 16)
|
||||
b.check(err)
|
||||
logo, err := gtk.ImageNewFromIconName("das-logo", gtk.ICON_SIZE_DIALOG)
|
||||
b.check(err)
|
||||
titles, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 4)
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
logo.SetPixelSize(72)
|
||||
title := b.label("", false)
|
||||
title.SetMarkup(`<span size="xx-large" weight="bold">` + html.EscapeString(i18n.T("welcome.title")) + "</span>")
|
||||
titles.SetVAlign(gtk.ALIGN_CENTER)
|
||||
titles.PackStart(title, false, false, 0)
|
||||
titles.PackStart(b.label(i18n.T("welcome.subtitle"), true), false, false, 0)
|
||||
header.PackStart(logo, false, false, 0)
|
||||
header.PackStart(titles, true, true, 0)
|
||||
outer.PackStart(header, false, false, 0)
|
||||
|
||||
list, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 14)
|
||||
b.check(err)
|
||||
add := func(row *gtk.Box) {
|
||||
if sep, err := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL); err == nil {
|
||||
list.PackStart(sep, false, false, 0)
|
||||
}
|
||||
list.PackStart(row, false, false, 0)
|
||||
}
|
||||
|
||||
if system.IsLive() {
|
||||
install := b.button(i18n.T("welcome.install"), func() { launch("das-install") })
|
||||
if style, err := install.GetStyleContext(); err == nil {
|
||||
style.AddClass("suggested-action")
|
||||
}
|
||||
row, _ := b.row("das-logo", i18n.T("welcome.install"), i18n.T("welcome.install.desc"), install)
|
||||
add(row)
|
||||
}
|
||||
if row := languageRow(b); row != nil {
|
||||
add(row)
|
||||
}
|
||||
if row := foldersRow(b); row != nil {
|
||||
add(row)
|
||||
}
|
||||
if !system.IsLive() {
|
||||
row, _ := b.row("software-update-available", i18n.T("welcome.update"), i18n.T("welcome.update.desc"),
|
||||
b.button(i18n.T("welcome.update.open"), func() { launch("das-update") }))
|
||||
add(row)
|
||||
}
|
||||
row, _ := b.row("preferences-desktop", i18n.T("welcome.settings"), i18n.T("welcome.settings.desc"),
|
||||
b.button(i18n.T("welcome.open"), func() { launch("xfce4-settings-manager") }))
|
||||
add(row)
|
||||
row, _ = b.row("help-browser", i18n.T("welcome.help"), i18n.T("welcome.help.desc"),
|
||||
b.button(i18n.T("welcome.open"), func() { launch("xdg-open", helpURL) }))
|
||||
add(row)
|
||||
outer.PackStart(list, false, false, 0)
|
||||
|
||||
footer, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 12)
|
||||
b.check(err)
|
||||
autostart, err := gtk.CheckButtonNewWithLabel(i18n.T("welcome.autostart"))
|
||||
b.check(err)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
// The window opens by itself only once unless the user asks for more.
|
||||
autostart.SetActive(false)
|
||||
if _, err := os.Stat(settingsFile()); err == nil {
|
||||
autostart.SetActive(showAtLogin())
|
||||
} else if err := setShowAtLogin(false); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
autostart.Connect("toggled", func() {
|
||||
if err := setShowAtLogin(autostart.GetActive()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
})
|
||||
footer.PackStart(autostart, false, false, 0)
|
||||
footer.PackEnd(b.button(i18n.T("welcome.close"), func() { win.Close() }), false, false, 0)
|
||||
outer.PackEnd(footer, false, false, 0)
|
||||
|
||||
win.Add(outer)
|
||||
return win, b.err
|
||||
}
|
||||
|
||||
func launch(name string, args ...string) {
|
||||
if err := exec.Command(name, args...).Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// languageRow offers the UTF-8 locales the system has built. It returns nil
|
||||
// when there is nothing to choose from.
|
||||
func languageRow(b *builder) *gtk.Box {
|
||||
locales := availableLocales()
|
||||
if len(locales) < 2 {
|
||||
return nil
|
||||
}
|
||||
combo, err := gtk.ComboBoxTextNew()
|
||||
b.check(err)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
current := currentLocale()
|
||||
for _, l := range locales {
|
||||
combo.Append(l, localeName(l))
|
||||
}
|
||||
if !combo.SetActiveID(current) {
|
||||
combo.SetActive(0)
|
||||
}
|
||||
combo.SetVAlign(gtk.ALIGN_CENTER)
|
||||
|
||||
var desc *gtk.Label
|
||||
apply := b.button(i18n.T("welcome.apply"), func() {
|
||||
if err := setLanguage(combo.GetActiveID()); err != nil {
|
||||
desc.SetText(i18n.T("welcome.error", err))
|
||||
return
|
||||
}
|
||||
desc.SetText(i18n.T("welcome.lang.done"))
|
||||
})
|
||||
row, d := b.row("preferences-desktop-locale", i18n.T("welcome.lang"), i18n.T("welcome.lang.desc"), apply, combo)
|
||||
desc = d
|
||||
return row
|
||||
}
|
||||
|
||||
// foldersRow offers to rename home folders that kept English names.
|
||||
func foldersRow(b *builder) *gtk.Box {
|
||||
home := os.Getenv("HOME")
|
||||
renames, err := pendingRenames(home)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "das-welcome:", err)
|
||||
return nil
|
||||
}
|
||||
if len(renames) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, len(renames))
|
||||
for i, r := range renames {
|
||||
names[i] = filepath.Base(r.From)
|
||||
}
|
||||
var desc *gtk.Label
|
||||
var rename *gtk.Button
|
||||
rename = b.button(i18n.T("welcome.dirs.rename"), func() {
|
||||
if err := userdirs.Apply(home, renames); err != nil {
|
||||
desc.SetText(i18n.T("welcome.error", err))
|
||||
return
|
||||
}
|
||||
desc.SetText(i18n.T("welcome.dirs.done"))
|
||||
rename.SetSensitive(false)
|
||||
})
|
||||
row, d := b.row("folder", i18n.T("welcome.dirs"), i18n.T("welcome.dirs.desc", strings.Join(names, ", ")), rename)
|
||||
desc = d
|
||||
return row
|
||||
}
|
||||
|
||||
func pendingRenames(home string) ([]userdirs.Rename, error) {
|
||||
data, err := os.ReadFile(userdirs.ConfigFile(home))
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := context.Background()
|
||||
english, err := userdirs.Defaults(ctx, "C.UTF-8")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localized, err := userdirs.Defaults(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return userdirs.Plan(home, userdirs.Parse(string(data)), english, localized), nil
|
||||
}
|
||||
Reference in New Issue
Block a user