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..48d2d4206 --- /dev/null +++ b/backend/cmd/ambxst/cmds_mods.go @@ -0,0 +1,166 @@ +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 "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 + " ") + } + 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 "installDependencies": + return manager.InstallDependencies(params["id"].(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" + + " 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" + + " 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..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") @@ -373,4 +413,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..a35cf6602 --- /dev/null +++ b/backend/pkg/mods/manager.go @@ -0,0 +1,1988 @@ +package mods + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/url" + "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 + + // 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 { + 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"` + AuthorURL string `json:"authorUrl,omitempty"` + Homepage string `json:"homepage,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"` + Untested bool `json:"untested,omitempty"` + UntestedMessage string `json:"untestedMessage,omitempty"` + UnknownFields []string `json:"unknownFields,omitempty"` +} + +type DependencyInfo struct { + ID string `json:"id"` + Source string `json:"source,omitempty"` + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` +} + +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) + + fetched, err := acquirePackage(source, filepath.Join(tmp, "package")) + if err != nil { + return Status{}, err + } + packageRoot := fetched.root + source = fetched.source + + 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) + } + state.Mods = append(state.Mods, InstalledMod{ + ID: manifest.ID, + Enabled: false, + Order: len(state.Mods), + Source: source, + SourceType: fetched.sourceType, + Revision: fetched.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) +} + +// 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 + revision 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) + 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) + } + 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: fetched.source, sourceType: fetched.sourceType, + revision: fetched.revision, + } + } + + 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: dependency.revision, + 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() + 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) + } + 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) + 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 := enabledOrderChanged(state.Mods, next.Mods) + 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 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() + 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) { + refreshedRevision := "" + 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 + } + case "git-subdir": + fetched, acquireErr := acquirePackage(installed.Source, updatedRoot) + if acquireErr != nil { + return Status{}, acquireErr + } + updatedRoot = fetched.root + refreshedRevision = fetched.revision + default: + return Status{}, fmt.Errorf("mod %q has unsupported source type %q", installed.ID, installed.SourceType) + } + if installed.SourceType != "git-subdir" { + 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 = refreshedRevision + 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) + } + 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) + for _, operation := range manifest.Operations { + if err := applyOperation(tmp, packageRoot, operation); err != nil { + 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") + } + 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 +} + +// 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{ + 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() + } + } + 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) + 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 := m.affectedFilesFor(manifest, 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) + untestedMessage := untestedBase(manifest, status.BaseRevision) + compatibilityMessage := "" + 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, + 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, + DependencyState: dependencyState, + Conflicts: manifest.Conflicts, + Commands: manifest.Commands, + Permissions: manifest.Permissions, + AffectedFiles: files, + HasSettings: manifest.Settings != nil, + Valid: true, + Compatible: compatibilityErr == nil, + 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 }) + 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())) + } +} + +// 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. +// 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 + } + settings := [][2]string{ + {"user.email", "mods@ambxst.invalid"}, + {"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 { + 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 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 + } + // 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. + if err := runCommand(generation, "git", "apply", "--3way", source); err != nil { + 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 + } + 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) + } + 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, revision string) string { + if len(manifest.Compatibility.TestedBaseCommits) == 0 { + return "" + } + if revision == "" { + return "the base revision is unknown" + } + for _, tested := range manifest.Compatibility.TestedBaseCommits { + if strings.EqualFold(revision, tested) { + return "" + } + } + return fmt.Sprintf("not tested on base revision %s", shortRevision(revision)) +} + +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) +} + +// 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(localCommandTimeout, 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@") +} + +// 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 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 acquired{}, fmt.Errorf("clone source: %w", err) + } + if err := runCommandTimeout(2*time.Minute, destination, "git", "sparse-checkout", "set", "--no-cone", subdirectory); err != nil { + return acquired{}, fmt.Errorf("select package directory: %w", err) + } + packageRoot, err := safeJoin(destination, filepath.FromSlash(subdirectory)) + if err != nil { + return acquired{}, fmt.Errorf("package directory: %w", err) + } + if _, err := os.Stat(filepath.Join(packageRoot, ManifestFile)); err != nil { + return acquired{}, fmt.Errorf("package directory has no %s", ManifestFile) + } + 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 acquired{}, fmt.Errorf("clone source: %w", err) + } + } else { + absolute, err := filepath.Abs(source) + if err != nil { + return acquired{}, err + } + info, err := os.Stat(absolute) + if err != nil { + 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 acquired{}, fmt.Errorf("copy source: %w", err) + } + } else { + sourceType = "archive" + if err := extractPackageArchive(absolute, destination); err != nil { + return acquired{}, err + } + } + source = absolute + } + packageRoot, err := locatePackageRoot(destination) + if err != nil { + return acquired{}, err + } + return acquired{root: packageRoot, source: source, sourceType: sourceType, revision: gitRevision(packageRoot)}, 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 "" + } + 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..ec8a975d2 --- /dev/null +++ b/backend/pkg/mods/manager_test.go @@ -0,0 +1,1005 @@ +package mods + +import ( + "archive/tar" + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "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 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") + 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 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") + 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 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 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 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() + 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) + 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 +} + +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)) +} + +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") + } +} diff --git a/backend/pkg/mods/manifest.go b/backend/pkg/mods/manifest.go new file mode 100644 index 000000000..659db9c77 --- /dev/null +++ b/backend/pkg/mods/manifest.go @@ -0,0 +1,445 @@ +// 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" + "errors" + "fmt" + "io" + "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"` + 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"` + Conflicts []string `json:"conflicts,omitempty"` + Commands []string `json:"commands,omitempty"` + 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 { + 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 + 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) + } + 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 + } + 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") + } + 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 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 { + 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..f007478f5 --- /dev/null +++ b/backend/pkg/mods/manifest_test.go @@ -0,0 +1,109 @@ +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 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") + 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) + } +} + +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/backend/pkg/mods/service.go b/backend/pkg/mods/service.go new file mode 100644 index 000000000..79bcc7e0e --- /dev/null +++ b/backend/pkg/mods/service.go @@ -0,0 +1,156 @@ +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, + "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, + }, + }) +} + +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) 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 { + 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"` + Position *int `json:"position"` +} + +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) + } + if params.Position != nil { + return s.manager.MoveTo(params.ID, *params.Position) + } + 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..dbf3f0821 --- /dev/null +++ b/docs/mods/README.md @@ -0,0 +1,237 @@ +# 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": [], + "dependencySources": {}, + "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. + +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 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. + +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 +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. + +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. + +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 +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. + +`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 +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**. 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 + +`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..7e7bef80e --- /dev/null +++ b/docs/mods/manifest.schema.json @@ -0,0 +1,130 @@ +{ + "$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 + } + } + }, + "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", + "propertyNames": { + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" + }, + "additionalProperties": { "type": "string", "minLength": 1 } + }, + "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..7088f8c54 --- /dev/null +++ b/modules/services/ModsService.qml @@ -0,0 +1,159 @@ +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 statusMessageKey: "" + 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 ?? ""; + // 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; + } + + function request(method, params, successMessage, requiresRestart, onSuccess) { + if (root.busy) + return; + root.busy = true; + root.errorMessage = ""; + root.statusMessage = ""; + root.statusMessageKey = ""; + BackendService.call(method, params ?? {}, (result, error) => { + root.busy = false; + if (error) { + root.errorMessage = String(error); + return; + } + root.applyStatus(result); + root.statusMessageKey = successMessage ?? ""; + if (requiresRestart && (result?.restartRequired === undefined)) + root.restartRequired = true; + if (onSuccess) + onSuccess(result); + }); + } + + function refresh() { + root.request("mods.status", {}, "", false); + } + + function install(source) { + 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 ? "mods.status_enabled" : "mods.status_disabled", true); + } + + function update(id, enabled) { + root.request("mods.update", { id }, "mods.status_updated", enabled); + } + + function remove(id, enabled) { + root.request("mods.remove", { id }, "mods.status_removed", enabled); + } + + function move(id, direction) { + root.request("mods.move", { id, direction }, "mods.status_order_updated", root.activeGeneration !== ""); + } + + function moveTo(id, position) { + root.request("mods.move", { id, position }, "mods.status_order_updated", root.activeGeneration !== ""); + } + + function rebuild() { + root.request("mods.rebuild", {}, "mods.status_rebuilt", true); + } + + function rollback() { + root.request("mods.rollback", {}, "mods.status_rolled_back", 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.statusMessageKey = "mods.status_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..c52cc9df0 --- /dev/null +++ b/modules/widgets/dashboard/controls/ModsPanel.qml @@ -0,0 +1,1380 @@ +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: 1240 + property int horizontalMargin: 20 + property string searchQuery: "" + property string sortMode: "name" + property string selectedId: "" + 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: "" + 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", + "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 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.install": "Install", + "mods.install_dependencies": "Install required mods", + "mods.installed_count": "Installed · %1", + "mods.invalid_number": "Enter a valid number.", + "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", + "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.requirements": "Required commands", + "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.show_files": "Show %1", + "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.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…" + }) + + function tr(key, argument) { + const fallback = root.fallbackText[key] ?? key; + if (root.i18nActive) { + try { + if (typeof I18n !== "undefined" && typeof I18n.t === "function") { + // 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. + } + } + return argument === undefined ? fallback : fallback.replace("%1", String(argument)); + } + + function dependenciesReady(mod) { + 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 && mod.enabled) + return Colors.warning; + return mod.enabled ? Colors.success : Colors.error; + } + + 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 hasInstalledMods: (ModsService.mods ?? []).length > 0 + 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; + } + // 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 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: { + if (visible) + 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: 34 + leftPadding: 14 + rightPadding: 14 + 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 { + variant: action.surface + 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 + // 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 + elide: Text.ElideRight + } + } + + // 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 + + 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 + } + + Text { + Layout.fillWidth: true + 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: trailingSlot + Layout.alignment: Qt.AlignVCenter + spacing: 6 + } + } + + Flickable { + id: mainFlickable + anchors.fill: parent + contentHeight: mainColumn.implicitHeight + 8 + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: root.confirmKind === "" + + 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 { + text: root.tr("mods.rebuild") + onClicked: ModsService.rebuild() + } + } + + StyledRect { + visible: !ModsService.generationCurrent || ModsService.restartRequired + || ModsService.errorMessage !== "" || ModsService.statusMessage !== "" + || ModsService.statusMessageKey !== "" + Layout.fillWidth: true + Layout.preferredHeight: statusRow.implicitHeight + 18 + variant: ModsService.errorMessage !== "" || !ModsService.generationCurrent ? "focus" : "common" + radius: Styling.radius(-2) + enableShadow: false + + 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 + } + + 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 + } + + 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() + } + } + } + + StyledRect { + Layout.fillWidth: true + Layout.preferredHeight: installColumn.implicitHeight + 28 + variant: "pane" + radius: Styling.radius(0) + + 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 + } + + RowLayout { + Layout.fillWidth: true + 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()) + } + } + + 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 + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 8 + + 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 + } + + Item { Layout.fillWidth: true } + + 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 + } + + 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 + } + } + + ColumnLayout { + id: modList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 4 + + Repeater { + model: root.filteredMods + + 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 + 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 + + StyledRect { + anchors.fill: parent + visible: modRow.dropTarget + 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; + } + } + + 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; + root.draggingId = modRow.modelData.id; + root.dropIndex = modRow.index; + return; + } + const landing = root.dropIndex; + root.draggingId = ""; + root.dropIndex = -1; + if (landing >= 0 && landing !== modRow.index) + ModsService.moveTo(modRow.modelData.id, landing); + } + } + } + + // Status rail: the state is readable before any text is. + Rectangle { + Layout.alignment: Qt.AlignVCenter + implicitWidth: 6 + implicitHeight: 6 + radius: 3 + // Green for running, red for off, on the + // selected row too: the state is the point. + color: root.stateColor(modRow.modelData) + } + + 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 || 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; + 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 + + 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 + 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: dragPreviewSurface.item + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + } + } + } + } + } + + StyledRect { + visible: root.hasInstalledMods && !!root.selectedMod + Layout.fillWidth: true + Layout.preferredHeight: details.implicitHeight + 28 + variant: "pane" + radius: Styling.radius(0) + + ColumnLayout { + id: details + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + 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 { + 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 + } + } + + StyledRect { + Layout.alignment: Qt.AlignVCenter + implicitWidth: stateChip.implicitWidth + 20 + implicitHeight: 24 + variant: "focus" + 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 { + 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 + } + + 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 { + 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) + } + } + + 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) + } + } + + MetaRow { + visible: (root.selectedMod?.homepage ?? "") !== "" + label: root.tr("mods.homepage") + value: root.selectedMod?.homepage ?? "" + mono: true + + ActionButton { + text: root.tr("mods.open_link") + onClicked: Qt.openUrlExternally(root.selectedMod.homepage) + } + } + + 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) + } + + 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) + } + } + + MetaRow { + visible: (root.selectedMod?.permissions ?? []).length > 0 + label: root.tr("mods.permissions") + value: (root.selectedMod?.permissions ?? []).join(", ") + } + + MetaRow { + visible: (root.selectedMod?.commands ?? []).length > 0 + label: root.tr("mods.requirements") + value: (root.selectedMod?.commands ?? []).join(", ") + mono: true + } + + MetaRow { + visible: (root.selectedMod?.conflicts ?? []).length > 0 + label: root.tr("mods.conflicts") + value: (root.selectedMod?.conflicts ?? []).join(", ") + mono: true + } + + 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 + } + } + } + + 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) + } + } + + MetaRow { + label: root.tr("mods.affected_files") + value: (root.selectedMod?.affectedFiles ?? []).length === 0 ? root.tr("mods.none") : "" + + 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 + } + } + + 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 + + ColumnLayout { + id: fileList + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 2 + + Repeater { + model: root.selectedMod?.affectedFiles ?? [] + + 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 + } + } + } + } + + 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 + } + + 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); + } + + 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] + ? 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 + + 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) + } + } + } + } + } + + Separator { Layout.fillWidth: true } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + 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 ?? ""); + } + } + + ActionButton { + 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 + ? 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() + } + } + } + } + + // 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 + + 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 + } + + 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() + } + } + } + } + } +} 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 } ]