From 79a9902edfb83f741935c6cf88f1cf50d0fe84c5 Mon Sep 17 00:00:00 2001 From: flathead Date: Mon, 31 Aug 2026 18:51:10 +0300 Subject: [PATCH 01/17] feat(mods): add native modification manager Add declarative mod packages that can be installed from local directories, archives, or Git repositories. Enabled packages are composed onto a clean shell source tree and activated through immutable generations, with compatibility checks, conflict detection, rollback, and startup recovery. Expose the same operations through the ambxst mods CLI and a lazy-loaded Settings panel. The manager performs no polling or background update checks; repository access and generation builds happen only after explicit user actions. --- .gitignore | 3 + README.md | 2 +- backend/cmd/ambxst/cmds_mods.go | 158 ++ backend/cmd/ambxst/commands.go | 2 +- backend/cmd/ambxst/main.go | 7 +- backend/cmd/ambxst/screen.go | 2 +- backend/pkg/daemon/daemon.go | 132 +- backend/pkg/mods/manager.go | 1479 +++++++++++++++++ backend/pkg/mods/manager_test.go | 553 ++++++ backend/pkg/mods/manifest.go | 374 +++++ backend/pkg/mods/manifest_test.go | 66 + backend/pkg/mods/service.go | 143 ++ backend/pkg/mods/version.go | 79 + backend/pkg/mods/version_test.go | 22 + backend/pkg/paths/paths.go | 26 +- backend/pkg/paths/shell_source.go | 103 +- backend/pkg/paths/shell_source_test.go | 66 + docs/mods/README.md | 179 ++ docs/mods/manifest.schema.json | 115 ++ docs/mods/settings.schema.json | 114 ++ .../ambxst.mod.json | 24 + .../compact-player-volume-scroll.patch | 20 + modules/services/ModsService.qml | 146 ++ .../widgets/dashboard/controls/ModsPanel.qml | 755 +++++++++ .../dashboard/controls/SettingsIndex.qml | 7 +- .../dashboard/controls/SettingsTab.qml | 19 +- 26 files changed, 4543 insertions(+), 53 deletions(-) create mode 100644 backend/cmd/ambxst/cmds_mods.go create mode 100644 backend/pkg/mods/manager.go create mode 100644 backend/pkg/mods/manager_test.go create mode 100644 backend/pkg/mods/manifest.go create mode 100644 backend/pkg/mods/manifest_test.go create mode 100644 backend/pkg/mods/service.go create mode 100644 backend/pkg/mods/version.go create mode 100644 backend/pkg/mods/version_test.go create mode 100644 backend/pkg/paths/shell_source_test.go create mode 100644 docs/mods/README.md create mode 100644 docs/mods/manifest.schema.json create mode 100644 docs/mods/settings.schema.json create mode 100644 examples/mods/compact-player-volume-scroll/ambxst.mod.json create mode 100644 examples/mods/compact-player-volume-scroll/patches/compact-player-volume-scroll.patch create mode 100644 modules/services/ModsService.qml create mode 100644 modules/widgets/dashboard/controls/ModsPanel.qml diff --git a/.gitignore b/.gitignore index ee259ff41..097d0106f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ scripts/__pycache__ .sisyphus result /ambxst + +# Keep Go package sources visible when a global ignore file excludes pkg/. +!/backend/pkg/ diff --git a/README.md b/README.md index 155ecfd92..a81127ed6 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Nope! Besides the Ambxst import block in your `hyprland.conf` or `hyprland.lua`, - [x] Support for different layouts (dwindle, master, scrolling, etc.) - [x] Multi-monitor support - [x] Customizable keybindings -- [ ] Plugin and extension system +- [x] [Mod manager with native Settings integration](docs/mods/README.md) - [ ] Compatibility with other Wayland compositors --- diff --git a/backend/cmd/ambxst/cmds_mods.go b/backend/cmd/ambxst/cmds_mods.go new file mode 100644 index 000000000..7b0ccb084 --- /dev/null +++ b/backend/cmd/ambxst/cmds_mods.go @@ -0,0 +1,158 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + modpkg "ambxst/backend/pkg/mods" + "ambxst/backend/pkg/paths" +) + +func runMods(args []string) { + command := "list" + if len(args) > 0 { + command = args[0] + } + + var ( + status modpkg.Status + err error + ) + switch command { + case "list", "status": + status, err = callMods("status", nil) + case "install": + if len(args) != 2 { + modsUsage("Usage: ambxst mods install ") + } + status, err = callMods("install", map[string]any{"source": args[1]}) + case "enable", "disable": + if len(args) != 2 { + modsUsage("Usage: ambxst mods " + command + " ") + } + status, err = callMods("setEnabled", map[string]any{"id": args[1], "enabled": command == "enable"}) + case "remove", "update": + if len(args) != 2 { + modsUsage("Usage: ambxst mods " + command + " ") + } + status, err = callMods(command, map[string]any{"id": args[1]}) + case "move": + if len(args) != 3 || (args[2] != "up" && args[2] != "down") { + modsUsage("Usage: ambxst mods move ") + } + direction := 1 + if args[2] == "up" { + direction = -1 + } + status, err = callMods("move", map[string]any{"id": args[1], "direction": direction}) + case "rebuild", "rollback": + if len(args) != 1 { + modsUsage("Usage: ambxst mods " + command) + } + status, err = callMods(command, nil) + case "help", "--help", "-h": + modsUsage("") + default: + modsUsage("Unknown mods command: " + command) + } + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + printModStatus(status) + if status.RestartRequired && isAlive() { + fmt.Println("Restart Ambxst to load the active generation.") + } +} + +func callMods(method string, params map[string]any) (modpkg.Status, error) { + if isAlive() { + result, err := newClient().Call("mods."+method, params) + if err != nil { + return modpkg.Status{}, err + } + var status modpkg.Status + if err := json.Unmarshal(result, &status); err != nil { + return modpkg.Status{}, err + } + return status, nil + } + + manager := modpkg.NewManager(paths.New()) + switch method { + case "status": + return manager.Status() + case "install": + return manager.Install(params["source"].(string)) + case "setEnabled": + return manager.SetEnabled(params["id"].(string), params["enabled"].(bool)) + case "remove": + return manager.Remove(params["id"].(string)) + case "update": + return manager.Update(params["id"].(string)) + case "move": + return manager.Move(params["id"].(string), params["direction"].(int)) + case "rebuild": + return manager.Rebuild() + case "rollback": + return manager.Rollback() + default: + return modpkg.Status{}, fmt.Errorf("unsupported mods method %q", method) + } +} + +func printModStatus(status modpkg.Status) { + fmt.Printf("Ambxst %s", status.BaseVersion) + if status.BaseRevision != "" { + fmt.Printf(" (%s)", shortModRevision(status.BaseRevision)) + } + fmt.Println() + if status.ActiveGeneration == "" { + fmt.Println("Active generation: base") + } else { + fmt.Println("Active generation:", status.ActiveGeneration) + } + if len(status.Mods) == 0 { + fmt.Println("No mods installed.") + return + } + for _, mod := range status.Mods { + state := "disabled" + if mod.Enabled { + state = "enabled" + } + fmt.Printf("%-9s %-28s %s\n", state, mod.ID, mod.Version) + } +} + +func shortModRevision(revision string) string { + if len(revision) > 12 { + return revision[:12] + } + return revision +} + +func modsUsage(message string) { + if message != "" { + fmt.Fprintln(os.Stderr, message) + fmt.Fprintln(os.Stderr) + } + fmt.Print("Ambxst Mods\n\n" + + "Usage: ambxst mods \n\n" + + "Commands:\n" + + " list Show installed mods and generation state\n" + + " install Install from a directory, archive, or Git URL\n" + + " enable Enable a mod and build a generation\n" + + " disable Disable a mod and build a generation\n" + + " update Refresh a mod from its original source\n" + + " remove Remove a mod package\n" + + " move Change patch load order\n" + + " rebuild Rebuild the enabled mod set\n" + + " rollback Activate the previous generation\n" + + " help Show this help\n") + if message != "" { + os.Exit(2) + } + os.Exit(0) +} diff --git a/backend/cmd/ambxst/commands.go b/backend/cmd/ambxst/commands.go index c0c97bb70..6d05682b1 100644 --- a/backend/cmd/ambxst/commands.go +++ b/backend/cmd/ambxst/commands.go @@ -373,4 +373,4 @@ func doSuspend() { "--dest=org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager.Suspend", "boolean:true").Run() } -} \ No newline at end of file +} diff --git a/backend/cmd/ambxst/main.go b/backend/cmd/ambxst/main.go index efaf2eddb..793725f70 100644 --- a/backend/cmd/ambxst/main.go +++ b/backend/cmd/ambxst/main.go @@ -3,9 +3,9 @@ package main import ( "encoding/json" "fmt" + "net" "os" "os/exec" - "net" "path/filepath" "strconv" "strings" @@ -45,6 +45,9 @@ func main() { case "goodbye": runGoodbye() return + case "mods": + runMods(args[1:]) + return } } @@ -128,6 +131,7 @@ func newClient() *ipc.Client { } // runIpc dispatches a JSON-RPC call to the running ambxst process. +// // ambxst ipc call func runIpc(args []string) int { if len(args) < 2 || args[0] != "call" { @@ -383,6 +387,7 @@ Commands: -tint Enable tint for this wallpaper only -monitor Apply to a specific monitor preset [-l|"Name"] List or apply a preset (name supports quotes) + mods [command] Manage Ambxst modifications help Show this help message version, -v, --version Show Ambxst version goodbye Uninstall Ambxst diff --git a/backend/cmd/ambxst/screen.go b/backend/cmd/ambxst/screen.go index 24f948c37..1b71609c2 100644 --- a/backend/cmd/ambxst/screen.go +++ b/backend/cmd/ambxst/screen.go @@ -70,4 +70,4 @@ func notifyShell(summary, body, urgency string) { if _, err := newClient().Call("notify.send", params); err != nil { _ = notify.SendFallback(summary, body, urgency) } -} \ No newline at end of file +} diff --git a/backend/pkg/daemon/daemon.go b/backend/pkg/daemon/daemon.go index 545654349..83a6c1995 100644 --- a/backend/pkg/daemon/daemon.go +++ b/backend/pkg/daemon/daemon.go @@ -13,6 +13,7 @@ import ( "time" "ambxst/backend/pkg/ipc" + "ambxst/backend/pkg/mods" "ambxst/backend/pkg/paths" "ambxst/backend/pkg/svc" "ambxst/backend/pkg/svc/caffeine" @@ -45,21 +46,23 @@ type Daemon struct { paths *paths.Paths srv *ipc.Server - ui *svc.UIService - sleep *sleep.Service - clipboard *clipboard.Service - network *network.Service - compositor *compositor.Service - caffeine *caffeine.Service - gamemode *gamemode.Service - powerprof *powerprofile.Service - nightlight *nightlight.Service - recorder *recordersvc.Service - - shutdownCh chan struct{} + ui *svc.UIService + sleep *sleep.Service + clipboard *clipboard.Service + network *network.Service + compositor *compositor.Service + caffeine *caffeine.Service + gamemode *gamemode.Service + powerprof *powerprofile.Service + nightlight *nightlight.Service + recorder *recordersvc.Service + mods *mods.Manager + + shutdownCh chan struct{} shutdownOnce sync.Once - qsCmd *exec.Cmd + qsCmd *exec.Cmd + qsDone <-chan error } // New wires every service into a freshly constructed server. The caller is @@ -139,6 +142,11 @@ func New() (*Daemon, error) { presetSvc := preset.NewService(d.paths) presetSvc.Register(d.srv) + modsManager := mods.NewManager(d.paths) + modsSvc := mods.NewService(modsManager) + modsSvc.Register(d.srv) + d.mods = modsManager + shotSvc := screenshot.NewService(d.paths) shotSvc.Register(d.srv) @@ -184,11 +192,11 @@ func (d *Daemon) TriggerShutdown() { // requested, or a terminating signal is received. On exit it tears down // every child it owns in the right order: // -// 1. close the IPC listener (refuse new connections) -// 2. SIGTERM → Quickshell; SIGKILL its process group if it ignores -// 3. compositor.Close() → axctl daemon + axctl subscribe -// 4. clipboard.Close() → wl-paste --watch -// 5. sleep.Close() → dbus connection +// 1. close the IPC listener (refuse new connections) +// 2. SIGTERM → Quickshell; SIGKILL its process group if it ignores +// 3. compositor.Close() → axctl daemon + axctl subscribe +// 4. clipboard.Close() → wl-paste --watch +// 5. sleep.Close() → dbus connection func (d *Daemon) Run(qsBin, shellQML string) error { if err := d.srv.Listen(); err != nil { return fmt.Errorf("ipc listen: %w", err) @@ -231,20 +239,65 @@ func (d *Daemon) Run(qsBin, shellQML string) error { signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(sigCh) - qsDone := make(chan error, 1) - go func() { qsDone <- d.qsCmd.Wait() }() + d.qsDone = waitForProcess(d.qsCmd) + var healthTimer *time.Timer + var healthCh <-chan time.Time + if d.mods != nil && d.mods.HasPendingActivation() { + healthTimer = time.NewTimer(8 * time.Second) + healthCh = healthTimer.C + } + defer func() { + if healthTimer != nil { + healthTimer.Stop() + } + }() - select { - case s := <-sigCh: - log.Printf("[ambxst] received %v, shutting down", s) - case <-d.shutdownCh: - log.Printf("[ambxst] shutdown requested via IPC") - case err := <-qsDone: - log.Printf("[ambxst] qs exited: %v", err) + for { + select { + case s := <-sigCh: + log.Printf("[ambxst] received %v, shutting down", s) + d.shutdown() + return nil + case <-d.shutdownCh: + log.Printf("[ambxst] shutdown requested via IPC") + d.shutdown() + return nil + case <-healthCh: + if err := d.mods.MarkHealthy(); err != nil { + log.Printf("[ambxst] mod activation health check: %v", err) + } + healthCh = nil + case err := <-d.qsDone: + log.Printf("[ambxst] qs exited: %v", err) + d.qsCmd = nil + d.qsDone = nil + if healthCh != nil { + recovered, recoverErr := d.mods.RecoverFailedActivation() + if recoverErr != nil { + log.Printf("[ambxst] mod activation rollback: %v", recoverErr) + } + healthCh = nil + if recovered { + fallback := filepath.Join(paths.FindShellSource(), "shell.qml") + log.Printf("[ambxst] retrying with previous shell generation") + if spawnErr := d.spawnQS(qsBin, fallback); spawnErr != nil { + d.shutdown() + return fmt.Errorf("spawn rollback shell: %w", spawnErr) + } + d.qsDone = waitForProcess(d.qsCmd) + continue + } + } + d.shutdown() + return nil + } } +} - d.shutdown() - return nil +func waitForProcess(cmd *exec.Cmd) <-chan error { + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + return done } // spawnQS launches Quickshell as a child of the current process, in its @@ -270,19 +323,20 @@ func (d *Daemon) spawnQS(qsBin, shellQML string) error { func (d *Daemon) shutdown() { if d.qsCmd != nil && d.qsCmd.Process != nil { _ = d.qsCmd.Process.Signal(syscall.SIGTERM) - done := make(chan struct{}) - go func() { _ = d.qsCmd.Wait(); close(done) }() - select { - case <-done: - case <-time.After(1500 * time.Millisecond): - if pgid, err := syscall.Getpgid(d.qsCmd.Process.Pid); err == nil && pgid > 0 { - _ = syscall.Kill(-pgid, syscall.SIGKILL) - } else { - _ = d.qsCmd.Process.Kill() + if d.qsDone != nil { + select { + case <-d.qsDone: + case <-time.After(1500 * time.Millisecond): + if pgid, err := syscall.Getpgid(d.qsCmd.Process.Pid); err == nil && pgid > 0 { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } else { + _ = d.qsCmd.Process.Kill() + } + <-d.qsDone } - <-done } d.qsCmd = nil + d.qsDone = nil } if d.compositor != nil && d.compositor.Manager() != nil { diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go new file mode 100644 index 000000000..cba14d580 --- /dev/null +++ b/backend/pkg/mods/manager.go @@ -0,0 +1,1479 @@ +package mods + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "ambxst/backend/pkg/paths" +) + +const stateVersion = 1 + +const ( + maxPackageFiles = 10000 + maxPackageBytes = 128 << 20 +) + +type Manager struct { + paths *paths.Paths + mu sync.Mutex +} + +type State struct { + Version int `json:"version"` + Mods []InstalledMod `json:"mods"` + ActiveGeneration string `json:"activeGeneration,omitempty"` + PreviousGeneration string `json:"previousGeneration,omitempty"` +} + +type InstalledMod struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Order int `json:"order"` + Source string `json:"source"` + SourceType string `json:"sourceType"` + Revision string `json:"revision,omitempty"` + InstalledAt string `json:"installedAt"` +} + +type ModInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + License string `json:"license,omitempty"` + Author string `json:"author,omitempty"` + Enabled bool `json:"enabled"` + Order int `json:"order"` + Source string `json:"source"` + SourceType string `json:"sourceType"` + Revision string `json:"revision,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` + Commands []string `json:"commands,omitempty"` + Permissions []string `json:"permissions,omitempty"` + AffectedFiles []string `json:"affectedFiles"` + HasSettings bool `json:"hasSettings"` + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` + Compatible bool `json:"compatible"` + CompatibilityError string `json:"compatibilityError,omitempty"` +} + +type ModSettings struct { + ID string `json:"id"` + Fields []SettingField `json:"fields"` + Values map[string]any `json:"values"` + RestartRequired bool `json:"restartRequired,omitempty"` +} + +type Status struct { + BasePath string `json:"basePath"` + BaseVersion string `json:"baseVersion"` + BaseRevision string `json:"baseRevision,omitempty"` + ActiveGeneration string `json:"activeGeneration,omitempty"` + PreviousGeneration string `json:"previousGeneration,omitempty"` + GenerationCurrent bool `json:"generationCurrent"` + GenerationError string `json:"generationError,omitempty"` + RestartRequired bool `json:"restartRequired"` + Mods []ModInfo `json:"mods"` +} + +type generationMetadata struct { + ID string `json:"id"` + CreatedAt string `json:"createdAt"` + BasePath string `json:"basePath"` + BaseVersion string `json:"baseVersion"` + BaseRevision string `json:"baseRevision,omitempty"` + Mods []string `json:"mods"` +} + +type pendingActivation struct { + Generation string `json:"generation"` + PreviousGeneration string `json:"previousGeneration,omitempty"` +} + +func NewManager(p *paths.Paths) *Manager { + return &Manager{paths: p} +} + +func (m *Manager) Status() (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + state, err := m.loadState() + if err != nil { + return Status{}, err + } + return m.statusFor(state) +} + +func (m *Manager) Install(source string) (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + + source = strings.TrimSpace(source) + if source == "" { + return Status{}, fmt.Errorf("source is required") + } + if err := os.MkdirAll(m.paths.ModPackagesDir(), 0o755); err != nil { + return Status{}, err + } + tmp, err := os.MkdirTemp(m.paths.ModPackagesDir(), ".install-") + if err != nil { + return Status{}, err + } + defer os.RemoveAll(tmp) + + packageRoot := filepath.Join(tmp, "package") + sourceType := "local" + if isGitSource(source) { + sourceType = "git" + if err := runCommandTimeout(5*time.Minute, "", "git", "clone", "--depth=1", source, packageRoot); err != nil { + return Status{}, fmt.Errorf("clone source: %w", err) + } + } else { + absolute, err := filepath.Abs(source) + if err != nil { + return Status{}, err + } + info, err := os.Stat(absolute) + if err != nil { + return Status{}, fmt.Errorf("inspect source: %w", err) + } + if info.IsDir() { + if err := copyTree(absolute, packageRoot, func(path string, entry fs.DirEntry) bool { + return path != absolute && entry.IsDir() && entry.Name() == ".git" + }); err != nil { + return Status{}, fmt.Errorf("copy source: %w", err) + } + } else { + sourceType = "archive" + if err := extractPackageArchive(absolute, packageRoot); err != nil { + return Status{}, err + } + } + source = absolute + } + packageRoot, err = locatePackageRoot(packageRoot) + if err != nil { + return Status{}, err + } + + manifest, err := LoadManifest(packageRoot) + if err != nil { + return Status{}, err + } + state, err := m.loadState() + if err != nil { + return Status{}, err + } + if _, ok := findInstalled(state, manifest.ID); ok { + return Status{}, fmt.Errorf("mod %q is already installed", manifest.ID) + } + destination := filepath.Join(m.paths.ModPackagesDir(), manifest.ID) + if _, err := os.Stat(destination); err == nil { + return Status{}, fmt.Errorf("package directory already exists for %q", manifest.ID) + } + if err := os.Rename(packageRoot, destination); err != nil { + return Status{}, fmt.Errorf("store package: %w", err) + } + revision := gitRevision(destination) + state.Mods = append(state.Mods, InstalledMod{ + ID: manifest.ID, + Enabled: false, + Order: len(state.Mods), + Source: source, + SourceType: sourceType, + Revision: revision, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + }) + if err := m.saveState(state); err != nil { + os.RemoveAll(destination) + return Status{}, err + } + return m.statusFor(state) +} + +func (m *Manager) SetEnabled(id string, enabled bool) (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + state, err := m.loadState() + if err != nil { + return Status{}, err + } + index, ok := findInstalled(state, id) + if !ok { + return Status{}, fmt.Errorf("mod %q is not installed", id) + } + if state.Mods[index].Enabled == enabled { + return m.statusFor(state) + } + next := cloneState(state) + next.Mods[index].Enabled = enabled + if err := m.composeAndActivate(state, &next); err != nil { + return Status{}, err + } + return m.statusForRestart(next, true) +} + +func (m *Manager) Rebuild() (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + state, err := m.loadState() + if err != nil { + return Status{}, err + } + hasEnabled := false + for _, installed := range state.Mods { + hasEnabled = hasEnabled || installed.Enabled + } + if !hasEnabled && state.ActiveGeneration == "" { + return m.statusFor(state) + } + next := cloneState(state) + if err := m.composeAndActivate(state, &next); err != nil { + return Status{}, err + } + restart := state.ActiveGeneration != "" || hasEnabled + return m.statusForRestart(next, restart) +} + +func (m *Manager) Remove(id string) (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + state, err := m.loadState() + if err != nil { + return Status{}, err + } + index, ok := findInstalled(state, id) + if !ok { + return Status{}, fmt.Errorf("mod %q is not installed", id) + } + next := cloneState(state) + next.Mods = append(next.Mods[:index], next.Mods[index+1:]...) + for i := range next.Mods { + next.Mods[i].Order = i + } + if state.Mods[index].Enabled { + if err := m.composeAndActivate(state, &next); err != nil { + return Status{}, err + } + } else if err := m.saveState(next); err != nil { + return Status{}, err + } + if err := os.RemoveAll(filepath.Join(m.paths.ModPackagesDir(), id)); err != nil { + return Status{}, fmt.Errorf("remove package: %w", err) + } + if err := os.Remove(filepath.Join(m.paths.ModSettingsDir(), id+".json")); err != nil && !os.IsNotExist(err) { + return Status{}, fmt.Errorf("remove settings: %w", err) + } + return m.statusForRestart(next, state.Mods[index].Enabled) +} + +func (m *Manager) Move(id string, direction int) (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + if direction != -1 && direction != 1 { + return Status{}, fmt.Errorf("direction must be -1 or 1") + } + state, err := m.loadState() + if err != nil { + return Status{}, err + } + index, ok := findInstalled(state, id) + if !ok { + return Status{}, fmt.Errorf("mod %q is not installed", id) + } + target := index + direction + if target < 0 || target >= len(state.Mods) { + return m.statusFor(state) + } + next := cloneState(state) + next.Mods[index], next.Mods[target] = next.Mods[target], next.Mods[index] + for i := range next.Mods { + next.Mods[i].Order = i + } + rebuild := next.Mods[index].Enabled && next.Mods[target].Enabled + if rebuild { + if err := m.composeAndActivate(state, &next); err != nil { + return Status{}, err + } + } else if err := m.saveState(next); err != nil { + return Status{}, err + } + return m.statusForRestart(next, rebuild) +} + +func (m *Manager) Update(id string) (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + state, err := m.loadState() + if err != nil { + return Status{}, err + } + index, ok := findInstalled(state, id) + if !ok { + return Status{}, fmt.Errorf("mod %q is not installed", id) + } + installed := state.Mods[index] + packageRoot := filepath.Join(m.paths.ModPackagesDir(), id) + if installed.SourceType != "git" { + return m.updateLocalSource(state, index, installed, packageRoot) + } + oldRevision := gitRevision(packageRoot) + if err := runCommandTimeout(5*time.Minute, packageRoot, "git", "pull", "--ff-only"); err != nil { + return Status{}, fmt.Errorf("update mod: %w", err) + } + manifest, err := LoadManifest(packageRoot) + if err != nil || manifest.ID != id { + _ = runCommand(packageRoot, "git", "reset", "--hard", oldRevision) + if err != nil { + return Status{}, err + } + return Status{}, fmt.Errorf("updated package changed its id") + } + next := cloneState(state) + next.Mods[index].Revision = gitRevision(packageRoot) + if installed.Enabled { + if err := m.composeAndActivate(state, &next); err != nil { + _ = runCommand(packageRoot, "git", "reset", "--hard", oldRevision) + return Status{}, err + } + } else if err := m.saveState(next); err != nil { + _ = runCommand(packageRoot, "git", "reset", "--hard", oldRevision) + return Status{}, err + } + return m.statusForRestart(next, installed.Enabled) +} + +func (m *Manager) updateLocalSource(state State, index int, installed InstalledMod, packageRoot string) (Status, error) { + tmp, err := os.MkdirTemp(m.paths.ModPackagesDir(), ".update-") + if err != nil { + return Status{}, err + } + defer os.RemoveAll(tmp) + + updatedRoot := filepath.Join(tmp, "package") + switch installed.SourceType { + case "local": + info, err := os.Stat(installed.Source) + if err != nil { + return Status{}, fmt.Errorf("inspect source: %w", err) + } + if !info.IsDir() { + return Status{}, fmt.Errorf("local source is not a directory") + } + if err := copyTree(installed.Source, updatedRoot, func(path string, entry fs.DirEntry) bool { + return path != installed.Source && entry.IsDir() && entry.Name() == ".git" + }); err != nil { + return Status{}, fmt.Errorf("copy source: %w", err) + } + case "archive": + if err := extractPackageArchive(installed.Source, updatedRoot); err != nil { + return Status{}, err + } + default: + return Status{}, fmt.Errorf("mod %q has unsupported source type %q", installed.ID, installed.SourceType) + } + updatedRoot, err = locatePackageRoot(updatedRoot) + if err != nil { + return Status{}, err + } + manifest, err := LoadManifest(updatedRoot) + if err != nil { + return Status{}, err + } + if manifest.ID != installed.ID { + return Status{}, fmt.Errorf("updated package changed its id") + } + + backup := filepath.Join(tmp, "previous") + if err := os.Rename(packageRoot, backup); err != nil { + return Status{}, fmt.Errorf("prepare package update: %w", err) + } + restore := func() { + _ = os.RemoveAll(packageRoot) + _ = os.Rename(backup, packageRoot) + } + if err := os.Rename(updatedRoot, packageRoot); err != nil { + restore() + return Status{}, fmt.Errorf("store package update: %w", err) + } + + next := cloneState(state) + next.Mods[index].Revision = "" + if installed.Enabled { + if err := m.composeAndActivate(state, &next); err != nil { + restore() + return Status{}, err + } + } else if err := m.saveState(next); err != nil { + restore() + return Status{}, err + } + return m.statusForRestart(next, installed.Enabled) +} + +func (m *Manager) Settings(id string) (ModSettings, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.loadSettings(id) +} + +func (m *Manager) SetSetting(id, key string, value any) (ModSettings, error) { + m.mu.Lock() + defer m.mu.Unlock() + settings, err := m.loadSettings(id) + if err != nil { + return ModSettings{}, err + } + var field *SettingField + for i := range settings.Fields { + if settings.Fields[i].Key == key { + field = &settings.Fields[i] + break + } + } + if field == nil { + return ModSettings{}, fmt.Errorf("unknown setting %q", key) + } + if err := validateSettingValue(*field, value); err != nil { + return ModSettings{}, fmt.Errorf("setting %s: %w", key, err) + } + settings.Values[key] = value + data, err := json.MarshalIndent(settings.Values, "", " ") + if err != nil { + return ModSettings{}, err + } + path := filepath.Join(m.paths.ModSettingsDir(), id+".json") + if err := writeAtomic(path, append(data, '\n'), 0o644); err != nil { + return ModSettings{}, err + } + settings.RestartRequired = field.RestartRequired + return settings, nil +} + +func (m *Manager) Rollback() (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + state, err := m.loadState() + if err != nil { + return Status{}, err + } + if state.PreviousGeneration == "" { + return Status{}, fmt.Errorf("no previous generation is available") + } + previousPath := filepath.Join(m.paths.ModGenerationsDir(), state.PreviousGeneration) + if _, err := os.Stat(filepath.Join(previousPath, "shell.qml")); err != nil { + return Status{}, fmt.Errorf("previous generation is unavailable") + } + metadata, err := readGenerationMetadata(previousPath) + if err != nil { + return Status{}, err + } + pending, activationPending := m.readPendingActivation() + next := cloneState(state) + next.ActiveGeneration, next.PreviousGeneration = state.PreviousGeneration, state.ActiveGeneration + if activationPending && pending.Generation == state.ActiveGeneration { + if pending.PreviousGeneration != state.PreviousGeneration { + return Status{}, fmt.Errorf("pending activation does not match mod state") + } + // Do not expose the untested generation as a rollback target. + next.PreviousGeneration = "" + } + enabled := make(map[string]bool, len(metadata.Mods)) + for _, id := range metadata.Mods { + enabled[id] = true + } + for i := range next.Mods { + next.Mods[i].Enabled = enabled[next.Mods[i].ID] + } + if err := m.saveState(next); err != nil { + return Status{}, err + } + // The target has already passed its startup trial. + if err := os.Remove(m.paths.ModPendingActivationFile()); err != nil && !os.IsNotExist(err) { + return Status{}, err + } + return m.statusForRestart(next, true) +} + +func (m *Manager) HasPendingActivation() bool { + m.mu.Lock() + defer m.mu.Unlock() + pending, ok := m.readPendingActivation() + if !ok { + return false + } + state, err := m.loadState() + if err != nil || state.ActiveGeneration != pending.Generation { + _ = os.Remove(m.paths.ModPendingActivationFile()) + return false + } + if state.ActiveGeneration == "" { + return paths.FindBaseShellSource() != "" + } + generation := filepath.Join(m.paths.ModGenerationsDir(), state.ActiveGeneration) + if paths.ValidateModGeneration(generation, paths.FindBaseShellSource()) != nil { + return false + } + return true +} + +func (m *Manager) MarkHealthy() error { + m.mu.Lock() + defer m.mu.Unlock() + if err := os.Remove(m.paths.ModPendingActivationFile()); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// RecoverFailedActivation restores the generation that was active before the +// most recent transaction. It returns false when no pending activation exists. +func (m *Manager) RecoverFailedActivation() (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + data, err := os.ReadFile(m.paths.ModPendingActivationFile()) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + var pending pendingActivation + if err := json.Unmarshal(data, &pending); err != nil { + return false, fmt.Errorf("parse pending activation: %w", err) + } + state, err := m.loadState() + if err != nil { + return false, err + } + if state.ActiveGeneration != pending.Generation { + _ = os.Remove(m.paths.ModPendingActivationFile()) + return false, nil + } + + state.ActiveGeneration = pending.PreviousGeneration + state.PreviousGeneration = "" + enabled := make(map[string]bool) + if state.ActiveGeneration != "" { + metadata, err := readGenerationMetadata(filepath.Join(m.paths.ModGenerationsDir(), state.ActiveGeneration)) + if err != nil { + return false, err + } + for _, id := range metadata.Mods { + enabled[id] = true + } + } + for i := range state.Mods { + state.Mods[i].Enabled = enabled[state.Mods[i].ID] + } + if err := m.saveState(state); err != nil { + return false, err + } + if err := os.Remove(m.paths.ModPendingActivationFile()); err != nil && !os.IsNotExist(err) { + return false, err + } + return true, nil +} + +func (m *Manager) composeAndActivate(previous State, next *State) error { + knownGood := m.knownGoodGeneration(previous) + enabled := 0 + for _, mod := range next.Mods { + if mod.Enabled { + enabled++ + } + } + if enabled == 0 { + next.PreviousGeneration = knownGood + next.ActiveGeneration = "" + if knownGood != "" { + if err := m.writePendingActivation("", knownGood); err != nil { + return err + } + } else if err := os.Remove(m.paths.ModPendingActivationFile()); err != nil && !os.IsNotExist(err) { + return err + } + if err := m.saveState(*next); err != nil { + if knownGood != "" { + _ = os.Remove(m.paths.ModPendingActivationFile()) + } + return err + } + return nil + } + + generation, err := m.buildGeneration(*next) + if err != nil { + return err + } + next.PreviousGeneration = knownGood + next.ActiveGeneration = filepath.Base(generation) + if err := m.writePendingActivation(next.ActiveGeneration, next.PreviousGeneration); err != nil { + return err + } + if err := m.saveState(*next); err != nil { + _ = os.Remove(m.paths.ModPendingActivationFile()) + return err + } + m.cleanupGenerations(*next) + return nil +} + +// knownGoodGeneration keeps consecutive, not-yet-started rebuilds anchored to +// the last generation that completed the startup health window. +func (m *Manager) knownGoodGeneration(state State) string { + pending, ok := m.readPendingActivation() + if !ok || pending.Generation != state.ActiveGeneration { + return state.ActiveGeneration + } + return pending.PreviousGeneration +} + +func (m *Manager) readPendingActivation() (pendingActivation, bool) { + data, err := os.ReadFile(m.paths.ModPendingActivationFile()) + if err != nil { + return pendingActivation{}, false + } + var pending pendingActivation + if json.Unmarshal(data, &pending) != nil { + return pendingActivation{}, false + } + return pending, true +} + +func (m *Manager) writePendingActivation(generation, previous string) error { + pending := pendingActivation{Generation: generation, PreviousGeneration: previous} + data, err := json.MarshalIndent(pending, "", " ") + if err != nil { + return err + } + return writeAtomic(m.paths.ModPendingActivationFile(), append(data, '\n'), 0o644) +} + +func (m *Manager) buildGeneration(state State) (string, error) { + base := paths.FindBaseShellSource() + if base == "" { + return "", fmt.Errorf("Ambxst base source was not found") + } + manifests, ordered, err := m.resolve(state, base) + if err != nil { + return "", err + } + if err := os.MkdirAll(m.paths.ModGenerationsDir(), 0o755); err != nil { + return "", err + } + tmp, err := os.MkdirTemp(m.paths.ModGenerationsDir(), ".build-") + if err != nil { + return "", err + } + keep := false + defer func() { + if !keep { + os.RemoveAll(tmp) + } + }() + + if err := exportBase(base, tmp); err != nil { + return "", fmt.Errorf("export base source: %w", err) + } + owners := make(map[string]string) + for _, id := range ordered { + manifest := manifests[id] + packageRoot := filepath.Join(m.paths.ModPackagesDir(), id) + files, err := manifest.AffectedFiles(packageRoot) + if err != nil { + return "", fmt.Errorf("mod %s: %w", id, err) + } + for _, file := range files { + if owner, exists := owners[file]; exists { + return "", fmt.Errorf("mods %s and %s both modify %s", owner, id, file) + } + owners[file] = id + } + for _, operation := range manifest.Operations { + if err := applyOperation(tmp, packageRoot, operation); err != nil { + return "", fmt.Errorf("mod %s: %w", id, err) + } + } + } + if _, err := os.Stat(filepath.Join(tmp, "shell.qml")); err != nil { + return "", fmt.Errorf("generation has no shell.qml") + } + baseVersion := readTrimmed(filepath.Join(base, "version")) + baseRevision := gitRevision(base) + hash := sha256.Sum256([]byte(baseRevision + strings.Join(ordered, "\x00") + time.Now().UTC().Format(time.RFC3339Nano))) + id := time.Now().UTC().Format("20060102T150405Z") + "-" + hex.EncodeToString(hash[:4]) + metadata := generationMetadata{ + ID: id, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + BasePath: base, + BaseVersion: baseVersion, + BaseRevision: baseRevision, + Mods: ordered, + } + data, _ := json.MarshalIndent(metadata, "", " ") + if err := os.WriteFile(filepath.Join(tmp, ".ambxst-generation.json"), append(data, '\n'), 0o644); err != nil { + return "", err + } + final := filepath.Join(m.paths.ModGenerationsDir(), id) + if err := os.Rename(tmp, final); err != nil { + return "", err + } + keep = true + return final, nil +} + +func (m *Manager) resolve(state State, base string) (map[string]Manifest, []string, error) { + manifests := make(map[string]Manifest) + installed := make(map[string]InstalledMod) + for _, mod := range state.Mods { + installed[mod.ID] = mod + if !mod.Enabled { + continue + } + manifest, err := LoadManifest(filepath.Join(m.paths.ModPackagesDir(), mod.ID)) + if err != nil { + return nil, nil, fmt.Errorf("mod %s: %w", mod.ID, err) + } + if err := checkCompatibility(manifest, base); err != nil { + return nil, nil, fmt.Errorf("mod %s: %w", mod.ID, err) + } + for _, command := range manifest.Commands { + if _, err := exec.LookPath(command); err != nil { + return nil, nil, fmt.Errorf("mod %s requires command %q", mod.ID, command) + } + } + manifests[mod.ID] = manifest + } + for id, manifest := range manifests { + for _, dependency := range manifest.Dependencies { + dep, ok := installed[dependency] + if !ok || !dep.Enabled { + return nil, nil, fmt.Errorf("mod %s requires enabled mod %s", id, dependency) + } + } + for _, conflict := range manifest.Conflicts { + if other, ok := installed[conflict]; ok && other.Enabled { + return nil, nil, fmt.Errorf("mods %s and %s conflict", id, conflict) + } + } + } + ordered, err := topologicalOrder(state.Mods, manifests) + return manifests, ordered, err +} + +func (m *Manager) statusFor(state State) (Status, error) { + base := paths.FindBaseShellSource() + status := Status{ + BasePath: base, + BaseVersion: readTrimmed(filepath.Join(base, "version")), + BaseRevision: gitRevision(base), + ActiveGeneration: state.ActiveGeneration, + PreviousGeneration: state.PreviousGeneration, + GenerationCurrent: true, + Mods: make([]ModInfo, 0, len(state.Mods)), + } + if pending, ok := m.readPendingActivation(); ok && pending.Generation == state.ActiveGeneration { + status.RestartRequired = true + } + if state.ActiveGeneration != "" { + generation := filepath.Join(m.paths.ModGenerationsDir(), state.ActiveGeneration) + if err := paths.ValidateModGeneration(generation, base); err != nil { + status.GenerationCurrent = false + status.GenerationError = err.Error() + } + } + for _, installed := range state.Mods { + root := filepath.Join(m.paths.ModPackagesDir(), installed.ID) + manifest, err := LoadManifest(root) + if err != nil { + status.Mods = append(status.Mods, ModInfo{ + ID: installed.ID, + Name: installed.ID, + Enabled: installed.Enabled, + Order: installed.Order, + Source: installed.Source, + SourceType: installed.SourceType, + Revision: installed.Revision, + Valid: false, + Error: err.Error(), + }) + continue + } + files, err := manifest.AffectedFiles(root) + if err != nil { + status.Mods = append(status.Mods, ModInfo{ + ID: manifest.ID, + Name: manifest.Name, + Version: manifest.Version, + Description: manifest.Description, + Enabled: installed.Enabled, + Order: installed.Order, + Source: installed.Source, + SourceType: installed.SourceType, + Revision: installed.Revision, + Valid: false, + Error: err.Error(), + }) + continue + } + compatibilityErr := checkCompatibility(manifest, base) + compatibilityMessage := "" + if compatibilityErr != nil { + compatibilityMessage = compatibilityErr.Error() + } + status.Mods = append(status.Mods, ModInfo{ + ID: manifest.ID, + Name: manifest.Name, + Version: manifest.Version, + Description: manifest.Description, + License: manifest.License, + Author: manifest.Author, + Enabled: installed.Enabled, + Order: installed.Order, + Source: installed.Source, + SourceType: installed.SourceType, + Revision: installed.Revision, + Dependencies: manifest.Dependencies, + Conflicts: manifest.Conflicts, + Commands: manifest.Commands, + Permissions: manifest.Permissions, + AffectedFiles: files, + HasSettings: manifest.Settings != nil, + Valid: true, + Compatible: compatibilityErr == nil, + CompatibilityError: compatibilityMessage, + }) + } + sort.SliceStable(status.Mods, func(i, j int) bool { return status.Mods[i].Order < status.Mods[j].Order }) + return status, nil +} + +func (m *Manager) statusForRestart(state State, restart bool) (Status, error) { + status, err := m.statusFor(state) + if err != nil { + return Status{}, err + } + status.RestartRequired = status.RestartRequired || restart + return status, nil +} + +func (m *Manager) loadSettings(id string) (ModSettings, error) { + state, err := m.loadState() + if err != nil { + return ModSettings{}, err + } + if _, ok := findInstalled(state, id); !ok { + return ModSettings{}, fmt.Errorf("mod %q is not installed", id) + } + packageRoot := filepath.Join(m.paths.ModPackagesDir(), id) + manifest, err := LoadManifest(packageRoot) + if err != nil { + return ModSettings{}, err + } + if manifest.Settings == nil { + return ModSettings{ID: id, Fields: []SettingField{}, Values: map[string]any{}}, nil + } + schemaPath, err := safeJoin(packageRoot, manifest.Settings.Schema) + if err != nil { + return ModSettings{}, err + } + schema, err := LoadSettingsSchema(schemaPath) + if err != nil { + return ModSettings{}, err + } + values := make(map[string]any, len(schema.Fields)) + for _, field := range schema.Fields { + values[field.Key] = field.Default + } + data, err := os.ReadFile(filepath.Join(m.paths.ModSettingsDir(), id+".json")) + if err == nil { + var stored map[string]any + if json.Unmarshal(data, &stored) == nil { + for _, field := range schema.Fields { + if value, ok := stored[field.Key]; ok && validateSettingValue(field, value) == nil { + values[field.Key] = value + } + } + } + } + return ModSettings{ID: id, Fields: schema.Fields, Values: values}, nil +} + +func (m *Manager) loadState() (State, error) { + data, err := os.ReadFile(m.paths.ModStateFile()) + if os.IsNotExist(err) { + return State{Version: stateVersion, Mods: []InstalledMod{}}, nil + } + if err != nil { + return State{}, err + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + return State{}, fmt.Errorf("parse mod state: %w", err) + } + if state.Version != stateVersion { + return State{}, fmt.Errorf("unsupported mod state version %d", state.Version) + } + if state.Mods == nil { + state.Mods = []InstalledMod{} + } + return state, nil +} + +func (m *Manager) saveState(state State) error { + state.Version = stateVersion + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return writeAtomic(m.paths.ModStateFile(), append(data, '\n'), 0o644) +} + +func (m *Manager) cleanupGenerations(state State) { + entries, err := os.ReadDir(m.paths.ModGenerationsDir()) + if err != nil { + return + } + protected := map[string]bool{state.ActiveGeneration: true, state.PreviousGeneration: true} + var candidates []os.DirEntry + for _, entry := range entries { + if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") && !protected[entry.Name()] { + candidates = append(candidates, entry) + } + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() > candidates[j].Name() }) + if len(candidates) <= 1 { + return + } + for _, entry := range candidates[1:] { + _ = os.RemoveAll(filepath.Join(m.paths.ModGenerationsDir(), entry.Name())) + } +} + +func applyOperation(generation, packageRoot string, op Operation) error { + source, err := safeJoin(packageRoot, op.Source) + if err != nil { + return err + } + if op.Type == "patch" { + if err := runCommand(generation, "git", "apply", "--check", "--whitespace=error-all", source); err != nil { + return fmt.Errorf("patch check failed: %w", err) + } + if err := runCommand(generation, "git", "apply", "--whitespace=error-all", source); err != nil { + return fmt.Errorf("apply patch: %w", err) + } + return nil + } + target, err := safeJoin(generation, op.Target) + if err != nil { + return err + } + if info, err := os.Stat(target); err == nil { + if info.IsDir() { + return fmt.Errorf("overlay target is a directory: %s", op.Target) + } + if !op.Replace { + return fmt.Errorf("overlay target already exists: %s", op.Target) + } + hash, err := fileSHA256(target) + if err != nil { + return err + } + if !strings.EqualFold(hash, op.ExpectedSHA256) { + return fmt.Errorf("overlay target changed: %s", op.Target) + } + } else { + if !os.IsNotExist(err) { + return err + } + if op.Replace { + return fmt.Errorf("overlay target is missing: %s", op.Target) + } + } + return copyFile(source, target) +} + +func checkCompatibility(manifest Manifest, base string) error { + baseVersion := readTrimmed(filepath.Join(base, "version")) + if !matchesVersion(baseVersion, manifest.Compatibility.Ambxst) { + return fmt.Errorf("Ambxst %s does not match %q", baseVersion, manifest.Compatibility.Ambxst) + } + if len(manifest.Compatibility.TestedBaseCommits) > 0 { + revision := gitRevision(base) + matched := false + for _, allowed := range manifest.Compatibility.TestedBaseCommits { + if strings.EqualFold(revision, allowed) { + matched = true + break + } + } + if !matched { + return fmt.Errorf("base revision %s has not been tested", shortRevision(revision)) + } + } + return nil +} + +func topologicalOrder(installed []InstalledMod, manifests map[string]Manifest) ([]string, error) { + order := make(map[string]int) + for _, mod := range installed { + order[mod.ID] = mod.Order + } + visiting := make(map[string]bool) + visited := make(map[string]bool) + var result []string + var visit func(string) error + visit = func(id string) error { + if visited[id] { + return nil + } + if visiting[id] { + return fmt.Errorf("dependency cycle includes %s", id) + } + visiting[id] = true + deps := append([]string{}, manifests[id].Dependencies...) + sort.SliceStable(deps, func(i, j int) bool { return order[deps[i]] < order[deps[j]] }) + for _, dependency := range deps { + if _, ok := manifests[dependency]; !ok { + return fmt.Errorf("mod %s requires unavailable mod %s", id, dependency) + } + if err := visit(dependency); err != nil { + return err + } + } + visiting[id] = false + visited[id] = true + result = append(result, id) + return nil + } + ids := make([]string, 0, len(manifests)) + for id := range manifests { + ids = append(ids, id) + } + sort.SliceStable(ids, func(i, j int) bool { return order[ids[i]] < order[ids[j]] }) + for _, id := range ids { + if err := visit(id); err != nil { + return nil, err + } + } + return result, nil +} + +func exportBase(base, destination string) error { + if gitRevision(base) != "" { + cmd := exec.Command("git", "-C", base, "archive", "--format=tar", "HEAD") + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return err + } + extractErr := extractTar(stdout, destination, true, 0, 0) + if extractErr != nil { + // Stop the producer before waiting. Otherwise git can block forever + // writing to a pipe that is no longer being read. + _ = stdout.Close() + _ = cmd.Process.Kill() + } + waitErr := cmd.Wait() + if extractErr != nil { + return extractErr + } + return errors.Join(extractErr, waitErr) + } + return copyTree(base, destination, func(path string, entry fs.DirEntry) bool { + relative, err := filepath.Rel(base, path) + if err != nil || strings.Contains(relative, string(filepath.Separator)) { + return false + } + name := entry.Name() + return name == ".git" || name == ".ui-craft" || name == "dist" || name == "ambxst" + }) +} + +func extractTar(reader io.Reader, destination string, allowSymlinks bool, maxFiles int, maxBytes int64) error { + tarReader := tar.NewReader(reader) + files := 0 + var totalBytes int64 + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + files++ + totalBytes += header.Size + if maxFiles > 0 && files > maxFiles { + return fmt.Errorf("archive contains too many files") + } + if maxBytes > 0 && totalBytes > maxBytes { + return fmt.Errorf("archive expands beyond the size limit") + } + target, err := safeJoin(destination, header.Name) + if err != nil { + return err + } + switch header.Typeflag { + case tar.TypeXHeader, tar.TypeXGlobalHeader: + // archive/tar applies PAX metadata to the following entry. Git uses + // a global header for commit metadata in its default tar output. + continue + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(header.Mode)&0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&0o755) + if err != nil { + return err + } + _, copyErr := io.Copy(file, tarReader) + closeErr := file.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return err + } + case tar.TypeSymlink: + if !allowSymlinks { + return fmt.Errorf("package symlinks are not allowed: %s", header.Name) + } + cleanLink := filepath.Clean(header.Linkname) + if filepath.IsAbs(cleanLink) || cleanLink == ".." || strings.HasPrefix(cleanLink, ".."+string(filepath.Separator)) { + return fmt.Errorf("base archive contains unsafe symlink %q", header.Name) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := os.Symlink(header.Linkname, target); err != nil { + return err + } + default: + return fmt.Errorf("archive contains unsupported entry %q", header.Name) + } + } +} + +func extractPackageArchive(path, destination string) error { + lower := strings.ToLower(path) + switch { + case strings.HasSuffix(lower, ".zip"): + return extractZipPackage(path, destination) + case strings.HasSuffix(lower, ".tar"): + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + return extractTar(file, destination, false, maxPackageFiles, maxPackageBytes) + case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + reader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("open gzip archive: %w", err) + } + defer reader.Close() + return extractTar(reader, destination, false, maxPackageFiles, maxPackageBytes) + default: + return fmt.Errorf("unsupported package archive") + } +} + +func extractZipPackage(path, destination string) error { + archive, err := zip.OpenReader(path) + if err != nil { + return fmt.Errorf("open zip archive: %w", err) + } + defer archive.Close() + if len(archive.File) > maxPackageFiles { + return fmt.Errorf("archive contains too many files") + } + var total uint64 + for _, entry := range archive.File { + total += entry.UncompressedSize64 + if total > maxPackageBytes { + return fmt.Errorf("archive expands beyond the size limit") + } + if entry.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("package symlinks are not allowed: %s", entry.Name) + } + target, err := safeJoin(destination, entry.Name) + if err != nil { + return err + } + if entry.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + reader, err := entry.Open() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + reader.Close() + return err + } + output, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, entry.Mode().Perm()&0o755) + if err != nil { + reader.Close() + return err + } + copyErr := func() error { + _, err := io.Copy(output, reader) + return errors.Join(err, output.Close(), reader.Close()) + }() + if copyErr != nil { + return copyErr + } + } + return nil +} + +func locatePackageRoot(root string) (string, error) { + if _, err := os.Stat(filepath.Join(root, ManifestFile)); err == nil { + return root, nil + } + entries, err := os.ReadDir(root) + if err != nil { + return "", err + } + var matches []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + candidate := filepath.Join(root, entry.Name()) + if _, err := os.Stat(filepath.Join(candidate, ManifestFile)); err == nil { + matches = append(matches, candidate) + } + } + if len(matches) != 1 { + return "", fmt.Errorf("archive must contain one package manifest") + } + return matches[0], nil +} + +func copyTree(source, destination string, skip func(string, fs.DirEntry) bool) error { + info, err := os.Stat(source) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("source is not a directory") + } + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if skip != nil && path != source && skip(path, entry) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + info, err := entry.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlinks are not allowed: %s", path) + } + if entry.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported file type: %s", path) + } + return copyFile(path, target) + }) +} + +func copyFile(source, destination string) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + info, err := input.Stat() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + output, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return err + } + _, copyErr := io.Copy(output, input) + return errors.Join(copyErr, output.Close()) +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + hash := sha256.New() + if _, err := io.Copy(hash, f); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func writeAtomic(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func runCommand(directory, name string, args ...string) error { + return runCommandTimeout(0, directory, name, args...) +} + +func runCommandTimeout(timeout time.Duration, directory, name string, args ...string) error { + var ( + cmd *exec.Cmd + cancel context.CancelFunc + ) + if timeout > 0 { + ctx, stop := context.WithTimeout(context.Background(), timeout) + cancel = stop + cmd = exec.CommandContext(ctx, name, args...) + } else { + cmd = exec.Command(name, args...) + } + if cancel != nil { + defer cancel() + } + cmd.Dir = directory + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message != "" { + return fmt.Errorf("%s: %w", message, err) + } + return err + } + return nil +} + +func isGitSource(source string) bool { + return strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "ssh://") || strings.HasPrefix(source, "git@") +} + +func gitRevision(directory string) string { + if directory == "" { + return "" + } + cmd := exec.Command("git", "-C", directory, "rev-parse", "HEAD") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func shortRevision(revision string) string { + if len(revision) > 12 { + return revision[:12] + } + if revision == "" { + return "unknown" + } + return revision +} + +func readTrimmed(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func readGenerationMetadata(root string) (generationMetadata, error) { + data, err := os.ReadFile(filepath.Join(root, ".ambxst-generation.json")) + if err != nil { + return generationMetadata{}, fmt.Errorf("read generation metadata: %w", err) + } + var metadata generationMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return generationMetadata{}, fmt.Errorf("parse generation metadata: %w", err) + } + return metadata, nil +} + +func findInstalled(state State, id string) (int, bool) { + for i, mod := range state.Mods { + if mod.ID == id { + return i, true + } + } + return -1, false +} + +func cloneState(state State) State { + cloned := state + cloned.Mods = append([]InstalledMod{}, state.Mods...) + return cloned +} diff --git a/backend/pkg/mods/manager_test.go b/backend/pkg/mods/manager_test.go new file mode 100644 index 000000000..981eb56e4 --- /dev/null +++ b/backend/pkg/mods/manager_test.go @@ -0,0 +1,553 @@ +package mods + +import ( + "archive/tar" + "archive/zip" + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "ambxst/backend/pkg/paths" +) + +func TestManagerInstallEnableDisable(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + writeTestFile(t, filepath.Join(base, "assets", "ambxst", "icon.svg"), "\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + packageRoot := filepath.Join(root, "package") + writeTestFile(t, filepath.Join(packageRoot, "payload", "Feature.qml"), "Item {}\n") + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: "example.feature", + Name: "Example feature", + Version: "1.0.0", + Compatibility: Compatibility{ + API: APIVersion, + Ambxst: ">=1.2.0 <1.3.0", + }, + Operations: []Operation{{ + Type: "overlay", + Source: "payload/Feature.qml", + Target: "modules/example/Feature.qml", + }}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(packageRoot, ManifestFile), string(data)) + + p := &paths.Paths{ + ConfigDir: filepath.Join(root, "config"), + DataDir: filepath.Join(root, "data"), + StateDir: filepath.Join(root, "state"), + CacheDir: filepath.Join(root, "cache"), + } + manager := NewManager(p) + status, err := manager.Install(packageRoot) + if err != nil { + t.Fatal(err) + } + if len(status.Mods) != 1 || status.Mods[0].Enabled { + t.Fatalf("unexpected install status: %#v", status.Mods) + } + + status, err = manager.SetEnabled("example.feature", true) + if err != nil { + t.Fatal(err) + } + if status.ActiveGeneration == "" || !status.Mods[0].Enabled { + t.Fatalf("mod was not activated: %#v", status) + } + activePath := filepath.Join(p.ModGenerationsDir(), status.ActiveGeneration) + if _, err := os.Stat(filepath.Join(activePath, "modules", "example", "Feature.qml")); err != nil { + t.Fatalf("generation does not contain the overlay: %v", err) + } + if _, err := os.Stat(filepath.Join(activePath, "assets", "ambxst", "icon.svg")); err != nil { + t.Fatalf("base asset directory was omitted: %v", err) + } + if _, err := os.Stat(p.ModPendingActivationFile()); err != nil { + t.Fatalf("activation was not marked pending: %v", err) + } + writeTestFile(t, filepath.Join(base, "version"), "1.2.6\n") + status, err = manager.Status() + if err != nil { + t.Fatal(err) + } + if status.GenerationCurrent || status.GenerationError == "" { + t.Fatalf("stale generation was not reported: %#v", status) + } + if manager.HasPendingActivation() { + t.Fatal("stale generation retained a startup health check") + } + + status, err = manager.SetEnabled("example.feature", false) + if err != nil { + t.Fatal(err) + } + if status.ActiveGeneration != "" || status.Mods[0].Enabled { + t.Fatalf("mod was not disabled: %#v", status) + } + if _, err := os.Stat(p.ModPendingActivationFile()); !os.IsNotExist(err) { + t.Fatalf("pending activation still exists: %v", err) + } +} + +func TestManagerRecoversFailedActivation(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + packageRoot := filepath.Join(root, "package") + writeTestFile(t, filepath.Join(packageRoot, "Feature.qml"), "Item {}\n") + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: "example.recovery", + Name: "Recovery fixture", + Version: "1.0.0", + Operations: []Operation{{ + Type: "overlay", Source: "Feature.qml", Target: "Feature.qml", + }}, + } + data, _ := json.Marshal(manifest) + writeTestFile(t, filepath.Join(packageRoot, ManifestFile), string(data)) + + p := &paths.Paths{ + ConfigDir: filepath.Join(root, "config"), + DataDir: filepath.Join(root, "data"), + StateDir: filepath.Join(root, "state"), + CacheDir: filepath.Join(root, "cache"), + } + manager := NewManager(p) + if _, err := manager.Install(packageRoot); err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled("example.recovery", true); err != nil { + t.Fatal(err) + } + recovered, err := manager.RecoverFailedActivation() + if err != nil { + t.Fatal(err) + } + if !recovered { + t.Fatal("pending activation was not recovered") + } + status, err := manager.Status() + if err != nil { + t.Fatal(err) + } + if status.ActiveGeneration != "" || status.Mods[0].Enabled { + t.Fatalf("failed generation remained active: %#v", status) + } +} + +func TestTopologicalOrderUsesDependenciesBeforeUserOrder(t *testing.T) { + installed := []InstalledMod{ + {ID: "child", Order: 0, Enabled: true}, + {ID: "base", Order: 1, Enabled: true}, + } + manifests := map[string]Manifest{ + "child": {ID: "child", Dependencies: []string{"base"}}, + "base": {ID: "base"}, + } + ordered, err := topologicalOrder(installed, manifests) + if err != nil { + t.Fatal(err) + } + if len(ordered) != 2 || ordered[0] != "base" || ordered[1] != "child" { + t.Fatalf("unexpected order: %#v", ordered) + } +} + +func TestConsecutiveBuildsKeepLastKnownGoodGeneration(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + p := testPaths(root) + manager := NewManager(p) + first := writeOverlayPackage(t, root, "first", "example.first", "First.qml") + second := writeOverlayPackage(t, root, "second", "example.second", "Second.qml") + if _, err := manager.Install(first); err != nil { + t.Fatal(err) + } + if _, err := manager.Install(second); err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled("example.first", true); err != nil { + t.Fatal(err) + } + status, err := manager.SetEnabled("example.second", true) + if err != nil { + t.Fatal(err) + } + if status.PreviousGeneration != "" { + t.Fatalf("untested generation became a rollback target: %#v", status) + } + recovered, err := manager.RecoverFailedActivation() + if err != nil || !recovered { + t.Fatalf("recover pending generation: recovered=%v err=%v", recovered, err) + } + status, err = manager.Status() + if err != nil { + t.Fatal(err) + } + if status.ActiveGeneration != "" || status.Mods[0].Enabled || status.Mods[1].Enabled { + t.Fatalf("recovery did not restore the base generation: %#v", status) + } +} + +func TestRollbackDoesNotExposeUntestedGeneration(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + p := testPaths(root) + manager := NewManager(p) + first := writeOverlayPackage(t, root, "first", "example.first", "First.qml") + second := writeOverlayPackage(t, root, "second", "example.second", "Second.qml") + if _, err := manager.Install(first); err != nil { + t.Fatal(err) + } + if _, err := manager.Install(second); err != nil { + t.Fatal(err) + } + firstStatus, err := manager.SetEnabled("example.first", true) + if err != nil { + t.Fatal(err) + } + if err := manager.MarkHealthy(); err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled("example.second", true); err != nil { + t.Fatal(err) + } + status, err := manager.Rollback() + if err != nil { + t.Fatal(err) + } + if status.ActiveGeneration != firstStatus.ActiveGeneration || status.PreviousGeneration != "" { + t.Fatalf("untested generation remained available: %#v", status) + } + if _, err := os.Stat(p.ModPendingActivationFile()); !os.IsNotExist(err) { + t.Fatalf("rollback left a pending activation: %v", err) + } +} + +func TestBaseActivationCanRecoverKnownGoodGeneration(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + p := testPaths(root) + manager := NewManager(p) + packageRoot := writeOverlayPackage(t, root, "source", "example.base-recovery", "Feature.qml") + if _, err := manager.Install(packageRoot); err != nil { + t.Fatal(err) + } + active, err := manager.SetEnabled("example.base-recovery", true) + if err != nil { + t.Fatal(err) + } + if err := manager.MarkHealthy(); err != nil { + t.Fatal(err) + } + disabled, err := manager.SetEnabled("example.base-recovery", false) + if err != nil { + t.Fatal(err) + } + if disabled.ActiveGeneration != "" || !disabled.RestartRequired || !manager.HasPendingActivation() { + t.Fatalf("base activation was not tracked: %#v", disabled) + } + recovered, err := manager.RecoverFailedActivation() + if err != nil || !recovered { + t.Fatalf("recover base activation: recovered=%v err=%v", recovered, err) + } + status, err := manager.Status() + if err != nil { + t.Fatal(err) + } + if status.ActiveGeneration != active.ActiveGeneration || !status.Mods[0].Enabled { + t.Fatalf("known-good generation was not restored: %#v", status) + } +} + +func TestInstallZipArchiveAndPersistSettings(t *testing.T) { + root := t.TempDir() + packageRoot := filepath.Join(root, "source") + writeTestFile(t, filepath.Join(packageRoot, "Feature.qml"), "Item {}\n") + writeTestFile(t, filepath.Join(packageRoot, "settings.json"), `{ + "version": 1, + "fields": [{"key":"limit","label":"Limit","type":"integer","default":3,"minimum":1,"maximum":5}] +}`) + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: "example.archive", + Name: "Archive fixture", + Version: "1.0.0", + Settings: &SettingsRef{Schema: "settings.json"}, + Operations: []Operation{{ + Type: "overlay", Source: "Feature.qml", Target: "Feature.qml", + }}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(packageRoot, ManifestFile), string(data)) + + archivePath := filepath.Join(root, "package.zip") + archiveFile, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + zipWriter := zip.NewWriter(archiveFile) + for _, name := range []string{ManifestFile, "Feature.qml", "settings.json"} { + entry, err := zipWriter.Create("package/" + name) + if err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(filepath.Join(packageRoot, name)) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(contents); err != nil { + t.Fatal(err) + } + } + if err := zipWriter.Close(); err != nil { + t.Fatal(err) + } + if err := archiveFile.Close(); err != nil { + t.Fatal(err) + } + + manager := NewManager(testPaths(root)) + status, err := manager.Install(archivePath) + if err != nil { + t.Fatal(err) + } + if len(status.Mods) != 1 || status.Mods[0].SourceType != "archive" { + t.Fatalf("unexpected archive install status: %#v", status.Mods) + } + settings, err := manager.Settings("example.archive") + if err != nil || settings.Values["limit"] != float64(3) { + t.Fatalf("unexpected default settings: %#v err=%v", settings, err) + } + if _, err := manager.SetSetting("example.archive", "limit", float64(7)); err == nil { + t.Fatal("out-of-range setting was accepted") + } + settings, err = manager.SetSetting("example.archive", "limit", float64(5)) + if err != nil || settings.Values["limit"] != float64(5) { + t.Fatalf("setting was not persisted: %#v err=%v", settings, err) + } +} + +func TestUpdateLocalSourceRestoresPackageOnValidationFailure(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + packageRoot := filepath.Join(root, "source") + writeTestFile(t, filepath.Join(packageRoot, "Feature.qml"), "Item { property int value: 1 }\n") + writeManifest := func(id, version string) { + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: id, + Name: "Local update fixture", + Version: version, + Operations: []Operation{{ + Type: "overlay", Source: "Feature.qml", Target: "Feature.qml", + }}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(packageRoot, ManifestFile), string(data)) + } + writeManifest("example.local-update", "1.0.0") + + manager := NewManager(testPaths(root)) + if _, err := manager.Install(packageRoot); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(packageRoot, "Feature.qml"), "Item { property int value: 2 }\n") + writeManifest("example.local-update", "1.1.0") + status, err := manager.Update("example.local-update") + if err != nil { + t.Fatal(err) + } + if status.Mods[0].Version != "1.1.0" || status.RestartRequired { + t.Fatalf("local update was not installed cleanly: %#v", status) + } + + writeManifest("example.changed-id", "2.0.0") + if _, err := manager.Update("example.local-update"); err == nil { + t.Fatal("package id change was accepted") + } + status, err = manager.Status() + if err != nil { + t.Fatal(err) + } + if status.Mods[0].Version != "1.1.0" { + t.Fatalf("failed update replaced the installed package: %#v", status.Mods[0]) + } +} + +func TestPackageTarRejectsPathTraversal(t *testing.T) { + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + contents := []byte("unsafe") + if err := writer.WriteHeader(&tar.Header{Name: "../outside", Mode: 0o644, Size: int64(len(contents))}); err != nil { + t.Fatal(err) + } + if _, err := writer.Write(contents); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := extractTar(&buffer, t.TempDir(), false, maxPackageFiles, maxPackageBytes); err == nil { + t.Fatal("archive path traversal was accepted") + } +} + +func TestExtractTarAcceptsPAXHeaders(t *testing.T) { + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + if err := writer.WriteHeader(&tar.Header{ + Name: "pax_global_header", + Typeflag: tar.TypeXGlobalHeader, + PAXRecords: map[string]string{ + "comment": "test revision", + }, + }); err != nil { + t.Fatal(err) + } + contents := []byte("Item {}\n") + if err := writer.WriteHeader(&tar.Header{Name: "shell.qml", Mode: 0o644, Size: int64(len(contents))}); err != nil { + t.Fatal(err) + } + if _, err := writer.Write(contents); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + destination := t.TempDir() + if err := extractTar(&buffer, destination, true, 0, 0); err != nil { + t.Fatalf("extract PAX archive: %v", err) + } + if data, err := os.ReadFile(filepath.Join(destination, "shell.qml")); err != nil || string(data) != string(contents) { + t.Fatalf("unexpected extracted file: data=%q err=%v", data, err) + } +} + +func TestStatusKeepsInvalidPackageVisible(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + p := testPaths(root) + manager := NewManager(p) + packageRoot := writeOverlayPackage(t, root, "source", "example.invalid", "Feature.qml") + if _, err := manager.Install(packageRoot); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(p.ModPackagesDir(), "example.invalid", ManifestFile)); err != nil { + t.Fatal(err) + } + status, err := manager.Status() + if err != nil { + t.Fatal(err) + } + if len(status.Mods) != 1 || status.Mods[0].Valid || status.Mods[0].Error == "" { + t.Fatalf("invalid package was hidden: %#v", status.Mods) + } +} + +func TestStatusReportsCompatibilityWithoutActivating(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + packageRoot := filepath.Join(root, "source") + writeTestFile(t, filepath.Join(packageRoot, "Feature.qml"), "Item {}\n") + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: "example.incompatible", + Name: "Incompatible fixture", + Version: "1.0.0", + Compatibility: Compatibility{Ambxst: ">=2.0.0"}, + Operations: []Operation{{ + Type: "overlay", Source: "Feature.qml", Target: "Feature.qml", + }}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(packageRoot, ManifestFile), string(data)) + + manager := NewManager(testPaths(root)) + status, err := manager.Install(packageRoot) + if err != nil { + t.Fatal(err) + } + if len(status.Mods) != 1 || !status.Mods[0].Valid || status.Mods[0].Compatible || status.Mods[0].CompatibilityError == "" { + t.Fatalf("compatibility state was not reported: %#v", status.Mods) + } +} + +func testPaths(root string) *paths.Paths { + return &paths.Paths{ + ConfigDir: filepath.Join(root, "config"), + DataDir: filepath.Join(root, "data"), + StateDir: filepath.Join(root, "state"), + CacheDir: filepath.Join(root, "cache"), + } +} + +func writeOverlayPackage(t *testing.T, root, directory, id, target string) string { + t.Helper() + packageRoot := filepath.Join(root, directory) + writeTestFile(t, filepath.Join(packageRoot, target), "Item {}\n") + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: id, + Name: id, + Version: "1.0.0", + Operations: []Operation{{ + Type: "overlay", Source: target, Target: target, + }}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(packageRoot, ManifestFile), string(data)) + return packageRoot +} diff --git a/backend/pkg/mods/manifest.go b/backend/pkg/mods/manifest.go new file mode 100644 index 000000000..332871f69 --- /dev/null +++ b/backend/pkg/mods/manifest.go @@ -0,0 +1,374 @@ +// Package mods installs and composes Ambxst modifications. +// +// The package has no background worker. Repository access and generation +// builds only happen in response to an explicit method call. +package mods + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +const ( + ManifestFile = "ambxst.mod.json" + APIVersion = 1 +) + +var idPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) +var settingKeyPattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`) +var sha256Pattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) + +type Manifest struct { + Schema string `json:"$schema,omitempty"` + ManifestVersion int `json:"manifestVersion"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + License string `json:"license,omitempty"` + Author string `json:"author,omitempty"` + Compatibility Compatibility `json:"compatibility,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` + Commands []string `json:"commands,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Settings *SettingsRef `json:"settings,omitempty"` + Operations []Operation `json:"operations"` +} + +type Compatibility struct { + API int `json:"api,omitempty"` + Ambxst string `json:"ambxst,omitempty"` + TestedBaseCommits []string `json:"testedBaseCommits,omitempty"` +} + +type Operation struct { + Type string `json:"type"` + Source string `json:"source"` + Target string `json:"target,omitempty"` + Replace bool `json:"replace,omitempty"` + ExpectedSHA256 string `json:"expectedSha256,omitempty"` +} + +type SettingsRef struct { + Schema string `json:"schema"` +} + +type SettingsSchema struct { + Schema string `json:"$schema,omitempty"` + Version int `json:"version"` + Fields []SettingField `json:"fields"` +} + +type SettingField struct { + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + Default any `json:"default"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Options []SettingOption `json:"options,omitempty"` + RestartRequired bool `json:"restartRequired,omitempty"` +} + +type SettingOption struct { + Label string `json:"label"` + Value string `json:"value"` +} + +func LoadManifest(root string) (Manifest, error) { + data, err := os.ReadFile(filepath.Join(root, ManifestFile)) + if err != nil { + return Manifest{}, fmt.Errorf("read manifest: %w", err) + } + var manifest Manifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return Manifest{}, fmt.Errorf("parse manifest: %w", err) + } + if err := manifest.Validate(root); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func (m Manifest) Validate(root string) error { + if m.ManifestVersion != APIVersion { + return fmt.Errorf("unsupported manifest version %d", m.ManifestVersion) + } + if !idPattern.MatchString(m.ID) { + return fmt.Errorf("invalid mod id %q", m.ID) + } + if strings.TrimSpace(m.Name) == "" { + return fmt.Errorf("mod name is required") + } + if _, ok := parseVersion(m.Version); !ok { + return fmt.Errorf("invalid mod version %q", m.Version) + } + if m.Compatibility.API != 0 && m.Compatibility.API != APIVersion { + return fmt.Errorf("mod requires API %d", m.Compatibility.API) + } + references := make(map[string]bool) + for _, id := range append(append([]string{}, m.Dependencies...), m.Conflicts...) { + if !idPattern.MatchString(id) { + return fmt.Errorf("invalid referenced mod id %q", id) + } + if id == m.ID { + return fmt.Errorf("mod cannot reference itself as a dependency or conflict") + } + if references[id] { + return fmt.Errorf("duplicate dependency or conflict %q", id) + } + references[id] = true + } + if len(m.Operations) == 0 { + return fmt.Errorf("mod has no operations") + } + for i, op := range m.Operations { + if op.Type != "overlay" && op.Type != "patch" { + return fmt.Errorf("operation %d has unsupported type %q", i+1, op.Type) + } + source, err := safeJoin(root, op.Source) + if err != nil { + return fmt.Errorf("operation %d source: %w", i+1, err) + } + info, err := os.Stat(source) + if err != nil { + return fmt.Errorf("operation %d source: %w", i+1, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("operation %d source is not a regular file", i+1) + } + if op.Type == "overlay" { + if op.Target == "" { + return fmt.Errorf("operation %d target is required", i+1) + } + if _, err := safeJoin(root, op.Target); err != nil { + return fmt.Errorf("operation %d target: %w", i+1, err) + } + if op.Replace && op.ExpectedSHA256 == "" { + return fmt.Errorf("operation %d requires expectedSha256 when replace is true", i+1) + } + if op.ExpectedSHA256 != "" && !sha256Pattern.MatchString(op.ExpectedSHA256) { + return fmt.Errorf("operation %d has an invalid expectedSha256", i+1) + } + if !op.Replace && op.ExpectedSHA256 != "" { + return fmt.Errorf("operation %d has expectedSha256 without replace", i+1) + } + } else if op.Target != "" || op.Replace || op.ExpectedSHA256 != "" { + return fmt.Errorf("operation %d has overlay fields on a patch", i+1) + } + } + if m.Settings != nil { + schemaPath, err := safeJoin(root, m.Settings.Schema) + if err != nil { + return fmt.Errorf("settings schema: %w", err) + } + if _, err := LoadSettingsSchema(schemaPath); err != nil { + return err + } + } + return validatePackageTree(root) +} + +func LoadSettingsSchema(path string) (SettingsSchema, error) { + data, err := os.ReadFile(path) + if err != nil { + return SettingsSchema{}, fmt.Errorf("read settings schema: %w", err) + } + var schema SettingsSchema + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&schema); err != nil { + return SettingsSchema{}, fmt.Errorf("parse settings schema: %w", err) + } + if schema.Version != 1 { + return SettingsSchema{}, fmt.Errorf("unsupported settings schema version %d", schema.Version) + } + seen := make(map[string]bool) + for _, field := range schema.Fields { + if !settingKeyPattern.MatchString(field.Key) { + return SettingsSchema{}, fmt.Errorf("invalid setting key %q", field.Key) + } + if seen[field.Key] { + return SettingsSchema{}, fmt.Errorf("duplicate setting key %q", field.Key) + } + seen[field.Key] = true + if strings.TrimSpace(field.Label) == "" { + return SettingsSchema{}, fmt.Errorf("setting %s has no label", field.Key) + } + if field.Minimum != nil && field.Maximum != nil && *field.Minimum > *field.Maximum { + return SettingsSchema{}, fmt.Errorf("setting %s minimum exceeds maximum", field.Key) + } + if field.Type == "enum" { + if field.Minimum != nil || field.Maximum != nil || len(field.Options) == 0 { + return SettingsSchema{}, fmt.Errorf("setting %s has invalid enum constraints", field.Key) + } + options := make(map[string]bool) + for _, option := range field.Options { + if strings.TrimSpace(option.Label) == "" || option.Value == "" { + return SettingsSchema{}, fmt.Errorf("setting %s has an invalid option", field.Key) + } + if options[option.Value] { + return SettingsSchema{}, fmt.Errorf("setting %s has duplicate option %q", field.Key, option.Value) + } + options[option.Value] = true + } + } else if len(field.Options) != 0 { + return SettingsSchema{}, fmt.Errorf("setting %s has options for a non-enum type", field.Key) + } + if (field.Type == "boolean" || field.Type == "string") && (field.Minimum != nil || field.Maximum != nil) { + return SettingsSchema{}, fmt.Errorf("setting %s has numeric constraints", field.Key) + } + if err := validateSettingValue(field, field.Default); err != nil { + return SettingsSchema{}, fmt.Errorf("setting %s default: %w", field.Key, err) + } + } + return schema, nil +} + +func validateSettingValue(field SettingField, value any) error { + switch field.Type { + case "boolean": + if _, ok := value.(bool); !ok { + return fmt.Errorf("expected a boolean") + } + case "string": + if _, ok := value.(string); !ok { + return fmt.Errorf("expected a string") + } + case "integer", "number": + number, ok := value.(float64) + if !ok { + return fmt.Errorf("expected a number") + } + if field.Type == "integer" && number != float64(int64(number)) { + return fmt.Errorf("expected an integer") + } + if field.Minimum != nil && number < *field.Minimum { + return fmt.Errorf("must be at least %v", *field.Minimum) + } + if field.Maximum != nil && number > *field.Maximum { + return fmt.Errorf("must be at most %v", *field.Maximum) + } + case "enum": + text, ok := value.(string) + if !ok { + return fmt.Errorf("expected an option value") + } + for _, option := range field.Options { + if option.Value == text { + return nil + } + } + return fmt.Errorf("unknown option %q", text) + default: + return fmt.Errorf("unsupported type %q", field.Type) + } + return nil +} + +func (m Manifest) AffectedFiles(root string) ([]string, error) { + seen := make(map[string]struct{}) + for _, op := range m.Operations { + if op.Type == "overlay" { + seen[filepath.ToSlash(filepath.Clean(op.Target))] = struct{}{} + continue + } + patchPath, err := safeJoin(root, op.Source) + if err != nil { + return nil, err + } + files, err := patchTargets(patchPath) + if err != nil { + return nil, err + } + for _, file := range files { + seen[file] = struct{}{} + } + } + files := make([]string, 0, len(seen)) + for file := range seen { + files = append(files, file) + } + sort.Strings(files) + return files, nil +} + +func patchTargets(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read patch: %w", err) + } + defer f.Close() + + seen := make(map[string]struct{}) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "+++ ") && !strings.HasPrefix(line, "--- ") { + continue + } + name := strings.Fields(strings.TrimSpace(line[4:])) + if len(name) == 0 || name[0] == "/dev/null" { + continue + } + target := strings.TrimPrefix(strings.TrimPrefix(name[0], "a/"), "b/") + if !isSafeRelative(target) { + return nil, fmt.Errorf("patch contains unsafe target %q", target) + } + seen[filepath.ToSlash(filepath.Clean(target))] = struct{}{} + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan patch: %w", err) + } + out := make([]string, 0, len(seen)) + for target := range seen { + out = append(out, target) + } + sort.Strings(out) + return out, nil +} + +func safeJoin(root, relative string) (string, error) { + if !isSafeRelative(relative) { + return "", fmt.Errorf("unsafe relative path %q", relative) + } + return filepath.Join(root, filepath.Clean(relative)), nil +} + +func isSafeRelative(path string) bool { + if path == "" || filepath.IsAbs(path) { + return false + } + clean := filepath.Clean(path) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) +} + +func validatePackageTree(root string) error { + return filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if path != root && entry.IsDir() && entry.Name() == ".git" { + return filepath.SkipDir + } + info, err := entry.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("package symlinks are not allowed: %s", path) + } + return nil + }) +} diff --git a/backend/pkg/mods/manifest_test.go b/backend/pkg/mods/manifest_test.go new file mode 100644 index 000000000..5c8342db0 --- /dev/null +++ b/backend/pkg/mods/manifest_test.go @@ -0,0 +1,66 @@ +package mods + +import ( + "os" + "path/filepath" + "testing" +) + +func TestManifestRejectsUnsafeOverlayTarget(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "payload.qml"), "Item {}\n") + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: "example.mod", + Name: "Example", + Version: "1.0.0", + Operations: []Operation{{ + Type: "overlay", + Source: "payload.qml", + Target: "../shell.qml", + }}, + } + if err := manifest.Validate(root); err == nil { + t.Fatal("expected unsafe target to fail validation") + } +} + +func TestPatchTargets(t *testing.T) { + path := filepath.Join(t.TempDir(), "change.patch") + writeTestFile(t, path, "--- a/one.qml\n+++ b/one.qml\n@@ -1 +1 @@\n-old\n+new\n--- /dev/null\n+++ b/two.qml\n@@ -0,0 +1 @@\n+new\n") + targets, err := patchTargets(path) + if err != nil { + t.Fatal(err) + } + if len(targets) != 2 || targets[0] != "one.qml" || targets[1] != "two.qml" { + t.Fatalf("unexpected patch targets: %#v", targets) + } +} + +func TestCompactPlayerVolumeScrollExample(t *testing.T) { + root := filepath.Join("..", "..", "..", "examples", "mods", "compact-player-volume-scroll") + manifest, err := LoadManifest(root) + if err != nil { + t.Fatal(err) + } + if manifest.ID != "community.compact-player-volume-scroll" { + t.Fatalf("unexpected example id %q", manifest.ID) + } + files, err := manifest.AffectedFiles(root) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 || files[0] != "modules/widgets/defaultview/CompactPlayer.qml" { + t.Fatalf("unexpected example targets: %#v", files) + } +} + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/backend/pkg/mods/service.go b/backend/pkg/mods/service.go new file mode 100644 index 000000000..68b0efa62 --- /dev/null +++ b/backend/pkg/mods/service.go @@ -0,0 +1,143 @@ +package mods + +import ( + "encoding/json" + "fmt" + + "ambxst/backend/pkg/ipc" +) + +type Service struct { + manager *Manager +} + +func NewService(manager *Manager) *Service { + return &Service{manager: manager} +} + +func (s *Service) Register(server *ipc.Server) { + server.Register(&ipc.Service{ + Name: "mods", + Methods: map[string]ipc.HandlerFunc{ + "status": s.status, + "install": s.install, + "setEnabled": s.setEnabled, + "remove": s.remove, + "move": s.move, + "update": s.update, + "rebuild": s.rebuild, + "rollback": s.rollback, + "settings": s.settings, + "setSetting": s.setSetting, + }, + }) +} + +func (s *Service) status(_ json.RawMessage) (any, error) { + return s.manager.Status() +} + +type sourceParams struct { + Source string `json:"source"` +} + +func (s *Service) install(raw json.RawMessage) (any, error) { + var params sourceParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("invalid install request: %w", err) + } + return s.manager.Install(params.Source) +} + +type enabledParams struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` +} + +func (s *Service) setEnabled(raw json.RawMessage) (any, error) { + var params enabledParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("invalid enable request: %w", err) + } + return s.manager.SetEnabled(params.ID, params.Enabled) +} + +type idParams struct { + ID string `json:"id"` +} + +func decodeID(raw json.RawMessage) (string, error) { + var params idParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return "", err + } + if !idPattern.MatchString(params.ID) { + return "", fmt.Errorf("invalid mod id %q", params.ID) + } + return params.ID, nil +} + +func (s *Service) remove(raw json.RawMessage) (any, error) { + id, err := decodeID(raw) + if err != nil { + return nil, fmt.Errorf("invalid remove request: %w", err) + } + return s.manager.Remove(id) +} + +func (s *Service) update(raw json.RawMessage) (any, error) { + id, err := decodeID(raw) + if err != nil { + return nil, fmt.Errorf("invalid update request: %w", err) + } + return s.manager.Update(id) +} + +type moveParams struct { + ID string `json:"id"` + Direction int `json:"direction"` +} + +func (s *Service) move(raw json.RawMessage) (any, error) { + var params moveParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("invalid move request: %w", err) + } + if !idPattern.MatchString(params.ID) { + return nil, fmt.Errorf("invalid mod id %q", params.ID) + } + return s.manager.Move(params.ID, params.Direction) +} + +func (s *Service) rebuild(_ json.RawMessage) (any, error) { + return s.manager.Rebuild() +} + +func (s *Service) rollback(_ json.RawMessage) (any, error) { + return s.manager.Rollback() +} + +func (s *Service) settings(raw json.RawMessage) (any, error) { + id, err := decodeID(raw) + if err != nil { + return nil, fmt.Errorf("invalid settings request: %w", err) + } + return s.manager.Settings(id) +} + +type settingParams struct { + ID string `json:"id"` + Key string `json:"key"` + Value any `json:"value"` +} + +func (s *Service) setSetting(raw json.RawMessage) (any, error) { + var params settingParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("invalid setting request: %w", err) + } + if !idPattern.MatchString(params.ID) || !settingKeyPattern.MatchString(params.Key) { + return nil, fmt.Errorf("invalid mod id or setting key") + } + return s.manager.SetSetting(params.ID, params.Key, params.Value) +} diff --git a/backend/pkg/mods/version.go b/backend/pkg/mods/version.go new file mode 100644 index 000000000..606f9c011 --- /dev/null +++ b/backend/pkg/mods/version.go @@ -0,0 +1,79 @@ +package mods + +import ( + "strconv" + "strings" +) + +type version [3]int + +func parseVersion(raw string) (version, bool) { + base := strings.SplitN(strings.TrimSpace(raw), "-", 2)[0] + parts := strings.Split(base, ".") + if len(parts) != 3 { + return version{}, false + } + var out version + for i, part := range parts { + value, err := strconv.Atoi(part) + if err != nil || value < 0 { + return version{}, false + } + out[i] = value + } + return out, true +} + +func compareVersion(a, b version) int { + for i := range a { + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + return 0 +} + +func matchesVersion(rawVersion, constraint string) bool { + if strings.TrimSpace(constraint) == "" { + return true + } + current, ok := parseVersion(rawVersion) + if !ok { + return false + } + for _, term := range strings.Fields(constraint) { + op := "=" + value := term + for _, candidate := range []string{">=", "<=", ">", "<", "="} { + if strings.HasPrefix(term, candidate) { + op = candidate + value = strings.TrimPrefix(term, candidate) + break + } + } + required, ok := parseVersion(value) + if !ok { + return false + } + cmp := compareVersion(current, required) + switch op { + case ">=": + ok = cmp >= 0 + case "<=": + ok = cmp <= 0 + case ">": + ok = cmp > 0 + case "<": + ok = cmp < 0 + default: + ok = cmp == 0 + } + if !ok { + return false + } + } + return true +} diff --git a/backend/pkg/mods/version_test.go b/backend/pkg/mods/version_test.go new file mode 100644 index 000000000..623a94f3b --- /dev/null +++ b/backend/pkg/mods/version_test.go @@ -0,0 +1,22 @@ +package mods + +import "testing" + +func TestMatchesVersion(t *testing.T) { + tests := []struct { + version string + constraint string + want bool + }{ + {"1.2.5", ">=1.2.0 <1.3.0", true}, + {"1.3.0", ">=1.2.0 <1.3.0", false}, + {"1.2.5", "=1.2.5", true}, + {"1.2", ">=1.0.0", false}, + {"1.2.5-dev", ">1.2.4", true}, + } + for _, test := range tests { + if got := matchesVersion(test.version, test.constraint); got != test.want { + t.Errorf("matchesVersion(%q, %q) = %v, want %v", test.version, test.constraint, got, test.want) + } + } +} diff --git a/backend/pkg/paths/paths.go b/backend/pkg/paths/paths.go index 6c52e64f1..0e32879b9 100644 --- a/backend/pkg/paths/paths.go +++ b/backend/pkg/paths/paths.go @@ -97,6 +97,30 @@ func (p *Paths) ShellPathFile() string { return filepath.Join(p.DataDir, "shell_repo") } +func (p *Paths) ModsDir() string { + return filepath.Join(p.DataDir, "mods") +} + +func (p *Paths) ModPackagesDir() string { + return filepath.Join(p.ModsDir(), "packages") +} + +func (p *Paths) ModGenerationsDir() string { + return filepath.Join(p.ModsDir(), "generations") +} + +func (p *Paths) ModPendingActivationFile() string { + return filepath.Join(p.ModsDir(), "pending-activation.json") +} + +func (p *Paths) ModStateFile() string { + return filepath.Join(p.ConfigDir, "mods.json") +} + +func (p *Paths) ModSettingsDir() string { + return filepath.Join(p.ConfigDir, "mods") +} + // ShellSourceDir returns the absolute path to the Ambxst shell source // tree (see FindShellSource for the lookup rules). Callers that already // hold a *Paths simply ignore it; the receiver is unused. @@ -146,4 +170,4 @@ func (p *Paths) PicturesDir() string { // VideosDir mirrors xdg-user-dir VIDEOS. func (p *Paths) VideosDir() string { return userDir("VIDEOS", "Videos") -} \ No newline at end of file +} diff --git a/backend/pkg/paths/shell_source.go b/backend/pkg/paths/shell_source.go index 8c06650c6..fd6cda490 100644 --- a/backend/pkg/paths/shell_source.go +++ b/backend/pkg/paths/shell_source.go @@ -1,16 +1,30 @@ package paths import ( + "encoding/json" + "fmt" "os" + "os/exec" "path/filepath" "strings" ) -// FindShellSource returns the absolute path to the Ambxst shell source -// tree, used to locate bundled assets (presets, wallpapers, etc.) the -// installed/launched binary otherwise doesn't ship alongside itself. +type modGenerationMetadata struct { + ID string `json:"id"` + BasePath string `json:"basePath"` + BaseVersion string `json:"baseVersion"` + BaseRevision string `json:"baseRevision"` +} + +type modStateSource struct { + ActiveGeneration string `json:"activeGeneration"` +} + +// FindShellSource returns the active Ambxst shell tree. A valid mod generation +// takes precedence unless AMBXST_MODS_DISABLED=1. Without one, the base source +// lookup below is used. // -// Lookup order — first hit wins: +// Base source lookup order — first hit wins: // 1. $AMBXST_SHELL — explicit override for development. // 2. The directory containing the running binary, if shell.qml is a // sibling/ancestor (handles `go build` outputs in ./ and ./backend). @@ -22,6 +36,79 @@ import ( // Returns "" only when nothing matched (callers must handle the empty // case for asset lookups). func FindShellSource() string { + base := FindBaseShellSource() + p := New() + if os.Getenv("AMBXST_MODS_DISABLED") != "1" { + if dir := p.activeModGeneration(); ValidateModGeneration(dir, base) == nil { + return dir + } + } + return base +} + +func (p *Paths) activeModGeneration() string { + data, err := os.ReadFile(p.ModStateFile()) + if err != nil { + return "" + } + var state modStateSource + if json.Unmarshal(data, &state) != nil { + return "" + } + id := state.ActiveGeneration + if id == "" || id == "." || filepath.Base(id) != id { + return "" + } + return filepath.Join(p.ModGenerationsDir(), id) +} + +// ValidateModGeneration checks that a generated shell was composed from the +// current base source. A stale generation is never launched after an Ambxst +// update. +func ValidateModGeneration(generation, base string) error { + if generation == "" || base == "" { + return fmt.Errorf("generation or base source is unavailable") + } + if !fileExists(filepath.Join(generation, "shell.qml")) { + return fmt.Errorf("generation has no shell.qml") + } + data, err := os.ReadFile(filepath.Join(generation, ".ambxst-generation.json")) + if err != nil { + return fmt.Errorf("read generation metadata: %w", err) + } + var metadata modGenerationMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return fmt.Errorf("parse generation metadata: %w", err) + } + if metadata.ID == "" || metadata.ID != filepath.Base(generation) { + return fmt.Errorf("generation metadata does not match its directory") + } + baseVersion := readSourceValue(filepath.Join(base, "version")) + if metadata.BaseVersion == "" || baseVersion == "" { + return fmt.Errorf("generation or base version is unavailable") + } + if metadata.BaseVersion != baseVersion { + return fmt.Errorf("generation uses Ambxst %s; base is %s", metadata.BaseVersion, baseVersion) + } + if metadata.BaseRevision != "" { + cmd := exec.Command("git", "-C", base, "rev-parse", "HEAD") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("read base revision: %w", err) + } + if revision := strings.TrimSpace(string(output)); revision != metadata.BaseRevision { + return fmt.Errorf("generation was built from a different Ambxst revision") + } + } else if filepath.Clean(metadata.BasePath) != filepath.Clean(base) { + return fmt.Errorf("generation was built from a different Ambxst source") + } + return nil +} + +// FindBaseShellSource returns the unmodified Ambxst source tree. Mod +// composition uses this function so an active generation is never layered on +// top of another generation. +func FindBaseShellSource() string { if v := os.Getenv("AMBXST_SHELL"); v != "" { if _, err := os.Stat(filepath.Join(v, "shell.qml")); err == nil { return v @@ -68,3 +155,11 @@ func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } + +func readSourceValue(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} diff --git a/backend/pkg/paths/shell_source_test.go b/backend/pkg/paths/shell_source_test.go new file mode 100644 index 000000000..6c9c986b8 --- /dev/null +++ b/backend/pkg/paths/shell_source_test.go @@ -0,0 +1,66 @@ +package paths + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestFindShellSourcePrefersActiveGeneration(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + t.Setenv("HOME", root) + t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data")) + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "") + p := New() + active := filepath.Join(p.ModGenerationsDir(), "generation") + for _, directory := range []string{base, active} { + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "shell.qml"), []byte("ShellRoot {}\n"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(base, "version"), []byte("1.2.5\n"), 0o644); err != nil { + t.Fatal(err) + } + metadata, err := json.Marshal(modGenerationMetadata{ + ID: filepath.Base(active), + BasePath: base, + BaseVersion: "1.2.5", + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(active, ".ambxst-generation.json"), metadata, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(p.ModStateFile()), 0o755); err != nil { + t.Fatal(err) + } + state, err := json.Marshal(modStateSource{ActiveGeneration: filepath.Base(active)}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p.ModStateFile(), state, 0o644); err != nil { + t.Fatal(err) + } + if got := FindShellSource(); got != active { + t.Fatalf("FindShellSource = %q, want %q", got, active) + } + + if err := os.WriteFile(filepath.Join(base, "version"), []byte("1.3.0\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := FindShellSource(); got != base { + t.Fatalf("stale generation source = %q, want %q", got, base) + } + + t.Setenv("AMBXST_MODS_DISABLED", "1") + if got := FindShellSource(); got != base { + t.Fatalf("disabled mod source = %q, want %q", got, base) + } +} diff --git a/docs/mods/README.md b/docs/mods/README.md new file mode 100644 index 000000000..570e59c98 --- /dev/null +++ b/docs/mods/README.md @@ -0,0 +1,179 @@ +# Ambxst mod packages + +Ambxst mods are declarative source transformations. A package contains an +`ambxst.mod.json` manifest, payload files, and optional unified patches. The +manager composes enabled packages onto a clean Ambxst source tree and commits +the active generation in one atomic state-file update after every operation +succeeds. + +The manager does not poll repositories or run a resident worker. It reads local +state when requested, accesses the network only for an explicit install or +update, and builds a generation only when the enabled set or load order changes. + +## Package layout + +```text +example-mod/ +├── ambxst.mod.json +├── settings.json +├── patches/ +│ └── feature.patch +└── payload/ + └── Feature.qml +``` + +Packages can be installed from a local directory, a `.zip`, `.tar`, `.tar.gz`, +or `.tgz` archive, or a Git URL. New packages are always disabled. Archive +extraction rejects links, path traversal, more than 10,000 entries, and expanded +content over 128 MiB. + +Update pulls a Git source with fast-forward only. Local-directory and archive +packages are reloaded from their original path. The old package is restored if +the replacement fails validation or generation composition. + +## Manifest + +```json +{ + "$schema": "https://raw.githubusercontent.com/Axenide/Ambxst/dev/docs/mods/manifest.schema.json", + "manifestVersion": 1, + "id": "org.example.feature", + "name": "Example feature", + "version": "1.0.0", + "description": "Adds one focused shell feature.", + "license": "MIT", + "author": "Example contributor", + "compatibility": { + "api": 1, + "ambxst": ">=1.2.0 <1.3.0", + "testedBaseCommits": ["full-git-commit"] + }, + "dependencies": [], + "conflicts": [], + "commands": [], + "permissions": ["Reads active media state"], + "settings": { + "schema": "settings.json" + }, + "operations": [ + { + "type": "overlay", + "source": "payload/Feature.qml", + "target": "modules/example/Feature.qml" + }, + { + "type": "patch", + "source": "patches/feature.patch" + } + ] +} +``` + +An overlay can add a file. Replacing an existing file also requires +`"replace": true` and the current target's `expectedSha256`. This makes a base +change fail visibly instead of silently overwriting newer code. Patches are +checked with `git apply --check --whitespace=error-all` before they are applied. + +Two enabled mods cannot currently modify the same target file. This conservative +rule keeps load order explicit and prevents a successful build whose behavior +depends on patch coincidence. Dependencies are applied before dependents; user +load order resolves the remaining order. + +`commands` declares executables that must be available before composition. +`permissions` is review metadata shown to the user. It is not a sandbox or an +authorization mechanism: installed QML runs with the user's permissions. + +## Settings schema + +Mod settings use data, not package-provided settings UI. This keeps the Settings +surface native and prevents arbitrary controls from running before a mod is +enabled. + +```json +{ + "$schema": "https://raw.githubusercontent.com/Axenide/Ambxst/dev/docs/mods/settings.schema.json", + "version": 1, + "fields": [ + { + "key": "showLabel", + "label": "Show label", + "description": "Display a label next to the indicator.", + "type": "boolean", + "default": true, + "restartRequired": false + }, + { + "key": "limit", + "label": "Item limit", + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 20, + "restartRequired": true + } + ] +} +``` + +Supported field types are `boolean`, `string`, `integer`, `number`, and `enum`. +Enum fields require an `options` array with `label` and `value` strings. + +Enabled QML can read its values without polling and react to changes through the +native service: + +```qml +import qs.modules.services + +Component.onCompleted: ModsService.getSettings("org.example.feature", (settings, error) => { + if (!error) + applySettings(settings.values); +}) + +Connections { + target: ModsService + function onSettingChanged(modId, key, value) { + if (modId === "org.example.feature") + applySetting(key, value); + } +} +``` + +## Activation and recovery + +Each change creates an immutable generation under +`$XDG_DATA_HOME/ambxst/mods/generations`. The active generation changes +atomically in `$XDG_CONFIG_HOME/ambxst/mods.json`. The existing shell keeps +running until the user restarts Ambxst. + +On the next start, the daemon gives the new generation an eight-second health +window. If Quickshell exits during that window, Ambxst restores the last +known-good generation and starts it immediately. After the window closes, no mod +health timer or worker remains active. `AMBXST_MODS_DISABLED=1 ambxst` bypasses +the active generation for manual recovery. + +Ambxst also compares the generation metadata with the current base version and +Git revision before launch. After a base update, a stale generation is skipped +and the clean base starts. Settings then reports that a rebuild is required. + +## Commands + +```bash +ambxst mods list +ambxst mods install ./example-mod +ambxst mods enable org.example.feature +ambxst mods move org.example.feature up +ambxst mods update org.example.feature +ambxst mods rebuild +ambxst mods rollback +ambxst mods disable org.example.feature +ambxst mods remove org.example.feature +``` + +The same operations are available in **Settings → Mods**. + +## Example + +`examples/mods/compact-player-volume-scroll` packages the compact-player volume +scroll change as a patch-only mod. It is intentionally small: the same patch can +be reviewed for upstream inclusion or installed through the manager without +editing the base checkout. diff --git a/docs/mods/manifest.schema.json b/docs/mods/manifest.schema.json new file mode 100644 index 000000000..5a1e1f593 --- /dev/null +++ b/docs/mods/manifest.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Axenide/Ambxst/dev/docs/mods/manifest.schema.json", + "title": "Ambxst mod manifest", + "type": "object", + "additionalProperties": false, + "required": ["manifestVersion", "id", "name", "version", "operations"], + "properties": { + "$schema": { "type": "string" }, + "manifestVersion": { "const": 1 }, + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" + }, + "name": { "type": "string", "minLength": 1 }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "description": { "type": "string" }, + "license": { "type": "string" }, + "author": { "type": "string" }, + "compatibility": { + "type": "object", + "additionalProperties": false, + "properties": { + "api": { "const": 1 }, + "ambxst": { "type": "string" }, + "testedBaseCommits": { + "type": "array", + "items": { "type": "string", "minLength": 7 }, + "uniqueItems": true + } + } + }, + "dependencies": { "$ref": "#/$defs/modIds" }, + "conflicts": { "$ref": "#/$defs/modIds" }, + "commands": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "permissions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "settings": { + "type": "object", + "additionalProperties": false, + "required": ["schema"], + "properties": { + "schema": { "$ref": "#/$defs/relativePath" } + } + }, + "operations": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { "$ref": "#/$defs/overlay" }, + { "$ref": "#/$defs/patch" } + ] + } + } + }, + "$defs": { + "relativePath": { + "type": "string", + "minLength": 1, + "not": { "pattern": "(^/|(^|/)\\.\\.(/|$))" } + }, + "modIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" + }, + "uniqueItems": true + }, + "overlay": { + "type": "object", + "additionalProperties": false, + "required": ["type", "source", "target"], + "properties": { + "type": { "const": "overlay" }, + "source": { "$ref": "#/$defs/relativePath" }, + "target": { "$ref": "#/$defs/relativePath" }, + "replace": { "type": "boolean" }, + "expectedSha256": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + } + }, + "allOf": [ + { + "if": { + "properties": { "replace": { "const": true } }, + "required": ["replace"] + }, + "then": { "required": ["expectedSha256"] } + } + ] + }, + "patch": { + "type": "object", + "additionalProperties": false, + "required": ["type", "source"], + "properties": { + "type": { "const": "patch" }, + "source": { "$ref": "#/$defs/relativePath" } + } + } + } +} diff --git a/docs/mods/settings.schema.json b/docs/mods/settings.schema.json new file mode 100644 index 000000000..8aa176e08 --- /dev/null +++ b/docs/mods/settings.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Axenide/Ambxst/dev/docs/mods/settings.schema.json", + "title": "Ambxst mod settings schema", + "type": "object", + "additionalProperties": false, + "required": ["version", "fields"], + "properties": { + "version": { "const": 1 }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/field" } + } + }, + "$defs": { + "baseField": { + "type": "object", + "required": ["key", "label", "type", "default"], + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "label": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "restartRequired": { "type": "boolean" } + } + }, + "field": { + "oneOf": [ + { + "allOf": [ + { "$ref": "#/$defs/baseField" }, + { + "additionalProperties": false, + "properties": { + "key": true, + "label": true, + "description": true, + "restartRequired": true, + "type": { "const": "boolean" }, + "default": { "type": "boolean" } + } + } + ] + }, + { + "allOf": [ + { "$ref": "#/$defs/baseField" }, + { + "additionalProperties": false, + "properties": { + "key": true, + "label": true, + "description": true, + "restartRequired": true, + "type": { "const": "string" }, + "default": { "type": "string" } + } + } + ] + }, + { + "allOf": [ + { "$ref": "#/$defs/baseField" }, + { + "additionalProperties": false, + "properties": { + "key": true, + "label": true, + "description": true, + "restartRequired": true, + "type": { "enum": ["integer", "number"] }, + "default": { "type": "number" }, + "minimum": { "type": "number" }, + "maximum": { "type": "number" } + } + } + ] + }, + { + "allOf": [ + { "$ref": "#/$defs/baseField" }, + { + "additionalProperties": false, + "required": ["options"], + "properties": { + "key": true, + "label": true, + "description": true, + "restartRequired": true, + "type": { "const": "enum" }, + "default": { "type": "string" }, + "options": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["label", "value"], + "properties": { + "label": { "type": "string", "minLength": 1 }, + "value": { "type": "string", "minLength": 1 } + } + } + } + } + } + ] + } + ] + } + } +} diff --git a/examples/mods/compact-player-volume-scroll/ambxst.mod.json b/examples/mods/compact-player-volume-scroll/ambxst.mod.json new file mode 100644 index 000000000..bc48553ac --- /dev/null +++ b/examples/mods/compact-player-volume-scroll/ambxst.mod.json @@ -0,0 +1,24 @@ +{ + "$schema": "../../../docs/mods/manifest.schema.json", + "manifestVersion": 1, + "id": "community.compact-player-volume-scroll", + "name": "Compact player volume scroll", + "version": "1.0.0", + "description": "Changes the default sink volume when the pointer wheel is used over the compact player.", + "compatibility": { + "api": 1, + "ambxst": ">=1.2.5 <1.3.0", + "testedBaseCommits": [ + "8f00dbaa831824e24af3a1644d32ae465acc60ea" + ] + }, + "permissions": [ + "Changes the default audio sink volume from pointer wheel input" + ], + "operations": [ + { + "type": "patch", + "source": "patches/compact-player-volume-scroll.patch" + } + ] +} diff --git a/examples/mods/compact-player-volume-scroll/patches/compact-player-volume-scroll.patch b/examples/mods/compact-player-volume-scroll/patches/compact-player-volume-scroll.patch new file mode 100644 index 000000000..021a6e160 --- /dev/null +++ b/examples/mods/compact-player-volume-scroll/patches/compact-player-volume-scroll.patch @@ -0,0 +1,20 @@ +diff --git a/modules/widgets/defaultview/CompactPlayer.qml b/modules/widgets/defaultview/CompactPlayer.qml +index 265b87e8..3a9a8e24 100644 +--- a/modules/widgets/defaultview/CompactPlayer.qml ++++ b/modules/widgets/defaultview/CompactPlayer.qml +@@ -127,3 +127,15 @@ Item { ++ WheelHandler { ++ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad ++ onWheel: event => { ++ if (!compactPlayer.player) ++ return; ++ if (event.angleDelta.y > 0) ++ Audio.incrementVolume(); ++ else if (event.angleDelta.y < 0) ++ Audio.decrementVolume(); ++ } ++ } ++ + StyledRect { + variant: "common" + anchors.fill: parent diff --git a/modules/services/ModsService.qml b/modules/services/ModsService.qml new file mode 100644 index 000000000..02fd43041 --- /dev/null +++ b/modules/services/ModsService.qml @@ -0,0 +1,146 @@ +pragma Singleton + +import QtQuick +import Quickshell + +Singleton { + id: root + + signal settingChanged(string modId, string key, var value) + signal installed(string source) + + property var mods: [] + property string basePath: "" + property string baseVersion: "" + property string baseRevision: "" + property string activeGeneration: "" + property string previousGeneration: "" + property bool generationCurrent: true + property string generationError: "" + property bool busy: false + property bool loaded: false + property bool restartRequired: false + property string errorMessage: "" + property string statusMessage: "" + property string settingsModId: "" + property var settingsFields: [] + property var settingsValues: ({}) + property bool settingsBusy: false + + function applyStatus(result) { + root.mods = result?.mods ?? []; + root.basePath = result?.basePath ?? ""; + root.baseVersion = result?.baseVersion ?? ""; + root.baseRevision = result?.baseRevision ?? ""; + root.activeGeneration = result?.activeGeneration ?? ""; + root.previousGeneration = result?.previousGeneration ?? ""; + root.generationCurrent = result?.generationCurrent ?? true; + root.generationError = result?.generationError ?? ""; + root.loaded = true; + } + + function request(method, params, successMessage, requiresRestart, onSuccess) { + if (root.busy) + return; + root.busy = true; + root.errorMessage = ""; + root.statusMessage = ""; + BackendService.call(method, params ?? {}, (result, error) => { + root.busy = false; + if (error) { + root.errorMessage = String(error); + return; + } + root.applyStatus(result); + root.statusMessage = successMessage ?? ""; + if (result?.restartRequired ?? requiresRestart) + root.restartRequired = true; + if (onSuccess) + onSuccess(result); + }); + } + + function refresh() { + root.request("mods.status", {}, "", false); + } + + function install(source) { + root.request("mods.install", { source }, "Mod installed in the disabled state.", false, + () => root.installed(source)); + } + + function setEnabled(id, enabled) { + root.request("mods.setEnabled", { id, enabled }, enabled ? "Mod enabled." : "Mod disabled.", true); + } + + function update(id, enabled) { + root.request("mods.update", { id }, "Mod updated.", enabled); + } + + function remove(id, enabled) { + root.request("mods.remove", { id }, "Mod removed.", enabled); + } + + function move(id, direction) { + root.request("mods.move", { id, direction }, "Load order updated.", root.activeGeneration !== ""); + } + + function rebuild() { + root.request("mods.rebuild", {}, "Generation rebuilt.", true); + } + + function rollback() { + root.request("mods.rollback", {}, "Previous generation restored.", true); + } + + function loadSettings(id) { + root.settingsModId = id ?? ""; + root.settingsFields = []; + root.settingsValues = ({}); + if (!id) + return; + root.settingsBusy = true; + BackendService.call("mods.settings", { id }, (result, error) => { + root.settingsBusy = false; + if (root.settingsModId !== id) + return; + if (error) { + root.errorMessage = String(error); + return; + } + root.settingsFields = result?.fields ?? []; + root.settingsValues = result?.values ?? ({}); + }); + } + + function getSettings(id, callback) { + BackendService.call("mods.settings", { id }, (result, error) => { + callback(result ?? null, error ? String(error) : ""); + }); + } + + function setSetting(id, key, value) { + if (root.settingsBusy) + return; + root.settingsBusy = true; + root.errorMessage = ""; + BackendService.call("mods.setSetting", { id, key, value }, (result, error) => { + root.settingsBusy = false; + if (error) { + root.errorMessage = String(error); + return; + } + root.settingsFields = result?.fields ?? []; + root.settingsValues = result?.values ?? ({}); + root.statusMessage = "Setting saved."; + root.settingChanged(id, key, result?.values?.[key] ?? value); + if (result?.restartRequired) + root.restartRequired = true; + }); + } + + function restart() { + root.restartRequired = false; + Quickshell.execDetached(["ambxst", "reload"]); + } +} diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml new file mode 100644 index 000000000..ac6cfcf73 --- /dev/null +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -0,0 +1,755 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.config +import qs.modules.components +import qs.modules.services +import qs.modules.theme + +Item { + id: root + + property int maxContentWidth: 760 + property string searchQuery: "" + property string sortMode: "name" + property string selectedId: "" + property string removeArmedId: "" + + readonly property int contentWidth: Math.min(width, maxContentWidth) + readonly property var filteredMods: { + const query = root.searchQuery.trim().toLowerCase(); + const items = (ModsService.mods ?? []).filter(mod => { + if (!query) + return true; + return (mod.name ?? "").toLowerCase().includes(query) + || (mod.id ?? "").toLowerCase().includes(query) + || (mod.description ?? "").toLowerCase().includes(query); + }); + return items.slice().sort((a, b) => { + if (root.sortMode === "loadOrder") + return (a.order ?? 0) - (b.order ?? 0); + if (root.sortMode === "state" && !!a.enabled !== !!b.enabled) + return a.enabled ? -1 : 1; + return (a.name ?? a.id).localeCompare(b.name ?? b.id); + }); + } + readonly property var selectedMod: { + const mods = ModsService.mods ?? []; + for (let i = 0; i < mods.length; i++) { + if (mods[i].id === root.selectedId) + return mods[i]; + } + return mods.length > 0 ? mods[0] : null; + } + + onSelectedModChanged: { + if (selectedMod && selectedId !== selectedMod.id) { + selectedId = selectedMod.id; + return; + } + ModsService.loadSettings(selectedMod?.id ?? ""); + removeArmedId = ""; + } + + Component.onCompleted: ModsService.refresh() + + Connections { + target: ModsService + function onInstalled(source) { + if (sourceInput.text.trim() === source) + sourceInput.text = ""; + } + } + + component ActionButton: Button { + id: action + property bool primary: false + property bool destructive: false + + implicitHeight: 36 + leftPadding: 12 + rightPadding: 12 + enabled: !ModsService.busy + + background: StyledRect { + variant: action.primary ? "primary" : ((action.hovered || action.activeFocus || action.down) ? "focus" : "common") + radius: Styling.radius(-2) + enableShadow: false + } + + contentItem: Text { + text: action.text + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: action.primary ? Font.DemiBold : Font.Medium + color: action.destructive ? Colors.error : action.primary ? Styling.srItem("primary") : Colors.overBackground + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + ColumnLayout { + width: root.contentWidth + height: parent.height + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + + PanelTitlebar { + title: "Mods" + statusText: ModsService.busy ? "Working…" : "" + actions: [ + { + icon: Icons.arrowCounterClockwise, + tooltip: "Refresh mod state", + enabled: !ModsService.busy, + onClicked: function () { ModsService.refresh(); } + } + ] + + ActionButton { + text: "Rebuild" + onClicked: ModsService.rebuild() + } + } + + StyledRect { + visible: !ModsService.generationCurrent || ModsService.restartRequired + || ModsService.errorMessage !== "" || ModsService.statusMessage !== "" + Layout.fillWidth: true + Layout.preferredHeight: statusRow.implicitHeight + 16 + variant: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? "focus" : "common" + radius: Styling.radius(-2) + enableShadow: false + + RowLayout { + id: statusRow + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Text { + text: ModsService.errorMessage !== "" || !ModsService.generationCurrent + ? Icons.alert : (ModsService.restartRequired ? Icons.arrowCounterClockwise : Icons.accept) + font.family: Icons.font + font.pixelSize: 16 + color: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? Colors.error : Colors.overBackground + } + + Text { + Layout.fillWidth: true + text: ModsService.errorMessage !== "" ? ModsService.errorMessage + : !ModsService.generationCurrent ? "Rebuild required: " + ModsService.generationError + : ModsService.restartRequired ? "Restart Ambxst to load the active generation." + : ModsService.statusMessage + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? Colors.error : Colors.overBackground + wrapMode: Text.Wrap + } + + ActionButton { + visible: !ModsService.generationCurrent + text: "Rebuild" + primary: true + onClicked: ModsService.rebuild() + } + + ActionButton { + visible: ModsService.restartRequired + text: "Restart now" + primary: true + onClicked: ModsService.restart() + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Package source" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.Medium + color: Colors.overBackground + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + TextField { + id: sourceInput + Layout.fillWidth: true + implicitHeight: 40 + placeholderText: "Local directory, package archive, or Git URL" + color: Colors.overBackground + placeholderTextColor: Colors.outline + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + selectByMouse: true + enabled: !ModsService.busy + Accessible.name: "Package source" + Accessible.description: "Local directory, package archive, or Git URL" + + background: StyledRect { + variant: sourceInput.activeFocus ? "focus" : "common" + radius: Styling.radius(-2) + enableShadow: false + } + + onAccepted: { + const source = text.trim(); + if (source !== "") { + ModsService.install(source); + } + } + } + + ActionButton { + text: "Install" + primary: true + enabled: !ModsService.busy && sourceInput.text.trim() !== "" + onClicked: { + ModsService.install(sourceInput.text.trim()); + } + } + } + } + + Text { + Layout.fillWidth: true + text: "Packages run with your user permissions. Install code only from sources you trust." + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + wrapMode: Text.Wrap + } + + GridLayout { + Layout.fillWidth: true + Layout.fillHeight: true + columns: root.width >= 680 ? 2 : 1 + columnSpacing: 8 + rowSpacing: 8 + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: 300 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + SearchInput { + Layout.fillWidth: true + placeholderText: "Search installed mods…" + clearOnEscape: true + onSearchTextChanged: text => root.searchQuery = text + } + + ActionButton { + text: root.sortMode === "name" ? "Sort: Name" + : root.sortMode === "state" ? "Sort: State" + : "Sort: Load order" + onClicked: root.sortMode = root.sortMode === "name" ? "state" + : root.sortMode === "state" ? "loadOrder" + : "name" + } + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + variant: "pane" + radius: Styling.radius(0) + + Text { + visible: ModsService.loaded && root.filteredMods.length === 0 + anchors.centerIn: parent + width: Math.min(parent.width - 32, 260) + text: ModsService.mods.length === 0 + ? "No mods are installed. Add a package source above." + : "No installed mods match this search." + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: Colors.outline + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + Flickable { + anchors.fill: parent + anchors.margins: 6 + contentHeight: modList.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ColumnLayout { + id: modList + width: parent.width + spacing: 4 + + Repeater { + model: root.filteredMods + + delegate: StyledRect { + id: modRow + required property var modelData + Layout.fillWidth: true + Layout.preferredHeight: 58 + variant: root.selectedMod?.id === modelData.id ? "primary" + : (rowMouse.containsMouse || activeFocus ? "focus" : "common") + radius: Styling.radius(-2) + enableShadow: false + activeFocusOnTab: true + Accessible.role: Accessible.ListItem + Accessible.name: modelData.name + ", " + (modelData.enabled ? "enabled" : "disabled") + Accessible.onPressAction: root.selectedId = modelData.id + + Keys.onReturnPressed: root.selectedId = modelData.id + Keys.onEnterPressed: root.selectedId = modelData.id + Keys.onSpacePressed: root.selectedId = modelData.id + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + modRow.forceActiveFocus(); + root.selectedId = modRow.modelData.id; + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + + Text { + Layout.fillWidth: true + text: modRow.modelData.name + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: modRow.item + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: (modRow.modelData.version || "Unknown version") + " · " + + (!modRow.modelData.valid ? "Package error" + : !modRow.modelData.compatible ? "Incompatible" + : modRow.modelData.enabled ? "Enabled" : "Disabled") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: modRow.item + opacity: 0.72 + elide: Text.ElideRight + } + } + + ActionButton { + text: modRow.modelData.enabled ? "Disable" : "Enable" + primary: !modRow.modelData.enabled + enabled: !ModsService.busy && (modRow.modelData.enabled + || (modRow.modelData.valid && modRow.modelData.compatible)) + onClicked: { + root.selectedId = modRow.modelData.id; + ModsService.setEnabled(modRow.modelData.id, !modRow.modelData.enabled); + } + } + } + } + } + } + } + } + } + + StyledRect { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: 420 + Layout.minimumHeight: root.width >= 680 ? 0 : 260 + variant: "pane" + radius: Styling.radius(0) + + Text { + visible: !root.selectedMod + anchors.centerIn: parent + text: "Select a mod to inspect its package details." + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: Colors.outline + } + + Flickable { + visible: !!root.selectedMod + anchors.fill: parent + anchors.margins: 14 + contentHeight: details.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ColumnLayout { + id: details + width: parent.width + spacing: 8 + + Text { + Layout.fillWidth: true + text: root.selectedMod?.name ?? "" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(1) + font.weight: Font.DemiBold + color: Colors.overBackground + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + text: (root.selectedMod?.id ?? "") + " · " + (root.selectedMod?.version ?? "") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + wrapMode: Text.WrapAnywhere + } + + Text { + visible: (root.selectedMod?.author ?? "") !== "" + || (root.selectedMod?.license ?? "") !== "" + Layout.fillWidth: true + text: [root.selectedMod?.author ?? "", root.selectedMod?.license ?? ""] + .filter(value => value !== "").join(" · ") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + text: root.selectedMod?.description ?? "" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + wrapMode: Text.Wrap + } + + Text { + visible: (root.selectedMod?.error ?? "") !== "" + || (root.selectedMod?.compatibilityError ?? "") !== "" + Layout.fillWidth: true + text: "Package status\n" + (root.selectedMod?.error + || root.selectedMod?.compatibilityError || "Unknown error") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.error + wrapMode: Text.WrapAnywhere + } + + Separator { Layout.fillWidth: true } + + Text { + Layout.fillWidth: true + text: "Source\n" + (root.selectedMod?.source ?? "Unknown") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + wrapMode: Text.WrapAnywhere + } + + Text { + Layout.fillWidth: true + visible: (root.selectedMod?.revision ?? "") !== "" + text: "Revision\n" + (root.selectedMod?.revision ?? "") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + wrapMode: Text.WrapAnywhere + } + + Text { + Layout.fillWidth: true + text: "Affected files\n" + ((root.selectedMod?.affectedFiles ?? []).join("\n") || "None") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + wrapMode: Text.WrapAnywhere + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + Layout.fillWidth: true + text: "Load order " + String((root.selectedMod?.order ?? 0) + 1) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + } + + ActionButton { + text: "Move up" + enabled: !ModsService.busy && (root.selectedMod?.order ?? 0) > 0 + onClicked: ModsService.move(root.selectedMod.id, -1) + } + + ActionButton { + text: "Move down" + enabled: !ModsService.busy && (root.selectedMod?.order ?? 0) < ModsService.mods.length - 1 + onClicked: ModsService.move(root.selectedMod.id, 1) + } + } + + Text { + Layout.fillWidth: true + visible: (root.selectedMod?.permissions ?? []).length > 0 + text: "Declared permissions\n" + (root.selectedMod?.permissions ?? []).join(", ") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + visible: (root.selectedMod?.dependencies ?? []).length > 0 + || (root.selectedMod?.commands ?? []).length > 0 + text: "Requirements\n" + (root.selectedMod?.dependencies ?? []) + .concat(root.selectedMod?.commands ?? []).join(", ") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + wrapMode: Text.WrapAnywhere + } + + Text { + Layout.fillWidth: true + visible: (root.selectedMod?.conflicts ?? []).length > 0 + text: "Conflicts\n" + (root.selectedMod?.conflicts ?? []).join(", ") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + wrapMode: Text.WrapAnywhere + } + + ColumnLayout { + visible: root.selectedMod?.hasSettings ?? false + Layout.fillWidth: true + spacing: 6 + + Separator { Layout.fillWidth: true } + + Text { + text: "Settings" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: Colors.overBackground + } + + Text { + visible: ModsService.settingsBusy + text: "Loading settings…" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + } + + Repeater { + model: ModsService.settingsFields + + delegate: ColumnLayout { + id: settingRow + required property var modelData + Layout.fillWidth: true + spacing: 3 + + function saveTextValue(text) { + let value = text; + if (settingRow.modelData.type === "integer") + value = parseInt(text, 10); + else if (settingRow.modelData.type === "number") + value = parseFloat(text); + if (typeof value === "number" && !Number.isFinite(value)) { + ModsService.errorMessage = "Enter a valid number."; + return; + } + ModsService.setSetting(root.selectedMod.id, settingRow.modelData.key, value); + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + + Text { + Layout.fillWidth: true + text: settingRow.modelData.label + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.Medium + color: Colors.overBackground + wrapMode: Text.Wrap + } + + Text { + visible: (settingRow.modelData.description ?? "") !== "" + Layout.fillWidth: true + text: settingRow.modelData.description ?? "" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + wrapMode: Text.Wrap + } + } + + ActionButton { + visible: settingRow.modelData.type === "boolean" + text: ModsService.settingsValues[settingRow.modelData.key] ? "On" : "Off" + primary: !!ModsService.settingsValues[settingRow.modelData.key] + onClicked: ModsService.setSetting( + root.selectedMod.id, + settingRow.modelData.key, + !ModsService.settingsValues[settingRow.modelData.key] + ) + } + + ActionButton { + visible: settingRow.modelData.type === "enum" + text: { + const options = settingRow.modelData.options ?? []; + const value = ModsService.settingsValues[settingRow.modelData.key]; + for (let i = 0; i < options.length; i++) { + if (options[i].value === value) + return options[i].label; + } + return String(value ?? "Select"); + } + onClicked: { + const options = settingRow.modelData.options ?? []; + if (options.length === 0) + return; + const value = ModsService.settingsValues[settingRow.modelData.key]; + let index = options.findIndex(option => option.value === value); + index = (index + 1) % options.length; + ModsService.setSetting(root.selectedMod.id, settingRow.modelData.key, options[index].value); + } + } + } + + RowLayout { + visible: settingRow.modelData.type === "string" + || settingRow.modelData.type === "integer" + || settingRow.modelData.type === "number" + Layout.fillWidth: true + spacing: 6 + + TextField { + id: settingInput + Layout.fillWidth: true + implicitHeight: 36 + text: parent.visible ? String(ModsService.settingsValues[settingRow.modelData.key] ?? "") : "" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + selectByMouse: true + inputMethodHints: settingRow.modelData.type === "string" ? Qt.ImhNone : Qt.ImhFormattedNumbersOnly + Accessible.name: settingRow.modelData.label + Accessible.description: settingRow.modelData.description ?? "" + background: StyledRect { + variant: settingInput.activeFocus ? "focus" : "common" + radius: Styling.radius(-2) + enableShadow: false + } + onAccepted: settingRow.saveTextValue(text) + } + + ActionButton { + text: "Save" + enabled: !ModsService.busy && !ModsService.settingsBusy + onClicked: settingRow.saveTextValue(settingInput.text) + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + ActionButton { + text: "Update" + onClicked: ModsService.update(root.selectedMod.id, root.selectedMod.enabled) + } + + Item { Layout.fillWidth: true } + + ActionButton { + text: root.removeArmedId === root.selectedMod?.id ? "Confirm remove" : "Remove" + destructive: true + onClicked: { + if (root.removeArmedId !== root.selectedMod.id) { + root.removeArmedId = root.selectedMod.id; + return; + } + ModsService.remove(root.selectedMod.id, root.selectedMod.enabled); + root.removeArmedId = ""; + } + } + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + Layout.fillWidth: true + text: "Base " + (ModsService.baseVersion || "unknown") + + (ModsService.baseRevision ? " · " + ModsService.baseRevision.substring(0, 12) : "") + + " · Active " + (ModsService.activeGeneration || "base") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + elide: Text.ElideMiddle + } + + ActionButton { + visible: ModsService.previousGeneration !== "" + text: "Rollback" + onClicked: ModsService.rollback() + } + } + } +} diff --git a/modules/widgets/dashboard/controls/SettingsIndex.qml b/modules/widgets/dashboard/controls/SettingsIndex.qml index 6a6064093..777686e2f 100644 --- a/modules/widgets/dashboard/controls/SettingsIndex.qml +++ b/modules/widgets/dashboard/controls/SettingsIndex.qml @@ -11,11 +11,16 @@ QtObject { // it will try to guess what users would want to search, not the feature name only // Main Sections: - // 0: Network, 1: Bluetooth, 2: Mixer, 3: Effects, 4: Theme, 5: Binds, 6: System, 7: Compositor, 8: Ambxst + // 0: Network, 1: Bluetooth, 2: Mixer, 3: AI, 4: Effects, 5: Theme, 6: Binds, 7: System, 8: Compositor, 9: Ambxst, 10: Mods property var dynamicItems: [] readonly property var staticItems: [ + // --- Mods --- + { label: "Mods", keywords: "extensions plugins modifications packages", section: 10, subSection: "", subLabel: "", icon: Icons.plug, isIcon: true }, + { label: "Install mod", keywords: "add local directory archive git repository source", section: 10, subSection: "", subLabel: "Mods", icon: Icons.plug, isIcon: true }, + { label: "Rollback generation", keywords: "restore recover previous failed", section: 10, subSection: "", subLabel: "Mods", icon: Icons.arrowCounterClockwise, isIcon: true }, + // --- Network --- { label: "Network", keywords: "internet wifi connection ethernet ip", section: 0, subSection: "", subLabel: "", icon: Icons.wifiHigh, isIcon: true }, diff --git a/modules/widgets/dashboard/controls/SettingsTab.qml b/modules/widgets/dashboard/controls/SettingsTab.qml index fcaeef560..8faf81ee4 100644 --- a/modules/widgets/dashboard/controls/SettingsTab.qml +++ b/modules/widgets/dashboard/controls/SettingsTab.qml @@ -70,18 +70,19 @@ Rectangle { property int currentPanelIndex: 0 property var aggregatedItems: [] property bool isIndexing: false + readonly property var panels: contentArea.panelComponents.filter(panel => panel.section !== 10) // Helper to load panels one by one Loader { id: indexerLoader active: settingsIndexer.isIndexing asynchronous: true - source: settingsIndexer.isIndexing && settingsIndexer.currentPanelIndex < contentArea.panelComponents.length ? contentArea.panelComponents[settingsIndexer.currentPanelIndex].component : "" + source: settingsIndexer.isIndexing && settingsIndexer.currentPanelIndex < settingsIndexer.panels.length ? settingsIndexer.panels[settingsIndexer.currentPanelIndex].component : "" onStatusChanged: { if (status === Loader.Ready && item) { // Scrape - const sectionId = contentArea.panelComponents[settingsIndexer.currentPanelIndex].section; + const sectionId = settingsIndexer.panels[settingsIndexer.currentPanelIndex].section; const newItems = SettingsCrawler.crawl(item, sectionId); settingsIndexer.aggregatedItems = settingsIndexer.aggregatedItems.concat(newItems); @@ -95,7 +96,7 @@ Rectangle { } onCurrentPanelIndexChanged: { - if (currentPanelIndex >= contentArea.panelComponents.length) { + if (currentPanelIndex >= settingsIndexer.panels.length) { // Done if (isIndexing) { isIndexing = false; @@ -259,6 +260,12 @@ Rectangle { label: "Ambxst", section: 9, isIcon: false + }, + { + icon: Icons.plug, + label: "Mods", + section: 10, + isIcon: true } ] @@ -538,7 +545,7 @@ Rectangle { clip: true property int previousSection: 0 - readonly property int maxContentWidth: 480 + readonly property int maxContentWidth: root.currentSection === 10 ? 760 : 480 // Track section changes for animation direction onVisibleChanged: { @@ -595,6 +602,10 @@ Rectangle { { component: "ShellPanel.qml", section: 9 + }, + { + component: "ModsPanel.qml", + section: 10 } ] From d2cdcdb479230e565da943fd3e2717d84f6f30e3 Mon Sep 17 00:00:00 2001 From: flathead Date: Mon, 31 Aug 2026 23:44:42 +0300 Subject: [PATCH 02/17] fix(mods): compose non-overlapping patches in load order Several focused mods need to register against the same Ambxst integration files. Rejecting shared filenames prevented otherwise independent patches from being enabled together. Apply operations in load order and let the existing patch preflight reject overlapping hunks. Failed composition still leaves the active generation unchanged. --- backend/pkg/mods/manager.go | 11 ------- backend/pkg/mods/manager_test.go | 56 ++++++++++++++++++++++++++++++++ docs/mods/README.md | 10 +++--- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index cba14d580..abd2ecd40 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -695,20 +695,9 @@ func (m *Manager) buildGeneration(state State) (string, error) { if err := exportBase(base, tmp); err != nil { return "", fmt.Errorf("export base source: %w", err) } - owners := make(map[string]string) for _, id := range ordered { manifest := manifests[id] packageRoot := filepath.Join(m.paths.ModPackagesDir(), id) - files, err := manifest.AffectedFiles(packageRoot) - if err != nil { - return "", fmt.Errorf("mod %s: %w", id, err) - } - for _, file := range files { - if owner, exists := owners[file]; exists { - return "", fmt.Errorf("mods %s and %s both modify %s", owner, id, file) - } - owners[file] = id - } for _, operation := range manifest.Operations { if err := applyOperation(tmp, packageRoot, operation); err != nil { return "", fmt.Errorf("mod %s: %w", id, err) diff --git a/backend/pkg/mods/manager_test.go b/backend/pkg/mods/manager_test.go index 981eb56e4..01c0ce5c5 100644 --- a/backend/pkg/mods/manager_test.go +++ b/backend/pkg/mods/manager_test.go @@ -168,6 +168,42 @@ func TestTopologicalOrderUsesDependenciesBeforeUserOrder(t *testing.T) { } } +func TestManagerComposesNonOverlappingPatchesToSameFile(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "first\ntwo\nmiddle\nfour\nlast\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + first := filepath.Join(root, "first") + writePatchPackage(t, first, "example.first", "@@ -1,2 +1,2 @@\n-first\n+one\n two\n") + second := filepath.Join(root, "second") + writePatchPackage(t, second, "example.second", "@@ -4,2 +4,2 @@\n four\n-last\n+five\n") + + manager := NewManager(testPaths(root)) + if _, err := manager.Install(first); err != nil { + t.Fatal(err) + } + if _, err := manager.Install(second); err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled("example.first", true); err != nil { + t.Fatal(err) + } + status, err := manager.SetEnabled("example.second", true) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(manager.paths.ModGenerationsDir(), status.ActiveGeneration, "shell.qml")) + if err != nil { + t.Fatal(err) + } + if string(data) != "one\ntwo\nmiddle\nfour\nfive\n" { + t.Fatalf("patches were not composed in order: %q", data) + } +} + func TestConsecutiveBuildsKeepLastKnownGoodGeneration(t *testing.T) { root := t.TempDir() base := filepath.Join(root, "base") @@ -551,3 +587,23 @@ func writeOverlayPackage(t *testing.T, root, directory, id, target string) strin writeTestFile(t, filepath.Join(packageRoot, ManifestFile), string(data)) return packageRoot } + +func writePatchPackage(t *testing.T, root, id, hunk string) { + t.Helper() + patch := "diff --git a/shell.qml b/shell.qml\n--- a/shell.qml\n+++ b/shell.qml\n" + hunk + writeTestFile(t, filepath.Join(root, "patches", "change.patch"), patch) + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: id, + Name: id, + Version: "1.0.0", + Operations: []Operation{{ + Type: "patch", Source: "patches/change.patch", + }}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(root, ManifestFile), string(data)) +} diff --git a/docs/mods/README.md b/docs/mods/README.md index 570e59c98..6404ff3eb 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -74,10 +74,12 @@ An overlay can add a file. Replacing an existing file also requires change fail visibly instead of silently overwriting newer code. Patches are checked with `git apply --check --whitespace=error-all` before they are applied. -Two enabled mods cannot currently modify the same target file. This conservative -rule keeps load order explicit and prevents a successful build whose behavior -depends on patch coincidence. Dependencies are applied before dependents; user -load order resolves the remaining order. +Operations are applied in load order. Separate patches may change different +parts of the same file; if their hunks overlap or one patch invalidates another, +`git apply --check` stops the build and the active generation remains unchanged. +Overlay replacements still verify the target checksum at the point where they +run. Dependencies are applied before dependents; user load order resolves the +remaining order. `commands` declares executables that must be available before composition. `permissions` is review metadata shown to the user. It is not a sandbox or an From aeb913fe61a544b52082695091fac9643c7b0c75 Mon Sep 17 00:00:00 2001 From: flathead Date: Mon, 31 Aug 2026 23:48:44 +0300 Subject: [PATCH 03/17] feat(mods): add exact drag-and-drop load ordering Expose an exact-position move operation and add a native drag handle when the list is sorted by load order. Reordering enabled packages rebuilds the immutable generation in the chosen order. Disabled-only moves update state without rebuilding, and failed composition leaves the active generation untouched. --- backend/pkg/mods/manager.go | 47 ++++++++++- backend/pkg/mods/manager_test.go | 32 ++++++++ backend/pkg/mods/service.go | 4 + docs/mods/README.md | 5 +- modules/services/ModsService.qml | 4 + .../widgets/dashboard/controls/ModsPanel.qml | 78 +++++++++++++++++++ 6 files changed, 166 insertions(+), 4 deletions(-) diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index abd2ecd40..7db37085e 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -300,16 +300,40 @@ func (m *Manager) Move(id string, direction int) (Status, error) { if !ok { return Status{}, fmt.Errorf("mod %q is not installed", id) } - target := index + direction + return m.moveTo(state, index, index+direction) +} + +func (m *Manager) MoveTo(id string, position int) (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + state, err := m.loadState() + if err != nil { + return Status{}, err + } + index, ok := findInstalled(state, id) + if !ok { + return Status{}, fmt.Errorf("mod %q is not installed", id) + } + return m.moveTo(state, index, position) +} + +func (m *Manager) moveTo(state State, index, target int) (Status, error) { if target < 0 || target >= len(state.Mods) { + return Status{}, fmt.Errorf("position must be between 0 and %d", len(state.Mods)-1) + } + if target == index { return m.statusFor(state) } next := cloneState(state) - next.Mods[index], next.Mods[target] = next.Mods[target], next.Mods[index] + moved := next.Mods[index] + next.Mods = append(next.Mods[:index], next.Mods[index+1:]...) + next.Mods = append(next.Mods, InstalledMod{}) + copy(next.Mods[target+1:], next.Mods[target:]) + next.Mods[target] = moved for i := range next.Mods { next.Mods[i].Order = i } - rebuild := next.Mods[index].Enabled && next.Mods[target].Enabled + rebuild := enabledOrderChanged(state.Mods, next.Mods) if rebuild { if err := m.composeAndActivate(state, &next); err != nil { return Status{}, err @@ -320,6 +344,23 @@ func (m *Manager) Move(id string, direction int) (Status, error) { return m.statusForRestart(next, rebuild) } +func enabledOrderChanged(before, after []InstalledMod) bool { + beforeIndex := 0 + for _, mod := range after { + if !mod.Enabled { + continue + } + for beforeIndex < len(before) && !before[beforeIndex].Enabled { + beforeIndex++ + } + if beforeIndex >= len(before) || before[beforeIndex].ID != mod.ID { + return true + } + beforeIndex++ + } + return false +} + func (m *Manager) Update(id string) (Status, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/backend/pkg/mods/manager_test.go b/backend/pkg/mods/manager_test.go index 01c0ce5c5..20c29ff9a 100644 --- a/backend/pkg/mods/manager_test.go +++ b/backend/pkg/mods/manager_test.go @@ -5,6 +5,7 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" "os" "path/filepath" "testing" @@ -204,6 +205,37 @@ func TestManagerComposesNonOverlappingPatchesToSameFile(t *testing.T) { } } +func TestManagerMovesModToExactPosition(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + manager := NewManager(testPaths(root)) + for i, id := range []string{"example.first", "example.second", "example.third"} { + packageRoot := writeOverlayPackage(t, root, id, id, fmt.Sprintf("Feature%d.qml", i)) + if _, err := manager.Install(packageRoot); err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled(id, true); err != nil { + t.Fatal(err) + } + } + + status, err := manager.MoveTo("example.first", 2) + if err != nil { + t.Fatal(err) + } + want := []string{"example.second", "example.third", "example.first"} + for i, mod := range status.Mods { + if mod.ID != want[i] || mod.Order != i { + t.Fatalf("unexpected order at %d: %#v", i, status.Mods) + } + } +} + func TestConsecutiveBuildsKeepLastKnownGoodGeneration(t *testing.T) { root := t.TempDir() base := filepath.Join(root, "base") diff --git a/backend/pkg/mods/service.go b/backend/pkg/mods/service.go index 68b0efa62..8c410eb0f 100644 --- a/backend/pkg/mods/service.go +++ b/backend/pkg/mods/service.go @@ -96,6 +96,7 @@ func (s *Service) update(raw json.RawMessage) (any, error) { type moveParams struct { ID string `json:"id"` Direction int `json:"direction"` + Position *int `json:"position"` } func (s *Service) move(raw json.RawMessage) (any, error) { @@ -106,6 +107,9 @@ func (s *Service) move(raw json.RawMessage) (any, error) { if !idPattern.MatchString(params.ID) { return nil, fmt.Errorf("invalid mod id %q", params.ID) } + if params.Position != nil { + return s.manager.MoveTo(params.ID, *params.Position) + } return s.manager.Move(params.ID, params.Direction) } diff --git a/docs/mods/README.md b/docs/mods/README.md index 6404ff3eb..150d8b404 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -171,7 +171,10 @@ ambxst mods disable org.example.feature ambxst mods remove org.example.feature ``` -The same operations are available in **Settings → Mods**. +The same operations are available in **Settings → Mods**. Switch the list to +**Sort: Load order**, then drag the handle beside a package to place it at an +exact position. The manager rebuilds enabled packages in that order; the new +generation takes effect after Ambxst restarts. ## Example diff --git a/modules/services/ModsService.qml b/modules/services/ModsService.qml index 02fd43041..1d44d1cdc 100644 --- a/modules/services/ModsService.qml +++ b/modules/services/ModsService.qml @@ -85,6 +85,10 @@ Singleton { root.request("mods.move", { id, direction }, "Load order updated.", root.activeGeneration !== ""); } + function moveTo(id, position) { + root.request("mods.move", { id, position }, "Load order updated.", root.activeGeneration !== ""); + } + function rebuild() { root.request("mods.rebuild", {}, "Generation rebuilt.", true); } diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index ac6cfcf73..ac0bd22c8 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -320,6 +320,21 @@ Item { Keys.onEnterPressed: root.selectedId = modelData.id Keys.onSpacePressed: root.selectedId = modelData.id + DropArea { + anchors.fill: parent + keys: ["ambxstMod"] + enabled: root.sortMode === "loadOrder" && root.searchQuery === "" + property int loadOrder: modRow.modelData.order + + StyledRect { + anchors.fill: parent + visible: parent.containsDrag + variant: "focus" + radius: Styling.radius(-2) + enableShadow: false + } + } + MouseArea { id: rowMouse anchors.fill: parent @@ -337,6 +352,36 @@ Item { anchors.rightMargin: 8 spacing: 8 + Text { + visible: root.sortMode === "loadOrder" && root.searchQuery === "" + text: Icons.dotsNine + font.family: Icons.font + font.pixelSize: 17 + color: modRow.item + opacity: reorderDrag.active ? 1 : 0.65 + Accessible.role: Accessible.Button + Accessible.name: "Drag to change load order" + + DragHandler { + id: reorderDrag + target: dragPreview + xAxis.enabled: false + enabled: !ModsService.busy + onActiveChanged: { + if (active) { + dragPreview.x = modRow.x; + dragPreview.y = modRow.y; + return; + } + const target = dragPreview.Drag.target; + if (target && target.loadOrder !== undefined + && target.loadOrder !== modRow.modelData.order) + ModsService.moveTo(modRow.modelData.id, target.loadOrder); + dragPreview.Drag.drop(); + } + } + } + ColumnLayout { Layout.fillWidth: true spacing: 1 @@ -376,6 +421,39 @@ Item { } } } + + Item { + id: dragPreview + parent: modList + width: modRow.width + height: modRow.height + visible: reorderDrag.active + z: 100 + + StyledRect { + anchors.fill: parent + variant: "primary" + radius: Styling.radius(-2) + + Text { + anchors.fill: parent + anchors.margins: 10 + text: modRow.modelData.name + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: parent.item + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + Drag.active: reorderDrag.active + Drag.source: modRow + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + Drag.keys: ["ambxstMod"] + } } } } From cfd7e2b0e88a5db45ee2e07ae1f22a5f00dc6c4e Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 00:18:50 +0300 Subject: [PATCH 04/17] fix(mods): keep drag preview outside the list layout --- modules/widgets/dashboard/controls/ModsPanel.qml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index ac0bd22c8..fc94e8ef8 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -369,8 +369,9 @@ Item { enabled: !ModsService.busy onActiveChanged: { if (active) { - dragPreview.x = modRow.x; - dragPreview.y = modRow.y; + const point = modRow.mapToItem(dragPreview.parent, 0, 0); + dragPreview.x = point.x; + dragPreview.y = point.y; return; } const target = dragPreview.Drag.target; @@ -424,7 +425,7 @@ Item { Item { id: dragPreview - parent: modList + parent: modList.parent width: modRow.width height: modRow.height visible: reorderDrag.active From 42d1ecbe12dd0d82698217f8a5b93e80041ab496 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 00:50:34 +0300 Subject: [PATCH 05/17] feat(mods): install required packages explicitly --- backend/cmd/ambxst/cmds_mods.go | 8 + backend/pkg/mods/manager.go | 336 +++++++++++++++--- backend/pkg/mods/manager_test.go | 136 +++++++ backend/pkg/mods/manifest.go | 51 ++- backend/pkg/mods/manifest_test.go | 18 + backend/pkg/mods/service.go | 29 +- docs/mods/README.md | 20 ++ docs/mods/manifest.schema.json | 7 + modules/services/ModsService.qml | 26 +- .../widgets/dashboard/controls/ModsPanel.qml | 252 ++++++++++--- 10 files changed, 739 insertions(+), 144 deletions(-) diff --git a/backend/cmd/ambxst/cmds_mods.go b/backend/cmd/ambxst/cmds_mods.go index 7b0ccb084..48d2d4206 100644 --- a/backend/cmd/ambxst/cmds_mods.go +++ b/backend/cmd/ambxst/cmds_mods.go @@ -27,6 +27,11 @@ func runMods(args []string) { modsUsage("Usage: ambxst mods install ") } status, err = callMods("install", map[string]any{"source": args[1]}) + case "install-dependencies": + if len(args) != 2 { + modsUsage("Usage: ambxst mods install-dependencies ") + } + status, err = callMods("installDependencies", map[string]any{"id": args[1]}) case "enable", "disable": if len(args) != 2 { modsUsage("Usage: ambxst mods " + command + " ") @@ -85,6 +90,8 @@ func callMods(method string, params map[string]any) (modpkg.Status, error) { return manager.Status() case "install": return manager.Install(params["source"].(string)) + case "installDependencies": + return manager.InstallDependencies(params["id"].(string)) case "setEnabled": return manager.SetEnabled(params["id"].(string), params["enabled"].(bool)) case "remove": @@ -143,6 +150,7 @@ func modsUsage(message string) { "Commands:\n" + " list Show installed mods and generation state\n" + " install Install from a directory, archive, or Git URL\n" + + " install-dependencies Install and enable a mod's requirements\n" + " enable Enable a mod and build a generation\n" + " disable Disable a mod and build a generation\n" + " update Refresh a mod from its original source\n" + diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index 7db37085e..da2f19b0d 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "io/fs" + "net/url" "os" "os/exec" "path/filepath" @@ -53,27 +54,35 @@ type InstalledMod struct { } type ModInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - License string `json:"license,omitempty"` - Author string `json:"author,omitempty"` - Enabled bool `json:"enabled"` - Order int `json:"order"` - Source string `json:"source"` - SourceType string `json:"sourceType"` - Revision string `json:"revision,omitempty"` - Dependencies []string `json:"dependencies,omitempty"` - Conflicts []string `json:"conflicts,omitempty"` - Commands []string `json:"commands,omitempty"` - Permissions []string `json:"permissions,omitempty"` - AffectedFiles []string `json:"affectedFiles"` - HasSettings bool `json:"hasSettings"` - Valid bool `json:"valid"` - Error string `json:"error,omitempty"` - Compatible bool `json:"compatible"` - CompatibilityError string `json:"compatibilityError,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + License string `json:"license,omitempty"` + Author string `json:"author,omitempty"` + Enabled bool `json:"enabled"` + Order int `json:"order"` + Source string `json:"source"` + SourceType string `json:"sourceType"` + Revision string `json:"revision,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + DependencyState []DependencyInfo `json:"dependencyState,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` + Commands []string `json:"commands,omitempty"` + Permissions []string `json:"permissions,omitempty"` + AffectedFiles []string `json:"affectedFiles"` + HasSettings bool `json:"hasSettings"` + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` + Compatible bool `json:"compatible"` + CompatibilityError string `json:"compatibilityError,omitempty"` +} + +type DependencyInfo struct { + ID string `json:"id"` + Source string `json:"source,omitempty"` + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` } type ModSettings struct { @@ -140,40 +149,11 @@ func (m *Manager) Install(source string) (Status, error) { } defer os.RemoveAll(tmp) - packageRoot := filepath.Join(tmp, "package") - sourceType := "local" - if isGitSource(source) { - sourceType = "git" - if err := runCommandTimeout(5*time.Minute, "", "git", "clone", "--depth=1", source, packageRoot); err != nil { - return Status{}, fmt.Errorf("clone source: %w", err) - } - } else { - absolute, err := filepath.Abs(source) - if err != nil { - return Status{}, err - } - info, err := os.Stat(absolute) - if err != nil { - return Status{}, fmt.Errorf("inspect source: %w", err) - } - if info.IsDir() { - if err := copyTree(absolute, packageRoot, func(path string, entry fs.DirEntry) bool { - return path != absolute && entry.IsDir() && entry.Name() == ".git" - }); err != nil { - return Status{}, fmt.Errorf("copy source: %w", err) - } - } else { - sourceType = "archive" - if err := extractPackageArchive(absolute, packageRoot); err != nil { - return Status{}, err - } - } - source = absolute - } - packageRoot, err = locatePackageRoot(packageRoot) + packageRoot, normalizedSource, sourceType, err := acquirePackage(source, filepath.Join(tmp, "package")) if err != nil { return Status{}, err } + source = normalizedSource manifest, err := LoadManifest(packageRoot) if err != nil { @@ -210,6 +190,153 @@ func (m *Manager) Install(source string) (Status, error) { return m.statusFor(state) } +// InstallDependencies installs missing requirements and enables the complete +// dependency chain. The selected mod remains in its current state. +func (m *Manager) InstallDependencies(id string) (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + + state, err := m.loadState() + if err != nil { + return Status{}, err + } + _, ok := findInstalled(state, id) + if !ok { + return Status{}, fmt.Errorf("mod %q is not installed", id) + } + root := filepath.Join(m.paths.ModPackagesDir(), id) + manifest, err := LoadManifest(root) + if err != nil { + return Status{}, fmt.Errorf("mod %s: %w", id, err) + } + if len(manifest.Dependencies) == 0 { + return m.statusFor(state) + } + + tmp, err := os.MkdirTemp(m.paths.ModPackagesDir(), ".dependencies-") + if err != nil { + return Status{}, err + } + defer os.RemoveAll(tmp) + + type stagedDependency struct { + root string + source string + sourceType string + } + installed := make(map[string]int, len(state.Mods)) + for i, mod := range state.Mods { + installed[mod.ID] = i + } + staged := make(map[string]stagedDependency) + visiting := map[string]bool{id: true} + visited := make(map[string]bool) + order := make([]string, 0) + + var visit func(string, string) error + visit = func(dependencyID, source string) error { + if visited[dependencyID] { + return nil + } + if visiting[dependencyID] { + return fmt.Errorf("dependency cycle includes %s", dependencyID) + } + visiting[dependencyID] = true + + var dependencyManifest Manifest + if installedIndex, exists := installed[dependencyID]; exists { + packageRoot := filepath.Join(m.paths.ModPackagesDir(), state.Mods[installedIndex].ID) + loaded, loadErr := LoadManifest(packageRoot) + if loadErr != nil { + return fmt.Errorf("dependency %s: %w", dependencyID, loadErr) + } + dependencyManifest = loaded + } else { + if strings.TrimSpace(source) == "" { + return fmt.Errorf("mod %s requires %s but provides no package source", id, dependencyID) + } + stageRoot := filepath.Join(tmp, dependencyID) + packageRoot, normalizedSource, sourceType, acquireErr := acquirePackage(source, stageRoot) + if acquireErr != nil { + return fmt.Errorf("install dependency %s: %w", dependencyID, acquireErr) + } + loaded, loadErr := LoadManifest(packageRoot) + if loadErr != nil { + return fmt.Errorf("dependency %s: %w", dependencyID, loadErr) + } + if loaded.ID != dependencyID { + return fmt.Errorf("dependency source for %s contains mod %s", dependencyID, loaded.ID) + } + dependencyManifest = loaded + staged[dependencyID] = stagedDependency{ + root: packageRoot, source: normalizedSource, sourceType: sourceType, + } + } + + for _, childID := range dependencyManifest.Dependencies { + if err := visit(childID, dependencyManifest.DependencySources[childID]); err != nil { + return err + } + } + visiting[dependencyID] = false + visited[dependencyID] = true + order = append(order, dependencyID) + return nil + } + for _, dependencyID := range manifest.Dependencies { + if err := visit(dependencyID, manifest.DependencySources[dependencyID]); err != nil { + return Status{}, err + } + } + + next := cloneState(state) + added := make([]string, 0, len(staged)) + changed := false + for _, dependencyID := range order { + if installedIndex, exists := findInstalled(next, dependencyID); exists { + if !next.Mods[installedIndex].Enabled { + next.Mods[installedIndex].Enabled = true + changed = true + } + continue + } + dependency := staged[dependencyID] + destination := filepath.Join(m.paths.ModPackagesDir(), dependencyID) + if _, statErr := os.Stat(destination); statErr == nil { + return Status{}, fmt.Errorf("package directory already exists for %q", dependencyID) + } else if !os.IsNotExist(statErr) { + return Status{}, statErr + } + if err := os.Rename(dependency.root, destination); err != nil { + for _, addedID := range added { + _ = os.RemoveAll(filepath.Join(m.paths.ModPackagesDir(), addedID)) + } + return Status{}, fmt.Errorf("store dependency %s: %w", dependencyID, err) + } + added = append(added, dependencyID) + next.Mods = append(next.Mods, InstalledMod{ + ID: dependencyID, + Enabled: true, + Order: len(next.Mods), + Source: dependency.source, + SourceType: dependency.sourceType, + Revision: gitRevision(destination), + InstalledAt: time.Now().UTC().Format(time.RFC3339), + }) + changed = true + } + if !changed { + return m.statusFor(state) + } + if err := m.composeAndActivate(state, &next); err != nil { + for _, addedID := range added { + _ = os.RemoveAll(filepath.Join(m.paths.ModPackagesDir(), addedID)) + } + return Status{}, err + } + return m.statusForRestart(next, true) +} + func (m *Manager) SetEnabled(id string, enabled bool) (Status, error) { m.mu.Lock() defer m.mu.Unlock() @@ -429,12 +556,20 @@ func (m *Manager) updateLocalSource(state State, index int, installed InstalledM if err := extractPackageArchive(installed.Source, updatedRoot); err != nil { return Status{}, err } + case "git-subdir": + acquiredRoot, _, _, acquireErr := acquirePackage(installed.Source, updatedRoot) + if acquireErr != nil { + return Status{}, acquireErr + } + updatedRoot = acquiredRoot default: return Status{}, fmt.Errorf("mod %q has unsupported source type %q", installed.ID, installed.SourceType) } - updatedRoot, err = locatePackageRoot(updatedRoot) - if err != nil { - return Status{}, err + if installed.SourceType != "git-subdir" { + updatedRoot, err = locatePackageRoot(updatedRoot) + if err != nil { + return Status{}, err + } } manifest, err := LoadManifest(updatedRoot) if err != nil { @@ -832,6 +967,10 @@ func (m *Manager) statusFor(state State) (Status, error) { status.GenerationError = err.Error() } } + installedByID := make(map[string]InstalledMod, len(state.Mods)) + for _, installed := range state.Mods { + installedByID[installed.ID] = installed + } for _, installed := range state.Mods { root := filepath.Join(m.paths.ModPackagesDir(), installed.ID) manifest, err := LoadManifest(root) @@ -871,6 +1010,16 @@ func (m *Manager) statusFor(state State) (Status, error) { if compatibilityErr != nil { compatibilityMessage = compatibilityErr.Error() } + dependencyState := make([]DependencyInfo, 0, len(manifest.Dependencies)) + for _, dependencyID := range manifest.Dependencies { + dependency, dependencyInstalled := installedByID[dependencyID] + dependencyState = append(dependencyState, DependencyInfo{ + ID: dependencyID, + Source: manifest.DependencySources[dependencyID], + Installed: dependencyInstalled, + Enabled: dependencyInstalled && dependency.Enabled, + }) + } status.Mods = append(status.Mods, ModInfo{ ID: manifest.ID, Name: manifest.Name, @@ -884,6 +1033,7 @@ func (m *Manager) statusFor(state State) (Status, error) { SourceType: installed.SourceType, Revision: installed.Revision, Dependencies: manifest.Dependencies, + DependencyState: dependencyState, Conflicts: manifest.Conflicts, Commands: manifest.Commands, Permissions: manifest.Permissions, @@ -1451,6 +1601,82 @@ func isGitSource(source string) bool { return strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "ssh://") || strings.HasPrefix(source, "git@") } +func acquirePackage(source, destination string) (string, string, string, error) { + source = strings.TrimSpace(source) + if source == "" { + return "", "", "", fmt.Errorf("source is required") + } + sourceType := "local" + if repository, ref, subdirectory, ok := parseGitHubTreeSource(source); ok { + sourceType = "git-subdir" + if err := runCommandTimeout(5*time.Minute, "", "git", "clone", "--depth=1", "--filter=blob:none", "--sparse", "--branch", ref, repository, destination); err != nil { + return "", "", "", fmt.Errorf("clone source: %w", err) + } + if err := runCommandTimeout(2*time.Minute, destination, "git", "sparse-checkout", "set", "--no-cone", subdirectory); err != nil { + return "", "", "", fmt.Errorf("select package directory: %w", err) + } + packageRoot, err := safeJoin(destination, filepath.FromSlash(subdirectory)) + if err != nil { + return "", "", "", fmt.Errorf("package directory: %w", err) + } + if _, err := os.Stat(filepath.Join(packageRoot, ManifestFile)); err != nil { + return "", "", "", fmt.Errorf("package directory has no %s", ManifestFile) + } + return packageRoot, source, sourceType, nil + } else if isGitSource(source) { + sourceType = "git" + if err := runCommandTimeout(5*time.Minute, "", "git", "clone", "--depth=1", source, destination); err != nil { + return "", "", "", fmt.Errorf("clone source: %w", err) + } + } else { + absolute, err := filepath.Abs(source) + if err != nil { + return "", "", "", err + } + info, err := os.Stat(absolute) + if err != nil { + return "", "", "", fmt.Errorf("inspect source: %w", err) + } + if info.IsDir() { + if err := copyTree(absolute, destination, func(path string, entry fs.DirEntry) bool { + return path != absolute && entry.IsDir() && entry.Name() == ".git" + }); err != nil { + return "", "", "", fmt.Errorf("copy source: %w", err) + } + } else { + sourceType = "archive" + if err := extractPackageArchive(absolute, destination); err != nil { + return "", "", "", err + } + } + source = absolute + } + packageRoot, err := locatePackageRoot(destination) + if err != nil { + return "", "", "", err + } + return packageRoot, source, sourceType, nil +} + +func parseGitHubTreeSource(source string) (string, string, string, bool) { + parsed, err := url.Parse(source) + if err != nil || !strings.EqualFold(parsed.Hostname(), "github.com") { + return "", "", "", false + } + parts := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/") + if len(parts) < 5 || parts[2] != "tree" { + return "", "", "", false + } + for i := range parts { + parts[i], err = url.PathUnescape(parts[i]) + if err != nil || parts[i] == "" || parts[i] == "." || parts[i] == ".." { + return "", "", "", false + } + } + repository := "https://github.com/" + parts[0] + "/" + strings.TrimSuffix(parts[1], ".git") + ".git" + return repository, parts[3], strings.Join(parts[4:], "/"), true +} + func gitRevision(directory string) string { if directory == "" { return "" diff --git a/backend/pkg/mods/manager_test.go b/backend/pkg/mods/manager_test.go index 20c29ff9a..260d03cc8 100644 --- a/backend/pkg/mods/manager_test.go +++ b/backend/pkg/mods/manager_test.go @@ -169,6 +169,142 @@ func TestTopologicalOrderUsesDependenciesBeforeUserOrder(t *testing.T) { } } +func TestParseGitHubTreeSource(t *testing.T) { + repository, ref, subdirectory, ok := parseGitHubTreeSource( + "https://github.com/flathead/ambxst-mods/tree/main/packages/i18n", + ) + if !ok { + t.Fatal("expected GitHub tree URL to be recognized") + } + if repository != "https://github.com/flathead/ambxst-mods.git" || ref != "main" || subdirectory != "packages/i18n" { + t.Fatalf("unexpected GitHub tree source: %q %q %q", repository, ref, subdirectory) + } + if _, _, _, ok := parseGitHubTreeSource("https://example.com/owner/repo/tree/main/package"); ok { + t.Fatal("non-GitHub URL was recognized as a tree source") + } +} + +func TestGitHubTreeSourceIntegration(t *testing.T) { + if os.Getenv("AMBXST_TEST_GITHUB") != "1" { + t.Skip("set AMBXST_TEST_GITHUB=1 to run network integration tests") + } + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + manager := NewManager(testPaths(root)) + status, err := manager.Install("https://github.com/flathead/ambxst-mods/tree/main/packages/i18n") + if err != nil { + t.Fatal(err) + } + if len(status.Mods) != 1 || status.Mods[0].ID != "community.i18n" || status.Mods[0].SourceType != "git-subdir" { + t.Fatalf("unexpected tree install status: %#v", status.Mods) + } +} + +func TestManagerInstallsAndEnablesRequiredMods(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + dependencyRoot := writeOverlayPackage(t, root, "dependency", "example.i18n", "I18n.qml") + parentRoot := filepath.Join(root, "parent") + writeTestFile(t, filepath.Join(parentRoot, "Feature.qml"), "Item {}\n") + parentManifest := Manifest{ + ManifestVersion: APIVersion, + ID: "example.feature", + Name: "Feature", + Version: "1.0.0", + Dependencies: []string{"example.i18n"}, + DependencySources: map[string]string{"example.i18n": dependencyRoot}, + Operations: []Operation{{ + Type: "overlay", Source: "Feature.qml", Target: "Feature.qml", + }}, + } + data, err := json.Marshal(parentManifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(parentRoot, ManifestFile), string(data)) + + manager := NewManager(testPaths(root)) + status, err := manager.Install(parentRoot) + if err != nil { + t.Fatal(err) + } + if len(status.Mods[0].DependencyState) != 1 || status.Mods[0].DependencyState[0].Installed { + t.Fatalf("missing dependency was not reported: %#v", status.Mods[0].DependencyState) + } + + status, err = manager.InstallDependencies("example.feature") + if err != nil { + t.Fatal(err) + } + if len(status.Mods) != 2 || status.Mods[0].Enabled || !status.Mods[1].Enabled { + t.Fatalf("unexpected dependency state: %#v", status.Mods) + } + if !status.Mods[0].DependencyState[0].Installed || !status.Mods[0].DependencyState[0].Enabled { + t.Fatalf("ready dependency was not reported: %#v", status.Mods[0].DependencyState) + } + active := filepath.Join(manager.paths.ModGenerationsDir(), status.ActiveGeneration) + if _, err := os.Stat(filepath.Join(active, "I18n.qml")); err != nil { + t.Fatalf("dependency was not composed: %v", err) + } + + status, err = manager.SetEnabled("example.feature", true) + if err != nil { + t.Fatal(err) + } + if !status.Mods[0].Enabled || !status.Mods[1].Enabled { + t.Fatalf("parent and dependency were not enabled: %#v", status.Mods) + } +} + +func TestManagerRejectsDependencyWithUnexpectedID(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + + wrongRoot := writeOverlayPackage(t, root, "wrong", "example.wrong", "Wrong.qml") + parentRoot := filepath.Join(root, "parent") + writeTestFile(t, filepath.Join(parentRoot, "Feature.qml"), "Item {}\n") + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: "example.feature", + Name: "Feature", + Version: "1.0.0", + Dependencies: []string{"example.required"}, + DependencySources: map[string]string{"example.required": wrongRoot}, + Operations: []Operation{{ + Type: "overlay", Source: "Feature.qml", Target: "Feature.qml", + }}, + } + data, _ := json.Marshal(manifest) + writeTestFile(t, filepath.Join(parentRoot, ManifestFile), string(data)) + + manager := NewManager(testPaths(root)) + if _, err := manager.Install(parentRoot); err != nil { + t.Fatal(err) + } + if _, err := manager.InstallDependencies("example.feature"); err == nil { + t.Fatal("expected mismatched dependency id to fail") + } + status, err := manager.Status() + if err != nil { + t.Fatal(err) + } + if len(status.Mods) != 1 || status.Mods[0].Enabled { + t.Fatalf("failed dependency install changed state: %#v", status.Mods) + } +} + func TestManagerComposesNonOverlappingPatchesToSameFile(t *testing.T) { root := t.TempDir() base := filepath.Join(root, "base") diff --git a/backend/pkg/mods/manifest.go b/backend/pkg/mods/manifest.go index 332871f69..0498fcc89 100644 --- a/backend/pkg/mods/manifest.go +++ b/backend/pkg/mods/manifest.go @@ -26,21 +26,22 @@ var settingKeyPattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`) var sha256Pattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) type Manifest struct { - Schema string `json:"$schema,omitempty"` - ManifestVersion int `json:"manifestVersion"` - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - License string `json:"license,omitempty"` - Author string `json:"author,omitempty"` - Compatibility Compatibility `json:"compatibility,omitempty"` - Dependencies []string `json:"dependencies,omitempty"` - Conflicts []string `json:"conflicts,omitempty"` - Commands []string `json:"commands,omitempty"` - Permissions []string `json:"permissions,omitempty"` - Settings *SettingsRef `json:"settings,omitempty"` - Operations []Operation `json:"operations"` + Schema string `json:"$schema,omitempty"` + ManifestVersion int `json:"manifestVersion"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + License string `json:"license,omitempty"` + Author string `json:"author,omitempty"` + Compatibility Compatibility `json:"compatibility,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + DependencySources map[string]string `json:"dependencySources,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` + Commands []string `json:"commands,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Settings *SettingsRef `json:"settings,omitempty"` + Operations []Operation `json:"operations"` } type Compatibility struct { @@ -130,6 +131,17 @@ func (m Manifest) Validate(root string) error { } references[id] = true } + for id, source := range m.DependencySources { + if !idPattern.MatchString(id) { + return fmt.Errorf("invalid dependency source id %q", id) + } + if !stringInList(m.Dependencies, id) { + return fmt.Errorf("dependency source %q is not declared as a dependency", id) + } + if strings.TrimSpace(source) == "" { + return fmt.Errorf("dependency source %q is empty", id) + } + } if len(m.Operations) == 0 { return fmt.Errorf("mod has no operations") } @@ -180,6 +192,15 @@ func (m Manifest) Validate(root string) error { return validatePackageTree(root) } +func stringInList(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + func LoadSettingsSchema(path string) (SettingsSchema, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/backend/pkg/mods/manifest_test.go b/backend/pkg/mods/manifest_test.go index 5c8342db0..9fa84c4df 100644 --- a/backend/pkg/mods/manifest_test.go +++ b/backend/pkg/mods/manifest_test.go @@ -25,6 +25,24 @@ func TestManifestRejectsUnsafeOverlayTarget(t *testing.T) { } } +func TestManifestRejectsUndeclaredDependencySource(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "payload.qml"), "Item {}\n") + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: "example.mod", + Name: "Example", + Version: "1.0.0", + DependencySources: map[string]string{"example.base": "https://example.test/base.git"}, + Operations: []Operation{{ + Type: "overlay", Source: "payload.qml", Target: "payload.qml", + }}, + } + if err := manifest.Validate(root); err == nil { + t.Fatal("expected undeclared dependency source to fail validation") + } +} + func TestPatchTargets(t *testing.T) { path := filepath.Join(t.TempDir(), "change.patch") writeTestFile(t, path, "--- a/one.qml\n+++ b/one.qml\n@@ -1 +1 @@\n-old\n+new\n--- /dev/null\n+++ b/two.qml\n@@ -0,0 +1 @@\n+new\n") diff --git a/backend/pkg/mods/service.go b/backend/pkg/mods/service.go index 8c410eb0f..79bcc7e0e 100644 --- a/backend/pkg/mods/service.go +++ b/backend/pkg/mods/service.go @@ -19,16 +19,17 @@ func (s *Service) Register(server *ipc.Server) { server.Register(&ipc.Service{ Name: "mods", Methods: map[string]ipc.HandlerFunc{ - "status": s.status, - "install": s.install, - "setEnabled": s.setEnabled, - "remove": s.remove, - "move": s.move, - "update": s.update, - "rebuild": s.rebuild, - "rollback": s.rollback, - "settings": s.settings, - "setSetting": s.setSetting, + "status": s.status, + "install": s.install, + "installDependencies": s.installDependencies, + "setEnabled": s.setEnabled, + "remove": s.remove, + "move": s.move, + "update": s.update, + "rebuild": s.rebuild, + "rollback": s.rollback, + "settings": s.settings, + "setSetting": s.setSetting, }, }) } @@ -77,6 +78,14 @@ func decodeID(raw json.RawMessage) (string, error) { return params.ID, nil } +func (s *Service) installDependencies(raw json.RawMessage) (any, error) { + id, err := decodeID(raw) + if err != nil { + return nil, fmt.Errorf("invalid dependency install request: %w", err) + } + return s.manager.InstallDependencies(id) +} + func (s *Service) remove(raw json.RawMessage) (any, error) { id, err := decodeID(raw) if err != nil { diff --git a/docs/mods/README.md b/docs/mods/README.md index 150d8b404..798eb9d53 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -49,6 +49,7 @@ the replacement fails validation or generation composition. "testedBaseCommits": ["full-git-commit"] }, "dependencies": [], + "dependencySources": {}, "conflicts": [], "commands": [], "permissions": ["Reads active media state"], @@ -85,6 +86,25 @@ remaining order. `permissions` is review metadata shown to the user. It is not a sandbox or an authorization mechanism: installed QML runs with the user's permissions. +Required mods are listed by ID in `dependencies`. A distributable package can +also map a dependency ID to its package repository in `dependencySources`: + +```json +"dependencies": ["community.i18n"], +"dependencySources": { + "community.i18n": "https://github.com/example/ambxst-mod-i18n.git" +} +``` + +Settings shows missing and disabled requirements before the mod can be enabled. +It downloads them only after the user chooses **Install required mods**. The +source package must declare the expected ID; a different manifest is rejected. + +The package source field accepts a local directory, an archive, a Git repository, +or a GitHub directory URL such as +`https://github.com/owner/repository/tree/main/packages/example`. GitHub directory +installs use a shallow sparse checkout and retain the original URL for updates. + ## Settings schema Mod settings use data, not package-provided settings UI. This keeps the Settings diff --git a/docs/mods/manifest.schema.json b/docs/mods/manifest.schema.json index 5a1e1f593..95561f6f9 100644 --- a/docs/mods/manifest.schema.json +++ b/docs/mods/manifest.schema.json @@ -34,6 +34,13 @@ } }, "dependencies": { "$ref": "#/$defs/modIds" }, + "dependencySources": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" + }, + "additionalProperties": { "type": "string", "minLength": 1 } + }, "conflicts": { "$ref": "#/$defs/modIds" }, "commands": { "type": "array", diff --git a/modules/services/ModsService.qml b/modules/services/ModsService.qml index 1d44d1cdc..ab39abbdf 100644 --- a/modules/services/ModsService.qml +++ b/modules/services/ModsService.qml @@ -22,6 +22,7 @@ Singleton { property bool restartRequired: false property string errorMessage: "" property string statusMessage: "" + property string statusMessageKey: "" property string settingsModId: "" property var settingsFields: [] property var settingsValues: ({}) @@ -45,6 +46,7 @@ Singleton { root.busy = true; root.errorMessage = ""; root.statusMessage = ""; + root.statusMessageKey = ""; BackendService.call(method, params ?? {}, (result, error) => { root.busy = false; if (error) { @@ -52,7 +54,7 @@ Singleton { return; } root.applyStatus(result); - root.statusMessage = successMessage ?? ""; + root.statusMessageKey = successMessage ?? ""; if (result?.restartRequired ?? requiresRestart) root.restartRequired = true; if (onSuccess) @@ -65,36 +67,40 @@ Singleton { } function install(source) { - root.request("mods.install", { source }, "Mod installed in the disabled state.", false, + root.request("mods.install", { source }, "mods.status_installed", false, () => root.installed(source)); } + function installDependencies(id) { + root.request("mods.installDependencies", { id }, "mods.status_dependencies_installed", true); + } + function setEnabled(id, enabled) { - root.request("mods.setEnabled", { id, enabled }, enabled ? "Mod enabled." : "Mod disabled.", true); + root.request("mods.setEnabled", { id, enabled }, enabled ? "mods.status_enabled" : "mods.status_disabled", true); } function update(id, enabled) { - root.request("mods.update", { id }, "Mod updated.", enabled); + root.request("mods.update", { id }, "mods.status_updated", enabled); } function remove(id, enabled) { - root.request("mods.remove", { id }, "Mod removed.", enabled); + root.request("mods.remove", { id }, "mods.status_removed", enabled); } function move(id, direction) { - root.request("mods.move", { id, direction }, "Load order updated.", root.activeGeneration !== ""); + root.request("mods.move", { id, direction }, "mods.status_order_updated", root.activeGeneration !== ""); } function moveTo(id, position) { - root.request("mods.move", { id, position }, "Load order updated.", root.activeGeneration !== ""); + root.request("mods.move", { id, position }, "mods.status_order_updated", root.activeGeneration !== ""); } function rebuild() { - root.request("mods.rebuild", {}, "Generation rebuilt.", true); + root.request("mods.rebuild", {}, "mods.status_rebuilt", true); } function rollback() { - root.request("mods.rollback", {}, "Previous generation restored.", true); + root.request("mods.rollback", {}, "mods.status_rolled_back", true); } function loadSettings(id) { @@ -136,7 +142,7 @@ Singleton { } root.settingsFields = result?.fields ?? []; root.settingsValues = result?.values ?? ({}); - root.statusMessage = "Setting saved."; + root.statusMessageKey = "mods.status_setting_saved"; root.settingChanged(id, key, result?.values?.[key] ?? value); if (result?.restartRequired) root.restartRequired = true; diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index fc94e8ef8..9ab00dcca 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -17,6 +17,94 @@ Item { property string selectedId: "" property string removeArmedId: "" + readonly property bool i18nActive: (ModsService.mods ?? []).some(mod => + mod.id === "community.i18n" && mod.enabled) + readonly property var fallbackText: ({ + "common.off": "Off", + "common.on": "On", + "common.save": "Save", + "mods.active": "Active", + "mods.affected_files": "Affected files", + "mods.base": "Base", + "mods.confirm_remove": "Confirm remove", + "mods.conflicts": "Conflicts", + "mods.dependency_disabled": "Disabled", + "mods.dependency_missing": "Missing", + "mods.dependency_ready": "Ready", + "mods.disable": "Disable", + "mods.disabled": "Disabled", + "mods.drag_order": "Drag to change load order", + "mods.empty": "No mods are installed. Add a package source above.", + "mods.enable": "Enable", + "mods.enabled": "Enabled", + "mods.incompatible": "Incompatible", + "mods.install": "Install", + "mods.install_dependencies": "Install required mods", + "mods.invalid_number": "Enter a valid number.", + "mods.load_order": "Load order %1", + "mods.loading_settings": "Loading settings…", + "mods.move_down": "Move down", + "mods.move_up": "Move up", + "mods.no_matches": "No installed mods match this search.", + "mods.none": "None", + "mods.package_error": "Package error", + "mods.package_source": "Package source", + "mods.package_status": "Package status", + "mods.permissions": "Declared permissions", + "mods.rebuild": "Rebuild", + "mods.rebuild_required": "Rebuild required: %1", + "mods.refresh": "Refresh mod state", + "mods.remove": "Remove", + "mods.required_mods": "Required mods", + "mods.restart_now": "Restart now", + "mods.restart_required": "Restart Ambxst to load the active generation.", + "mods.revision": "Revision", + "mods.rollback": "Rollback", + "mods.search": "Search installed mods…", + "mods.select": "Select", + "mods.select_hint": "Select a mod to inspect its package details.", + "mods.settings": "Settings", + "mods.sort_load_order": "Sort: Load order", + "mods.sort_name": "Sort: Name", + "mods.sort_state": "Sort: State", + "mods.source": "Source", + "mods.source_placeholder": "Local directory, package archive, or Git URL", + "mods.status_dependencies_installed": "Required mods installed and enabled.", + "mods.status_disabled": "Mod disabled.", + "mods.status_enabled": "Mod enabled.", + "mods.status_installed": "Mod installed in the disabled state.", + "mods.status_order_updated": "Load order updated.", + "mods.status_rebuilt": "Generation rebuilt.", + "mods.status_removed": "Mod removed.", + "mods.status_rolled_back": "Previous generation restored.", + "mods.status_setting_saved": "Setting saved.", + "mods.status_updated": "Mod updated.", + "mods.title": "Mods", + "mods.trust_warning": "Packages run with your user permissions. Install code only from sources you trust.", + "mods.unknown": "Unknown", + "mods.unknown_error": "Unknown error", + "mods.unknown_version": "Unknown version", + "mods.update": "Update", + "mods.working": "Working…" + }) + + function tr(key, argument) { + if (root.i18nActive) { + try { + if (typeof I18n !== "undefined" && typeof I18n.t === "function") + return I18n.t(key, argument); + } catch (error) { + // The English fallback keeps Mods available if the translator is unavailable. + } + } + const fallback = root.fallbackText[key] ?? key; + return argument === undefined ? fallback : fallback.replace("%1", String(argument)); + } + + function dependenciesReady(mod) { + return (mod?.dependencyState ?? []).every(dependency => dependency.enabled); + } + readonly property int contentWidth: Math.min(width, maxContentWidth) readonly property var filteredMods: { const query = root.searchQuery.trim().toLowerCase(); @@ -97,19 +185,19 @@ Item { spacing: 8 PanelTitlebar { - title: "Mods" - statusText: ModsService.busy ? "Working…" : "" + title: root.tr("mods.title") + statusText: ModsService.busy ? root.tr("mods.working") : "" actions: [ { icon: Icons.arrowCounterClockwise, - tooltip: "Refresh mod state", + tooltip: root.tr("mods.refresh"), enabled: !ModsService.busy, onClicked: function () { ModsService.refresh(); } } ] ActionButton { - text: "Rebuild" + text: root.tr("mods.rebuild") onClicked: ModsService.rebuild() } } @@ -117,6 +205,7 @@ Item { StyledRect { visible: !ModsService.generationCurrent || ModsService.restartRequired || ModsService.errorMessage !== "" || ModsService.statusMessage !== "" + || ModsService.statusMessageKey !== "" Layout.fillWidth: true Layout.preferredHeight: statusRow.implicitHeight + 16 variant: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? "focus" : "common" @@ -140,8 +229,9 @@ Item { Text { Layout.fillWidth: true text: ModsService.errorMessage !== "" ? ModsService.errorMessage - : !ModsService.generationCurrent ? "Rebuild required: " + ModsService.generationError - : ModsService.restartRequired ? "Restart Ambxst to load the active generation." + : !ModsService.generationCurrent ? root.tr("mods.rebuild_required", ModsService.generationError) + : ModsService.restartRequired ? root.tr("mods.restart_required") + : ModsService.statusMessageKey !== "" ? root.tr(ModsService.statusMessageKey) : ModsService.statusMessage font.family: Config.theme.font font.pixelSize: Styling.fontSize(-1) @@ -151,14 +241,14 @@ Item { ActionButton { visible: !ModsService.generationCurrent - text: "Rebuild" + text: root.tr("mods.rebuild") primary: true onClicked: ModsService.rebuild() } ActionButton { visible: ModsService.restartRequired - text: "Restart now" + text: root.tr("mods.restart_now") primary: true onClicked: ModsService.restart() } @@ -170,7 +260,7 @@ Item { spacing: 4 Text { - text: "Package source" + text: root.tr("mods.package_source") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-1) font.weight: Font.Medium @@ -185,15 +275,15 @@ Item { id: sourceInput Layout.fillWidth: true implicitHeight: 40 - placeholderText: "Local directory, package archive, or Git URL" + placeholderText: root.tr("mods.source_placeholder") color: Colors.overBackground placeholderTextColor: Colors.outline font.family: Config.theme.font font.pixelSize: Styling.fontSize(-1) selectByMouse: true enabled: !ModsService.busy - Accessible.name: "Package source" - Accessible.description: "Local directory, package archive, or Git URL" + Accessible.name: root.tr("mods.package_source") + Accessible.description: root.tr("mods.source_placeholder") background: StyledRect { variant: sourceInput.activeFocus ? "focus" : "common" @@ -210,7 +300,7 @@ Item { } ActionButton { - text: "Install" + text: root.tr("mods.install") primary: true enabled: !ModsService.busy && sourceInput.text.trim() !== "" onClicked: { @@ -222,7 +312,7 @@ Item { Text { Layout.fillWidth: true - text: "Packages run with your user permissions. Install code only from sources you trust." + text: root.tr("mods.trust_warning") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.outline @@ -248,15 +338,15 @@ Item { SearchInput { Layout.fillWidth: true - placeholderText: "Search installed mods…" + placeholderText: root.tr("mods.search") clearOnEscape: true onSearchTextChanged: text => root.searchQuery = text } ActionButton { - text: root.sortMode === "name" ? "Sort: Name" - : root.sortMode === "state" ? "Sort: State" - : "Sort: Load order" + text: root.sortMode === "name" ? root.tr("mods.sort_name") + : root.sortMode === "state" ? root.tr("mods.sort_state") + : root.tr("mods.sort_load_order") onClicked: root.sortMode = root.sortMode === "name" ? "state" : root.sortMode === "state" ? "loadOrder" : "name" @@ -274,8 +364,8 @@ Item { anchors.centerIn: parent width: Math.min(parent.width - 32, 260) text: ModsService.mods.length === 0 - ? "No mods are installed. Add a package source above." - : "No installed mods match this search." + ? root.tr("mods.empty") + : root.tr("mods.no_matches") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-1) color: Colors.outline @@ -313,7 +403,8 @@ Item { enableShadow: false activeFocusOnTab: true Accessible.role: Accessible.ListItem - Accessible.name: modelData.name + ", " + (modelData.enabled ? "enabled" : "disabled") + Accessible.name: modelData.name + ", " + (modelData.enabled + ? root.tr("mods.enabled") : root.tr("mods.disabled")) Accessible.onPressAction: root.selectedId = modelData.id Keys.onReturnPressed: root.selectedId = modelData.id @@ -360,7 +451,7 @@ Item { color: modRow.item opacity: reorderDrag.active ? 1 : 0.65 Accessible.role: Accessible.Button - Accessible.name: "Drag to change load order" + Accessible.name: root.tr("mods.drag_order") DragHandler { id: reorderDrag @@ -399,10 +490,10 @@ Item { Text { Layout.fillWidth: true - text: (modRow.modelData.version || "Unknown version") + " · " - + (!modRow.modelData.valid ? "Package error" - : !modRow.modelData.compatible ? "Incompatible" - : modRow.modelData.enabled ? "Enabled" : "Disabled") + text: (modRow.modelData.version || root.tr("mods.unknown_version")) + " · " + + (!modRow.modelData.valid ? root.tr("mods.package_error") + : !modRow.modelData.compatible ? root.tr("mods.incompatible") + : modRow.modelData.enabled ? root.tr("mods.enabled") : root.tr("mods.disabled")) font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: modRow.item @@ -412,10 +503,11 @@ Item { } ActionButton { - text: modRow.modelData.enabled ? "Disable" : "Enable" + text: modRow.modelData.enabled ? root.tr("mods.disable") : root.tr("mods.enable") primary: !modRow.modelData.enabled enabled: !ModsService.busy && (modRow.modelData.enabled - || (modRow.modelData.valid && modRow.modelData.compatible)) + || (modRow.modelData.valid && modRow.modelData.compatible + && root.dependenciesReady(modRow.modelData))) onClicked: { root.selectedId = modRow.modelData.id; ModsService.setEnabled(modRow.modelData.id, !modRow.modelData.enabled); @@ -473,7 +565,7 @@ Item { Text { visible: !root.selectedMod anchors.centerIn: parent - text: "Select a mod to inspect its package details." + text: root.tr("mods.select_hint") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-1) color: Colors.outline @@ -540,8 +632,8 @@ Item { visible: (root.selectedMod?.error ?? "") !== "" || (root.selectedMod?.compatibilityError ?? "") !== "" Layout.fillWidth: true - text: "Package status\n" + (root.selectedMod?.error - || root.selectedMod?.compatibilityError || "Unknown error") + text: root.tr("mods.package_status") + "\n" + (root.selectedMod?.error + || root.selectedMod?.compatibilityError || root.tr("mods.unknown_error")) font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.error @@ -552,7 +644,7 @@ Item { Text { Layout.fillWidth: true - text: "Source\n" + (root.selectedMod?.source ?? "Unknown") + text: root.tr("mods.source") + "\n" + (root.selectedMod?.source ?? root.tr("mods.unknown")) font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.overBackground @@ -562,7 +654,7 @@ Item { Text { Layout.fillWidth: true visible: (root.selectedMod?.revision ?? "") !== "" - text: "Revision\n" + (root.selectedMod?.revision ?? "") + text: root.tr("mods.revision") + "\n" + (root.selectedMod?.revision ?? "") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.overBackground @@ -571,7 +663,8 @@ Item { Text { Layout.fillWidth: true - text: "Affected files\n" + ((root.selectedMod?.affectedFiles ?? []).join("\n") || "None") + text: root.tr("mods.affected_files") + "\n" + + ((root.selectedMod?.affectedFiles ?? []).join("\n") || root.tr("mods.none")) font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.overBackground @@ -584,20 +677,20 @@ Item { Text { Layout.fillWidth: true - text: "Load order " + String((root.selectedMod?.order ?? 0) + 1) + text: root.tr("mods.load_order", String((root.selectedMod?.order ?? 0) + 1)) font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.overBackground } ActionButton { - text: "Move up" + text: root.tr("mods.move_up") enabled: !ModsService.busy && (root.selectedMod?.order ?? 0) > 0 onClicked: ModsService.move(root.selectedMod.id, -1) } ActionButton { - text: "Move down" + text: root.tr("mods.move_down") enabled: !ModsService.busy && (root.selectedMod?.order ?? 0) < ModsService.mods.length - 1 onClicked: ModsService.move(root.selectedMod.id, 1) } @@ -606,19 +699,68 @@ Item { Text { Layout.fillWidth: true visible: (root.selectedMod?.permissions ?? []).length > 0 - text: "Declared permissions\n" + (root.selectedMod?.permissions ?? []).join(", ") + text: root.tr("mods.permissions") + "\n" + (root.selectedMod?.permissions ?? []).join(", ") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.overBackground wrapMode: Text.Wrap } + ColumnLayout { + Layout.fillWidth: true + visible: (root.selectedMod?.dependencyState ?? []).length > 0 + spacing: 4 + + Text { + text: root.tr("mods.required_mods") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.DemiBold + color: Colors.overBackground + } + + Repeater { + model: root.selectedMod?.dependencyState ?? [] + + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 8 + + Text { + Layout.fillWidth: true + text: modelData.id + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + elide: Text.ElideMiddle + } + + Text { + text: modelData.enabled ? root.tr("mods.dependency_ready") + : modelData.installed ? root.tr("mods.dependency_disabled") + : root.tr("mods.dependency_missing") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: modelData.enabled ? Colors.success : Colors.warning + } + } + } + + ActionButton { + visible: !root.dependenciesReady(root.selectedMod) + text: root.tr("mods.install_dependencies") + primary: true + enabled: !ModsService.busy + onClicked: ModsService.installDependencies(root.selectedMod.id) + } + } + Text { Layout.fillWidth: true - visible: (root.selectedMod?.dependencies ?? []).length > 0 - || (root.selectedMod?.commands ?? []).length > 0 - text: "Requirements\n" + (root.selectedMod?.dependencies ?? []) - .concat(root.selectedMod?.commands ?? []).join(", ") + visible: (root.selectedMod?.commands ?? []).length > 0 + text: root.tr("mods.requirements") + "\n" + (root.selectedMod?.commands ?? []).join(", ") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.overBackground @@ -628,7 +770,7 @@ Item { Text { Layout.fillWidth: true visible: (root.selectedMod?.conflicts ?? []).length > 0 - text: "Conflicts\n" + (root.selectedMod?.conflicts ?? []).join(", ") + text: root.tr("mods.conflicts") + "\n" + (root.selectedMod?.conflicts ?? []).join(", ") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.overBackground @@ -643,7 +785,7 @@ Item { Separator { Layout.fillWidth: true } Text { - text: "Settings" + text: root.tr("mods.settings") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-1) font.weight: Font.DemiBold @@ -652,7 +794,7 @@ Item { Text { visible: ModsService.settingsBusy - text: "Loading settings…" + text: root.tr("mods.loading_settings") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.outline @@ -674,7 +816,7 @@ Item { else if (settingRow.modelData.type === "number") value = parseFloat(text); if (typeof value === "number" && !Number.isFinite(value)) { - ModsService.errorMessage = "Enter a valid number."; + ModsService.errorMessage = root.tr("mods.invalid_number"); return; } ModsService.setSetting(root.selectedMod.id, settingRow.modelData.key, value); @@ -711,7 +853,8 @@ Item { ActionButton { visible: settingRow.modelData.type === "boolean" - text: ModsService.settingsValues[settingRow.modelData.key] ? "On" : "Off" + text: ModsService.settingsValues[settingRow.modelData.key] + ? root.tr("common.on") : root.tr("common.off") primary: !!ModsService.settingsValues[settingRow.modelData.key] onClicked: ModsService.setSetting( root.selectedMod.id, @@ -729,7 +872,7 @@ Item { if (options[i].value === value) return options[i].label; } - return String(value ?? "Select"); + return String(value ?? root.tr("mods.select")); } onClicked: { const options = settingRow.modelData.options ?? []; @@ -771,7 +914,7 @@ Item { } ActionButton { - text: "Save" + text: root.tr("common.save") enabled: !ModsService.busy && !ModsService.settingsBusy onClicked: settingRow.saveTextValue(settingInput.text) } @@ -785,14 +928,15 @@ Item { spacing: 6 ActionButton { - text: "Update" + text: root.tr("mods.update") onClicked: ModsService.update(root.selectedMod.id, root.selectedMod.enabled) } Item { Layout.fillWidth: true } ActionButton { - text: root.removeArmedId === root.selectedMod?.id ? "Confirm remove" : "Remove" + text: root.removeArmedId === root.selectedMod?.id + ? root.tr("mods.confirm_remove") : root.tr("mods.remove") destructive: true onClicked: { if (root.removeArmedId !== root.selectedMod.id) { @@ -815,9 +959,9 @@ Item { Text { Layout.fillWidth: true - text: "Base " + (ModsService.baseVersion || "unknown") + text: root.tr("mods.base") + " " + (ModsService.baseVersion || root.tr("mods.unknown")) + (ModsService.baseRevision ? " · " + ModsService.baseRevision.substring(0, 12) : "") - + " · Active " + (ModsService.activeGeneration || "base") + + " · " + root.tr("mods.active") + " " + (ModsService.activeGeneration || "base") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) color: Colors.outline @@ -826,7 +970,7 @@ Item { ActionButton { visible: ModsService.previousGeneration !== "" - text: "Rollback" + text: root.tr("mods.rollback") onClicked: ModsService.rollback() } } From af2a686582521c1d1a9d4932b7b59cf138dcdd48 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 00:53:06 +0300 Subject: [PATCH 06/17] fix(mods): use the available settings space --- .../widgets/dashboard/controls/ModsPanel.qml | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index 9ab00dcca..0e95412b4 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -11,7 +11,8 @@ import qs.modules.theme Item { id: root - property int maxContentWidth: 760 + property int maxContentWidth: 1240 + property int horizontalMargin: 20 property string searchQuery: "" property string sortMode: "name" property string selectedId: "" @@ -105,7 +106,9 @@ Item { return (mod?.dependencyState ?? []).every(dependency => dependency.enabled); } - readonly property int contentWidth: Math.min(width, maxContentWidth) + readonly property int contentWidth: Math.max(0, Math.min(width - horizontalMargin * 2, maxContentWidth)) + readonly property bool wideLayout: contentWidth >= 900 + readonly property bool hasInstalledMods: (ModsService.mods ?? []).length > 0 readonly property var filteredMods: { const query = root.searchQuery.trim().toLowerCase(); const items = (ModsService.mods ?? []).filter(mod => { @@ -320,16 +323,18 @@ Item { } GridLayout { + id: managerGrid Layout.fillWidth: true Layout.fillHeight: true - columns: root.width >= 680 ? 2 : 1 - columnSpacing: 8 - rowSpacing: 8 + columns: root.wideLayout && root.hasInstalledMods ? 2 : 1 + columnSpacing: 12 + rowSpacing: 12 ColumnLayout { Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredWidth: 300 + Layout.fillHeight: root.wideLayout || !root.hasInstalledMods + Layout.preferredWidth: root.wideLayout && root.hasInstalledMods ? 380 : managerGrid.width + Layout.preferredHeight: root.wideLayout || !root.hasInstalledMods ? 0 : 280 spacing: 6 RowLayout { @@ -356,21 +361,35 @@ Item { StyledRect { Layout.fillWidth: true Layout.fillHeight: true + Layout.minimumHeight: root.hasInstalledMods ? 180 : 260 variant: "pane" radius: Styling.radius(0) - Text { + ColumnLayout { visible: ModsService.loaded && root.filteredMods.length === 0 anchors.centerIn: parent - width: Math.min(parent.width - 32, 260) - text: ModsService.mods.length === 0 - ? root.tr("mods.empty") - : root.tr("mods.no_matches") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - color: Colors.outline - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.Wrap + width: Math.min(parent.width - 48, 440) + spacing: 10 + + Text { + Layout.alignment: Qt.AlignHCenter + text: Icons.plug + font.family: Icons.font + font.pixelSize: 30 + color: Colors.outline + } + + Text { + Layout.fillWidth: true + text: ModsService.mods.length === 0 + ? root.tr("mods.empty") + : root.tr("mods.no_matches") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(0) + color: Colors.outline + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } } Flickable { @@ -555,10 +574,11 @@ Item { } StyledRect { + visible: root.hasInstalledMods Layout.fillWidth: true Layout.fillHeight: true - Layout.preferredWidth: 420 - Layout.minimumHeight: root.width >= 680 ? 0 : 260 + Layout.preferredWidth: root.wideLayout ? 720 : managerGrid.width + Layout.minimumHeight: 320 variant: "pane" radius: Styling.radius(0) From dbb40e9f56b28576b86cb791c2c32347fc3eec17 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 01:29:09 +0300 Subject: [PATCH 07/17] fix(mods): merge patches three-way when context moves --- backend/pkg/mods/manager.go | 81 ++++++++++++++++++++- backend/pkg/mods/manager_test.go | 121 +++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 3 deletions(-) diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index da2f19b0d..7ea970705 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -871,6 +871,9 @@ func (m *Manager) buildGeneration(state State) (string, error) { if err := exportBase(base, tmp); err != nil { return "", fmt.Errorf("export base source: %w", err) } + if err := initComposition(tmp, base); err != nil { + return "", fmt.Errorf("prepare composition: %w", err) + } for _, id := range ordered { manifest := manifests[id] packageRoot := filepath.Join(m.paths.ModPackagesDir(), id) @@ -879,6 +882,12 @@ func (m *Manager) buildGeneration(state State) (string, error) { return "", fmt.Errorf("mod %s: %w", id, err) } } + if err := commitComposition(tmp, "mod "+id); err != nil { + return "", fmt.Errorf("mod %s: %w", id, err) + } + } + if err := os.RemoveAll(filepath.Join(tmp, ".git")); err != nil { + return "", fmt.Errorf("clear composition history: %w", err) } if _, err := os.Stat(filepath.Join(tmp, "shell.qml")); err != nil { return "", fmt.Errorf("generation has no shell.qml") @@ -1150,16 +1159,82 @@ func (m *Manager) cleanupGenerations(state State) { } } +// initComposition turns the exported base into a throwaway Git repository so +// patches can be merged three-way instead of matching context exactly. The +// base object store is borrowed rather than copied, which keeps the pre-image +// blobs of older mods reachable after an Ambxst update. The repository is +// removed before the generation is activated. +func initComposition(generation, base string) error { + if err := runCommand(generation, "git", "init", "-q"); err != nil { + return err + } + settings := [][2]string{ + {"user.email", "mods@ambxst.invalid"}, + {"user.name", "Ambxst Mods"}, + {"commit.gpgsign", "false"}, + {"core.autocrlf", "false"}, + {"core.hooksPath", filepath.Join(generation, ".git", "unused-hooks")}, + } + for _, setting := range settings { + if err := runCommand(generation, "git", "config", setting[0], setting[1]); err != nil { + return err + } + } + if objects := gitObjectsDir(base); objects != "" { + alternates := filepath.Join(generation, ".git", "objects", "info", "alternates") + if err := os.MkdirAll(filepath.Dir(alternates), 0o755); err != nil { + return err + } + if err := os.WriteFile(alternates, []byte(objects+"\n"), 0o644); err != nil { + return err + } + } + return commitComposition(generation, "base") +} + +func commitComposition(generation, message string) error { + if err := runCommand(generation, "git", "add", "-A", "-f", "."); err != nil { + return err + } + return runCommand(generation, "git", "commit", "-q", "--allow-empty", "--no-verify", "-m", message) +} + +func gitObjectsDir(base string) string { + cmd := exec.Command("git", "-C", base, "rev-parse", "--git-path", "objects") + output, err := cmd.Output() + if err != nil { + return "" + } + directory := strings.TrimSpace(string(output)) + if directory == "" { + return "" + } + if !filepath.IsAbs(directory) { + directory = filepath.Join(base, directory) + } + if info, err := os.Stat(directory); err != nil || !info.IsDir() { + return "" + } + return directory +} + func applyOperation(generation, packageRoot string, op Operation) error { source, err := safeJoin(packageRoot, op.Source) if err != nil { return err } if op.Type == "patch" { - if err := runCommand(generation, "git", "apply", "--check", "--whitespace=error-all", source); err != nil { - return fmt.Errorf("patch check failed: %w", err) + if runCommand(generation, "git", "apply", "--check", "--whitespace=error-all", source) == nil { + if err := runCommand(generation, "git", "apply", "--whitespace=error-all", source); err != nil { + return fmt.Errorf("apply patch: %w", err) + } + return nil } - if err := runCommand(generation, "git", "apply", "--whitespace=error-all", source); err != nil { + // The context a patch was written against moves when an earlier mod + // edits the same file, or when Ambxst itself changes. A three-way + // merge against the recorded pre-image accepts that drift and still + // stops on hunks that touch the same lines. + if err := runCommand(generation, "git", "apply", "--3way", source); err != nil { return fmt.Errorf("apply patch: %w", err) } return nil diff --git a/backend/pkg/mods/manager_test.go b/backend/pkg/mods/manager_test.go index 260d03cc8..aed6068d8 100644 --- a/backend/pkg/mods/manager_test.go +++ b/backend/pkg/mods/manager_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "testing" @@ -735,6 +736,126 @@ func testPaths(root string) *paths.Paths { } } +func TestManagerComposesPatchesThatShareContext(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + original := "first\ntwo\nmiddle\nfour\nlast\n" + writeTestFile(t, filepath.Join(base, "shell.qml"), original) + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + first := filepath.Join(root, "first") + writeDiffPackage(t, first, "example.first", original, "first\ntwo\nMIDDLE\nfour\nlast\n") + second := filepath.Join(root, "second") + writeDiffPackage(t, second, "example.second", original, "first\ntwo\nmiddle\nfour\nfive\n") + + manager := NewManager(testPaths(root)) + if _, err := manager.Install(first); err != nil { + t.Fatal(err) + } + if _, err := manager.Install(second); err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled("example.first", true); err != nil { + t.Fatal(err) + } + status, err := manager.SetEnabled("example.second", true) + if err != nil { + t.Fatalf("second mod was refused although both edits are independent: %v", err) + } + data, err := os.ReadFile(filepath.Join(manager.paths.ModGenerationsDir(), status.ActiveGeneration, "shell.qml")) + if err != nil { + t.Fatal(err) + } + if string(data) != "first\ntwo\nMIDDLE\nfour\nfive\n" { + t.Fatalf("shared context was not merged: %q", data) + } + if _, err := os.Stat(filepath.Join(manager.paths.ModGenerationsDir(), status.ActiveGeneration, ".git")); !os.IsNotExist(err) { + t.Fatal("the generation still carries the composition repository") + } +} + +func TestManagerStopsOnOverlappingPatches(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + original := "first\ntwo\nmiddle\nfour\nlast\n" + writeTestFile(t, filepath.Join(base, "shell.qml"), original) + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + first := filepath.Join(root, "first") + writeDiffPackage(t, first, "example.first", original, "first\ntwo\nalpha\nfour\nlast\n") + second := filepath.Join(root, "second") + writeDiffPackage(t, second, "example.second", original, "first\ntwo\nbeta\nfour\nlast\n") + + manager := NewManager(testPaths(root)) + if _, err := manager.Install(first); err != nil { + t.Fatal(err) + } + if _, err := manager.Install(second); err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled("example.first", true); err != nil { + t.Fatal(err) + } + previous, err := manager.Status() + if err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled("example.second", true); err == nil { + t.Fatal("two mods rewriting the same line were composed") + } + current, err := manager.Status() + if err != nil { + t.Fatal(err) + } + if current.ActiveGeneration != previous.ActiveGeneration { + t.Fatal("a failed composition replaced the active generation") + } +} + +func writeDiffPackage(t *testing.T, root, id, before, after string) { + t.Helper() + repo := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = repo + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } + run("init", "-q") + run("config", "user.email", "test@example.com") + run("config", "user.name", "Test") + writeTestFile(t, filepath.Join(repo, "shell.qml"), before) + run("add", "shell.qml") + run("commit", "-q", "-m", "base") + writeTestFile(t, filepath.Join(repo, "shell.qml"), after) + diff := exec.Command("git", "diff") + diff.Dir = repo + patch, err := diff.Output() + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(root, "patches", "change.patch"), string(patch)) + manifest := Manifest{ + ManifestVersion: APIVersion, + ID: id, + Name: id, + Version: "1.0.0", + Operations: []Operation{{ + Type: "patch", Source: "patches/change.patch", + }}, + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(root, ManifestFile), string(data)) +} + func writeOverlayPackage(t *testing.T, root, directory, id, target string) string { t.Helper() packageRoot := filepath.Join(root, directory) From d700c22acc9b67d18e314f6ea4847f2e3e071ff5 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 01:31:03 +0300 Subject: [PATCH 08/17] fix(mods): keep an untested base revision advisory --- backend/pkg/mods/manager.go | 37 ++++++++++----- backend/pkg/mods/manager_test.go | 46 +++++++++++++++++++ docs/mods/README.md | 29 ++++++++---- .../widgets/dashboard/controls/ModsPanel.qml | 11 +++++ 4 files changed, 102 insertions(+), 21 deletions(-) diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index 7ea970705..9711c44a9 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -76,6 +76,8 @@ type ModInfo struct { Error string `json:"error,omitempty"` Compatible bool `json:"compatible"` CompatibilityError string `json:"compatibilityError,omitempty"` + Untested bool `json:"untested,omitempty"` + UntestedMessage string `json:"untestedMessage,omitempty"` } type DependencyInfo struct { @@ -1015,6 +1017,7 @@ func (m *Manager) statusFor(state State) (Status, error) { continue } compatibilityErr := checkCompatibility(manifest, base) + untestedMessage := untestedBase(manifest, base) compatibilityMessage := "" if compatibilityErr != nil { compatibilityMessage = compatibilityErr.Error() @@ -1051,6 +1054,8 @@ func (m *Manager) statusFor(state State) (Status, error) { Valid: true, Compatible: compatibilityErr == nil, CompatibilityError: compatibilityMessage, + Untested: untestedMessage != "", + UntestedMessage: untestedMessage, }) } sort.SliceStable(status.Mods, func(i, j int) bool { return status.Mods[i].Order < status.Mods[j].Order }) @@ -1273,20 +1278,28 @@ func checkCompatibility(manifest Manifest, base string) error { if !matchesVersion(baseVersion, manifest.Compatibility.Ambxst) { return fmt.Errorf("Ambxst %s does not match %q", baseVersion, manifest.Compatibility.Ambxst) } - if len(manifest.Compatibility.TestedBaseCommits) > 0 { - revision := gitRevision(base) - matched := false - for _, allowed := range manifest.Compatibility.TestedBaseCommits { - if strings.EqualFold(revision, allowed) { - matched = true - break - } - } - if !matched { - return fmt.Errorf("base revision %s has not been tested", shortRevision(revision)) + return nil +} + +// untestedBase reports a base revision the package author has not tested. A +// mod is still allowed to build there: the base moves with every Ambxst +// update, and refusing every unlisted revision would disable the whole +// collection after one upstream commit. Patch composition, the startup health +// check, and rollback remain the real guards. +func untestedBase(manifest Manifest, base string) string { + if len(manifest.Compatibility.TestedBaseCommits) == 0 { + return "" + } + revision := gitRevision(base) + if revision == "" { + return "the base revision is unknown" + } + for _, tested := range manifest.Compatibility.TestedBaseCommits { + if strings.EqualFold(revision, tested) { + return "" } } - return nil + return fmt.Sprintf("not tested on base revision %s", shortRevision(revision)) } func topologicalOrder(installed []InstalledMod, manifests map[string]Manifest) ([]string, error) { diff --git a/backend/pkg/mods/manager_test.go b/backend/pkg/mods/manager_test.go index aed6068d8..5eb1d334a 100644 --- a/backend/pkg/mods/manager_test.go +++ b/backend/pkg/mods/manager_test.go @@ -816,6 +816,52 @@ func TestManagerStopsOnOverlappingPatches(t *testing.T) { } } +func TestUntestedBaseRevisionWarnsInsteadOfBlocking(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "first\ntwo\nlast\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + source := filepath.Join(root, "source") + writeDiffPackage(t, source, "example.untested", "first\ntwo\nlast\n", "first\ntwo\nfive\n") + manifestPath := filepath.Join(source, ManifestFile) + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatal(err) + } + manifest.Compatibility = Compatibility{ + API: APIVersion, + Ambxst: ">=1.2.5 <1.3.0", + TestedBaseCommits: []string{"0123456789012345678901234567890123456789"}, + } + updated, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, manifestPath, string(updated)) + + manager := NewManager(testPaths(root)) + if _, err := manager.Install(source); err != nil { + t.Fatal(err) + } + status, err := manager.SetEnabled("example.untested", true) + if err != nil { + t.Fatalf("an untested base revision blocked the build: %v", err) + } + if status.ActiveGeneration == "" { + t.Fatal("no generation was activated") + } + if len(status.Mods) != 1 || !status.Mods[0].Compatible || !status.Mods[0].Untested { + t.Fatalf("the untested base was not reported: %#v", status.Mods) + } +} + func writeDiffPackage(t *testing.T, root, id, before, after string) { t.Helper() repo := t.TempDir() diff --git a/docs/mods/README.md b/docs/mods/README.md index 798eb9d53..e625e434a 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -72,15 +72,26 @@ the replacement fails validation or generation composition. An overlay can add a file. Replacing an existing file also requires `"replace": true` and the current target's `expectedSha256`. This makes a base -change fail visibly instead of silently overwriting newer code. Patches are -checked with `git apply --check --whitespace=error-all` before they are applied. - -Operations are applied in load order. Separate patches may change different -parts of the same file; if their hunks overlap or one patch invalidates another, -`git apply --check` stops the build and the active generation remains unchanged. -Overlay replacements still verify the target checksum at the point where they -run. Dependencies are applied before dependents; user load order resolves the -remaining order. +change fail visibly instead of silently overwriting newer code. + +Operations are applied in load order. A patch is first tried with +`git apply --check --whitespace=error-all`. Exact context rarely survives real +use: an earlier mod edits the same file, or Ambxst itself moves the lines a +patch was written against. So a patch that does not apply verbatim is retried +as a three-way merge against the pre-image blob recorded in the diff. The +composition runs in a temporary Git repository that borrows the base object +store, which keeps those blobs reachable after an Ambxst update. That repository +is deleted before the generation is activated, so a generation is plain source. + +Two mods rewriting the same lines still stop the build, and the active +generation remains unchanged. Overlay replacements still verify the target +checksum at the point where they run. Dependencies are applied before +dependents; user load order resolves the remaining order. + +`compatibility.ambxst` is a hard requirement: a mod outside the range is never +built. `compatibility.testedBaseCommits` is advisory. The base moves with every +Ambxst update, so an unlisted revision only marks the package as untested in +Settings; composition, the health window, and rollback remain the real guards. `commands` declares executables that must be available before composition. `permissions` is review metadata shown to the user. It is not a sandbox or an diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index 0e95412b4..fa5cc5473 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -39,6 +39,7 @@ Item { "mods.enable": "Enable", "mods.enabled": "Enabled", "mods.incompatible": "Incompatible", + "mods.untested_base": "%1. You can still enable it.", "mods.install": "Install", "mods.install_dependencies": "Install required mods", "mods.invalid_number": "Enter a valid number.", @@ -660,6 +661,16 @@ Item { wrapMode: Text.WrapAnywhere } + Text { + visible: (root.selectedMod?.untestedMessage ?? "") !== "" + Layout.fillWidth: true + text: root.tr("mods.untested_base", root.selectedMod?.untestedMessage ?? "") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.warning + wrapMode: Text.Wrap + } + Separator { Layout.fillWidth: true } Text { From 4127fdfd23757638413e9755f46bf9f142a1bb49 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 01:34:24 +0300 Subject: [PATCH 09/17] feat(mods): keep both insertions at a shared anchor --- backend/pkg/mods/manager.go | 98 +++++++++++++++++++++++++++++++- backend/pkg/mods/manager_test.go | 37 ++++++++++++ docs/mods/README.md | 4 +- 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index 9711c44a9..27075e6c3 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -1169,6 +1169,92 @@ func (m *Manager) cleanupGenerations(state State) { // base object store is borrowed rather than copied, which keeps the pre-image // blobs of older mods reachable after an Ambxst update. The repository is // removed before the generation is activated. +// resolveAddedBlocks settles the one merge conflict that load order can decide: +// two mods inserting new lines at the same place. Neither side removed base +// content there, so both blocks belong in the file, and the mod applied first +// goes first. Any conflict that also rewrites existing lines is left alone and +// stops the build. +func resolveAddedBlocks(generation string) (bool, error) { + cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=U") + cmd.Dir = generation + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("list conflicts: %w", err) + } + files := strings.Fields(strings.TrimSpace(string(output))) + if len(files) == 0 { + return false, nil + } + for _, name := range files { + path, err := safeJoin(generation, name) + if err != nil { + return false, err + } + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + merged, ok := mergeAddedBlocks(string(data)) + if !ok { + return false, nil + } + if err := os.WriteFile(path, []byte(merged), 0o644); err != nil { + return false, err + } + if err := runCommand(generation, "git", "add", "--", name); err != nil { + return false, err + } + } + return true, nil +} + +// mergeAddedBlocks keeps both sides of every diff3 conflict whose merge base is +// empty. It reports false as soon as a conflict has base content, so the caller +// can stop instead of guessing. +func mergeAddedBlocks(content string) (string, bool) { + lines := strings.Split(content, "\n") + var out []string + for index := 0; index < len(lines); index++ { + if !strings.HasPrefix(lines[index], "<<<<<<<") { + out = append(out, lines[index]) + continue + } + var ours, base, theirs []string + section := "ours" + index++ + closed := false + for ; index < len(lines); index++ { + line := lines[index] + switch { + case strings.HasPrefix(line, "|||||||"): + section = "base" + case line == "=======" || strings.HasPrefix(line, "======= "): + section = "theirs" + case strings.HasPrefix(line, ">>>>>>>"): + closed = true + default: + switch section { + case "ours": + ours = append(ours, line) + case "base": + base = append(base, line) + default: + theirs = append(theirs, line) + } + } + if closed { + break + } + } + if !closed || len(base) > 0 { + return "", false + } + out = append(out, ours...) + out = append(out, theirs...) + } + return strings.Join(out, "\n"), true +} + func initComposition(generation, base string) error { if err := runCommand(generation, "git", "init", "-q"); err != nil { return err @@ -1178,6 +1264,7 @@ func initComposition(generation, base string) error { {"user.name", "Ambxst Mods"}, {"commit.gpgsign", "false"}, {"core.autocrlf", "false"}, + {"merge.conflictStyle", "diff3"}, {"core.hooksPath", filepath.Join(generation, ".git", "unused-hooks")}, } for _, setting := range settings { @@ -1237,10 +1324,15 @@ func applyOperation(generation, packageRoot string, op Operation) error { } // The context a patch was written against moves when an earlier mod // edits the same file, or when Ambxst itself changes. A three-way - // merge against the recorded pre-image accepts that drift and still - // stops on hunks that touch the same lines. + // merge against the recorded pre-image accepts that drift. if err := runCommand(generation, "git", "apply", "--3way", source); err != nil { - return fmt.Errorf("apply patch: %w", err) + resolved, resolveErr := resolveAddedBlocks(generation) + if resolveErr != nil { + return fmt.Errorf("apply patch: %w", errors.Join(err, resolveErr)) + } + if !resolved { + return fmt.Errorf("apply patch: %w", err) + } } return nil } diff --git a/backend/pkg/mods/manager_test.go b/backend/pkg/mods/manager_test.go index 5eb1d334a..04a952a3c 100644 --- a/backend/pkg/mods/manager_test.go +++ b/backend/pkg/mods/manager_test.go @@ -862,6 +862,43 @@ func TestUntestedBaseRevisionWarnsInsteadOfBlocking(t *testing.T) { } } +func TestManagerKeepsBothInsertionsAtTheSameAnchor(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + original := "header\nanchor\nfooter\n" + writeTestFile(t, filepath.Join(base, "shell.qml"), original) + writeTestFile(t, filepath.Join(base, "version"), "1.2.5\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + first := filepath.Join(root, "first") + writeDiffPackage(t, first, "example.first", original, "header\nanchor\nwidget one\nfooter\n") + second := filepath.Join(root, "second") + writeDiffPackage(t, second, "example.second", original, "header\nanchor\nwidget two\nfooter\n") + + manager := NewManager(testPaths(root)) + if _, err := manager.Install(first); err != nil { + t.Fatal(err) + } + if _, err := manager.Install(second); err != nil { + t.Fatal(err) + } + if _, err := manager.SetEnabled("example.first", true); err != nil { + t.Fatal(err) + } + status, err := manager.SetEnabled("example.second", true) + if err != nil { + t.Fatalf("two independent insertions were refused: %v", err) + } + data, err := os.ReadFile(filepath.Join(manager.paths.ModGenerationsDir(), status.ActiveGeneration, "shell.qml")) + if err != nil { + t.Fatal(err) + } + if string(data) != "header\nanchor\nwidget one\nwidget two\nfooter\n" { + t.Fatalf("load order did not decide the insertion order: %q", data) + } +} + func writeDiffPackage(t *testing.T, root, id, before, after string) { t.Helper() repo := t.TempDir() diff --git a/docs/mods/README.md b/docs/mods/README.md index e625e434a..9808e8c1c 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -83,7 +83,9 @@ composition runs in a temporary Git repository that borrows the base object store, which keeps those blobs reachable after an Ambxst update. That repository is deleted before the generation is activated, so a generation is plain source. -Two mods rewriting the same lines still stop the build, and the active +Two mods that only insert new lines at the same anchor are both kept, in load +order; this is what lets independent bar widgets register next to each other. +Two mods rewriting the same existing lines still stop the build, and the active generation remains unchanged. Overlay replacements still verify the target checksum at the point where they run. Dependencies are applied before dependents; user load order resolves the remaining order. From 48169b1a105b65ff89a8252d6167d05cd93b2a62 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 01:48:03 +0300 Subject: [PATCH 10/17] docs(mods): require a new settings section id --- docs/mods/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/mods/README.md b/docs/mods/README.md index 9808e8c1c..70c0389eb 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -90,6 +90,11 @@ generation remains unchanged. Overlay replacements still verify the target checksum at the point where they run. Dependencies are applied before dependents; user load order resolves the remaining order. +A mod that adds a Settings section must claim a new `section` id and register +its panel under the same id. Renumbering the existing sections looks harmless in +one package and breaks as soon as a second package does it: the sidebar and the +panel list drift apart, and an entry opens somebody else's panel. + `compatibility.ambxst` is a hard requirement: a mod outside the range is never built. `compatibility.testedBaseCommits` is advisory. The base moves with every Ambxst update, so an unlisted revision only marks the package as untested in From e9b0fab2b511aa6f525ab863a45fa9603784fa82 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 02:02:37 +0300 Subject: [PATCH 11/17] feat(mods): rebuild the panel layout and add a trust prompt --- backend/pkg/mods/manager.go | 2 + backend/pkg/mods/manifest.go | 2 + docs/mods/README.md | 5 + docs/mods/manifest.schema.json | 8 + modules/services/ModsService.qml | 5 +- .../widgets/dashboard/controls/ModsPanel.qml | 1702 ++++++++++------- 6 files changed, 1039 insertions(+), 685 deletions(-) diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index 27075e6c3..364780512 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -60,6 +60,8 @@ type ModInfo struct { Description string `json:"description"` License string `json:"license,omitempty"` Author string `json:"author,omitempty"` + AuthorURL string `json:"authorUrl,omitempty"` + Homepage string `json:"homepage,omitempty"` Enabled bool `json:"enabled"` Order int `json:"order"` Source string `json:"source"` diff --git a/backend/pkg/mods/manifest.go b/backend/pkg/mods/manifest.go index 0498fcc89..99d9a3d9b 100644 --- a/backend/pkg/mods/manifest.go +++ b/backend/pkg/mods/manifest.go @@ -34,6 +34,8 @@ type Manifest struct { Description string `json:"description"` License string `json:"license,omitempty"` Author string `json:"author,omitempty"` + AuthorURL string `json:"authorUrl,omitempty"` + Homepage string `json:"homepage,omitempty"` Compatibility Compatibility `json:"compatibility,omitempty"` Dependencies []string `json:"dependencies,omitempty"` DependencySources map[string]string `json:"dependencySources,omitempty"` diff --git a/docs/mods/README.md b/docs/mods/README.md index 70c0389eb..b651f2f0c 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -100,6 +100,11 @@ built. `compatibility.testedBaseCommits` is advisory. The base moves with every Ambxst update, so an unlisted revision only marks the package as untested in Settings; composition, the health window, and rollback remain the real guards. +`author`, `authorUrl`, `homepage`, and `license` are shown before anything is +installed or enabled. Fill them in: the confirmation prompt is where a user +decides whether to trust the code, and an anonymous package gives them nothing +to check. + `commands` declares executables that must be available before composition. `permissions` is review metadata shown to the user. It is not a sandbox or an authorization mechanism: installed QML runs with the user's permissions. diff --git a/docs/mods/manifest.schema.json b/docs/mods/manifest.schema.json index 95561f6f9..7e7bef80e 100644 --- a/docs/mods/manifest.schema.json +++ b/docs/mods/manifest.schema.json @@ -33,6 +33,14 @@ } } }, + "authorUrl": { + "type": "string", + "description": "Link to the author's page, shown before install and enable." + }, + "homepage": { + "type": "string", + "description": "Link to the mod's own page, shown before install and enable." + }, "dependencies": { "$ref": "#/$defs/modIds" }, "dependencySources": { "type": "object", diff --git a/modules/services/ModsService.qml b/modules/services/ModsService.qml index ab39abbdf..7088f8c54 100644 --- a/modules/services/ModsService.qml +++ b/modules/services/ModsService.qml @@ -37,6 +37,9 @@ Singleton { root.previousGeneration = result?.previousGeneration ?? ""; root.generationCurrent = result?.generationCurrent ?? true; root.generationError = result?.generationError ?? ""; + // The backend owns this flag. Latching it to true locally kept the + // restart banner on screen after the daemon had already cleared it. + root.restartRequired = result?.restartRequired ?? false; root.loaded = true; } @@ -55,7 +58,7 @@ Singleton { } root.applyStatus(result); root.statusMessageKey = successMessage ?? ""; - if (result?.restartRequired ?? requiresRestart) + if (requiresRestart && (result?.restartRequired === undefined)) root.restartRequired = true; if (onSuccess) onSuccess(result); diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index fa5cc5473..a2e340670 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -17,16 +17,28 @@ Item { property string sortMode: "name" property string selectedId: "" property string removeArmedId: "" + property bool filesExpanded: false + + // Pending action awaiting the trust confirmation. "" means no prompt. + property string confirmKind: "" + property string confirmSource: "" + property var confirmMod: null readonly property bool i18nActive: (ModsService.mods ?? []).some(mod => mod.id === "community.i18n" && mod.enabled) readonly property var fallbackText: ({ + "common.cancel": "Cancel", "common.off": "Off", "common.on": "On", "common.save": "Save", "mods.active": "Active", "mods.affected_files": "Affected files", + "mods.author": "Author", "mods.base": "Base", + "mods.confirm_enable_body": "Enabling rebuilds the shell with this package's source changes. Its code then runs with your user permissions, like the rest of Ambxst. Read the patch and check who wrote it first.", + "mods.confirm_enable_title": "Do you trust this mod?", + "mods.confirm_install_body": "Installing downloads the package and leaves it disabled. Nothing from it runs until you enable it, which is the moment to have read the code.", + "mods.confirm_install_title": "Install from this source?", "mods.confirm_remove": "Confirm remove", "mods.conflicts": "Conflicts", "mods.dependency_disabled": "Disabled", @@ -35,20 +47,24 @@ Item { "mods.disable": "Disable", "mods.disabled": "Disabled", "mods.drag_order": "Drag to change load order", - "mods.empty": "No mods are installed. Add a package source above.", + "mods.empty": "No mods installed yet. Paste a package source above.", "mods.enable": "Enable", "mods.enabled": "Enabled", + "mods.hide_files": "Hide", + "mods.homepage": "Mod page", "mods.incompatible": "Incompatible", - "mods.untested_base": "%1. You can still enable it.", "mods.install": "Install", "mods.install_dependencies": "Install required mods", + "mods.installed_count": "Installed · %1", "mods.invalid_number": "Enter a valid number.", - "mods.load_order": "Load order %1", + "mods.license": "License", + "mods.load_order": "Load order", "mods.loading_settings": "Loading settings…", "mods.move_down": "Move down", "mods.move_up": "Move up", "mods.no_matches": "No installed mods match this search.", "mods.none": "None", + "mods.open_link": "Open", "mods.package_error": "Package error", "mods.package_source": "Package source", "mods.package_status": "Package status", @@ -58,6 +74,7 @@ Item { "mods.refresh": "Refresh mod state", "mods.remove": "Remove", "mods.required_mods": "Required mods", + "mods.requirements": "Required commands", "mods.restart_now": "Restart now", "mods.restart_required": "Restart Ambxst to load the active generation.", "mods.revision": "Revision", @@ -66,6 +83,7 @@ Item { "mods.select": "Select", "mods.select_hint": "Select a mod to inspect its package details.", "mods.settings": "Settings", + "mods.show_files": "Show %1", "mods.sort_load_order": "Sort: Load order", "mods.sort_name": "Sort: Name", "mods.sort_state": "Sort: State", @@ -86,20 +104,26 @@ Item { "mods.unknown": "Unknown", "mods.unknown_error": "Unknown error", "mods.unknown_version": "Unknown version", + "mods.untested_base": "%1. You can still enable it.", "mods.update": "Update", "mods.working": "Working…" }) function tr(key, argument) { + const fallback = root.fallbackText[key] ?? key; if (root.i18nActive) { try { - if (typeof I18n !== "undefined" && typeof I18n.t === "function") - return I18n.t(key, argument); + if (typeof I18n !== "undefined" && typeof I18n.t === "function") { + const translated = I18n.t(key, argument); + // A translator without this key returns the key itself, and + // the built-in English string beats showing a raw key. + if (translated !== undefined && translated !== key) + return translated; + } } catch (error) { // The English fallback keeps Mods available if the translator is unavailable. } } - const fallback = root.fallbackText[key] ?? key; return argument === undefined ? fallback : fallback.replace("%1", String(argument)); } @@ -107,8 +131,48 @@ Item { return (mod?.dependencyState ?? []).every(dependency => dependency.enabled); } + function stateLabel(mod) { + if (!mod) + return ""; + if (!mod.valid) + return root.tr("mods.package_error"); + if (!mod.compatible) + return root.tr("mods.incompatible"); + return mod.enabled ? root.tr("mods.enabled") : root.tr("mods.disabled"); + } + + function stateColor(mod) { + if (!mod || !mod.valid || !mod.compatible) + return Colors.error; + if (mod.untested) + return Colors.warning; + return mod.enabled ? Colors.primary : Colors.outline; + } + + function askConfirm(kind, mod, source) { + root.confirmKind = kind; + root.confirmMod = mod ?? null; + root.confirmSource = source ?? ""; + } + + function closeConfirm() { + root.confirmKind = ""; + root.confirmMod = null; + root.confirmSource = ""; + } + + function runConfirmed() { + const kind = root.confirmKind; + const mod = root.confirmMod; + const source = root.confirmSource; + root.closeConfirm(); + if (kind === "install") + ModsService.install(source); + else if (kind === "enable" && mod) + ModsService.setEnabled(mod.id, true); + } + readonly property int contentWidth: Math.max(0, Math.min(width - horizontalMargin * 2, maxContentWidth)) - readonly property bool wideLayout: contentWidth >= 900 readonly property bool hasInstalledMods: (ModsService.mods ?? []).length > 0 readonly property var filteredMods: { const query = root.searchQuery.trim().toLowerCase(); @@ -135,18 +199,25 @@ Item { } return mods.length > 0 ? mods[0] : null; } - - onSelectedModChanged: { - if (selectedMod && selectedId !== selectedMod.id) { - selectedId = selectedMod.id; - return; - } - ModsService.loadSettings(selectedMod?.id ?? ""); - removeArmedId = ""; + // Derived from selectedMod instead of written back into selectedId; that + // write-back is what made the selection bind to itself in a loop. + readonly property string effectiveId: root.selectedMod?.id ?? "" + + onEffectiveIdChanged: { + ModsService.loadSettings(root.effectiveId); + root.removeArmedId = ""; + root.filesExpanded = false; } Component.onCompleted: ModsService.refresh() + // The daemon clears the restart flag once a new generation survives its + // health window. Re-reading on show keeps the banner from outliving it. + onVisibleChanged: { + if (visible) + ModsService.refresh(); + } + Connections { target: ModsService function onInstalled(source) { @@ -160,13 +231,20 @@ Item { property bool primary: false property bool destructive: false - implicitHeight: 36 - leftPadding: 12 - rightPadding: 12 + implicitHeight: 34 + leftPadding: 14 + rightPadding: 14 enabled: !ModsService.busy + opacity: enabled ? 1 : 0.45 background: StyledRect { - variant: action.primary ? "primary" : ((action.hovered || action.activeFocus || action.down) ? "focus" : "common") + // "common" resolves to the same surface as the card behind it, so a + // resting secondary button used to read as plain text. "focus" is + // one step brighter and keeps the control visible on both grounds. + variant: action.primary + ? ((action.hovered || action.down) ? "primaryfocus" : "primary") + : ((action.hovered || action.down || action.activeFocus) + ? (action.destructive ? "error" : "secondary") : "focus") radius: Styling.radius(-2) enableShadow: false } @@ -176,398 +254,467 @@ Item { font.family: Config.theme.font font.pixelSize: Styling.fontSize(-1) font.weight: action.primary ? Font.DemiBold : Font.Medium - color: action.destructive ? Colors.error : action.primary ? Styling.srItem("primary") : Colors.overBackground + color: action.primary ? Styling.srItem("primary") + : action.destructive && !action.hovered ? Colors.error + : Colors.overBackground horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight } } - ColumnLayout { - width: root.contentWidth - height: parent.height - anchors.horizontalCenter: parent.horizontalCenter - spacing: 8 - - PanelTitlebar { - title: root.tr("mods.title") - statusText: ModsService.busy ? root.tr("mods.working") : "" - actions: [ - { - icon: Icons.arrowCounterClockwise, - tooltip: root.tr("mods.refresh"), - enabled: !ModsService.busy, - onClicked: function () { ModsService.refresh(); } - } - ] + // One label/value line. The label column has a fixed width so a stack of + // these reads as a table instead of loose paragraphs. + component MetaRow: RowLayout { + id: meta + property string label: "" + property string value: "" + property bool mono: false + default property alias trailing: trailingSlot.data - ActionButton { - text: root.tr("mods.rebuild") - onClicked: ModsService.rebuild() - } + Layout.fillWidth: true + spacing: 10 + + Text { + Layout.preferredWidth: 118 + Layout.alignment: Qt.AlignTop + text: meta.label + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + wrapMode: Text.Wrap } - StyledRect { - visible: !ModsService.generationCurrent || ModsService.restartRequired - || ModsService.errorMessage !== "" || ModsService.statusMessage !== "" - || ModsService.statusMessageKey !== "" + Text { Layout.fillWidth: true - Layout.preferredHeight: statusRow.implicitHeight + 16 - variant: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? "focus" : "common" - radius: Styling.radius(-2) - enableShadow: false + Layout.alignment: Qt.AlignVCenter + visible: meta.value !== "" + text: meta.value + font.family: meta.mono ? Config.theme.monoFont : Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + wrapMode: Text.WrapAnywhere + } - RowLayout { - id: statusRow - anchors.fill: parent - anchors.margins: 8 - spacing: 8 + RowLayout { + id: trailingSlot + Layout.alignment: Qt.AlignVCenter + spacing: 6 + } + } - Text { - text: ModsService.errorMessage !== "" || !ModsService.generationCurrent - ? Icons.alert : (ModsService.restartRequired ? Icons.arrowCounterClockwise : Icons.accept) - font.family: Icons.font - font.pixelSize: 16 - color: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? Colors.error : Colors.overBackground - } + Flickable { + id: mainFlickable + anchors.fill: parent + contentHeight: mainColumn.implicitHeight + 8 + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: root.confirmKind === "" - Text { - Layout.fillWidth: true - text: ModsService.errorMessage !== "" ? ModsService.errorMessage - : !ModsService.generationCurrent ? root.tr("mods.rebuild_required", ModsService.generationError) - : ModsService.restartRequired ? root.tr("mods.restart_required") - : ModsService.statusMessageKey !== "" ? root.tr(ModsService.statusMessageKey) - : ModsService.statusMessage - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - color: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? Colors.error : Colors.overBackground - wrapMode: Text.Wrap - } + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + ColumnLayout { + id: mainColumn + width: root.contentWidth + x: Math.max(0, (mainFlickable.width - width) / 2) + spacing: 10 + + PanelTitlebar { + title: root.tr("mods.title") + statusText: ModsService.busy ? root.tr("mods.working") : "" + actions: [ + { + icon: Icons.arrowCounterClockwise, + tooltip: root.tr("mods.refresh"), + enabled: !ModsService.busy, + onClicked: function () { ModsService.refresh(); } + } + ] ActionButton { - visible: !ModsService.generationCurrent text: root.tr("mods.rebuild") - primary: true onClicked: ModsService.rebuild() } - - ActionButton { - visible: ModsService.restartRequired - text: root.tr("mods.restart_now") - primary: true - onClicked: ModsService.restart() - } } - } - ColumnLayout { - Layout.fillWidth: true - spacing: 4 - - Text { - text: root.tr("mods.package_source") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - font.weight: Font.Medium - color: Colors.overBackground - } - - RowLayout { + StyledRect { + visible: !ModsService.generationCurrent || ModsService.restartRequired + || ModsService.errorMessage !== "" || ModsService.statusMessage !== "" + || ModsService.statusMessageKey !== "" Layout.fillWidth: true - spacing: 8 + Layout.preferredHeight: statusRow.implicitHeight + 18 + variant: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? "focus" : "common" + radius: Styling.radius(-2) + enableShadow: false - TextField { - id: sourceInput - Layout.fillWidth: true - implicitHeight: 40 - placeholderText: root.tr("mods.source_placeholder") - color: Colors.overBackground - placeholderTextColor: Colors.outline - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - selectByMouse: true - enabled: !ModsService.busy - Accessible.name: root.tr("mods.package_source") - Accessible.description: root.tr("mods.source_placeholder") + RowLayout { + id: statusRow + anchors.fill: parent + anchors.margins: 9 + spacing: 10 + + Text { + Layout.alignment: Qt.AlignVCenter + text: ModsService.errorMessage !== "" || !ModsService.generationCurrent + ? Icons.alert : (ModsService.restartRequired ? Icons.arrowCounterClockwise : Icons.accept) + font.family: Icons.font + font.pixelSize: 16 + color: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? Colors.error : Colors.overBackground + } - background: StyledRect { - variant: sourceInput.activeFocus ? "focus" : "common" - radius: Styling.radius(-2) - enableShadow: false + Text { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: ModsService.errorMessage !== "" ? ModsService.errorMessage + : !ModsService.generationCurrent ? root.tr("mods.rebuild_required", ModsService.generationError) + : ModsService.restartRequired ? root.tr("mods.restart_required") + : ModsService.statusMessageKey !== "" ? root.tr(ModsService.statusMessageKey) + : ModsService.statusMessage + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? Colors.error : Colors.overBackground + wrapMode: Text.Wrap } - onAccepted: { - const source = text.trim(); - if (source !== "") { - ModsService.install(source); - } + ActionButton { + visible: !ModsService.generationCurrent + text: root.tr("mods.rebuild") + primary: true + onClicked: ModsService.rebuild() } - } - ActionButton { - text: root.tr("mods.install") - primary: true - enabled: !ModsService.busy && sourceInput.text.trim() !== "" - onClicked: { - ModsService.install(sourceInput.text.trim()); + ActionButton { + visible: ModsService.restartRequired + text: root.tr("mods.restart_now") + primary: true + onClicked: ModsService.restart() } } } - } - Text { - Layout.fillWidth: true - text: root.tr("mods.trust_warning") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.outline - wrapMode: Text.Wrap - } - - GridLayout { - id: managerGrid - Layout.fillWidth: true - Layout.fillHeight: true - columns: root.wideLayout && root.hasInstalledMods ? 2 : 1 - columnSpacing: 12 - rowSpacing: 12 - - ColumnLayout { + StyledRect { Layout.fillWidth: true - Layout.fillHeight: root.wideLayout || !root.hasInstalledMods - Layout.preferredWidth: root.wideLayout && root.hasInstalledMods ? 380 : managerGrid.width - Layout.preferredHeight: root.wideLayout || !root.hasInstalledMods ? 0 : 280 - spacing: 6 + Layout.preferredHeight: installColumn.implicitHeight + 28 + variant: "pane" + radius: Styling.radius(0) - RowLayout { - Layout.fillWidth: true - spacing: 6 + ColumnLayout { + id: installColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 8 + + Text { + text: root.tr("mods.package_source") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: Colors.overBackground + } - SearchInput { + RowLayout { Layout.fillWidth: true - placeholderText: root.tr("mods.search") - clearOnEscape: true - onSearchTextChanged: text => root.searchQuery = text + spacing: 8 + + TextField { + id: sourceInput + Layout.fillWidth: true + implicitHeight: 38 + placeholderText: root.tr("mods.source_placeholder") + color: Colors.overBackground + placeholderTextColor: Colors.outline + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + selectByMouse: true + enabled: !ModsService.busy + Accessible.name: root.tr("mods.package_source") + Accessible.description: root.tr("mods.source_placeholder") + + background: StyledRect { + variant: sourceInput.activeFocus ? "focus" : "common" + radius: Styling.radius(-2) + enableShadow: false + } + + onAccepted: { + const source = text.trim(); + if (source !== "") + root.askConfirm("install", null, source); + } + } + + ActionButton { + text: root.tr("mods.install") + primary: true + enabled: !ModsService.busy && sourceInput.text.trim() !== "" + onClicked: root.askConfirm("install", null, sourceInput.text.trim()) + } } - ActionButton { - text: root.sortMode === "name" ? root.tr("mods.sort_name") - : root.sortMode === "state" ? root.tr("mods.sort_state") - : root.tr("mods.sort_load_order") - onClicked: root.sortMode = root.sortMode === "name" ? "state" - : root.sortMode === "state" ? "loadOrder" - : "name" + Text { + Layout.fillWidth: true + text: root.tr("mods.trust_warning") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + wrapMode: Text.Wrap } } + } - StyledRect { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: root.hasInstalledMods ? 180 : 260 - variant: "pane" - radius: Styling.radius(0) + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 8 - ColumnLayout { - visible: ModsService.loaded && root.filteredMods.length === 0 - anchors.centerIn: parent - width: Math.min(parent.width - 48, 440) - spacing: 10 + Text { + text: root.tr("mods.installed_count", String((ModsService.mods ?? []).length)) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: Colors.overBackground + } - Text { - Layout.alignment: Qt.AlignHCenter - text: Icons.plug - font.family: Icons.font - font.pixelSize: 30 - color: Colors.outline - } + Item { Layout.fillWidth: true } - Text { - Layout.fillWidth: true - text: ModsService.mods.length === 0 - ? root.tr("mods.empty") - : root.tr("mods.no_matches") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(0) - color: Colors.outline - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.Wrap - } + SearchInput { + Layout.preferredWidth: 220 + visible: root.hasInstalledMods + placeholderText: root.tr("mods.search") + clearOnEscape: true + onSearchTextChanged: text => root.searchQuery = text + } + + ActionButton { + visible: root.hasInstalledMods + text: root.sortMode === "name" ? root.tr("mods.sort_name") + : root.sortMode === "state" ? root.tr("mods.sort_state") + : root.tr("mods.sort_load_order") + onClicked: root.sortMode = root.sortMode === "name" ? "state" + : root.sortMode === "state" ? "loadOrder" + : "name" + } + } + + StyledRect { + Layout.fillWidth: true + Layout.preferredHeight: root.filteredMods.length > 0 + ? modList.implicitHeight + 16 + : emptyState.implicitHeight + 48 + variant: "pane" + radius: Styling.radius(0) + + ColumnLayout { + id: emptyState + visible: ModsService.loaded && root.filteredMods.length === 0 + anchors.centerIn: parent + width: Math.min(parent.width - 48, 420) + spacing: 10 + + Text { + Layout.alignment: Qt.AlignHCenter + text: Icons.plug + font.family: Icons.font + font.pixelSize: 28 + color: Colors.outline } - Flickable { - anchors.fill: parent - anchors.margins: 6 - contentHeight: modList.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds + Text { + Layout.fillWidth: true + text: (ModsService.mods ?? []).length === 0 + ? root.tr("mods.empty") + : root.tr("mods.no_matches") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: Colors.outline + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + } - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded - } + ColumnLayout { + id: modList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 4 - ColumnLayout { - id: modList - width: parent.width - spacing: 4 + Repeater { + model: root.filteredMods - Repeater { - model: root.filteredMods + delegate: StyledRect { + id: modRow + required property var modelData + readonly property bool current: root.effectiveId === modelData.id - delegate: StyledRect { - id: modRow - required property var modelData - Layout.fillWidth: true - Layout.preferredHeight: 58 - variant: root.selectedMod?.id === modelData.id ? "primary" - : (rowMouse.containsMouse || activeFocus ? "focus" : "common") + Layout.fillWidth: true + Layout.preferredHeight: 54 + variant: modRow.current ? "primary" + : (rowMouse.containsMouse || activeFocus ? "focus" : "common") + radius: Styling.radius(-2) + enableShadow: false + activeFocusOnTab: true + Accessible.role: Accessible.ListItem + Accessible.name: modelData.name + ", " + root.stateLabel(modelData) + Accessible.onPressAction: root.selectedId = modelData.id + + Keys.onReturnPressed: root.selectedId = modelData.id + Keys.onEnterPressed: root.selectedId = modelData.id + Keys.onSpacePressed: root.selectedId = modelData.id + + DropArea { + anchors.fill: parent + keys: ["ambxstMod"] + enabled: root.sortMode === "loadOrder" && root.searchQuery === "" + property int loadOrder: modRow.modelData.order + + StyledRect { + anchors.fill: parent + visible: parent.containsDrag + variant: "focus" radius: Styling.radius(-2) enableShadow: false - activeFocusOnTab: true - Accessible.role: Accessible.ListItem - Accessible.name: modelData.name + ", " + (modelData.enabled - ? root.tr("mods.enabled") : root.tr("mods.disabled")) - Accessible.onPressAction: root.selectedId = modelData.id - - Keys.onReturnPressed: root.selectedId = modelData.id - Keys.onEnterPressed: root.selectedId = modelData.id - Keys.onSpacePressed: root.selectedId = modelData.id + } + } - DropArea { - anchors.fill: parent - keys: ["ambxstMod"] - enabled: root.sortMode === "loadOrder" && root.searchQuery === "" - property int loadOrder: modRow.modelData.order - - StyledRect { - anchors.fill: parent - visible: parent.containsDrag - variant: "focus" - radius: Styling.radius(-2) - enableShadow: false - } - } + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + modRow.forceActiveFocus(); + root.selectedId = modRow.modelData.id; + } + } - MouseArea { - id: rowMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - modRow.forceActiveFocus(); - root.selectedId = modRow.modelData.id; + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + spacing: 10 + + Text { + visible: root.sortMode === "loadOrder" && root.searchQuery === "" + text: Icons.dotsNine + font.family: Icons.font + font.pixelSize: 17 + color: modRow.item + opacity: reorderDrag.active ? 1 : 0.6 + Accessible.role: Accessible.Button + Accessible.name: root.tr("mods.drag_order") + + DragHandler { + id: reorderDrag + target: dragPreview + xAxis.enabled: false + enabled: !ModsService.busy + onActiveChanged: { + if (active) { + const point = modRow.mapToItem(dragPreview.parent, 0, 0); + dragPreview.x = point.x; + dragPreview.y = point.y; + return; + } + const target = dragPreview.Drag.target; + if (target && target.loadOrder !== undefined + && target.loadOrder !== modRow.modelData.order) + ModsService.moveTo(modRow.modelData.id, target.loadOrder); + dragPreview.Drag.drop(); } } + } - RowLayout { - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 8 - spacing: 8 + // Status rail: the state is readable before any text is. + Rectangle { + Layout.alignment: Qt.AlignVCenter + implicitWidth: 6 + implicitHeight: 6 + radius: 3 + color: modRow.current ? modRow.item : root.stateColor(modRow.modelData) + opacity: modRow.modelData.enabled || !modRow.modelData.valid ? 1 : 0.55 + } - Text { - visible: root.sortMode === "loadOrder" && root.searchQuery === "" - text: Icons.dotsNine - font.family: Icons.font - font.pixelSize: 17 - color: modRow.item - opacity: reorderDrag.active ? 1 : 0.65 - Accessible.role: Accessible.Button - Accessible.name: root.tr("mods.drag_order") - - DragHandler { - id: reorderDrag - target: dragPreview - xAxis.enabled: false - enabled: !ModsService.busy - onActiveChanged: { - if (active) { - const point = modRow.mapToItem(dragPreview.parent, 0, 0); - dragPreview.x = point.x; - dragPreview.y = point.y; - return; - } - const target = dragPreview.Drag.target; - if (target && target.loadOrder !== undefined - && target.loadOrder !== modRow.modelData.order) - ModsService.moveTo(modRow.modelData.id, target.loadOrder); - dragPreview.Drag.drop(); - } - } - } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 - ColumnLayout { - Layout.fillWidth: true - spacing: 1 - - Text { - Layout.fillWidth: true - text: modRow.modelData.name - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - font.weight: Font.DemiBold - color: modRow.item - elide: Text.ElideRight - } + Text { + Layout.fillWidth: true + text: modRow.modelData.name + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: modRow.item + elide: Text.ElideRight + } - Text { - Layout.fillWidth: true - text: (modRow.modelData.version || root.tr("mods.unknown_version")) + " · " - + (!modRow.modelData.valid ? root.tr("mods.package_error") - : !modRow.modelData.compatible ? root.tr("mods.incompatible") - : modRow.modelData.enabled ? root.tr("mods.enabled") : root.tr("mods.disabled")) - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: modRow.item - opacity: 0.72 - elide: Text.ElideRight - } - } + Text { + Layout.fillWidth: true + text: (modRow.modelData.version || root.tr("mods.unknown_version")) + + " · " + root.stateLabel(modRow.modelData) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: modRow.item + opacity: 0.7 + elide: Text.ElideRight + } + } - ActionButton { - text: modRow.modelData.enabled ? root.tr("mods.disable") : root.tr("mods.enable") - primary: !modRow.modelData.enabled - enabled: !ModsService.busy && (modRow.modelData.enabled - || (modRow.modelData.valid && modRow.modelData.compatible - && root.dependenciesReady(modRow.modelData))) - onClicked: { - root.selectedId = modRow.modelData.id; - ModsService.setEnabled(modRow.modelData.id, !modRow.modelData.enabled); - } + ActionButton { + text: modRow.modelData.enabled ? root.tr("mods.disable") : root.tr("mods.enable") + primary: !modRow.modelData.enabled + enabled: !ModsService.busy && (modRow.modelData.enabled + || (modRow.modelData.valid && modRow.modelData.compatible + && root.dependenciesReady(modRow.modelData))) + onClicked: { + root.selectedId = modRow.modelData.id; + if (modRow.modelData.enabled) { + ModsService.setEnabled(modRow.modelData.id, false); + return; } + root.askConfirm("enable", modRow.modelData, modRow.modelData.source ?? ""); } + } + } - Item { - id: dragPreview - parent: modList.parent - width: modRow.width - height: modRow.height - visible: reorderDrag.active - z: 100 - - StyledRect { - anchors.fill: parent - variant: "primary" - radius: Styling.radius(-2) - - Text { - anchors.fill: parent - anchors.margins: 10 - text: modRow.modelData.name - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - font.weight: Font.DemiBold - color: parent.item - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } + Item { + id: dragPreview + parent: modList.parent + width: modRow.width + height: modRow.height + visible: reorderDrag.active + z: 100 + + StyledRect { + id: dragPreviewSurface + anchors.fill: parent + variant: "primary" + radius: Styling.radius(-2) - Drag.active: reorderDrag.active - Drag.source: modRow - Drag.hotSpot.x: width / 2 - Drag.hotSpot.y: height / 2 - Drag.keys: ["ambxstMod"] + Text { + anchors.fill: parent + anchors.margins: 10 + text: modRow.modelData.name + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: dragPreviewSurface.item + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight } } + + Drag.active: reorderDrag.active + Drag.source: modRow + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + Drag.keys: ["ambxstMod"] } } } @@ -575,434 +722,621 @@ Item { } StyledRect { - visible: root.hasInstalledMods + visible: root.hasInstalledMods && !!root.selectedMod Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredWidth: root.wideLayout ? 720 : managerGrid.width - Layout.minimumHeight: 320 + Layout.preferredHeight: details.implicitHeight + 28 variant: "pane" radius: Styling.radius(0) - Text { - visible: !root.selectedMod - anchors.centerIn: parent - text: root.tr("mods.select_hint") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - color: Colors.outline - } - - Flickable { - visible: !!root.selectedMod - anchors.fill: parent + ColumnLayout { + id: details + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top anchors.margins: 14 - contentHeight: details.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - - ScrollBar.vertical: ScrollBar { - policy: ScrollBar.AsNeeded - } + spacing: 8 - ColumnLayout { - id: details - width: parent.width - spacing: 8 + RowLayout { + Layout.fillWidth: true + spacing: 10 - Text { + ColumnLayout { Layout.fillWidth: true - text: root.selectedMod?.name ?? "" - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(1) - font.weight: Font.DemiBold - color: Colors.overBackground - wrapMode: Text.Wrap - } + spacing: 2 - Text { - Layout.fillWidth: true - text: (root.selectedMod?.id ?? "") + " · " + (root.selectedMod?.version ?? "") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.outline - wrapMode: Text.WrapAnywhere - } + Text { + Layout.fillWidth: true + text: root.selectedMod?.name ?? "" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(1) + font.weight: Font.DemiBold + color: Colors.overBackground + elide: Text.ElideRight + } - Text { - visible: (root.selectedMod?.author ?? "") !== "" - || (root.selectedMod?.license ?? "") !== "" - Layout.fillWidth: true - text: [root.selectedMod?.author ?? "", root.selectedMod?.license ?? ""] - .filter(value => value !== "").join(" · ") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.outline - wrapMode: Text.Wrap + Text { + Layout.fillWidth: true + text: (root.selectedMod?.id ?? "") + " · " + (root.selectedMod?.version ?? "") + font.family: Config.theme.monoFont + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + elide: Text.ElideRight + } } - Text { - Layout.fillWidth: true - text: root.selectedMod?.description ?? "" - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - color: Colors.overBackground - wrapMode: Text.Wrap + StyledRect { + Layout.alignment: Qt.AlignVCenter + implicitWidth: stateChip.implicitWidth + 20 + implicitHeight: 24 + variant: "common" + radius: Styling.radius(-2) + enableShadow: false + + Text { + id: stateChip + anchors.centerIn: parent + text: root.stateLabel(root.selectedMod) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: root.stateColor(root.selectedMod) + } } + } - Text { - visible: (root.selectedMod?.error ?? "") !== "" - || (root.selectedMod?.compatibilityError ?? "") !== "" - Layout.fillWidth: true - text: root.tr("mods.package_status") + "\n" + (root.selectedMod?.error - || root.selectedMod?.compatibilityError || root.tr("mods.unknown_error")) - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.error - wrapMode: Text.WrapAnywhere + Text { + Layout.fillWidth: true + visible: (root.selectedMod?.description ?? "") !== "" + text: root.selectedMod?.description ?? "" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + wrapMode: Text.Wrap + } + + Text { + visible: (root.selectedMod?.error ?? "") !== "" + || (root.selectedMod?.compatibilityError ?? "") !== "" + Layout.fillWidth: true + text: root.tr("mods.package_status") + ": " + (root.selectedMod?.error + || root.selectedMod?.compatibilityError || root.tr("mods.unknown_error")) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.error + wrapMode: Text.WrapAnywhere + } + + Text { + visible: (root.selectedMod?.untestedMessage ?? "") !== "" + Layout.fillWidth: true + text: root.tr("mods.untested_base", root.selectedMod?.untestedMessage ?? "") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.warning + wrapMode: Text.Wrap + } + + Separator { Layout.fillWidth: true } + + MetaRow { + visible: (root.selectedMod?.author ?? "") !== "" + label: root.tr("mods.author") + value: root.selectedMod?.author ?? "" + + ActionButton { + visible: (root.selectedMod?.authorUrl ?? "") !== "" + text: root.tr("mods.open_link") + onClicked: Qt.openUrlExternally(root.selectedMod.authorUrl) } + } - Text { - visible: (root.selectedMod?.untestedMessage ?? "") !== "" - Layout.fillWidth: true - text: root.tr("mods.untested_base", root.selectedMod?.untestedMessage ?? "") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.warning - wrapMode: Text.Wrap + MetaRow { + visible: (root.selectedMod?.license ?? "") !== "" + label: root.tr("mods.license") + value: root.selectedMod?.license ?? "" + } + + MetaRow { + label: root.tr("mods.source") + value: root.selectedMod?.source ?? root.tr("mods.unknown") + mono: true + + ActionButton { + visible: (root.selectedMod?.source ?? "").startsWith("http") + text: root.tr("mods.open_link") + onClicked: Qt.openUrlExternally(root.selectedMod.source) } + } - Separator { Layout.fillWidth: true } + MetaRow { + visible: (root.selectedMod?.homepage ?? "") !== "" + label: root.tr("mods.homepage") + value: root.selectedMod?.homepage ?? "" + mono: true - Text { - Layout.fillWidth: true - text: root.tr("mods.source") + "\n" + (root.selectedMod?.source ?? root.tr("mods.unknown")) - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.overBackground - wrapMode: Text.WrapAnywhere + ActionButton { + text: root.tr("mods.open_link") + onClicked: Qt.openUrlExternally(root.selectedMod.homepage) } + } - Text { - Layout.fillWidth: true - visible: (root.selectedMod?.revision ?? "") !== "" - text: root.tr("mods.revision") + "\n" + (root.selectedMod?.revision ?? "") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.overBackground - wrapMode: Text.WrapAnywhere + MetaRow { + visible: (root.selectedMod?.revision ?? "") !== "" + label: root.tr("mods.revision") + value: (root.selectedMod?.revision ?? "").substring(0, 12) + mono: true + } + + MetaRow { + label: root.tr("mods.load_order") + value: String((root.selectedMod?.order ?? 0) + 1) + + ActionButton { + text: root.tr("mods.move_up") + enabled: !ModsService.busy && (root.selectedMod?.order ?? 0) > 0 + onClicked: ModsService.move(root.selectedMod.id, -1) } - Text { - Layout.fillWidth: true - text: root.tr("mods.affected_files") + "\n" - + ((root.selectedMod?.affectedFiles ?? []).join("\n") || root.tr("mods.none")) - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.overBackground - wrapMode: Text.WrapAnywhere + ActionButton { + text: root.tr("mods.move_down") + enabled: !ModsService.busy + && (root.selectedMod?.order ?? 0) < (ModsService.mods ?? []).length - 1 + onClicked: ModsService.move(root.selectedMod.id, 1) } + } - RowLayout { - Layout.fillWidth: true - spacing: 6 + MetaRow { + visible: (root.selectedMod?.permissions ?? []).length > 0 + label: root.tr("mods.permissions") + value: (root.selectedMod?.permissions ?? []).join(", ") + } - Text { - Layout.fillWidth: true - text: root.tr("mods.load_order", String((root.selectedMod?.order ?? 0) + 1)) - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.overBackground - } + MetaRow { + visible: (root.selectedMod?.commands ?? []).length > 0 + label: root.tr("mods.requirements") + value: (root.selectedMod?.commands ?? []).join(", ") + mono: true + } - ActionButton { - text: root.tr("mods.move_up") - enabled: !ModsService.busy && (root.selectedMod?.order ?? 0) > 0 - onClicked: ModsService.move(root.selectedMod.id, -1) - } + MetaRow { + visible: (root.selectedMod?.conflicts ?? []).length > 0 + label: root.tr("mods.conflicts") + value: (root.selectedMod?.conflicts ?? []).join(", ") + mono: true + } - ActionButton { - text: root.tr("mods.move_down") - enabled: !ModsService.busy && (root.selectedMod?.order ?? 0) < ModsService.mods.length - 1 - onClicked: ModsService.move(root.selectedMod.id, 1) + ColumnLayout { + Layout.fillWidth: true + visible: (root.selectedMod?.dependencyState ?? []).length > 0 + spacing: 4 + + Repeater { + model: root.selectedMod?.dependencyState ?? [] + + delegate: MetaRow { + required property var modelData + required property int index + label: index === 0 ? root.tr("mods.required_mods") : "" + value: modelData.id + mono: true + + Text { + text: modelData.enabled ? root.tr("mods.dependency_ready") + : modelData.installed ? root.tr("mods.dependency_disabled") + : root.tr("mods.dependency_missing") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: modelData.enabled ? Colors.success : Colors.warning + } } } - Text { - Layout.fillWidth: true - visible: (root.selectedMod?.permissions ?? []).length > 0 - text: root.tr("mods.permissions") + "\n" + (root.selectedMod?.permissions ?? []).join(", ") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.overBackground - wrapMode: Text.Wrap + ActionButton { + Layout.leftMargin: 128 + visible: !root.dependenciesReady(root.selectedMod) + text: root.tr("mods.install_dependencies") + primary: true + enabled: !ModsService.busy + onClicked: ModsService.installDependencies(root.selectedMod.id) } + } - ColumnLayout { - Layout.fillWidth: true - visible: (root.selectedMod?.dependencyState ?? []).length > 0 - spacing: 4 + MetaRow { + label: root.tr("mods.affected_files") + value: (root.selectedMod?.affectedFiles ?? []).length === 0 ? root.tr("mods.none") : "" - Text { - text: root.tr("mods.required_mods") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - font.weight: Font.DemiBold - color: Colors.overBackground - } + ActionButton { + visible: (root.selectedMod?.affectedFiles ?? []).length > 0 + text: root.filesExpanded ? root.tr("mods.hide_files") + : root.tr("mods.show_files", String((root.selectedMod?.affectedFiles ?? []).length)) + onClicked: root.filesExpanded = !root.filesExpanded + } + } - Repeater { - model: root.selectedMod?.dependencyState ?? [] + StyledRect { + visible: root.filesExpanded && (root.selectedMod?.affectedFiles ?? []).length > 0 + Layout.fillWidth: true + Layout.leftMargin: 128 + Layout.preferredHeight: fileList.implicitHeight + 16 + variant: "common" + radius: Styling.radius(-2) + enableShadow: false - delegate: RowLayout { - required property var modelData - Layout.fillWidth: true - spacing: 8 + ColumnLayout { + id: fileList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 2 - Text { - Layout.fillWidth: true - text: modelData.id - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.overBackground - elide: Text.ElideMiddle - } + Repeater { + model: root.selectedMod?.affectedFiles ?? [] - Text { - text: modelData.enabled ? root.tr("mods.dependency_ready") - : modelData.installed ? root.tr("mods.dependency_disabled") - : root.tr("mods.dependency_missing") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - font.weight: Font.Medium - color: modelData.enabled ? Colors.success : Colors.warning - } + delegate: Text { + required property string modelData + Layout.fillWidth: true + text: modelData + font.family: Config.theme.monoFont + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + elide: Text.ElideLeft } } - - ActionButton { - visible: !root.dependenciesReady(root.selectedMod) - text: root.tr("mods.install_dependencies") - primary: true - enabled: !ModsService.busy - onClicked: ModsService.installDependencies(root.selectedMod.id) - } } + } + + ColumnLayout { + visible: root.selectedMod?.hasSettings ?? false + Layout.fillWidth: true + spacing: 6 + + Separator { Layout.fillWidth: true } Text { - Layout.fillWidth: true - visible: (root.selectedMod?.commands ?? []).length > 0 - text: root.tr("mods.requirements") + "\n" + (root.selectedMod?.commands ?? []).join(", ") + text: root.tr("mods.settings") font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold color: Colors.overBackground - wrapMode: Text.WrapAnywhere } Text { - Layout.fillWidth: true - visible: (root.selectedMod?.conflicts ?? []).length > 0 - text: root.tr("mods.conflicts") + "\n" + (root.selectedMod?.conflicts ?? []).join(", ") + visible: ModsService.settingsBusy + text: root.tr("mods.loading_settings") font.family: Config.theme.font font.pixelSize: Styling.fontSize(-2) - color: Colors.overBackground - wrapMode: Text.WrapAnywhere + color: Colors.outline } - ColumnLayout { - visible: root.selectedMod?.hasSettings ?? false - Layout.fillWidth: true - spacing: 6 - - Separator { Layout.fillWidth: true } - - Text { - text: root.tr("mods.settings") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - font.weight: Font.DemiBold - color: Colors.overBackground - } + Repeater { + model: ModsService.settingsFields - Text { - visible: ModsService.settingsBusy - text: root.tr("mods.loading_settings") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.outline - } - - Repeater { - model: ModsService.settingsFields + delegate: ColumnLayout { + id: settingRow + required property var modelData + Layout.fillWidth: true + spacing: 3 + + function saveTextValue(text) { + let value = text; + if (settingRow.modelData.type === "integer") + value = parseInt(text, 10); + else if (settingRow.modelData.type === "number") + value = parseFloat(text); + if (typeof value === "number" && !Number.isFinite(value)) { + ModsService.errorMessage = root.tr("mods.invalid_number"); + return; + } + ModsService.setSetting(root.selectedMod.id, settingRow.modelData.key, value); + } - delegate: ColumnLayout { - id: settingRow - required property var modelData + RowLayout { Layout.fillWidth: true - spacing: 3 - - function saveTextValue(text) { - let value = text; - if (settingRow.modelData.type === "integer") - value = parseInt(text, 10); - else if (settingRow.modelData.type === "number") - value = parseFloat(text); - if (typeof value === "number" && !Number.isFinite(value)) { - ModsService.errorMessage = root.tr("mods.invalid_number"); - return; - } - ModsService.setSetting(root.selectedMod.id, settingRow.modelData.key, value); - } + spacing: 8 - RowLayout { + ColumnLayout { Layout.fillWidth: true - spacing: 8 + spacing: 1 - ColumnLayout { + Text { Layout.fillWidth: true - spacing: 1 - - Text { - Layout.fillWidth: true - text: settingRow.modelData.label - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - font.weight: Font.Medium - color: Colors.overBackground - wrapMode: Text.Wrap - } - - Text { - visible: (settingRow.modelData.description ?? "") !== "" - Layout.fillWidth: true - text: settingRow.modelData.description ?? "" - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.outline - wrapMode: Text.Wrap - } + text: settingRow.modelData.label + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.Medium + color: Colors.overBackground + wrapMode: Text.Wrap } - ActionButton { - visible: settingRow.modelData.type === "boolean" - text: ModsService.settingsValues[settingRow.modelData.key] - ? root.tr("common.on") : root.tr("common.off") - primary: !!ModsService.settingsValues[settingRow.modelData.key] - onClicked: ModsService.setSetting( - root.selectedMod.id, - settingRow.modelData.key, - !ModsService.settingsValues[settingRow.modelData.key] - ) + Text { + visible: (settingRow.modelData.description ?? "") !== "" + Layout.fillWidth: true + text: settingRow.modelData.description ?? "" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + wrapMode: Text.Wrap } + } - ActionButton { - visible: settingRow.modelData.type === "enum" - text: { - const options = settingRow.modelData.options ?? []; - const value = ModsService.settingsValues[settingRow.modelData.key]; - for (let i = 0; i < options.length; i++) { - if (options[i].value === value) - return options[i].label; - } - return String(value ?? root.tr("mods.select")); - } - onClicked: { - const options = settingRow.modelData.options ?? []; - if (options.length === 0) - return; - const value = ModsService.settingsValues[settingRow.modelData.key]; - let index = options.findIndex(option => option.value === value); - index = (index + 1) % options.length; - ModsService.setSetting(root.selectedMod.id, settingRow.modelData.key, options[index].value); + ActionButton { + visible: settingRow.modelData.type === "boolean" + text: ModsService.settingsValues[settingRow.modelData.key] + ? root.tr("common.on") : root.tr("common.off") + primary: !!ModsService.settingsValues[settingRow.modelData.key] + enabled: !ModsService.busy && !ModsService.settingsBusy + onClicked: ModsService.setSetting(root.selectedMod.id, + settingRow.modelData.key, + !ModsService.settingsValues[settingRow.modelData.key]) + } + + ActionButton { + visible: settingRow.modelData.type === "enum" + text: { + const options = settingRow.modelData.options ?? []; + const value = ModsService.settingsValues[settingRow.modelData.key]; + for (let i = 0; i < options.length; i++) { + if (options[i].value === value) + return options[i].label; } + return String(value ?? root.tr("mods.select")); + } + onClicked: { + const options = settingRow.modelData.options ?? []; + if (options.length === 0) + return; + const value = ModsService.settingsValues[settingRow.modelData.key]; + let index = options.findIndex(option => option.value === value); + index = (index + 1) % options.length; + ModsService.setSetting(root.selectedMod.id, settingRow.modelData.key, options[index].value); } } + } - RowLayout { - visible: settingRow.modelData.type === "string" - || settingRow.modelData.type === "integer" - || settingRow.modelData.type === "number" - Layout.fillWidth: true - spacing: 6 + RowLayout { + visible: settingRow.modelData.type === "string" + || settingRow.modelData.type === "integer" + || settingRow.modelData.type === "number" + Layout.fillWidth: true + spacing: 6 - TextField { - id: settingInput - Layout.fillWidth: true - implicitHeight: 36 - text: parent.visible ? String(ModsService.settingsValues[settingRow.modelData.key] ?? "") : "" - color: Colors.overBackground - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-1) - selectByMouse: true - inputMethodHints: settingRow.modelData.type === "string" ? Qt.ImhNone : Qt.ImhFormattedNumbersOnly - Accessible.name: settingRow.modelData.label - Accessible.description: settingRow.modelData.description ?? "" - background: StyledRect { - variant: settingInput.activeFocus ? "focus" : "common" - radius: Styling.radius(-2) - enableShadow: false - } - onAccepted: settingRow.saveTextValue(text) + TextField { + id: settingInput + Layout.fillWidth: true + implicitHeight: 34 + text: parent.visible ? String(ModsService.settingsValues[settingRow.modelData.key] ?? "") : "" + color: Colors.overBackground + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + selectByMouse: true + inputMethodHints: settingRow.modelData.type === "string" ? Qt.ImhNone : Qt.ImhFormattedNumbersOnly + Accessible.name: settingRow.modelData.label + Accessible.description: settingRow.modelData.description ?? "" + background: StyledRect { + variant: settingInput.activeFocus ? "focus" : "common" + radius: Styling.radius(-2) + enableShadow: false } + onAccepted: settingRow.saveTextValue(text) + } - ActionButton { - text: root.tr("common.save") - enabled: !ModsService.busy && !ModsService.settingsBusy - onClicked: settingRow.saveTextValue(settingInput.text) - } + ActionButton { + text: root.tr("common.save") + enabled: !ModsService.busy && !ModsService.settingsBusy + onClicked: settingRow.saveTextValue(settingInput.text) } } } } + } - RowLayout { - Layout.fillWidth: true - spacing: 6 + Separator { Layout.fillWidth: true } + + RowLayout { + Layout.fillWidth: true + spacing: 8 - ActionButton { - text: root.tr("mods.update") - onClicked: ModsService.update(root.selectedMod.id, root.selectedMod.enabled) + ActionButton { + text: root.selectedMod?.enabled ? root.tr("mods.disable") : root.tr("mods.enable") + primary: !root.selectedMod?.enabled + enabled: !ModsService.busy && (root.selectedMod?.enabled + || (root.selectedMod?.valid && root.selectedMod?.compatible + && root.dependenciesReady(root.selectedMod))) + onClicked: { + if (root.selectedMod.enabled) { + ModsService.setEnabled(root.selectedMod.id, false); + return; + } + root.askConfirm("enable", root.selectedMod, root.selectedMod.source ?? ""); } + } - Item { Layout.fillWidth: true } + ActionButton { + text: root.tr("mods.update") + onClicked: ModsService.update(root.selectedMod.id, root.selectedMod.enabled) + } - ActionButton { - text: root.removeArmedId === root.selectedMod?.id - ? root.tr("mods.confirm_remove") : root.tr("mods.remove") - destructive: true - onClicked: { - if (root.removeArmedId !== root.selectedMod.id) { - root.removeArmedId = root.selectedMod.id; - return; - } - ModsService.remove(root.selectedMod.id, root.selectedMod.enabled); - root.removeArmedId = ""; + Item { Layout.fillWidth: true } + + ActionButton { + text: root.removeArmedId === root.selectedMod?.id + ? root.tr("mods.confirm_remove") : root.tr("mods.remove") + destructive: true + onClicked: { + if (root.removeArmedId !== root.selectedMod.id) { + root.removeArmedId = root.selectedMod.id; + return; } + ModsService.remove(root.selectedMod.id, root.selectedMod.enabled); + root.removeArmedId = ""; } } } } } + + RowLayout { + Layout.fillWidth: true + Layout.bottomMargin: 4 + spacing: 8 + + Text { + Layout.fillWidth: true + text: root.tr("mods.base") + " " + (ModsService.baseVersion || root.tr("mods.unknown")) + + (ModsService.baseRevision ? " · " + ModsService.baseRevision.substring(0, 12) : "") + + " · " + root.tr("mods.active") + " " + (ModsService.activeGeneration || "base") + font.family: Config.theme.monoFont + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + elide: Text.ElideMiddle + } + + ActionButton { + visible: ModsService.previousGeneration !== "" + text: root.tr("mods.rollback") + onClicked: ModsService.rollback() + } + } } + } - RowLayout { - Layout.fillWidth: true - spacing: 8 + // Trust prompt. Installing and enabling both bring somebody else's code + // into the shell, so both say whose code it is before it happens. + Item { + anchors.fill: parent + visible: root.confirmKind !== "" + z: 50 - Text { - Layout.fillWidth: true - text: root.tr("mods.base") + " " + (ModsService.baseVersion || root.tr("mods.unknown")) - + (ModsService.baseRevision ? " · " + ModsService.baseRevision.substring(0, 12) : "") - + " · " + root.tr("mods.active") + " " + (ModsService.activeGeneration || "base") - font.family: Config.theme.font - font.pixelSize: Styling.fontSize(-2) - color: Colors.outline - elide: Text.ElideMiddle + Rectangle { + anchors.fill: parent + color: Colors.scrim + opacity: 0.55 + + MouseArea { + anchors.fill: parent + onClicked: root.closeConfirm() + } + } + + StyledRect { + anchors.centerIn: parent + width: Math.min(root.width - 48, 460) + height: confirmColumn.implicitHeight + 36 + variant: "popup" + radius: Styling.radius(1) + + MouseArea { + anchors.fill: parent } - ActionButton { - visible: ModsService.previousGeneration !== "" - text: root.tr("mods.rollback") - onClicked: ModsService.rollback() + ColumnLayout { + id: confirmColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 18 + spacing: 10 + + Text { + Layout.fillWidth: true + text: root.confirmKind === "enable" + ? root.tr("mods.confirm_enable_title") + : root.tr("mods.confirm_install_title") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(1) + font.weight: Font.DemiBold + color: Colors.overBackground + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + text: root.confirmKind === "enable" + ? root.tr("mods.confirm_enable_body") + : root.tr("mods.confirm_install_body") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + wrapMode: Text.Wrap + } + + Separator { Layout.fillWidth: true } + + MetaRow { + visible: (root.confirmMod?.author ?? "") !== "" + label: root.tr("mods.author") + value: root.confirmMod?.author ?? "" + + ActionButton { + visible: (root.confirmMod?.authorUrl ?? "") !== "" + text: root.tr("mods.open_link") + onClicked: Qt.openUrlExternally(root.confirmMod.authorUrl) + } + } + + MetaRow { + visible: (root.confirmMod?.license ?? "") !== "" + label: root.tr("mods.license") + value: root.confirmMod?.license ?? "" + } + + MetaRow { + visible: root.confirmSource !== "" + label: root.tr("mods.source") + value: root.confirmSource + mono: true + + ActionButton { + visible: root.confirmSource.startsWith("http") + text: root.tr("mods.open_link") + onClicked: Qt.openUrlExternally(root.confirmSource) + } + } + + MetaRow { + visible: (root.confirmMod?.homepage ?? "") !== "" + label: root.tr("mods.homepage") + value: root.confirmMod?.homepage ?? "" + mono: true + + ActionButton { + text: root.tr("mods.open_link") + onClicked: Qt.openUrlExternally(root.confirmMod.homepage) + } + } + + MetaRow { + visible: (root.confirmMod?.permissions ?? []).length > 0 + label: root.tr("mods.permissions") + value: (root.confirmMod?.permissions ?? []).join(", ") + } + + MetaRow { + visible: (root.confirmMod?.affectedFiles ?? []).length > 0 + label: root.tr("mods.affected_files") + value: String((root.confirmMod?.affectedFiles ?? []).length) + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + spacing: 8 + + Item { Layout.fillWidth: true } + + ActionButton { + text: root.tr("common.cancel") + onClicked: root.closeConfirm() + } + + ActionButton { + text: root.confirmKind === "enable" ? root.tr("mods.enable") : root.tr("mods.install") + primary: true + onClicked: root.runConfirmed() + } + } } } } From d70e8d605dc5d94e98dd0416f5c67edaa92bab26 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 02:10:37 +0300 Subject: [PATCH 12/17] fix(mods): report unknown manifest keys instead of refusing the package --- backend/pkg/mods/manager.go | 2 + backend/pkg/mods/manifest.go | 54 +++++++++++++++++-- backend/pkg/mods/manifest_test.go | 25 +++++++++ docs/mods/README.md | 4 ++ .../widgets/dashboard/controls/ModsPanel.qml | 11 ++++ 5 files changed, 93 insertions(+), 3 deletions(-) diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index 364780512..3db5ff189 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -80,6 +80,7 @@ type ModInfo struct { CompatibilityError string `json:"compatibilityError,omitempty"` Untested bool `json:"untested,omitempty"` UntestedMessage string `json:"untestedMessage,omitempty"` + UnknownFields []string `json:"unknownFields,omitempty"` } type DependencyInfo struct { @@ -1058,6 +1059,7 @@ func (m *Manager) statusFor(state State) (Status, error) { CompatibilityError: compatibilityMessage, Untested: untestedMessage != "", UntestedMessage: untestedMessage, + UnknownFields: manifest.UnknownFields, }) } sort.SliceStable(status.Mods, func(i, j int) bool { return status.Mods[i].Order < status.Mods[j].Order }) diff --git a/backend/pkg/mods/manifest.go b/backend/pkg/mods/manifest.go index 99d9a3d9b..659db9c77 100644 --- a/backend/pkg/mods/manifest.go +++ b/backend/pkg/mods/manifest.go @@ -8,7 +8,9 @@ import ( "bufio" "bytes" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "regexp" @@ -44,6 +46,9 @@ type Manifest struct { Permissions []string `json:"permissions,omitempty"` Settings *SettingsRef `json:"settings,omitempty"` Operations []Operation `json:"operations"` + + // Keys this build does not recognise, kept for the package status only. + UnknownFields []string `json:"-"` } type Compatibility struct { @@ -93,17 +98,60 @@ func LoadManifest(root string) (Manifest, error) { return Manifest{}, fmt.Errorf("read manifest: %w", err) } var manifest Manifest - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&manifest); err != nil { + if err := json.Unmarshal(data, &manifest); err != nil { return Manifest{}, fmt.Errorf("parse manifest: %w", err) } + // A key this build does not know is reported, never fatal. Refusing the + // package would mean any metadata added to the format later breaks every + // older Ambxst that reads it. + manifest.UnknownFields = unknownManifestFields(data) if err := manifest.Validate(root); err != nil { return Manifest{}, err } return manifest, nil } +func unknownManifestFields(data []byte) []string { + var probe Manifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var unknown []string + for { + err := decoder.Decode(&probe) + if err == nil || errors.Is(err, io.EOF) { + return unknown + } + const marker = "unknown field " + index := strings.Index(err.Error(), marker) + if index < 0 { + return unknown + } + name := strings.Trim(err.Error()[index+len(marker):], "\"") + if name == "" { + return unknown + } + for _, seen := range unknown { + if seen == name { + return unknown + } + } + unknown = append(unknown, name) + // The decoder stops at the first unknown key, so drop it and look again. + var generic map[string]json.RawMessage + if json.Unmarshal(data, &generic) != nil { + return unknown + } + delete(generic, name) + reduced, marshalErr := json.Marshal(generic) + if marshalErr != nil { + return unknown + } + data = reduced + decoder = json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + } +} + func (m Manifest) Validate(root string) error { if m.ManifestVersion != APIVersion { return fmt.Errorf("unsupported manifest version %d", m.ManifestVersion) diff --git a/backend/pkg/mods/manifest_test.go b/backend/pkg/mods/manifest_test.go index 9fa84c4df..f007478f5 100644 --- a/backend/pkg/mods/manifest_test.go +++ b/backend/pkg/mods/manifest_test.go @@ -82,3 +82,28 @@ func writeTestFile(t *testing.T, path, content string) { t.Fatal(err) } } + +func TestLoadManifestKeepsUnknownFields(t *testing.T) { + root := t.TempDir() + manifest := `{ + "manifestVersion": 1, + "id": "example.future", + "name": "Future fixture", + "version": "1.0.0", + "sponsorUrl": "https://example.invalid", + "operations": [{"type": "overlay", "source": "Feature.qml", "target": "Feature.qml"}] + }` + if err := os.WriteFile(filepath.Join(root, "Feature.qml"), []byte("Item {}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ManifestFile), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + loaded, err := LoadManifest(root) + if err != nil { + t.Fatalf("a package using a newer manifest key was rejected: %v", err) + } + if len(loaded.UnknownFields) != 1 || loaded.UnknownFields[0] != "sponsorUrl" { + t.Fatalf("the unknown key was not reported: %#v", loaded.UnknownFields) + } +} diff --git a/docs/mods/README.md b/docs/mods/README.md index b651f2f0c..0807bea81 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -105,6 +105,10 @@ installed or enabled. Fill them in: the confirmation prompt is where a user decides whether to trust the code, and an anonymous package gives them nothing to check. +A manifest key this Ambxst does not know is reported in the package status and +otherwise ignored, so metadata added to the format later does not break older +installs. + `commands` declares executables that must be available before composition. `permissions` is review metadata shown to the user. It is not a sandbox or an authorization mechanism: installed QML runs with the user's permissions. diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index a2e340670..20b1af1fd 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -104,6 +104,7 @@ Item { "mods.unknown": "Unknown", "mods.unknown_error": "Unknown error", "mods.unknown_version": "Unknown version", + "mods.unknown_fields": "Manifest keys this Ambxst does not know: %1", "mods.untested_base": "%1. You can still enable it.", "mods.update": "Update", "mods.working": "Working…" @@ -816,6 +817,16 @@ Item { wrapMode: Text.Wrap } + Text { + visible: (root.selectedMod?.unknownFields ?? []).length > 0 + Layout.fillWidth: true + text: root.tr("mods.unknown_fields", (root.selectedMod?.unknownFields ?? []).join(", ")) + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.warning + wrapMode: Text.Wrap + } + Separator { Layout.fillWidth: true } MetaRow { From fae62f730f2cf806d070c1331773407acd1342e9 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 02:13:27 +0300 Subject: [PATCH 13/17] fix(mods): translate only keys the mod actually carries --- .../widgets/dashboard/controls/ModsPanel.qml | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index 20b1af1fd..454d3583b 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -115,11 +115,14 @@ Item { if (root.i18nActive) { try { if (typeof I18n !== "undefined" && typeof I18n.t === "function") { - const translated = I18n.t(key, argument); - // A translator without this key returns the key itself, and - // the built-in English string beats showing a raw key. - if (translated !== undefined && translated !== key) - return translated; + // Ask only for keys the translator actually carries. A mod + // built against an older panel would otherwise answer every + // key with a humanised guess, which reads worse than the + // English string shipped right here. + const strings = I18n.strings ?? ({}); + const base = I18n.fallback ?? ({}); + if (strings[key] !== undefined || base[key] !== undefined) + return argument === undefined ? I18n.t(key) : I18n.t(key, argument); } } catch (error) { // The English fallback keeps Mods available if the translator is unavailable. @@ -212,6 +215,16 @@ Item { Component.onCompleted: ModsService.refresh() + // The daemon clears the restart flag when a new generation survives its + // health window, and the panel can already be open at that moment. This + // re-reads only while that banner is on screen and stops with it. + Timer { + interval: 5000 + repeat: true + running: root.visible && ModsService.restartRequired && !ModsService.busy + onTriggered: ModsService.refresh() + } + // The daemon clears the restart flag once a new generation survives its // health window. Re-reading on show keeps the banner from outliving it. onVisibleChanged: { @@ -769,7 +782,7 @@ Item { Layout.alignment: Qt.AlignVCenter implicitWidth: stateChip.implicitWidth + 20 implicitHeight: 24 - variant: "common" + variant: "focus" radius: Styling.radius(-2) enableShadow: false From 3d3941d23595e2b37f3704e5b580faa56f9c65fe Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 02:25:28 +0300 Subject: [PATCH 14/17] feat(mods): rebuild the generation when Ambxst updates --- backend/cmd/ambxst/commands.go | 40 ++++++++++++++++++++++++++++++++++ docs/mods/README.md | 6 +++++ 2 files changed, 46 insertions(+) diff --git a/backend/cmd/ambxst/commands.go b/backend/cmd/ambxst/commands.go index 6d05682b1..09e0fd8d5 100644 --- a/backend/cmd/ambxst/commands.go +++ b/backend/cmd/ambxst/commands.go @@ -43,10 +43,50 @@ func runUpdate() { cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: update failed: %v\n", err) + restartAmbxst() + return } + rebuildModsAfterUpdate() restartAmbxst() } +// The shell source has just changed, so an existing generation was composed +// from the previous one and Ambxst would start without it. Re-compose here +// instead of leaving the user on the clean base until they open Settings. +func rebuildModsAfterUpdate() { + status, err := callMods("status", nil) + if err != nil { + return + } + enabled := false + for _, mod := range status.Mods { + if mod.Enabled { + enabled = true + break + } + } + if !enabled { + return + } + fmt.Println("Rebuilding mods for the new Ambxst version...") + rebuilt, err := callMods("rebuild", nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: mods were not rebuilt: %v\n", err) + fmt.Fprintln(os.Stderr, "Ambxst starts without them. Settings > Mods can retry the build.") + return + } + for _, mod := range rebuilt.Mods { + if !mod.Compatible { + fmt.Printf(" %s is not compatible with this version: %s\n", mod.ID, mod.CompatibilityError) + continue + } + if mod.Untested { + fmt.Printf(" %s: %s\n", mod.ID, mod.UntestedMessage) + } + } + fmt.Println("Mods rebuilt.") +} + func runRefresh() { fmt.Println("Refreshing Ambxst profile...") execCommand("nix", "profile", "upgrade", "Ambxst", "--refresh", "--impure") diff --git a/docs/mods/README.md b/docs/mods/README.md index 0807bea81..dbf3f0821 100644 --- a/docs/mods/README.md +++ b/docs/mods/README.md @@ -204,6 +204,12 @@ Ambxst also compares the generation metadata with the current base version and Git revision before launch. After a base update, a stale generation is skipped and the clean base starts. Settings then reports that a rebuild is required. +`ambxst update` does that rebuild itself: once the new source is in place it +re-composes the enabled set, prints any mod whose declared compatibility no +longer matches, and only then restarts. A mod whose patch cannot be merged onto +the new source stops its own build, and Ambxst starts on the clean base rather +than on a half-applied tree. + ## Commands ```bash From 35448488cffedb3b381ad48518b4c3001df733ce Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 02:39:23 +0300 Subject: [PATCH 15/17] perf(mods): stop rescanning patches and re-reading the base revision per mod --- backend/pkg/mods/manager.go | 129 ++++++++++++++++++++++++------- backend/pkg/mods/manager_test.go | 24 ++++++ 2 files changed, 123 insertions(+), 30 deletions(-) diff --git a/backend/pkg/mods/manager.go b/backend/pkg/mods/manager.go index 3db5ff189..a35cf6602 100644 --- a/backend/pkg/mods/manager.go +++ b/backend/pkg/mods/manager.go @@ -34,6 +34,16 @@ const ( type Manager struct { paths *paths.Paths mu sync.Mutex + + // Scanning every patch on every status call is wasted work: the panel asks + // for status after each action and while a banner is up. Keyed by the + // package's patch stats, so an updated package rescans on its own. + affectedCache map[string]affectedFiles +} + +type affectedFiles struct { + stamp string + files []string } type State struct { @@ -154,11 +164,12 @@ func (m *Manager) Install(source string) (Status, error) { } defer os.RemoveAll(tmp) - packageRoot, normalizedSource, sourceType, err := acquirePackage(source, filepath.Join(tmp, "package")) + fetched, err := acquirePackage(source, filepath.Join(tmp, "package")) if err != nil { return Status{}, err } - source = normalizedSource + packageRoot := fetched.root + source = fetched.source manifest, err := LoadManifest(packageRoot) if err != nil { @@ -178,14 +189,13 @@ func (m *Manager) Install(source string) (Status, error) { if err := os.Rename(packageRoot, destination); err != nil { return Status{}, fmt.Errorf("store package: %w", err) } - revision := gitRevision(destination) state.Mods = append(state.Mods, InstalledMod{ ID: manifest.ID, Enabled: false, Order: len(state.Mods), Source: source, - SourceType: sourceType, - Revision: revision, + SourceType: fetched.sourceType, + Revision: fetched.revision, InstalledAt: time.Now().UTC().Format(time.RFC3339), }) if err := m.saveState(state); err != nil { @@ -228,6 +238,7 @@ func (m *Manager) InstallDependencies(id string) (Status, error) { root string source string sourceType string + revision string } installed := make(map[string]int, len(state.Mods)) for i, mod := range state.Mods { @@ -261,10 +272,11 @@ func (m *Manager) InstallDependencies(id string) (Status, error) { return fmt.Errorf("mod %s requires %s but provides no package source", id, dependencyID) } stageRoot := filepath.Join(tmp, dependencyID) - packageRoot, normalizedSource, sourceType, acquireErr := acquirePackage(source, stageRoot) + fetched, acquireErr := acquirePackage(source, stageRoot) if acquireErr != nil { return fmt.Errorf("install dependency %s: %w", dependencyID, acquireErr) } + packageRoot := fetched.root loaded, loadErr := LoadManifest(packageRoot) if loadErr != nil { return fmt.Errorf("dependency %s: %w", dependencyID, loadErr) @@ -274,7 +286,8 @@ func (m *Manager) InstallDependencies(id string) (Status, error) { } dependencyManifest = loaded staged[dependencyID] = stagedDependency{ - root: packageRoot, source: normalizedSource, sourceType: sourceType, + root: packageRoot, source: fetched.source, sourceType: fetched.sourceType, + revision: fetched.revision, } } @@ -325,7 +338,7 @@ func (m *Manager) InstallDependencies(id string) (Status, error) { Order: len(next.Mods), Source: dependency.source, SourceType: dependency.sourceType, - Revision: gitRevision(destination), + Revision: dependency.revision, InstalledAt: time.Now().UTC().Format(time.RFC3339), }) changed = true @@ -536,6 +549,7 @@ func (m *Manager) Update(id string) (Status, error) { } func (m *Manager) updateLocalSource(state State, index int, installed InstalledMod, packageRoot string) (Status, error) { + refreshedRevision := "" tmp, err := os.MkdirTemp(m.paths.ModPackagesDir(), ".update-") if err != nil { return Status{}, err @@ -562,11 +576,12 @@ func (m *Manager) updateLocalSource(state State, index int, installed InstalledM return Status{}, err } case "git-subdir": - acquiredRoot, _, _, acquireErr := acquirePackage(installed.Source, updatedRoot) + fetched, acquireErr := acquirePackage(installed.Source, updatedRoot) if acquireErr != nil { return Status{}, acquireErr } - updatedRoot = acquiredRoot + updatedRoot = fetched.root + refreshedRevision = fetched.revision default: return Status{}, fmt.Errorf("mod %q has unsupported source type %q", installed.ID, installed.SourceType) } @@ -598,7 +613,7 @@ func (m *Manager) updateLocalSource(state State, index int, installed InstalledM } next := cloneState(state) - next.Mods[index].Revision = "" + next.Mods[index].Revision = refreshedRevision if installed.Enabled { if err := m.composeAndActivate(state, &next); err != nil { restore() @@ -960,6 +975,47 @@ func (m *Manager) resolve(state State, base string) (map[string]Manifest, []stri return manifests, ordered, err } +// affectedFilesFor returns manifest.AffectedFiles, reusing the previous answer +// while every payload file keeps its size and modification time. +func (m *Manager) affectedFilesFor(manifest Manifest, root string) ([]string, error) { + stamp, ok := payloadStamp(manifest, root) + if ok { + if cached, hit := m.affectedCache[root]; hit && cached.stamp == stamp { + return cached.files, nil + } + } + files, err := manifest.AffectedFiles(root) + if err != nil { + return nil, err + } + if ok { + if m.affectedCache == nil { + m.affectedCache = make(map[string]affectedFiles) + } + m.affectedCache[root] = affectedFiles{stamp: stamp, files: files} + } + return files, nil +} + +func payloadStamp(manifest Manifest, root string) (string, bool) { + var builder strings.Builder + for _, op := range manifest.Operations { + if op.Type != "patch" { + continue + } + path, err := safeJoin(root, op.Source) + if err != nil { + return "", false + } + info, err := os.Stat(path) + if err != nil { + return "", false + } + fmt.Fprintf(&builder, "%s:%d:%d;", op.Source, info.Size(), info.ModTime().UnixNano()) + } + return builder.String(), true +} + func (m *Manager) statusFor(state State) (Status, error) { base := paths.FindBaseShellSource() status := Status{ @@ -1002,7 +1058,7 @@ func (m *Manager) statusFor(state State) (Status, error) { }) continue } - files, err := manifest.AffectedFiles(root) + files, err := m.affectedFilesFor(manifest, root) if err != nil { status.Mods = append(status.Mods, ModInfo{ ID: manifest.ID, @@ -1020,7 +1076,7 @@ func (m *Manager) statusFor(state State) (Status, error) { continue } compatibilityErr := checkCompatibility(manifest, base) - untestedMessage := untestedBase(manifest, base) + untestedMessage := untestedBase(manifest, status.BaseRevision) compatibilityMessage := "" if compatibilityErr != nil { compatibilityMessage = compatibilityErr.Error() @@ -1382,11 +1438,10 @@ func checkCompatibility(manifest Manifest, base string) error { // update, and refusing every unlisted revision would disable the whole // collection after one upstream commit. Patch composition, the startup health // check, and rollback remain the real guards. -func untestedBase(manifest Manifest, base string) string { +func untestedBase(manifest Manifest, revision string) string { if len(manifest.Compatibility.TestedBaseCommits) == 0 { return "" } - revision := gitRevision(base) if revision == "" { return "the base revision is unknown" } @@ -1750,8 +1805,12 @@ func writeAtomic(path string, data []byte, mode os.FileMode) error { return os.Rename(tmpPath, path) } +// Local git work is fast, but it holds the manager mutex, so a stalled process +// would freeze every other mods request. Bound it. +const localCommandTimeout = 2 * time.Minute + func runCommand(directory, name string, args ...string) error { - return runCommandTimeout(0, directory, name, args...) + return runCommandTimeout(localCommandTimeout, directory, name, args...) } func runCommandTimeout(timeout time.Duration, directory, name string, args ...string) error { @@ -1785,61 +1844,71 @@ func isGitSource(source string) bool { return strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "ssh://") || strings.HasPrefix(source, "git@") } -func acquirePackage(source, destination string) (string, string, string, error) { +// acquired describes a package fetched into a staging directory. The revision +// is only known for Git sources; a GitHub directory install keeps it because +// the clone that carried it is discarded right after. +type acquired struct { + root string + source string + sourceType string + revision string +} + +func acquirePackage(source, destination string) (acquired, error) { source = strings.TrimSpace(source) if source == "" { - return "", "", "", fmt.Errorf("source is required") + return acquired{}, fmt.Errorf("source is required") } sourceType := "local" if repository, ref, subdirectory, ok := parseGitHubTreeSource(source); ok { sourceType = "git-subdir" if err := runCommandTimeout(5*time.Minute, "", "git", "clone", "--depth=1", "--filter=blob:none", "--sparse", "--branch", ref, repository, destination); err != nil { - return "", "", "", fmt.Errorf("clone source: %w", err) + return acquired{}, fmt.Errorf("clone source: %w", err) } if err := runCommandTimeout(2*time.Minute, destination, "git", "sparse-checkout", "set", "--no-cone", subdirectory); err != nil { - return "", "", "", fmt.Errorf("select package directory: %w", err) + return acquired{}, fmt.Errorf("select package directory: %w", err) } packageRoot, err := safeJoin(destination, filepath.FromSlash(subdirectory)) if err != nil { - return "", "", "", fmt.Errorf("package directory: %w", err) + return acquired{}, fmt.Errorf("package directory: %w", err) } if _, err := os.Stat(filepath.Join(packageRoot, ManifestFile)); err != nil { - return "", "", "", fmt.Errorf("package directory has no %s", ManifestFile) + return acquired{}, fmt.Errorf("package directory has no %s", ManifestFile) } - return packageRoot, source, sourceType, nil + return acquired{root: packageRoot, source: source, sourceType: sourceType, revision: gitRevision(destination)}, nil } else if isGitSource(source) { sourceType = "git" if err := runCommandTimeout(5*time.Minute, "", "git", "clone", "--depth=1", source, destination); err != nil { - return "", "", "", fmt.Errorf("clone source: %w", err) + return acquired{}, fmt.Errorf("clone source: %w", err) } } else { absolute, err := filepath.Abs(source) if err != nil { - return "", "", "", err + return acquired{}, err } info, err := os.Stat(absolute) if err != nil { - return "", "", "", fmt.Errorf("inspect source: %w", err) + return acquired{}, fmt.Errorf("inspect source: %w", err) } if info.IsDir() { if err := copyTree(absolute, destination, func(path string, entry fs.DirEntry) bool { return path != absolute && entry.IsDir() && entry.Name() == ".git" }); err != nil { - return "", "", "", fmt.Errorf("copy source: %w", err) + return acquired{}, fmt.Errorf("copy source: %w", err) } } else { sourceType = "archive" if err := extractPackageArchive(absolute, destination); err != nil { - return "", "", "", err + return acquired{}, err } } source = absolute } packageRoot, err := locatePackageRoot(destination) if err != nil { - return "", "", "", err + return acquired{}, err } - return packageRoot, source, sourceType, nil + return acquired{root: packageRoot, source: source, sourceType: sourceType, revision: gitRevision(packageRoot)}, nil } func parseGitHubTreeSource(source string) (string, string, string, bool) { diff --git a/backend/pkg/mods/manager_test.go b/backend/pkg/mods/manager_test.go index 04a952a3c..ec8a975d2 100644 --- a/backend/pkg/mods/manager_test.go +++ b/backend/pkg/mods/manager_test.go @@ -979,3 +979,27 @@ func writePatchPackage(t *testing.T, root, id, hunk string) { } writeTestFile(t, filepath.Join(root, ManifestFile), string(data)) } + +func TestGitHubDirectoryInstallRecordsRevision(t *testing.T) { + if testing.Short() { + t.Skip("network integration test") + } + root := t.TempDir() + base := filepath.Join(root, "base") + writeTestFile(t, filepath.Join(base, "shell.qml"), "ShellRoot {}\n") + writeTestFile(t, filepath.Join(base, "version"), "1.2.6\n") + t.Setenv("AMBXST_SHELL", base) + t.Setenv("AMBXST_MODS_DISABLED", "1") + + manager := NewManager(testPaths(root)) + status, err := manager.Install("https://github.com/flathead/ambxst-mods/tree/main/packages/volume-scroll") + if err != nil { + t.Skipf("network unavailable: %v", err) + } + if len(status.Mods) != 1 { + t.Fatalf("expected one installed mod, got %#v", status.Mods) + } + if status.Mods[0].Revision == "" { + t.Fatal("a GitHub directory install recorded no upstream revision") + } +} From de25d8c82b1b701ba28d3a8a8b1a674c10f1aefa Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 02:47:15 +0300 Subject: [PATCH 16/17] fix(mods): take button labels from the surface and colour state green or red --- .../widgets/dashboard/controls/ModsPanel.qml | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index 454d3583b..624854b04 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -148,9 +148,9 @@ Item { function stateColor(mod) { if (!mod || !mod.valid || !mod.compatible) return Colors.error; - if (mod.untested) + if (mod.untested && mod.enabled) return Colors.warning; - return mod.enabled ? Colors.primary : Colors.outline; + return mod.enabled ? Colors.success : Colors.error; } function askConfirm(kind, mod, source) { @@ -251,14 +251,16 @@ Item { enabled: !ModsService.busy opacity: enabled ? 1 : 0.45 + readonly property bool engaged: hovered || down || activeFocus + // "common" resolves to the same surface as the card behind it, so a + // resting secondary button used to read as plain text. "focus" is one + // step brighter and keeps the control visible on both grounds. + readonly property string surface: action.primary + ? (action.engaged ? "primaryfocus" : "primary") + : (action.engaged ? (action.destructive ? "error" : "secondary") : "focus") + background: StyledRect { - // "common" resolves to the same surface as the card behind it, so a - // resting secondary button used to read as plain text. "focus" is - // one step brighter and keeps the control visible on both grounds. - variant: action.primary - ? ((action.hovered || action.down) ? "primaryfocus" : "primary") - : ((action.hovered || action.down || action.activeFocus) - ? (action.destructive ? "error" : "secondary") : "focus") + variant: action.surface radius: Styling.radius(-2) enableShadow: false } @@ -268,8 +270,10 @@ Item { font.family: Config.theme.font font.pixelSize: Styling.fontSize(-1) font.weight: action.primary ? Font.DemiBold : Font.Medium - color: action.primary ? Styling.srItem("primary") - : action.destructive && !action.hovered ? Colors.error + // Take the label colour from the surface underneath it. Keeping a + // fixed colour made hovered buttons read their own background. + color: action.primary || action.engaged ? Styling.srItem(action.surface) + : action.destructive ? Colors.error : Colors.overBackground horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -650,8 +654,9 @@ Item { implicitWidth: 6 implicitHeight: 6 radius: 3 - color: modRow.current ? modRow.item : root.stateColor(modRow.modelData) - opacity: modRow.modelData.enabled || !modRow.modelData.valid ? 1 : 0.55 + // Green for running, red for off, on the + // selected row too: the state is the point. + color: root.stateColor(modRow.modelData) } ColumnLayout { From 287573bf9e5f3ed4ac40f5ead8b5879b595c5577 Mon Sep 17 00:00:00 2001 From: flathead Date: Tue, 1 Sep 2026 02:52:34 +0300 Subject: [PATCH 17/17] fix(mods): decide the drop row from the dragged card's position --- .../widgets/dashboard/controls/ModsPanel.qml | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/modules/widgets/dashboard/controls/ModsPanel.qml b/modules/widgets/dashboard/controls/ModsPanel.qml index 624854b04..c52cc9df0 100644 --- a/modules/widgets/dashboard/controls/ModsPanel.qml +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -19,6 +19,12 @@ Item { property string removeArmedId: "" property bool filesExpanded: false + // Reordering state. dropIndex is derived from where the floating card sits, + // not from a drop target, because Drag.target is already cleared by the + // time the handler reports the release. + property string draggingId: "" + property int dropIndex: -1 + // Pending action awaiting the trust confirmation. "" means no prompt. property string confirmKind: "" property string confirmSource: "" @@ -568,7 +574,11 @@ Item { delegate: StyledRect { id: modRow required property var modelData + required property int index readonly property bool current: root.effectiveId === modelData.id + readonly property bool dropTarget: root.draggingId !== "" + && root.draggingId !== modelData.id + && root.dropIndex === index Layout.fillWidth: true Layout.preferredHeight: 54 @@ -585,19 +595,12 @@ Item { Keys.onEnterPressed: root.selectedId = modelData.id Keys.onSpacePressed: root.selectedId = modelData.id - DropArea { + StyledRect { anchors.fill: parent - keys: ["ambxstMod"] - enabled: root.sortMode === "loadOrder" && root.searchQuery === "" - property int loadOrder: modRow.modelData.order - - StyledRect { - anchors.fill: parent - visible: parent.containsDrag - variant: "focus" - radius: Styling.radius(-2) - enableShadow: false - } + visible: modRow.dropTarget + variant: "focus" + radius: Styling.radius(-2) + enableShadow: false } MouseArea { @@ -637,13 +640,15 @@ Item { const point = modRow.mapToItem(dragPreview.parent, 0, 0); dragPreview.x = point.x; dragPreview.y = point.y; + root.draggingId = modRow.modelData.id; + root.dropIndex = modRow.index; return; } - const target = dragPreview.Drag.target; - if (target && target.loadOrder !== undefined - && target.loadOrder !== modRow.modelData.order) - ModsService.moveTo(modRow.modelData.id, target.loadOrder); - dragPreview.Drag.drop(); + const landing = root.dropIndex; + root.draggingId = ""; + root.dropIndex = -1; + if (landing >= 0 && landing !== modRow.index) + ModsService.moveTo(modRow.modelData.id, landing); } } } @@ -710,6 +715,14 @@ Item { visible: reorderDrag.active z: 100 + onYChanged: { + if (!reorderDrag.active) + return; + const pitch = modRow.height + modList.spacing; + const slot = Math.round((dragPreview.y - modList.y) / pitch); + root.dropIndex = Math.max(0, Math.min(root.filteredMods.length - 1, slot)); + } + StyledRect { id: dragPreviewSurface anchors.fill: parent @@ -729,11 +742,6 @@ Item { } } - Drag.active: reorderDrag.active - Drag.source: modRow - Drag.hotSpot.x: width / 2 - Drag.hotSpot.y: height / 2 - Drag.keys: ["ambxstMod"] } } }