Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
79a9902
feat(mods): add native modification manager
flathead Aug 31, 2026
d2cdcdb
fix(mods): compose non-overlapping patches in load order
flathead Aug 31, 2026
aeb913f
feat(mods): add exact drag-and-drop load ordering
flathead Aug 31, 2026
cfd7e2b
fix(mods): keep drag preview outside the list layout
flathead Aug 31, 2026
42d1ecb
feat(mods): install required packages explicitly
flathead Aug 31, 2026
af2a686
fix(mods): use the available settings space
flathead Aug 31, 2026
dbb40e9
fix(mods): merge patches three-way when context moves
flathead Aug 31, 2026
d700c22
fix(mods): keep an untested base revision advisory
flathead Aug 31, 2026
4127fdf
feat(mods): keep both insertions at a shared anchor
flathead Aug 31, 2026
48169b1
docs(mods): require a new settings section id
flathead Aug 31, 2026
e9b0fab
feat(mods): rebuild the panel layout and add a trust prompt
flathead Aug 31, 2026
d70e8d6
fix(mods): report unknown manifest keys instead of refusing the package
flathead Aug 31, 2026
fae62f7
fix(mods): translate only keys the mod actually carries
flathead Aug 31, 2026
1afb723
Merge remote-tracking branch 'origin/dev' into feature/mod-manager
flathead Aug 31, 2026
3d3941d
feat(mods): rebuild the generation when Ambxst updates
flathead Aug 31, 2026
3544848
perf(mods): stop rescanning patches and re-reading the base revision …
flathead Aug 31, 2026
de25d8c
fix(mods): take button labels from the surface and colour state green…
flathead Aug 31, 2026
287573b
fix(mods): decide the drop row from the dragged card's position
flathead Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ scripts/__pycache__
.sisyphus
result
/ambxst

# Keep Go package sources visible when a global ignore file excludes pkg/.
!/backend/pkg/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
166 changes: 166 additions & 0 deletions backend/cmd/ambxst/cmds_mods.go
Original file line number Diff line number Diff line change
@@ -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 <directory|archive|git-url>")
}
status, err = callMods("install", map[string]any{"source": args[1]})
case "install-dependencies":
if len(args) != 2 {
modsUsage("Usage: ambxst mods install-dependencies <id>")
}
status, err = callMods("installDependencies", map[string]any{"id": args[1]})
case "enable", "disable":
if len(args) != 2 {
modsUsage("Usage: ambxst mods " + command + " <id>")
}
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 + " <id>")
}
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 <id> <up|down>")
}
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 <command>\n\n" +
"Commands:\n" +
" list Show installed mods and generation state\n" +
" install <source> Install from a directory, archive, or Git URL\n" +
" install-dependencies <id> Install and enable a mod's requirements\n" +
" enable <id> Enable a mod and build a generation\n" +
" disable <id> Disable a mod and build a generation\n" +
" update <id> Refresh a mod from its original source\n" +
" remove <id> Remove a mod package\n" +
" move <id> <up|down> 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)
}
42 changes: 41 additions & 1 deletion backend/cmd/ambxst/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -373,4 +413,4 @@ func doSuspend() {
"--dest=org.freedesktop.login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager.Suspend", "boolean:true").Run()
}
}
}
7 changes: 6 additions & 1 deletion backend/cmd/ambxst/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package main
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"net"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -45,6 +45,9 @@ func main() {
case "goodbye":
runGoodbye()
return
case "mods":
runMods(args[1:])
return
}
}

Expand Down Expand Up @@ -128,6 +131,7 @@ func newClient() *ipc.Client {
}

// runIpc dispatches a JSON-RPC call to the running ambxst process.
//
// ambxst ipc call <service.method> <json>
func runIpc(args []string) int {
if len(args) < 2 || args[0] != "call" {
Expand Down Expand Up @@ -383,6 +387,7 @@ Commands:
-tint Enable tint for this wallpaper only
-monitor <id|name> 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
Expand Down
2 changes: 1 addition & 1 deletion backend/cmd/ambxst/screen.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,4 @@ func notifyShell(summary, body, urgency string) {
if _, err := newClient().Call("notify.send", params); err != nil {
_ = notify.SendFallback(summary, body, urgency)
}
}
}
Loading