Compare commits

...
41 Commits
Author SHA1 Message Date
Guillem CANAL 09449c0b37 idle: retry suspend when a logind sleep inhibitor is released (#3866)
(cherry picked from commit 668dde2a33)
2026-10-23 10:59:13 -04:00
bbedward 6c3a646dc4 bump qml-common
port 2.0

(cherry picked from commit b7be4a344a)
2026-10-23 14:58:15 +00:00
Huỳnh Thiện Lộc a680e1bd39 feat(screenshot): add island snap targets (#3881)
port 2.0

(cherry picked from commit 1e31015acb)
2026-10-23 14:51:30 +00:00
bbedward 36ebe0e3ef fix(apppicker): hide NoDisplay handlers except the shell's own
port 2.0

(cherry picked from commit 71417b7ca7)
2026-10-23 14:06:44 +00:00
bbedward 0102bf6d40 i18n: sync
(cherry picked from commit bf9a51c448)
2026-10-23 09:59:53 -04:00
bbedward dbf07ea644 i18n: sync
(cherry picked from commit 1a46db0517)
2026-10-23 09:59:53 -04:00
bbedward 58d741ec07 cc: allow sliders to shrink to 0.5 height
related #3880
port 2.0

(cherry picked from commit 6bd0e24bd4)
2026-10-23 13:55:41 +00:00
Matt Jiang 2befd45974 powermenu: reboot into firmware setup from the power menu (#3877)
port 2.0

(cherry picked from commit e50bb088b6)
2026-10-23 13:29:48 +00:00
purian23 99eb2a06d0 sysupdate: hide flatpak updates when flatpak is excluded
Port 2.0

(cherry picked from commit efd9324a4d)
2026-10-23 10:28:24 +00:00
purian23 163c4d5a1f refactor(dot): major performance & stability improvements
- Hyprland & Mango users will benefit most while niri gains solid QOL updates
- New icon picker in dot settings
- Dragging, flinging and opening the dot now run on one static surface so they stay smooth on every compositor

Port 2.0

(cherry picked from commit 0cd03e0180)
2026-10-23 05:49:47 -04:00
purian23 d4ecaf82d4 fix(mango): keep layer animations off dms surfaces
- Stops Mango tweening the dot and island when layer_animations is on
Port 2.0

(cherry picked from commit ba7b3620a8)
2026-10-23 08:41:12 +00:00
purian23 ec4e04f06f fix(core): prevent clipboard & wellbeing data loss from concurrent sessions
Port 2.0

(cherry picked from commit c7692f830c)
2026-10-23 05:42:29 +00:00
purian23 5aa2e3f0d4 systemd: start enabled service on restart when idle
Port 2.0

(cherry picked from commit 5a20a8f162)
2026-10-23 04:27:03 +00:00
bbedward 11f99cc13f displays: ceil logical output sizes on niri so neighbours never overlap
fixes #3875
port 2.0

(cherry picked from commit acc2c947e5)
2026-10-23 04:00:30 +00:00
purian23 0ae0b96885 refactor(mango): deprecate pre-mango v18.0 binds & legacy management
(cherry picked from commit d8734bbe1a)
2026-10-22 23:02:56 -04:00
Mario Mohar f0826a953f wallpaper: push the cycling config when the service is created (#3874)
port 2.0

(cherry picked from commit 900e6110d9)
2026-10-22 21:09:21 +00:00
bbedward b5da117206 bar: drop pill blur region when a vertical pill collapses to zero height
fixes #3873
port 2.0

(cherry picked from commit 44d498e362)
2026-10-22 21:01:24 +00:00
alvarosth 430db4e493 Add opt-in administrator lock restrictions (#3869)
(cherry picked from commit 7781c207f1)
2026-10-22 14:57:52 -04:00
bbedward 2a636c7092 shell: restore QT disable decoration pragma
(cherry picked from commit 5b7777785a)
2026-10-22 14:53:15 -04:00
bbedward 8848ad82f1 distro/freebsd: drop elogind references
(cherry picked from commit aeacaf7afc)
2026-10-22 14:53:07 -04:00
Vaibhav Pratap Singh 404d5362e5 battery: pulse only the icon when the bar has no background (#3861)
(cherry picked from commit 0aab0ec09b)
2026-10-22 14:52:58 -04:00
bbedward 4ab57f14b8 plugins: label declared permissions as unenforced
(cherry picked from commit 14b3ddaf83)
2026-10-22 09:50:36 -04:00
bbedward ce81dfc95a plugins: keep manifest paths inside the plugin directory
(cherry picked from commit 6e86b53ccd)
2026-10-22 09:50:29 -04:00
bbedward a5511fe8d7 greeter: remove the embedded legacy greeter (again)
(cherry picked from commit c5d1d4fe46)
2026-10-22 09:50:19 -04:00
purian23 7d930f8f6c distos: compare ubuntu published commit by prefix
(cherry picked from commit 39a58f77ec)
2026-10-22 09:50:12 -04:00
Anton Kesy 11ef76b9d0 docs: use dux project names in README (#3847)
(cherry picked from commit 4f969c8e10)
2026-10-22 09:50:03 -04:00
purian23 e48de4cc2a notifications: keep popup content while it exits after the notification closes
Port 2.0

(cherry picked from commit 47c9d8d89b)
2026-10-22 01:58:36 +00:00
bbedward 1683fceac5 bar: keep far-edge island hosts at sheet size so the bar never jumps on expand
fixes #3855
port 2.0

(cherry picked from commit d8786eaaf7)
2026-10-22 01:57:39 +00:00
bbedward 2a83c5c69b battery: throttle pulse animation to 20fps
ref #3849

(cherry picked from commit 1b59a474dd)
2026-10-21 14:16:02 -04:00
bbedward 1acfae1755 dash: hold panel height while dragging cards unless hung from a top bar
lyrics: ignore floored position samples and shell refresh emits

(cherry picked from commit 28f921fa02)
2026-10-21 13:19:05 -04:00
Adarsh 1e1dbebb70 Theme Antigravity IDE via the VS Code extension (#3836)
(cherry picked from commit 479b1434de)
2026-10-21 13:18:58 -04:00
bbedward 53412d1f22 theme: publish typography for dux apps
(cherry picked from commit 8f2a486006)
2026-10-21 12:42:02 -04:00
bbedward 9ece8f26fa theme: skip matugen runs whose inputs match the last applied run
fixes #3841

(cherry picked from commit afeea489c9)
2026-10-21 11:25:29 -04:00
bbedward fd1295f959 core: only use the simple animation driver for the scrolling wallpaper
fixes #3837

(cherry picked from commit 27a703367f)
2026-10-21 11:25:20 -04:00
bbedward 1e3f16ac9d bar: slide an auto-hidden island by its compact height
fixes #3838

(cherry picked from commit 8e851b3dc4)
2026-10-21 11:25:11 -04:00
bbedward 4fa6f58720 workspaces: keep the pill highlight while hovering its app icons
fixes #3839

(cherry picked from commit 112d05901f)
2026-10-21 11:25:02 -04:00
Marlon Kranz ff45b3b4b2 fix(notifications): close popups when their screen stops showing them (#3844)
(cherry picked from commit b81954454a)
2026-10-21 11:24:49 -04:00
Tomás 12e54e0225 Keep MangoWM workspace apps on their own monitor (#3832)
port 2.0

(cherry picked from commit 203b1369b3)
2026-10-21 11:24:35 -04:00
bbedward f63d8bf4fb niri: only emit displayScales when a scale changed
ref #3842
port 2.0

(cherry picked from commit 90d3c9731c)
2026-10-21 15:18:54 +00:00
Leonard 1e8423992d refactor(ui): match the dock settings page with the bar settings page (#3829)
port 2.0

(cherry picked from commit 5ffc581ecf)
2026-10-21 00:54:58 +00:00
bbedward c4ecb988b4 matugen: neovim template reads the dms config dir"
port 2.0

(cherry picked from commit 3284acab28)
2026-10-20 21:03:37 +00:00
219 changed files with 7182 additions and 8848 deletions
+7 -7
View File
@@ -100,7 +100,7 @@ Notification center with grouping, rich text support, and keyboard navigation.
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews. MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
**Session Management** **Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/duxlabs-dev/dux-greeter). Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dux-greeter](https://github.com/duxlabs-dev/dux-greeter).
**Plugin System** **Plugin System**
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps
@@ -138,16 +138,16 @@ dms plugins restore ~/plugins.lock.json
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview) - **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs) - **Support:** [Ko-fi](https://ko-fi.com/duxlabs)
## Dank Projects ## Dux Projects
DMS is one piece of the suite. The rest lives in its own repos: DMS is one piece of the suite. The rest lives in its own repos:
- [dank-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it. - [dux-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it.
- [dankcalendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop. - [dux-calendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop.
- [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon. - [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
- [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher. - [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher.
- [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar. - [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dux-greeter, and dux-calendar.
- [dankgo](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps. - [dux-go](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps.
## Development ## Development
@@ -193,7 +193,7 @@ Contributions welcome. Bug fixes, widgets, features, documentation, and plugins
3. Test thoroughly 3. Test thoroughly
4. Open a pull request 4. Open a pull request
For documentation contributions, see [DankLinux-Docs](https://github.com/duxlabs-dev/dux-docs). For documentation contributions, see [dux-docs](https://github.com/duxlabs-dev/dux-docs).
## Credits ## Credits
-24
View File
@@ -55,32 +55,8 @@ var resolveIncludeCmd = &cobra.Command{
Run: runResolveInclude, Run: runResolveInclude,
} }
var mangoMigrateMain bool
var mangoMigrateCmd = &cobra.Command{
Use: "mango-migrate",
Short: "Respell Mango config keys for the installed Mango",
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dialect := mangoconf.Detect()
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
for _, path := range changed {
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
}
// Printed before any error: the shell needs the dialect even when a fragment failed.
fmt.Println(dialect)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() { func init() {
configCmd.AddCommand(resolveIncludeCmd) configCmd.AddCommand(resolveIncludeCmd)
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
configCmd.AddCommand(mangoMigrateCmd)
} }
type IncludeResult struct { type IncludeResult struct {
+65 -16
View File
@@ -858,6 +858,14 @@ func checkWindowManagers() []checkResult {
if c.name == "Hyprland" { if c.name == "Hyprland" {
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...) results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
} }
if c.name == "mangowc" && mangoLacksSnakeCase(ver) {
results = append(results, checkResult{
catCompositor, "Mango config format", statusError,
fmt.Sprintf("Mango %s is older than 0.18", ver),
"DMS writes Mango config with the snake_case keys of Mango 0.18 and later, which this release rejects; upgrade Mango",
doctorDocsURL + "#compositor-checks",
})
}
} }
if !foundAny { if !foundAny {
@@ -878,7 +886,7 @@ func checkWindowManagers() []checkResult {
} }
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" { if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
results = append(results, checkMangoConfig(mangoconf.Dir(), mangoconf.Detect())...) results = append(results, checkMangoConfig(mangoconf.Dir())...)
} }
results = append(results, checkCompositorBlurSupport()) results = append(results, checkCompositorBlurSupport())
@@ -886,33 +894,70 @@ func checkWindowManagers() []checkResult {
return results return results
} }
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`) var (
mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
mangoUnknownKeyword = regexp.MustCompile(`Unknown keyword:\s*(\S+)`)
ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
)
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult { // Mango's parser follows source= and always exits 0; only its output tells.
var mangoParseConfig = func(path string) (string, error) {
if _, err := exec.LookPath("mango"); err != nil {
return "", err
}
out, _ := exec.Command("mango", "-p", "-c", path).CombinedOutput()
return string(out), nil
}
// Each "Unknown keyword" line is followed by a location line naming the file.
func mangoUnknownKeywords(output string) []string {
var found []string
seen := map[string]bool{}
key := ""
for line := range strings.SplitSeq(ansiEscape.ReplaceAllString(output, ""), "\n") {
if m := mangoUnknownKeyword.FindStringSubmatch(line); m != nil {
key = m[1]
continue
}
slash := strings.LastIndex(line, "/")
if key == "" || slash < 0 {
continue
}
file, _, _ := strings.Cut(line[slash+1:], ":")
entry := key + " (" + file + ")"
if !seen[entry] {
seen[entry] = true
found = append(found, entry)
}
key = ""
}
return found
}
func checkMangoConfig(mangoDir string) []checkResult {
url := doctorDocsURL + "#compositor-checks" url := doctorDocsURL + "#compositor-checks"
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf")) mainPath := filepath.Join(mangoDir, "config.conf")
paths = append([]string{filepath.Join(mangoDir, "config.conf")}, paths...) var results []checkResult
if output, err := mangoParseConfig(mainPath); err == nil {
if unknown := mangoUnknownKeywords(output); len(unknown) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
"Unknown keys: " + strings.Join(unknown, ", "),
"Mango 0.18 renamed its config keys to snake_case. Run migrate-config-keys.sh from Mango's 0.18.0 release over ~/.config/mango, or rename the keys by hand.", url})
}
}
var stale, overviewBinds []string paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
paths = append([]string{mainPath}, paths...)
var overviewBinds []string
for _, path := range paths { for _, path := range paths {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
continue continue
} }
if dialect.Translate(string(data)) != string(data) {
stale = append(stale, filepath.Base(path))
}
if mangoLegacyOverviewBinds.Match(data) { if mangoLegacyOverviewBinds.Match(data) {
overviewBinds = append(overviewBinds, filepath.Base(path)) overviewBinds = append(overviewBinds, filepath.Base(path))
} }
} }
var results []checkResult
if len(stale) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
fmt.Sprintf("Not %s keys: %s", dialect, strings.Join(stale, ", ")),
"This Mango rejects those lines as unknown keywords. Run `dms config mango-migrate --main` (config.conf is backed up).", url})
}
if len(overviewBinds) > 0 { if len(overviewBinds) > 0 {
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn, results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "), "Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
@@ -967,6 +1012,10 @@ func hyprlandLacksLua(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0 return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
} }
func mangoLacksSnakeCase(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.18.0") < 0
}
func firstSubmatch(re *regexp.Regexp) func(string) string { func firstSubmatch(re *regexp.Regexp) func(string) string {
return func(s string) string { return func(s string) string {
if m := re.FindStringSubmatch(s); len(m) > 1 { if m := re.FindStringSubmatch(s); len(m) > 1 {
+28 -8
View File
@@ -8,8 +8,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
) )
func TestQuickshellVersionFailureDetails(t *testing.T) { func TestQuickshellVersionFailureDetails(t *testing.T) {
@@ -385,19 +383,41 @@ func TestCheckXDGMenuFile(t *testing.T) {
} }
} }
func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) { func TestCheckMangoConfigReportsUnknownKeysAndOverviewBinds(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644) _ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "dms", "layout.conf"), []byte("border_px=2\n"), 0o644) // Shape of `mango -p` 0.18.2 output, colours included; the same key twice collapses.
parsed := "\x1b[1m\x1b[31m[ERROR]:\x1b[0m Unknown keyword: \x1b[1m\x1b[31mexec-once\x1b[0m\n" +
"\x1b[1;31m╰─\x1b[1;33m[Index]\x1b[0m \x1b[1;36m" + dir + "/config.conf\x1b[0m:\x1b[1;35m1\x1b[0m\n ╰─exec-once=dms run\n\n" +
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:10\n ╰─overviewgappo=30\n" +
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:12\n"
prev := mangoParseConfig
mangoParseConfig = func(string) (string, error) { return parsed, nil }
t.Cleanup(func() { mangoParseConfig = prev })
if got := checkMangoConfig(dir, mangoconf.Legacy); len(got) != 2 || got[0].message != "Not legacy keys: layout.conf" { got := checkMangoConfig(dir)
t.Fatalf("legacy: %+v", got) if len(got) != 2 || got[0].message != "Unknown keys: exec-once (config.conf), overviewgappo (layout.conf)" || got[1].name != "Mango mouse binds" {
t.Fatalf("got %+v", got)
}
mangoParseConfig = func(string) (string, error) { return "", os.ErrNotExist }
if got := checkMangoConfig(dir); len(got) != 1 {
t.Fatalf("without mango on PATH only the bind check should run, got %+v", got)
}
}
func TestMangoLacksSnakeCase(t *testing.T) {
for ver, want := range map[string]bool{
"0.17.5": true,
"0.18.0": false,
"0.18.2": false,
"": false,
} {
if got := mangoLacksSnakeCase(ver); got != want {
t.Errorf("mangoLacksSnakeCase(%q) = %v, want %v", ver, got, want)
} }
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" {
t.Fatalf("snake: %+v", got)
} }
} }
+2 -2
View File
@@ -130,7 +130,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "layout.conf", mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig }, hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) }, mangoContent: func(_ string) string { return config.MangoLayoutConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
@@ -138,7 +138,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "colors.conf", mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig }, hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) }, mangoContent: func(_ string) string { return config.MangoColorsConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
+34 -1
View File
@@ -2,17 +2,21 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-go/shellapp" "github.com/duxlabs-dev/dux-go/shellapp"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"github.com/duxlabs-dev/dux-shell/core/internal/config" "github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/gpu" "github.com/duxlabs-dev/dux-shell/core/internal/gpu"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server" "github.com/duxlabs-dev/dux-shell/core/internal/server"
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed" "github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/afero"
) )
var shellApp = shellapp.New(shellapp.Config{ var shellApp = shellapp.New(shellapp.Config{
@@ -83,18 +87,47 @@ func preLaunch() {
ensureFontCache() ensureFontCache()
} }
// The elapsed-timer driver keeps the scrolling wallpaper spring in step above 60 Hz,
// but it makes every other animation jump after a dropped frame (#3837).
func scrollingWallpaperConfigured() bool {
configDir, err := os.UserConfigDir()
if err != nil {
return false
}
data, err := os.ReadFile(filepath.Join(configDir, "dms", "settings.json"))
if err != nil {
return false
}
return wantsSimpleAnimationDriver(data)
}
func wantsSimpleAnimationDriver(settings []byte) bool {
var parsed struct {
WallpaperFillMode string `json:"wallpaperFillMode"`
}
if json.Unmarshal(settings, &parsed) != nil {
return false
}
return parsed.WallpaperFillMode == "Scrolling"
}
func dmsExtraEnv(string) []string { func dmsExtraEnv(string) []string {
var env []string var env []string
if selfPath, err := os.Executable(); err == nil { if selfPath, err := os.Executable(); err == nil {
env = append(env, "DMS_EXECUTABLE="+selfPath) env = append(env, "DMS_EXECUTABLE="+selfPath)
} }
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" { if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" && scrollingWallpaperConfigured() {
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1") env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
} }
if _, set := os.LookupEnv("MALLOC_CONF"); !set { if _, set := os.LookupEnv("MALLOC_CONF"); !set {
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000") env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
} }
env = append(env, gpu.EGLVendorEnv()...) env = append(env, gpu.EGLVendorEnv()...)
policyEnv, err := clipolicy.ShellEnv(afero.NewOsFs())
if err != nil {
log.Errorf("CLI policy unreadable, enforcing every shell switch: %v", err)
}
env = append(env, policyEnv...)
// systemd user services miss the login shell PATH, so tools in ~/.local/bin // systemd user services miss the login shell PATH, so tools in ~/.local/bin
// (herdr, pip/cargo installs) would be invisible to the shell and its children. // (herdr, pip/cargo installs) would be invisible to the shell and its children.
for _, entry := range utils.EnvWithUserBinPath(nil) { for _, entry := range utils.EnvWithUserBinPath(nil) {
+28 -1
View File
@@ -20,9 +20,16 @@ var runSystemctl = func(args ...string) error {
return err return err
} }
// Assigned in init: shellApp's config refers back to trySystemdRestart.
var shellRunning func() bool
func init() {
shellRunning = func() bool { return len(shellApp.PIDs()) > 0 }
}
func trySystemdRestart() (bool, error) { func trySystemdRestart() (bool, error) {
if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil { if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil {
return false, nil return trySystemdStart()
} }
if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil { if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil {
@@ -30,3 +37,23 @@ func trySystemdRestart() (bool, error) {
} }
return true, nil return true, nil
} }
// With nothing running, restart would spawn an unmanaged daemon that blocks
// the unit once the session target starts it. A running hand-started
// instance is left to the plain restart path.
func trySystemdStart() (bool, error) {
if shellRunning() {
return false, nil
}
if err := runSystemctl("--user", "is-enabled", "--quiet", dmsSystemdUnit); err != nil {
return false, nil
}
if err := runSystemctl("--user", "is-active", "--quiet", "graphical-session.target"); err != nil {
return false, nil
}
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return true, fmt.Errorf("starting %s: %w", dmsSystemdUnit, err)
}
return true, nil
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"errors"
"slices"
"strings"
"testing"
)
func TestTrySystemdRestart(t *testing.T) {
tests := []struct {
name string
running bool
ok []string
wantHandled bool
wantCall string
}{
{"active unit restarts", false, []string{"is-active dms.service", "restart"}, true, "restart dms.service"},
{"enabled inactive unit starts", false, []string{"is-enabled", "is-active graphical-session.target", "start"}, true, "start dms.service"},
{"hand-started instance keeps plain restart", true, []string{"is-enabled", "is-active graphical-session.target", "start"}, false, ""},
{"session target not up", false, []string{"is-enabled"}, false, ""},
{"unit disabled", false, []string{"is-active graphical-session.target"}, false, ""},
{"no systemd", false, nil, false, ""},
}
origSystemctl, origRunning := runSystemctl, shellRunning
t.Cleanup(func() { runSystemctl, shellRunning = origSystemctl, origRunning })
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shellRunning = func() bool { return tt.running }
var calls []string
runSystemctl = func(args ...string) error {
call := strings.Join(slices.DeleteFunc(slices.Clone(args), func(a string) bool {
return strings.HasPrefix(a, "--")
}), " ")
calls = append(calls, call)
for _, prefix := range tt.ok {
if strings.HasPrefix(call, prefix) {
return nil
}
}
return errors.New("exit status 3")
}
handled, err := trySystemdRestart()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if handled != tt.wantHandled {
t.Fatalf("handled = %v, want %v (calls %q)", handled, tt.wantHandled, calls)
}
if tt.wantCall != "" && !slices.Contains(calls, tt.wantCall) {
t.Fatalf("missing %q in calls %q", tt.wantCall, calls)
}
if !tt.wantHandled && slices.ContainsFunc(calls, func(c string) bool { return strings.HasPrefix(c, "start") }) {
t.Fatalf("unexpected start in calls %q", calls)
}
})
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/dlclark/regexp2/v2 v2.9.1 github.com/dlclark/regexp2/v2 v2.9.1
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500 github.com/duxlabs-dev/dux-go v1.6.3-0.20261023041949-220eb5ff21dc
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/go-git/go-git/v6 v6.0.0-beta.1 github.com/go-git/go-git/v6 v6.0.0-beta.1
+2 -2
View File
@@ -97,8 +97,8 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ= github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500 h1:y02m7QgoX4rwauY9HePIPGCRDJ/fMirBmgj78IkeUsc= github.com/duxlabs-dev/dux-go v1.6.3-0.20261023041949-220eb5ff21dc h1:oZhWdM7mlEevEm1zaeOeDfIMQkvtZSW8UNqrAZldbAY=
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk= github.com/duxlabs-dev/dux-go v1.6.3-0.20261023041949-220eb5ff21dc/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk=
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok= github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok=
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0= github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
+72 -7
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -19,6 +20,23 @@ type File struct {
BlockedCommands *[]string `json:"blocked_commands"` BlockedCommands *[]string `json:"blocked_commands"`
Message *string `json:"message"` Message *string `json:"message"`
DisableRegistries *bool `json:"disable_registries"` DisableRegistries *bool `json:"disable_registries"`
DisableLockIPCUnlock *bool `json:"disable_lock_ipc_unlock"`
DisableLogindUnlock *bool `json:"disable_logind_unlock"`
DisableCustomLockPAM *bool `json:"disable_custom_lock_pam"`
DisableUserPlugins *bool `json:"disable_user_plugins"`
DisableSettingsIPCSet *bool `json:"disable_settings_ipc_set"`
}
// shellSwitches reach the shell as DMS_POLICY_<KEY> in its launch environment.
var shellSwitches = []struct {
key string
value func(*File) *bool
}{
{"disable_lock_ipc_unlock", func(p *File) *bool { return p.DisableLockIPCUnlock }},
{"disable_logind_unlock", func(p *File) *bool { return p.DisableLogindUnlock }},
{"disable_custom_lock_pam", func(p *File) *bool { return p.DisableCustomLockPAM }},
{"disable_user_plugins", func(p *File) *bool { return p.DisableUserPlugins }},
{"disable_settings_ipc_set", func(p *File) *bool { return p.DisableSettingsIPCSet }},
} }
// LoadFile returns nil without an error when the file does not exist. // LoadFile returns nil without an error when the file does not exist.
@@ -39,18 +57,65 @@ func LoadFile(fs afero.Fs, path string) (*File, error) {
return &policy, nil return &policy, nil
} }
// RegistriesDisabled reports whether the policy turns off the plugin and theme // load returns the policy files that exist, PackagedPath first.
// registries. An explicit value in AdminPath overrides PackagedPath. func load(fs afero.Fs) ([]*File, error) {
func RegistriesDisabled(fs afero.Fs) (bool, error) { var policies []*File
disabled := false
for _, path := range []string{PackagedPath, AdminPath} { for _, path := range []string{PackagedPath, AdminPath} {
policy, err := LoadFile(fs, path) policy, err := LoadFile(fs, path)
if err != nil {
return nil, err
}
if policy != nil {
policies = append(policies, policy)
}
}
return policies, nil
}
// flag reports a boolean key, false unless set. An explicit value in AdminPath
// overrides PackagedPath.
func flag(policies []*File, value func(*File) *bool) bool {
set := false
for _, policy := range policies {
if v := value(policy); v != nil {
set = *v
}
}
return set
}
// RegistriesDisabled reports whether the policy turns off the plugin and theme
// registries. An explicit value in AdminPath overrides PackagedPath.
func RegistriesDisabled(fs afero.Fs) (bool, error) {
policies, err := load(fs)
if err != nil { if err != nil {
return false, err return false, err
} }
if policy != nil && policy.DisableRegistries != nil { return flag(policies, func(p *File) *bool { return p.DisableRegistries }), nil
disabled = *policy.DisableRegistries
} }
// LogindUnlockDisabled reports whether a logind Unlock must leave a lock in
// place. An explicit value in AdminPath overrides PackagedPath.
func LogindUnlockDisabled(fs afero.Fs) (bool, error) {
policies, err := load(fs)
if err != nil {
return false, err
} }
return disabled, nil return flag(policies, func(p *File) *bool { return p.DisableLogindUnlock }), nil
}
// ShellEnv returns every shell switch as DMS_POLICY_<KEY>=1 or 0, so a value
// inherited by the shell never counts. A policy file that fails to load turns
// every switch on, and its error is returned with them.
func ShellEnv(fs afero.Fs) ([]string, error) {
policies, err := load(fs)
env := make([]string, 0, len(shellSwitches))
for _, s := range shellSwitches {
value := "0"
if err != nil || flag(policies, s.value) {
value = "1"
}
env = append(env, "DMS_POLICY_"+strings.ToUpper(s.key)+"="+value)
}
return env, err
} }
+101
View File
@@ -1,6 +1,7 @@
package clipolicy package clipolicy
import ( import (
"strings"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -45,3 +46,103 @@ func TestRegistriesDisabled(t *testing.T) {
}) })
} }
} }
func TestLogindUnlockDisabled(t *testing.T) {
tests := []struct {
name string
packaged string
admin string
want bool
wantErr string
}{
{name: "no policy files"},
{name: "admin policy", admin: `{"disable_logind_unlock": true}`, want: true},
{name: "admin policy overrides packaged", packaged: `{"disable_logind_unlock": true}`, admin: `{"disable_logind_unlock": false}`},
{name: "another key", admin: `{"disable_lock_ipc_unlock": true}`},
{name: "malformed admin policy", admin: "{not json", wantErr: "failed to parse " + AdminPath},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
if tt.packaged != "" {
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
}
if tt.admin != "" {
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
}
disabled, err := LogindUnlockDisabled(fs)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, disabled)
})
}
}
var shellSwitchEnv = map[string]string{
"disable_lock_ipc_unlock": "DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK",
"disable_logind_unlock": "DMS_POLICY_DISABLE_LOGIND_UNLOCK",
"disable_custom_lock_pam": "DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM",
"disable_user_plugins": "DMS_POLICY_DISABLE_USER_PLUGINS",
"disable_settings_ipc_set": "DMS_POLICY_DISABLE_SETTINGS_IPC_SET",
}
func TestShellEnv(t *testing.T) {
tests := []struct {
name string
packaged string
admin string
on bool
}{
{name: "no policy files"},
{name: "packaged policy", packaged: `{"KEY": true}`, on: true},
{name: "admin policy", admin: `{"KEY": true}`, on: true},
{name: "admin policy overrides packaged", packaged: `{"KEY": true}`, admin: `{"KEY": false}`},
{name: "admin policy without the key keeps packaged", packaged: `{"KEY": true}`, admin: `{"immutable_system": true}`, on: true},
}
for key, name := range shellSwitchEnv {
for _, tt := range tests {
t.Run(key+"/"+tt.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
if tt.packaged != "" {
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(strings.ReplaceAll(tt.packaged, "KEY", key)), 0o644))
}
if tt.admin != "" {
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(strings.ReplaceAll(tt.admin, "KEY", key)), 0o644))
}
var want []string
for _, other := range shellSwitchEnv {
if other == name && tt.on {
want = append(want, other+"=1")
} else {
want = append(want, other+"=0")
}
}
env, err := ShellEnv(fs)
require.NoError(t, err)
assert.ElementsMatch(t, want, env)
})
}
}
}
func TestShellEnvMalformedPolicyTurnsEverySwitchOn(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644))
var want []string
for _, name := range shellSwitchEnv {
want = append(want, name+"=1")
}
env, err := ShellEnv(fs)
require.ErrorContains(t, err, "failed to parse "+AdminPath)
assert.ElementsMatch(t, want, env)
}
@@ -1,6 +1,6 @@
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf). # Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
# Remove `source=./dms/colors.conf` from config.conf to override manually. # Remove `source=./dms/colors.conf` from config.conf to override manually.
bordercolor = 0x595959ff border_color = 0x595959ff
focuscolor = 0x8ab4f8ff focus_color = 0x8ab4f8ff
urgentcolor = 0xff5555ff urgent_color = 0xff5555ff
@@ -1,8 +1,12 @@
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf). # Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
border_radius=12 border_radius=12
gappih=5 gap_inner_horizontal=5
gappiv=5 gap_inner_vertical=5
gappoh=5 gap_outer_horizontal=5
gappov=5 gap_outer_vertical=5
borderpx=2 border_px=2
# layer_animations would tween every DMS layer move; override with no_animation:0 rules after the source lines.
layer_rule=no_animation:1,layer_name:^(quickshell)$
layer_rule=no_animation:1,layer_name:^dms:.*
+1 -1
View File
@@ -7,7 +7,7 @@ env=XDG_SESSION_TYPE,wayland
# Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs # Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs
# exec= on every config reload, and DMS reloads the config. # exec= on every config reload, and DMS reloads the config.
exec-once=dms run exec_once=dms run
source=./dms/colors.conf source=./dms/colors.conf
source=./dms/layout.conf source=./dms/layout.conf
+10 -12
View File
@@ -41,14 +41,13 @@ func nonEmptyFile(path string) (string, bool) {
} }
var ( var (
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|keymode|key_mode)\s*=`) mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|key_mode)\s*=`)
mangoMonitorLine = regexp.MustCompile(`^\s*(monitorrule|monitor_rule)\s*=`) mangoMonitorLine = regexp.MustCompile(`^\s*monitor_rule\s*=`)
mangoWindowLine = regexp.MustCompile(`^\s*(windowrule|window_rule|windowrule-once|window_rule_once)\s*=`) mangoWindowLine = regexp.MustCompile(`^\s*window_rule(_once)?\s*=`)
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\n(exec-once|exec_once)=dms run\n`) mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\nexec_once=dms run\n`)
) )
var ( var (
mangoDialect = mangoconf.Detect
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
) )
@@ -110,17 +109,16 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath)) cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
} }
dialect := mangoDialect()
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand) newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) { if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "") newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
} }
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil { if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err) result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error return result, result.Error
} }
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, dialect, existing, timestamp); err != nil { if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error return result, result.Error
} }
@@ -132,7 +130,7 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
// Like niri, fragments the user or the shell already filled are left alone; the // Like niri, fragments the user or the shell already filled are left alone; the
// replaced config.conf hands its binds, monitor and window rules to empty ones. // replaced config.conf hands its binds, monitor and window rules to empty ones.
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string, dialect mangoconf.Dialect, oldMain, timestamp string) error { func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand, oldMain, timestamp string) error {
stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand) stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
bindsPath := filepath.Join(dmsDir, "binds.conf") bindsPath := filepath.Join(dmsDir, "binds.conf")
if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 { if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 {
@@ -142,7 +140,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
} }
cd.log(fmt.Sprintf("Backed up existing binds to %s", backup)) cd.log(fmt.Sprintf("Backed up existing binds to %s", backup))
if cd.replaceBinds { if cd.replaceBinds {
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(stockBinds)), 0o644); err != nil { if err := os.WriteFile(bindsPath, []byte(stockBinds), 0o644); err != nil {
return fmt.Errorf("failed to write binds.conf: %w", err) return fmt.Errorf("failed to write binds.conf: %w", err)
} }
cd.log("Deployed DMS stock binds.conf") cd.log("Deployed DMS stock binds.conf")
@@ -160,7 +158,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
binds = mangoconf.BindsMovedHeader + "\n" + old binds = mangoconf.BindsMovedHeader + "\n" + old
cd.log("Moved existing binds from config.conf to binds.conf") cd.log("Moved existing binds from config.conf to binds.conf")
} }
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(binds)), 0o644); err != nil { if err := os.WriteFile(bindsPath, []byte(binds), 0o644); err != nil {
return fmt.Errorf("failed to write binds.conf: %w", err) return fmt.Errorf("failed to write binds.conf: %w", err)
} }
} }
@@ -182,7 +180,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name)) cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue continue
} }
if err := os.WriteFile(path, []byte(dialect.Translate(cfg.content)), 0o644); err != nil { if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err) return fmt.Errorf("failed to write %s: %w", cfg.name, err)
} }
cd.log(fmt.Sprintf("Deployed %s", cfg.name)) cd.log(fmt.Sprintf("Deployed %s", cfg.name))
+17 -17
View File
@@ -13,12 +13,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func stubMango(t *testing.T, dialect mangoconf.Dialect, target bool, addWants error) *int { func stubMango(t *testing.T, target bool, addWants error) *int {
t.Helper() t.Helper()
prevDialect, prevTarget := mangoDialect, mangoSessionTargetInstalled prevTarget := mangoSessionTargetInstalled
mangoDialect = func() mangoconf.Dialect { return dialect }
mangoSessionTargetInstalled = func() bool { return target } mangoSessionTargetInstalled = func() bool { return target }
t.Cleanup(func() { mangoDialect, mangoSessionTargetInstalled = prevDialect, prevTarget }) t.Cleanup(func() { mangoSessionTargetInstalled = prevTarget })
return stubAddWants(t, addWants) return stubAddWants(t, addWants)
} }
@@ -31,16 +30,16 @@ func readMango(t *testing.T, home, rel string) string {
const oldMangoConfig = `exec-once=waybar const oldMangoConfig = `exec-once=waybar
bind=SUPER,Return,spawn,foot bind=SUPER,Return,spawn,foot
keymode=resize key_mode=resize
bind=NONE,h,resizewin,-10,0 bind=NONE,h,resizewin,-10,0
monitorrule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1 monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
windowrule=appid:pavucontrol,isfloating:1 window_rule=app_id:pavucontrol,is_floating:1
` `
func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) { func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
stubMango(t, mangoconf.Legacy, false, nil) stubMango(t, false, nil)
require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644))
@@ -48,25 +47,26 @@ func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, result.BackupPath) assert.NotEmpty(t, result.BackupPath)
assert.Contains(t, readMango(t, home, "config.conf"), "exec-once=dms run") assert.Contains(t, readMango(t, home, "config.conf"), "exec_once=dms run")
assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf") assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf")
assert.Equal(t, "", readMango(t, home, "dms/input.conf")) assert.Equal(t, "", readMango(t, home, "dms/input.conf"))
binds := readMango(t, home, "dms/binds.conf") binds := readMango(t, home, "dms/binds.conf")
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkeymode=resize\nbind=NONE,h,resizewin,-10,0") assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkey_mode=resize\nbind=NONE,h,resizewin,-10,0")
assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}") assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}")
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "monitor_rule=name:^DP-1$")
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1") assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1")
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "windowrule=appid:pavucontrol,isfloating:1") assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "window_rule=app_id:pavucontrol,is_floating:1")
} }
func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) { func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
for _, replace := range []bool{false, true} { for _, replace := range []bool{false, true} {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
stubMango(t, mangoconf.Legacy, false, nil) stubMango(t, false, nil)
dms := filepath.Join(home, ".config", "mango", "dms") dms := filepath.Join(home, ".config", "mango", "dms")
require.NoError(t, os.MkdirAll(dms, 0o755)) require.NoError(t, os.MkdirAll(dms, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\nkey_mode=resize\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("windowrule=appid:keep,isglobal:1\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("window_rule=app_id:keep,is_global:1\n"), 0o644))
cd := NewConfigDeployer(nil) cd := NewConfigDeployer(nil)
cd.SetReplaceBinds(replace) cd.SetReplaceBinds(replace)
@@ -76,8 +76,8 @@ func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*")) backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*"))
assert.Len(t, backups, 1, "replace=%v", replace) assert.Len(t, backups, 1, "replace=%v", replace)
binds := readMango(t, home, "dms/binds.conf") binds := readMango(t, home, "dms/binds.conf")
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\n", "replace=%v", replace) assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\nkey_mode=resize\n", "replace=%v", replace)
assert.Equal(t, "windowrule=appid:keep,isglobal:1\n", readMango(t, home, "dms/windowrules.conf")) assert.Equal(t, "window_rule=app_id:keep,is_global:1\n", readMango(t, home, "dms/windowrules.conf"), "kept fragments are left alone")
} }
} }
@@ -97,7 +97,7 @@ func TestMangoDeploySystemdAutostart(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
calls := stubMango(t, mangoconf.Snake, tc.target, tc.addWants) calls := stubMango(t, tc.target, tc.addWants)
_, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true) _, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true)
require.NoError(t, err) require.NoError(t, err)
main := readMango(t, home, "config.conf") main := readMango(t, home, "config.conf")
+1 -1
View File
@@ -43,7 +43,7 @@ func TestDisableSessionRemovesEveryWantsLink(t *testing.T) {
func TestEnableSessionMangoWithoutTarget(t *testing.T) { func TestEnableSessionMangoWithoutTarget(t *testing.T) {
t.Setenv("HOME", t.TempDir()) t.Setenv("HOME", t.TempDir())
calls := stubMango(t, mangoconf.Snake, false, nil) calls := stubMango(t, false, nil)
unit, err := EnableSession(deps.WindowManagerMango) unit, err := EnableSession(deps.WindowManagerMango)
require.Error(t, err) require.Error(t, err)
assert.Empty(t, unit) assert.Empty(t, unit)
+19
View File
@@ -0,0 +1,19 @@
// Package mangoconf holds what DMS knows about Mango's config files: where they
// live, the session target, and the markers DMS leaves in the binds fragment.
package mangoconf
import (
"fmt"
"strings"
)
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
const maxValueLen = 255
func CheckLine(line string) error {
_, value, ok := strings.Cut(line, "=")
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
return nil
}
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
}
-216
View File
@@ -1,216 +0,0 @@
// Package mangoconf maps Mango config keys between the run-together names of
// Mango <= 0.17.5 and the snake_case names of upstream 8adc6bc3 (mangowm/mango#1491).
// Neither parser accepts the other's names.
package mangoconf
import (
"bytes"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"sync"
)
type Dialect int
const (
Legacy Dialect = iota
Snake
)
// Upstream migration order: where two legacy names share a snake name, the
// window-rule spelling comes first and wins the reverse lookup.
var renames = [][2]string{
{"windowrule-once", "window_rule_once"},
{"windowrule", "window_rule"},
{"monitorrule", "monitor_rule"},
{"tagrule", "tag_rule"},
{"layerrule", "layer_rule"},
{"devicerule", "device_rule"},
{"source-optional", "source_optional"},
{"exec-once", "exec_once"},
{"keymode", "key_mode"},
{"isnoborder", "no_border"},
{"isnoshadow", "no_shadow"},
{"isnoradius", "no_radius"},
{"isnoanimation", "no_animation"},
{"isnosizehint", "no_size_hint"},
{"noblur", "no_blur"},
{"noshadow", "no_shadow"},
{"noanim", "no_animation"},
{"nofocus", "no_focus"},
{"nofadein", "no_fade_in"},
{"nofadeout", "no_fade_out"},
{"noswallow", "no_swallow"},
{"special_gappih", "special_gap_inner_horizontal"},
{"special_gappiv", "special_gap_inner_vertical"},
{"special_gappoh", "special_gap_outer_horizontal"},
{"special_gappov", "special_gap_outer_vertical"},
{"gappih", "gap_inner_horizontal"},
{"gappiv", "gap_inner_vertical"},
{"gappoh", "gap_outer_horizontal"},
{"gappov", "gap_outer_vertical"},
{"overviewgappi", "overview_gap_inner"},
{"overviewgappo", "overview_gap_outer"},
{"default_mfact", "default_master_factor"},
{"default_nmaster", "default_master_count"},
{"animation_curve_opafadein", "animation_curve_opacity_fade_in"},
{"animation_curve_opafadeout", "animation_curve_opacity_fade_out"},
{"dwindle_hsplit", "dwindle_horizontal_split"},
{"dwindle_vsplit", "dwindle_vertical_split"},
{"fadein_begin_opacity", "fade_in_begin_opacity"},
{"fadeout_begin_opacity", "fade_out_begin_opacity"},
{"focusdir_only_zone_overlap", "focus_direction_only_zone_overlap"},
{"force_fakemaximize", "force_fake_maximize"},
{"idleinhibit_ignore_visible", "idle_inhibit_ignore_visible"},
{"idleinhibit_when_focus", "idle_inhibit_when_focus"},
{"idleinhibit_when_fullscreen", "idle_inhibit_when_fullscreen"},
{"syncobj_enable", "sync_obj_enable"},
{"isfakefullscreen", "is_fake_fullscreen"},
{"isfloating", "is_floating"},
{"isfullscreen", "is_fullscreen"},
{"isunglobal", "is_unmanaged_global"},
{"isglobal", "is_global"},
{"isnamedscratchpad", "is_named_scratchpad"},
{"isopensilent", "is_open_silent"},
{"isoverlay", "is_overlay"},
{"istagsilent", "is_tag_silent"},
{"isterm", "is_term"},
{"appid", "app_id"},
{"bordercolor", "border_color"},
{"borderpx", "border_px"},
{"dropcolor", "drop_color"},
{"focuscolor", "focus_color"},
{"globalcolor", "global_color"},
{"globalkeybinding", "global_key_binding"},
{"maximizescreencolor", "maximized_screen_color"},
{"mfact", "master_factor"},
{"nmaster", "master_count"},
{"numlockon", "numlock_on"},
{"offsetx", "offset_x"},
{"offsety", "offset_y"},
{"overlaycolor", "overlay_color"},
{"rootcolor", "root_color"},
{"scratchpadcolor", "scratchpad_color"},
{"shadowscolor", "shadows_color"},
{"sloppyfocus", "sloppy_focus"},
{"smartgaps", "smart_gaps"},
{"splitcolor", "split_color"},
{"urgentcolor", "urgent_color"},
{"warpcursor", "warp_cursor"},
}
var toSnake, toLegacy = func() (map[string]string, map[string]string) {
fwd := make(map[string]string, len(renames))
rev := make(map[string]string, len(renames))
for _, r := range renames {
fwd[r[0]] = r[1]
if _, ok := rev[r[1]]; !ok {
rev[r[1]] = r[0]
}
}
return fwd, rev
}()
// Normalize returns the snake_case name for a key in either dialect.
func Normalize(key string) string {
if s, ok := toSnake[key]; ok {
return s
}
return key
}
// Key spells a snake_case key in this dialect.
func (d Dialect) Key(snake string) string {
if d == Snake {
return snake
}
if l, ok := toLegacy[snake]; ok {
return l
}
return snake
}
var keyLine = regexp.MustCompile(`^(\s*)([A-Za-z][A-Za-z_-]*)(\s*=\s*)(.*)$`)
// Translate respells every line key, and the field keys of rule lines, in this dialect.
func (d Dialect) Translate(text string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
m := keyLine.FindStringSubmatch(line)
if m == nil {
continue
}
key := Normalize(m[2])
value := m[4]
switch key {
case "window_rule", "window_rule_once", "tag_rule":
value = d.translateFields(value, nil)
case "layer_rule":
value = d.translateFields(value, legacyLayerFields)
}
lines[i] = m[1] + d.Key(key) + m[3] + value
}
return strings.Join(lines, "\n")
}
// Layer rules spell these without the window rules' "is" prefix.
var legacyLayerFields = map[string]string{"no_shadow": "noshadow", "no_animation": "noanim"}
func (d Dialect) translateFields(value string, legacyOverride map[string]string) string {
parts := strings.Split(value, ",")
for i, part := range parts {
k, v, ok := strings.Cut(part, ":")
if !ok {
continue
}
trimmed := strings.TrimSpace(k)
key := d.Key(Normalize(trimmed))
if l, ok := legacyOverride[Normalize(trimmed)]; ok && d == Legacy {
key = l
}
parts[i] = strings.Replace(k, trimmed, key, 1) + ":" + v
}
return strings.Join(parts, ",")
}
// Legacy builds carry exec_once as a symbol name, so it cannot tell the dialects apart.
var snakeMarker = []byte("gap_inner_horizontal")
var (
detectOnce sync.Once
detected Dialect
)
// Detect reads the mango binary: snake_case shipped on master builds still reporting 0.17.5, before the 0.18.0 release.
func Detect() Dialect {
detectOnce.Do(func() {
path, err := exec.LookPath("mango")
if err != nil {
return
}
detected = DetectBinary(path)
})
return detected
}
func DetectBinary(path string) Dialect {
data, err := os.ReadFile(path)
if err != nil || !bytes.Contains(data, snakeMarker) {
return Legacy
}
return Snake
}
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
const maxValueLen = 255
func CheckLine(line string) error {
_, value, ok := strings.Cut(line, "=")
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
return nil
}
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
}
-68
View File
@@ -1,68 +0,0 @@
package mangoconf
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTranslateRespellsKeysBothWays(t *testing.T) {
legacy := "exec-once=dms run\nsource=./dms/colors.conf\ngappih = 5\nbordercolor = 0x595959ff\nwindowrule=appid:foo,isfloating:1,isnoanimation:1\nlayerrule=noanim:1,noshadow:1,layer_name:rofi\n# exec-once=kept\n"
snake := "exec_once=dms run\nsource=./dms/colors.conf\ngap_inner_horizontal = 5\nborder_color = 0x595959ff\nwindow_rule=app_id:foo,is_floating:1,no_animation:1\nlayer_rule=no_animation:1,no_shadow:1,layer_name:rofi\n# exec-once=kept\n"
assert.Equal(t, snake, Snake.Translate(legacy))
assert.Equal(t, legacy, Legacy.Translate(snake))
assert.Equal(t, legacy, Legacy.Translate(legacy))
assert.Equal(t, "tag_rule=id:1,master_factor:0.6,master_count:2\n", Snake.Translate("tagrule=id:1,mfact:0.6,nmaster:2\n"))
}
func TestDetectBinary(t *testing.T) {
dir := t.TempDir()
old := filepath.Join(dir, "old")
cur := filepath.Join(dir, "new")
require.NoError(t, os.WriteFile(old, []byte("\x00run_exec_once\x00gappih\x00"), 0o755))
require.NoError(t, os.WriteFile(cur, []byte("\x00exec_once\x00gap_inner_horizontal\x00"), 0o755))
assert.Equal(t, Legacy, DetectBinary(old))
assert.Equal(t, Snake, DetectBinary(cur))
assert.Equal(t, Legacy, DetectBinary(filepath.Join(dir, "missing")))
}
func TestMigrateLeavesUserConfigUnlessAsked(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
main := filepath.Join(dir, "config.conf")
layout := filepath.Join(dir, "dms", "layout.conf")
input := filepath.Join(dir, "dms", "input.conf")
require.NoError(t, os.WriteFile(main, []byte("exec-once=dms run\n"), 0o644))
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
require.NoError(t, os.WriteFile(input, []byte("numlockon=1\n"), 0o644))
changed, err := Snake.Migrate(dir, false)
require.NoError(t, err)
assert.Equal(t, []string{layout, input}, changed)
data, _ := os.ReadFile(main)
assert.Equal(t, "exec-once=dms run\n", string(data))
data, _ = os.ReadFile(input)
assert.Equal(t, "numlock_on=1\n", string(data))
changed, err = Snake.Migrate(dir, true)
require.NoError(t, err)
assert.Equal(t, []string{main}, changed)
backups, _ := filepath.Glob(main + ".backup.*")
assert.Len(t, backups, 1)
}
func TestMigrateReportsUnwritableFragmentAndContinues(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
binds := filepath.Join(dir, "dms", "binds.conf")
layout := filepath.Join(dir, "dms", "layout.conf")
require.NoError(t, os.WriteFile(binds, []byte("exec-once=foo\n"), 0o444))
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
changed, err := Snake.Migrate(dir, false)
assert.ErrorContains(t, err, "binds.conf")
assert.Equal(t, []string{layout}, changed)
}
-57
View File
@@ -1,57 +0,0 @@
package mangoconf
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// DMS-owned fragments under mango/dms; config.conf is the user's and only
// migrated on request.
var fragments = []string{"binds.conf", "colors.conf", "layout.conf", "cursor.conf", "input.conf", "outputs.conf", "windowrules.conf"}
// Migrate respells the dms fragments, and config.conf (after a backup) when includeMain is set.
// It returns the files rewritten; an unwritable file is reported without stopping the others.
func (d Dialect) Migrate(mangoDir string, includeMain bool) ([]string, error) {
var changed []string
var errs []error
paths := make([]string, 0, len(fragments)+1)
for _, f := range fragments {
paths = append(paths, filepath.Join(mangoDir, "dms", f))
}
if includeMain {
paths = append(paths, filepath.Join(mangoDir, "config.conf"))
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
translated := d.Translate(string(data))
if translated == string(data) {
continue
}
if filepath.Base(path) == "config.conf" {
backup := path + ".backup." + time.Now().Format("2006-01-02_15-04-05")
if err := os.WriteFile(backup, data, 0o644); err != nil {
errs = append(errs, fmt.Errorf("backup %s: %w", path, err))
continue
}
}
if err := os.WriteFile(path, []byte(translated), 0o644); err != nil {
errs = append(errs, fmt.Errorf("write %s: %w", path, err))
continue
}
changed = append(changed, path)
}
return changed, errors.Join(errs...)
}
func (d Dialect) String() string {
if d == Snake {
return "snake"
}
return "legacy"
}
+2 -9
View File
@@ -20,7 +20,6 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/dank16" "github.com/duxlabs-dev/dux-shell/core/internal/dank16"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
"github.com/lucasb-eyer/go-colorful" "github.com/lucasb-eyer/go-colorful"
@@ -44,7 +43,6 @@ const (
TemplateKindGTK TemplateKindGTK
TemplateKindVSCode TemplateKindVSCode
TemplateKindEmacs TemplateKindEmacs
TemplateKindMango
) )
type TemplateDef struct { type TemplateDef struct {
@@ -63,7 +61,7 @@ var templateRegistry = []TemplateDef{
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true}, {ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"}, {ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"}, {ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE", Kind: TemplateKindMango}, {ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE"},
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"}, {ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"}, {ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"}, {ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"},
@@ -725,12 +723,6 @@ output_path = '%s'
for _, editor := range vscodeEditors { for _, editor := range vscodeEditors {
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir) appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
} }
case TemplateKindMango:
configFile := tmpl.ConfigFile
if mangoconf.Detect() == mangoconf.Snake {
configFile = "mangowc-snake.toml"
}
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, configFile)
case TemplateKindEmacs: case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" { if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile) appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
@@ -965,6 +957,7 @@ var vscodeEditors = []vscodeEditor{
{"cursor", ".cursor"}, {"cursor", ".cursor"},
{"windsurf", ".windsurf"}, {"windsurf", ".windsurf"},
{"positron", ".positron"}, {"positron", ".positron"},
{"antigravity", ".antigravity-ide"},
{"vscode-insiders", ".vscode-insiders"}, {"vscode-insiders", ".vscode-insiders"},
} }
+26 -1
View File
@@ -511,7 +511,7 @@ func TestBuildMergedConfigColorsOnly(t *testing.T) {
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs") assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
} }
func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) { func TestBuildMergedConfigMangoSession(t *testing.T) {
t.Setenv("MANGO_INSTANCE_SIGNATURE", "") t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -555,6 +555,31 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
t.Fatalf("failed to read merged config: %v", err) t.Fatalf("failed to read merged config: %v", err)
} }
assert.NotContains(t, string(output), "[templates.dmsmango]") assert.NotContains(t, string(output), "[templates.dmsmango]")
socket := filepath.Join(tempDir, "mango.sock")
if err := os.WriteFile(socket, nil, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MANGO_INSTANCE_SIGNATURE", socket)
checker := mocks_utils.NewMockAppChecker(t)
checker.EXPECT().AnyCommandExists("mango").Return(true)
opts.AppChecker = checker
cfgFile, err = os.CreateTemp(tempDir, "merged-active-*.toml")
if err != nil {
t.Fatal(err)
}
defer cfgFile.Close()
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
t.Fatal(err)
}
if err := cfgFile.Close(); err != nil {
t.Fatal(err)
}
output, err = os.ReadFile(cfgFile.Name())
if err != nil {
t.Fatal(err)
}
assert.Contains(t, string(output), mangowcConfig)
} }
func TestAppendConfigConfigDirExists(t *testing.T) { func TestAppendConfigConfigDirExists(t *testing.T) {
+11 -6
View File
@@ -29,6 +29,7 @@ import (
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
berrors "go.etcd.io/bbolt/errors"
wlclient "github.com/duxlabs-dev/dux-go/wayland/client" wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control" "github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
@@ -55,8 +56,14 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
configPath, _ := getConfigPath() configPath, _ := getConfigPath()
db, err := openDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
m := &Manager{ m := &Manager{
config: config, config: config,
db: db,
configPath: configPath, configPath: configPath,
display: display, display: display,
wlCtx: wlCtx, wlCtx: wlCtx,
@@ -69,6 +76,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
if !config.Disabled { if !config.Disabled {
if err := m.setupRegistry(); err != nil { if err := m.setupRegistry(); err != nil {
db.Close()
return nil, err return nil, err
} }
} }
@@ -78,12 +86,6 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
go m.watchConfig() go m.watchConfig()
db, err := openDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
m.db = db
if err := m.migrateHashes(); err != nil { if err := m.migrateHashes(); err != nil {
log.Errorf("Failed to migrate hashes: %v", err) log.Errorf("Failed to migrate hashes: %v", err)
} }
@@ -117,6 +119,9 @@ func openDB(path string) (*bolt.DB, error) {
if err == nil { if err == nil {
return db, nil return db, nil
} }
if errors.Is(err, berrors.ErrTimeout) {
return nil, err
}
log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err) log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err)
@@ -887,6 +887,24 @@ func TestOpenDB_HealsGarbageFile(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestOpenDB_LockedByAnotherProcessKeepsHistory(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
holder, err := openDB(path)
require.NoError(t, err)
m := &Manager{config: DefaultConfig(), db: holder, dbPath: path}
require.NoError(t, m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"}))
_, err = openDB(path)
require.Error(t, err)
require.NoError(t, holder.Close())
reopened, err := openDB(path)
require.NoError(t, err)
defer reopened.Close()
m = &Manager{config: DefaultConfig(), db: reopened, dbPath: path}
assert.Len(t, m.GetHistory(), 1)
}
func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) { func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) {
for _, tt := range corruptDBCases { for _, tt := range corruptDBCases {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
+3 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
func NewManager() (*Manager, error) { func NewManager(keepLockOnUnlock bool) (*Manager, error) {
conn, err := dbus.SystemBus() conn, err := dbus.SystemBus()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to system bus: %w", err) return nil, fmt.Errorf("failed to connect to system bus: %w", err)
@@ -25,6 +25,8 @@ func NewManager() (*Manager, error) {
conn: conn, conn: conn,
dirty: make(chan struct{}, 1), dirty: make(chan struct{}, 1),
signals: make(chan *dbus.Signal, 256), signals: make(chan *dbus.Signal, 256),
keepLockOnUnlock: keepLockOnUnlock,
} }
m.sleepInhibitorEnabled.Store(true) m.sleepInhibitorEnabled.Store(true)
+9
View File
@@ -30,6 +30,14 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
} }
case dbusSessionInterface + ".Unlock": case dbusSessionInterface + ".Unlock":
if m.keepLockOnUnlock {
// Only the locker clears LockedHint, so take both from logind, and
// keep the cached state when logind cannot be read.
if m.sessionObj != nil && m.updateSessionState() == nil && !m.snapshotState().Locked {
m.prelockedReady.Store(false)
}
m.notifySubscribers()
} else {
lockedHint := m.sessionLockedHint() lockedHint := m.sessionLockedHint()
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.Locked = false m.state.Locked = false
@@ -37,6 +45,7 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
m.stateMutex.Unlock() m.stateMutex.Unlock()
m.notifySubscribers() m.notifySubscribers()
m.prelockedReady.Store(false) m.prelockedReady.Store(false)
}
// Cancel the lock timer if it's still running // Cancel the lock timer if it's still running
m.lockTimerMu.Lock() m.lockTimerMu.Lock()
+27 -11
View File
@@ -38,19 +38,22 @@ func TestManager_HandleDBusSignal_Lock(t *testing.T) {
func TestManager_HandleDBusSignal_Unlock(t *testing.T) { func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
lockedHint bool keepLock bool
call *dbus.Call // logind's GetAll answer; nil means no session object
wantLocked bool
wantLockedHint bool
}{ }{
{name: "logind cleared LockedHint", lockedHint: false}, {name: "logind cleared LockedHint", call: lockedHintCall(false)},
{name: "logind still reports LockedHint", lockedHint: true}, {name: "logind still reports LockedHint", call: lockedHintCall(true), wantLockedHint: true},
{name: "logind unreachable", call: &dbus.Call{Err: assert.AnError}},
{name: "policy, LockedHint still set", keepLock: true, call: lockedHintCall(true), wantLocked: true, wantLockedHint: true},
{name: "policy, LockedHint cleared", keepLock: true, call: lockedHintCall(false)},
{name: "policy, logind unreachable", keepLock: true, call: &dbus.Call{Err: assert.AnError}, wantLocked: true, wantLockedHint: true},
{name: "policy, no session object", keepLock: true, wantLocked: true, wantLockedHint: true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
sessionObj := mockdbus.NewMockBusObject(t)
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(&dbus.Call{
Body: []any{map[string]dbus.Variant{"LockedHint": dbus.MakeVariant(tt.lockedHint)}},
})
manager := &Manager{ manager := &Manager{
state: &SessionState{ state: &SessionState{
Locked: true, Locked: true,
@@ -58,21 +61,34 @@ func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
}, },
stateMutex: sync.RWMutex{}, stateMutex: sync.RWMutex{},
dirty: make(chan struct{}, 1), dirty: make(chan struct{}, 1),
sessionObj: sessionObj, keepLockOnUnlock: tt.keepLock,
} }
if tt.call != nil {
sessionObj := mockdbus.NewMockBusObject(t)
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(tt.call)
manager.sessionObj = sessionObj
}
manager.markLockerReady()
manager.handleDBusSignal(&dbus.Signal{ manager.handleDBusSignal(&dbus.Signal{
Name: "org.freedesktop.login1.Session.Unlock", Name: "org.freedesktop.login1.Session.Unlock",
}) })
assert.Equal(t, tt.wantLocked, manager.prelockedReady.Load())
manager.stateMutex.RLock() manager.stateMutex.RLock()
defer manager.stateMutex.RUnlock() defer manager.stateMutex.RUnlock()
assert.False(t, manager.state.Locked) assert.Equal(t, tt.wantLocked, manager.state.Locked)
assert.Equal(t, tt.lockedHint, manager.state.LockedHint) assert.Equal(t, tt.wantLockedHint, manager.state.LockedHint)
}) })
} }
} }
func lockedHintCall(lockedHint bool) *dbus.Call {
return &dbus.Call{Body: []any{map[string]dbus.Variant{
"LockedHint": dbus.MakeVariant(lockedHint),
}}}
}
func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) { func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
t.Run("preparing for sleep - true", func(t *testing.T) { t.Run("preparing for sleep - true", func(t *testing.T) {
manager := &Manager{ manager := &Manager{
+1
View File
@@ -69,4 +69,5 @@ type Manager struct {
lockTimer *time.Timer lockTimer *time.Timer
sleepInhibitorEnabled atomic.Bool sleepInhibitorEnabled atomic.Bool
fallbackDelay time.Duration fallbackDelay time.Duration
keepLockOnUnlock bool
} }
+8 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/duxlabs-dev/dux-go/ipc" "github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-go/syncmap" "github.com/duxlabs-dev/dux-go/syncmap"
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
"github.com/duxlabs-dev/dux-shell/core/internal/geolocation" "github.com/duxlabs-dev/dux-shell/core/internal/geolocation"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/matugen" "github.com/duxlabs-dev/dux-shell/core/internal/matugen"
@@ -37,6 +38,7 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/server/wlroutput" "github.com/duxlabs-dev/dux-shell/core/internal/server/wlroutput"
"github.com/duxlabs-dev/dux-shell/core/internal/systemd" "github.com/duxlabs-dev/dux-shell/core/internal/systemd"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/afero"
) )
const APIVersion = 38 const APIVersion = 38
@@ -108,7 +110,12 @@ func InitializeNetworkManager() error {
} }
func InitializeLoginctlManager() error { func InitializeLoginctlManager() error {
manager, err := loginctl.NewManager() keepLockOnUnlock, err := clipolicy.LogindUnlockDisabled(afero.NewOsFs())
if err != nil {
log.Errorf("CLI policy unreadable, keeping locks through logind Unlock: %v", err)
keepLockOnUnlock = true
}
manager, err := loginctl.NewManager(keepLockOnUnlock)
if err != nil { if err != nil {
log.Warnf("Failed to initialize loginctl manager: %v", err) log.Warnf("Failed to initialize loginctl manager: %v", err)
return err return err
@@ -194,3 +194,24 @@ func TestAlertRecordSurvivesRestart(t *testing.T) {
assert.Nil(t, s.Limit, "the alert already fired before the restart") assert.Nil(t, s.Limit, "the alert already fired before the restart")
} }
} }
func TestOpenDBLockedByAnotherProcessKeepsData(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
c := &clock{now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)}
now := func() time.Time { return c.now }
m, err := newManager(path, now)
require.NoError(t, err)
m.SetState("firefox", true, 1)
c.advance(10 * time.Second)
m.Flush()
_, err = newManager(path, now)
require.Error(t, err)
m.Close()
m, err = newManager(path, now)
require.NoError(t, err)
defer m.Close()
assert.Equal(t, int64(10), m.Summary(1)[0].Active)
}
+5
View File
@@ -2,6 +2,7 @@ package wellbeing
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -11,6 +12,7 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/utils" "github.com/duxlabs-dev/dux-shell/core/internal/utils"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
berrors "go.etcd.io/bbolt/errors"
) )
var ( var (
@@ -31,6 +33,9 @@ func openDB(path string) (*bolt.DB, error) {
if err == nil { if err == nil {
return db, nil return db, nil
} }
if errors.Is(err, berrors.ErrTimeout) {
return nil, err
}
log.Errorf("Wellbeing db unreadable, starting over: %v", err) log.Errorf("Wellbeing db unreadable, starting over: %v", err)
corruptPath := path + ".corrupt" corruptPath := path + ".corrupt"
os.Remove(corruptPath) os.Remove(corruptPath)
+1 -1
View File
@@ -179,7 +179,7 @@ func (m Model) configReplacementNote() string {
if m.useSystemdConfig() && mangoconf.SessionTargetInstalled() { if m.useSystemdConfig() && mangoconf.SessionTargetInstalled() {
return "Replacing Mango writes the DMS Mango template and starts the user systemd dms service with mango-session.target. Existing binds, monitor and window rules move into the dms/ files." return "Replacing Mango writes the DMS Mango template and starts the user systemd dms service with mango-session.target. Existing binds, monitor and window rules move into the dms/ files."
} }
return "Replacing Mango writes the DMS Mango template and starts DMS from Mango with " + mangoconf.Detect().Key("exec_once") + "=dms run. Existing binds, monitor and window rules move into the dms/ files." return "Replacing Mango writes the DMS Mango template and starts DMS from Mango with exec_once=dms run. Existing binds, monitor and window rules move into the dms/ files."
case config.BindsConfigType: case config.BindsConfigType:
return "Keep uses your binds as they are; replace starts from the DMS stock binds. A timestamped backup is kept either way." return "Keep uses your binds as they are; replace starts from the DMS stock binds. A timestamped backup is kept either way."
case "Ghostty": case "Ghostty":
+2 -2
View File
@@ -197,7 +197,7 @@ func (m Model) troubleshootingHints() (autostart, logs string) {
case deps.WindowManagerHyprland: case deps.WindowManagerHyprland:
autostart = `remove hl.exec_cmd("dms run") from ~/.config/hypr/hyprland.lua` autostart = `remove hl.exec_cmd("dms run") from ~/.config/hypr/hyprland.lua`
default: default:
autostart = "remove '" + mangoconf.Detect().Key("exec_once") + "=dms run' from ~/.config/mango/config.conf" autostart = "remove 'exec_once=dms run' from ~/.config/mango/config.conf"
} }
return autostart, logs return autostart, logs
} }
@@ -206,7 +206,7 @@ func (m Model) troubleshootingHints() (autostart, logs string) {
if mangoconf.SessionTargetInstalled() { if mangoconf.SessionTargetInstalled() {
return "rm ~/.config/systemd/user/mango-session.target.wants/dms.service", "journalctl --user -u dms" return "rm ~/.config/systemd/user/mango-session.target.wants/dms.service", "journalctl --user -u dms"
} }
return "remove '" + mangoconf.Detect().Key("exec_once") + "=dms run' from ~/.config/mango/config.conf", "qs -p ~/.config/quickshell/dms log" return "remove 'exec_once=dms run' from ~/.config/mango/config.conf", "qs -p ~/.config/quickshell/dms log"
} }
return "systemctl --user disable dms", "journalctl --user -u dms" return "systemctl --user disable dms", "journalctl --user -u dms"
} }
@@ -11,19 +11,17 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules" "github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
) )
// Mango window rules are flat `windowrule=key:value,...` lines (`window_rule=` // DMS-managed rules live in dms/windowrules.conf (sourced from config.conf),
// with snake_case fields after the upstream rename; both are read). DMS-managed rules // each preceded by an `# @id=<id> @name=<name>` comment so they round-trip.
// live in dms/windowrules.conf (sourced from config.conf), each preceded by an
// `# @id=<id> @name=<name>` comment so they round-trip.
type MangoWindowRule struct { type MangoWindowRule struct {
Source string Source string
Fields map[string]string Fields map[string]string
} }
var mangoWindowRuleRegex = regexp.MustCompile(`^(?:windowrule|window_rule)\s*=\s*(.+)$`) var mangoWindowRuleRegex = regexp.MustCompile(`^window_rule\s*=\s*(.+)$`)
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`) var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
var mangoOnceRuleRegex = regexp.MustCompile(`^(?:windowrule-once|window_rule_once)\s*=`) var mangoOnceRuleRegex = regexp.MustCompile(`^window_rule_once\s*=`)
// Fields the editor models; anything else on a DMS rule line is carried through saves. // Fields the editor models; anything else on a DMS rule line is carried through saves.
var mangoModeledFields = map[string]bool{ var mangoModeledFields = map[string]bool{
@@ -42,7 +40,7 @@ func mangoUnmodeledFields(value string) [][2]string {
if !ok { if !ok {
continue continue
} }
key := mangoconf.Normalize(strings.TrimSpace(k)) key := strings.TrimSpace(k)
sized := (key == "width" || key == "height") && (!hasWidth || !hasHeight) sized := (key == "width" || key == "height") && (!hasWidth || !hasHeight)
if key != "" && (!mangoModeledFields[key] || sized) { if key != "" && (!mangoModeledFields[key] || sized) {
extra = append(extra, [2]string{key, strings.TrimSpace(v)}) extra = append(extra, [2]string{key, strings.TrimSpace(v)})
@@ -62,7 +60,7 @@ func parseMangoWindowRuleLine(value string) map[string]string {
if !ok { if !ok {
continue continue
} }
key := mangoconf.Normalize(strings.TrimSpace(before)) key := strings.TrimSpace(before)
val := strings.TrimSpace(after) val := strings.TrimSpace(after)
if key != "" { if key != "" {
fields[key] = val fields[key] = val
@@ -89,7 +87,7 @@ func mangoOverridePath(configDir string) string {
return filepath.Join(configDir, "dms", "windowrules.conf") return filepath.Join(configDir, "dms", "windowrules.conf")
} }
// parseMangoRulesFile reads a config file and returns its windowrule= lines. // parseMangoRulesFile reads a config file and returns its window_rule= lines.
func parseMangoRulesFile(path, source string) []MangoWindowRule { func parseMangoRulesFile(path, source string) []MangoWindowRule {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -215,11 +213,11 @@ func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.
return result return result
} }
func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, extra ...[2]string) string { func formatMangoRule(rule windowrules.WindowRule, extra ...[2]string) string {
var parts []string var parts []string
add := func(k, v string) { add := func(k, v string) {
if v != "" { if v != "" {
parts = append(parts, dialect.Key(k)+":"+v) parts = append(parts, k+":"+v)
} }
} }
@@ -235,7 +233,7 @@ func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, ext
addBool := func(k string, b *bool) { addBool := func(k string, b *bool) {
if b != nil { if b != nil {
parts = append(parts, dialect.Key(k)+":"+mangoBoolStr(b)) parts = append(parts, k+":"+mangoBoolStr(b))
} }
} }
addBool("is_floating", rule.Actions.OpenFloating) addBool("is_floating", rule.Actions.OpenFloating)
@@ -249,19 +247,18 @@ func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, ext
add(kv[0], kv[1]) add(kv[0], kv[1])
} }
return dialect.Key("window_rule") + "=" + strings.Join(parts, ",") return "window_rule=" + strings.Join(parts, ",")
} }
type MangoWritableProvider struct { type MangoWritableProvider struct {
configDir string configDir string
dialect mangoconf.Dialect
// Filled by LoadDMSRules so the following write keeps what the editor cannot show. // Filled by LoadDMSRules so the following write keeps what the editor cannot show.
extraFields map[string][][2]string extraFields map[string][][2]string
onceLines []string onceLines []string
} }
func NewMangoWritableProvider(configDir string) *MangoWritableProvider { func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
return &MangoWritableProvider{configDir: configDir, dialect: mangoconf.Detect()} return &MangoWritableProvider{configDir: configDir}
} }
func (p *MangoWritableProvider) Name() string { return "mango" } func (p *MangoWritableProvider) Name() string { return "mango" }
@@ -360,7 +357,7 @@ func (p *MangoWritableProvider) WriteDMSRules(rules []windowrules.WindowRule) er
if id == "" { if id == "" {
id = fmt.Sprintf("rule_%d", i) id = fmt.Sprintf("rule_%d", i)
} }
line := formatMangoRule(r, p.dialect, p.extraFields[r.ID]...) line := formatMangoRule(r, p.extraFields[r.ID]...)
if err := mangoconf.CheckLine(line); err != nil { if err := mangoconf.CheckLine(line); err != nil {
return fmt.Errorf("rule %q: %w", r.Name, err) return fmt.Errorf("rule %q: %w", r.Name, err)
} }
@@ -369,7 +366,7 @@ func (p *MangoWritableProvider) WriteDMSRules(rules []windowrules.WindowRule) er
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
for _, line := range p.onceLines { for _, line := range p.onceLines {
sb.WriteString(p.dialect.Translate(line)) sb.WriteString(line)
sb.WriteString("\n") sb.WriteString("\n")
} }
@@ -6,13 +6,12 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules" "github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
) )
func TestParseMangoWindowRuleLineReadsBothDialects(t *testing.T) { func TestParseMangoWindowRuleLineSplitsFields(t *testing.T) {
for _, line := range []string{ for _, line := range []string{
"appid:firefox,title:Gmail,isfloating:1,tags:2,monitor:HDMI-A-1", "app_id:firefox,title:Gmail,is_floating:1,tags:2,monitor:HDMI-A-1",
"app_id: firefox , title:Gmail,is_floating:1,tags:2,monitor:HDMI-A-1", "app_id: firefox , title:Gmail,is_floating:1,tags:2,monitor:HDMI-A-1",
} { } {
fields := parseMangoWindowRuleLine(line) fields := parseMangoWindowRuleLine(line)
@@ -25,13 +24,10 @@ func TestParseMangoWindowRuleLineReadsBothDialects(t *testing.T) {
} }
} }
func TestMangoRulesWrittenInInstalledDialect(t *testing.T) { func TestMangoRulesWrittenInSnakeCase(t *testing.T) {
floating := true floating := true
rule := windowrules.WindowRule{MatchCriteria: windowrules.MatchCriteria{AppID: "discord"}, Actions: windowrules.Actions{OpenFloating: &floating, NoAnim: &floating}} rule := windowrules.WindowRule{MatchCriteria: windowrules.MatchCriteria{AppID: "discord"}, Actions: windowrules.Actions{OpenFloating: &floating, NoAnim: &floating}}
if got, want := formatMangoRule(rule, mangoconf.Legacy), "windowrule=appid:discord,isfloating:1,isnoanimation:1"; got != want { if got, want := formatMangoRule(rule), "window_rule=app_id:discord,is_floating:1,no_animation:1"; got != want {
t.Errorf("legacy = %q, want %q", got, want)
}
if got, want := formatMangoRule(rule, mangoconf.Snake), "window_rule=app_id:discord,is_floating:1,no_animation:1"; got != want {
t.Errorf("snake = %q, want %q", got, want) t.Errorf("snake = %q, want %q", got, want)
} }
@@ -39,7 +35,7 @@ func TestMangoRulesWrittenInInstalledDialect(t *testing.T) {
rulesPath := filepath.Join(dir, "dms", "windowrules.conf") rulesPath := filepath.Join(dir, "dms", "windowrules.conf")
_ = os.MkdirAll(filepath.Dir(rulesPath), 0o755) _ = os.MkdirAll(filepath.Dir(rulesPath), 0o755)
_ = os.WriteFile(rulesPath, []byte("# @id=a @name=A\nwindow_rule=app_id:discord,is_floating:1\n"), 0o644) _ = os.WriteFile(rulesPath, []byte("# @id=a @name=A\nwindow_rule=app_id:discord,is_floating:1\n"), 0o644)
loaded, err := (&MangoWritableProvider{configDir: dir, dialect: mangoconf.Snake}).LoadDMSRules() loaded, err := (&MangoWritableProvider{configDir: dir}).LoadDMSRules()
if err != nil || len(loaded) != 1 || loaded[0].MatchCriteria.AppID != "discord" || loaded[0].Actions.OpenFloating == nil { if err != nil || len(loaded) != 1 || loaded[0].MatchCriteria.AppID != "discord" || loaded[0].Actions.OpenFloating == nil {
t.Fatalf("snake rule not loaded: %+v, %v", loaded, err) t.Fatalf("snake rule not loaded: %+v, %v", loaded, err)
} }
@@ -47,7 +43,7 @@ func TestMangoRulesWrittenInInstalledDialect(t *testing.T) {
func TestConvertMangoRulesToWindowRules(t *testing.T) { func TestConvertMangoRulesToWindowRules(t *testing.T) {
mangoRules := []MangoWindowRule{ mangoRules := []MangoWindowRule{
{Source: "config.conf", Fields: parseMangoWindowRuleLine("appid:discord,tags:9,isfloating:1,noblur:1")}, {Source: "config.conf", Fields: parseMangoWindowRuleLine("app_id:discord,tags:9,is_floating:1,no_blur:1")},
} }
rules := ConvertMangoRulesToWindowRules(mangoRules) rules := ConvertMangoRulesToWindowRules(mangoRules)
if len(rules) != 1 { if len(rules) != 1 {
@@ -141,9 +137,9 @@ func TestMangoRuleSaveKeepsUnmodeledFieldsAndOnceRules(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "dms", "windowrules.conf") path := filepath.Join(dir, "dms", "windowrules.conf")
_ = os.MkdirAll(filepath.Dir(path), 0o755) _ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte("# @id=a @name=A\nwindowrule=appid:mpv,isglobal:1,offsetx:20,width:0.5\nwindowrule-once=appid:firefox,tags:2\n"), 0o644) _ = os.WriteFile(path, []byte("# @id=a @name=A\nwindow_rule=app_id:mpv,is_global:1,offset_x:20,width:0.5\nwindow_rule_once=app_id:firefox,tags:2\n"), 0o644)
p := &MangoWritableProvider{configDir: dir, dialect: mangoconf.Legacy} p := &MangoWritableProvider{configDir: dir}
floating := true floating := true
if err := p.SetRule(windowrules.WindowRule{ID: "b", Name: "B", MatchCriteria: windowrules.MatchCriteria{AppID: "foot"}, Actions: windowrules.Actions{OpenFloating: &floating}}); err != nil { if err := p.SetRule(windowrules.WindowRule{ID: "b", Name: "B", MatchCriteria: windowrules.MatchCriteria{AppID: "foot"}, Actions: windowrules.Actions{OpenFloating: &floating}}); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -151,9 +147,9 @@ func TestMangoRuleSaveKeepsUnmodeledFieldsAndOnceRules(t *testing.T) {
data, _ := os.ReadFile(path) data, _ := os.ReadFile(path)
got := string(data) got := string(data)
for _, want := range []string{ for _, want := range []string{
"windowrule=appid:mpv,isglobal:1,offsetx:20,width:0.5\n", "window_rule=app_id:mpv,is_global:1,offset_x:20,width:0.5\n",
"windowrule=appid:foot,isfloating:1\n", "window_rule=app_id:foot,is_floating:1\n",
"windowrule-once=appid:firefox,tags:2\n", "window_rule_once=app_id:firefox,tags:2\n",
} { } {
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got) t.Errorf("missing %q in:\n%s", want, got)
+5 -8
View File
@@ -19,9 +19,7 @@ core shell features.
- **Users:** `pw(8)`. - **Users:** `pw(8)`.
- **Package updates:** `pkg(8)`. - **Package updates:** `pkg(8)`.
- **Compositor/socket discovery:** `sockstat(1)` where Linux would use `/proc`. - **Compositor/socket discovery:** `sockstat(1)` where Linux would use `/proc`.
- **Greeter state:** FreeBSD rc service detection for greetd. - **Sessions:** FreeBSD has no logind. DMS lists the current session only.
- **Sessions:** elogind/loginctl is used when installed; otherwise DMS exposes
the current session without repeatedly executing a missing Linux command.
## Recommended runtime tools ## Recommended runtime tools
@@ -30,13 +28,12 @@ Quickshell, grim/slurp, wl-clipboard, and a notification/audio stack as desired)
from FreeBSD packages/ports. DMS's native audio path itself only needs the base from FreeBSD packages/ports. DMS's native audio path itself only needs the base
system `mixer` utility. system `mixer` utility.
For session switching and login-manager integration, install/use elogind so
`loginctl` is available. Without elogind, locking and the current-session shell
remain usable, but switching between already-running graphical sessions is not
provided by the base system.
## FreeBSD limitations that DMS should not fake ## FreeBSD limitations that DMS should not fake
- No logind, so the daemon's `org.freedesktop.login1` integration is off: no
lock or unlock requests from the session manager, no sleep inhibitor, and no
switching between running sessions (`dms switch-user`).
- Greeter status detection uses `systemctl` and reports greetd as not enabled.
- FreeBSD ACPI S4 hibernation is hardware/kernel dependent and is not presented - FreeBSD ACPI S4 hibernation is hardware/kernel dependent and is not presented
as generally available by DMS. as generally available by DMS.
- Charge thresholds are vendor-specific; the Linux `/sys/class/power_supply` - Charge thresholds are vendor-specific; the Linux `/sys/class/power_supply`
+3 -3
View File
@@ -100,7 +100,7 @@ include_package() {
[[ "$PACKAGE_FILTER" == "all" || "$PACKAGE_FILTER" == "$package" ]] [[ "$PACKAGE_FILTER" == "all" || "$PACKAGE_FILTER" == "$package" ]]
} }
CURRENT_COMMIT="$(git rev-parse --short=8 HEAD)" CURRENT_COMMIT="$(git rev-parse HEAD)"
LATEST_TAG="" LATEST_TAG=""
TARGETS=() TARGETS=()
@@ -118,9 +118,9 @@ for pkg_info in "${PACKAGES[@]}"; do
reason="missing from ${series}" reason="missing from ${series}"
elif [[ "$type" == "git" ]]; then elif [[ "$type" == "git" ]]; then
ppa_commit="$(embedded_commit "$ppa_version")" ppa_commit="$(embedded_commit "$ppa_version")"
if [[ "$ppa_commit" != "$CURRENT_COMMIT" ]]; then if [[ -z "$ppa_commit" || "${CURRENT_COMMIT:0:${#ppa_commit}}" != "$ppa_commit" ]]; then
needs_update=true needs_update=true
reason="commit ${ppa_commit:-none} -> ${CURRENT_COMMIT}" reason="commit ${ppa_commit:-none} -> ${CURRENT_COMMIT:0:9}"
fi fi
else else
if [[ -z "$LATEST_TAG" ]]; then if [[ -z "$LATEST_TAG" ]]; then
Generated
+3 -3
View File
@@ -3,11 +3,11 @@
"dank-qml-common": { "dank-qml-common": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1792513666, "lastModified": 1792767358,
"narHash": "sha256-RDqPdPrM5lnwRrfFGLz3IwgTjCV2Ie+nrTD3ldj1t/s=", "narHash": "sha256-0YG9NpPzk6jo2Y4amMYlCbVdE5Mdkv/WD2suSJj18CE=",
"owner": "duxlabs-dev", "owner": "duxlabs-dev",
"repo": "dux-qml-common", "repo": "dux-qml-common",
"rev": "ebf5e6c002da1bdaa4aaa2f319fea7b8507abc42", "rev": "fb4853fbf1e8a8a4ae8dc21d33daf20590188887",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -1
View File
@@ -111,7 +111,7 @@
inherit version; inherit version;
pname = "dms-shell"; pname = "dms-shell";
src = ./core; src = ./core;
vendorHash = "sha256-fN7v7DuWaqhtD28cTr0FZUIyW7nUqhQoVuatsL2fuo4="; vendorHash = "sha256-sKkafJm4PkV/gCdcwdmHVMTnlv8EO9hDk5YDxHwt9/E=";
subPackages = [ "cmd/dms" ]; subPackages = [ "cmd/dms" ];
+12
View File
@@ -0,0 +1,12 @@
pragma Singleton
import Quickshell
// cli-policy.json switches, set by `dms run` on each launch (core/internal/clipolicy).
Singleton {
readonly property bool disableLockIpcUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK") === "1"
readonly property bool disableLogindUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOGIND_UNLOCK") === "1"
readonly property bool disableCustomLockPam: Quickshell.env("DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM") === "1"
readonly property bool disableUserPlugins: Quickshell.env("DMS_POLICY_DISABLE_USER_PLUGINS") === "1"
readonly property bool disableSettingsIpcSet: Quickshell.env("DMS_POLICY_DISABLE_SETTINGS_IPC_SET") === "1"
}
+3 -11
View File
@@ -13,8 +13,6 @@ Singleton {
readonly property int cacheConfigVersion: 3 readonly property int cacheConfigVersion: 3
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation) readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation)
readonly property string _stateDir: Paths.strip(_stateUrl) readonly property string _stateDir: Paths.strip(_stateUrl)
@@ -52,8 +50,6 @@ Singleton {
property var fileBrowserSettings: ({}) property var fileBrowserSettings: ({})
Component.onCompleted: { Component.onCompleted: {
if (isGreeterMode)
return;
loadCache(); loadCache();
} }
@@ -280,7 +276,7 @@ Singleton {
FileView { FileView {
id: launcherCacheFile id: launcherCacheFile
path: isGreeterMode ? "" : _stateDir + "/dms/launcher_cache.json" path: _stateDir + "/dms/launcher_cache.json"
blockLoading: true blockLoading: true
blockWrites: true blockWrites: true
atomicWrites: true atomicWrites: true
@@ -290,19 +286,15 @@ Singleton {
FileView { FileView {
id: cacheFile id: cacheFile
path: isGreeterMode ? "" : _stateDir + "/dms/cache.json" path: _stateDir + "/dms/cache.json"
blockLoading: true blockLoading: true
blockWrites: true blockWrites: true
atomicWrites: true atomicWrites: true
watchChanges: !isGreeterMode watchChanges: true
onLoaded: { onLoaded: {
if (isGreeterMode)
return;
parseCache(cacheFile.text()); parseCache(cacheFile.text());
} }
onLoadFailed: error => { onLoadFailed: error => {
if (isGreeterMode)
return;
log.info("No cache file found, starting fresh"); log.info("No cache file found, starting fresh");
} }
} }
+18 -17
View File
@@ -420,7 +420,7 @@ function parseMangoOutputs(content) {
const result = {}; const result = {};
for (const line of content.split("\n")) { for (const line of content.split("\n")) {
const trimmed = line.trim(); const trimmed = line.trim();
const match = trimmed.match(/^(?:monitorrule|monitor_rule)\s*=\s*(.*)$/); const match = trimmed.match(/^monitor_rule\s*=\s*(.*)$/);
if (!match) if (!match)
continue; continue;
const entry = parseMangoRule(match[1]); const entry = parseMangoRule(match[1]);
@@ -445,10 +445,10 @@ function mangoRuleFields(rule) {
// Mango applies only the first matching monitor rule and resets hdr/icc/disable a rule omits, // Mango applies only the first matching monitor rule and resets hdr/icc/disable a rule omits,
// so unmodeled fields of every existing rule for an output are carried into DMS's rule, first one winning. // so unmodeled fields of every existing rule for an output are carried into DMS's rule, first one winning.
function mangoMonitorRuleLines(outputsData, existingText, snakeKeys) { function mangoMonitorRuleLines(outputsData, existingText) {
const carried = {}; const carried = {};
for (const line of (existingText || "").split("\n")) { for (const line of (existingText || "").split("\n")) {
const match = line.trim().match(/^(?:monitorrule|monitor_rule)\s*=\s*(.*)$/); const match = line.trim().match(/^monitor_rule\s*=\s*(.*)$/);
if (!match) if (!match)
continue; continue;
const fields = mangoRuleFields(match[1]); const fields = mangoRuleFields(match[1]);
@@ -496,7 +496,7 @@ function mangoMonitorRuleLines(outputsData, existingText, snakeKeys) {
} }
if (output.enabled === false) if (output.enabled === false)
fields.push("disable:1"); fields.push("disable:1");
lines.push((snakeKeys ? "monitor_rule=" : "monitorrule=") + fields.join(",")); lines.push("monitor_rule=" + fields.join(","));
} }
return lines; return lines;
} }
@@ -833,6 +833,16 @@ function physicalSize(output) {
}; };
} }
function roundLogical(value, compositor) {
switch (compositor) {
case "niri":
// niri rounds the placed output, smithay's Space ceils mapped ones, only ceil never overlaps in either order (#3875)
return Math.ceil(value);
default:
return Math.round(value);
}
}
function logicalSize(output, compositor) { function logicalSize(output, compositor) {
if (!output) if (!output)
return { return {
@@ -841,19 +851,10 @@ function logicalSize(output, compositor) {
}; };
const phys = physicalSize(output); const phys = physicalSize(output);
const scale = output.logical?.scale || 1.0; const scale = output.logical?.scale || 1.0;
switch (compositor) {
case "niri":
// niri floors logical sizes, rounding up by one leaves a dead seam at snapped edges (#2526)
return { return {
"w": Math.floor(phys.w / scale), "w": roundLogical(phys.w / scale, compositor),
"h": Math.floor(phys.h / scale) "h": roundLogical(phys.h / scale, compositor)
}; };
default:
return {
"w": Math.round(phys.w / scale),
"h": Math.round(phys.h / scale)
};
}
} }
function defaultBounds() { function defaultBounds() {
@@ -991,8 +992,8 @@ function recalculateAdjacentPositions(outputs, pendingChanges, changedOutput, ne
if (!output?.logical) if (!output?.logical)
return []; return [];
const oldPhys = physicalSize(output); const oldPhys = physicalSize(output);
const oldLogicalW = Math.round(oldPhys.w / (output.logical.scale || 1.0)); const oldLogicalW = roundLogical(oldPhys.w / (output.logical.scale || 1.0), compositor);
const newLogicalW = Math.round(oldPhys.w / newScale); const newLogicalW = roundLogical(oldPhys.w / newScale, compositor);
const changedX = pendingChanges[changedOutput]?.position?.x ?? output.logical.x; const changedX = pendingChanges[changedOutput]?.position?.x ?? output.logical.x;
const changedY = pendingChanges[changedOutput]?.position?.y ?? output.logical.y; const changedY = pendingChanges[changedOutput]?.position?.y ?? output.logical.y;
const moves = []; const moves = [];
+93
View File
@@ -338,9 +338,102 @@ Singleton {
_requestPopout(popout, tabIndex, triggerSource, true); _requestPopout(popout, tabIndex, triggerSource, true);
} }
function _islandBarBand(host, screen) {
const vertical = host.edge === "left" || host.edge === "right";
if (host.embedded) {
const start = vertical ? host.hostOriginX : host.hostOriginY;
return {
"start": start,
"end": start + host.bandThickness
};
}
if (host.freeMode)
return null;
const extent = vertical ? screen.width : screen.height;
const instances = ShellLayout.forScreen(screen)?.instances ?? [];
let start = Infinity;
let end = -Infinity;
for (const instance of instances) {
if (instance.edge !== host.edge || instance.barId === host.barId || (instance.kind !== "bar" && instance.kind !== "frame") || instance.rowThickness <= 0)
continue;
const config = SettingsData.getBarConfig(instance.barId);
if (!config || config.enabled === false || config.visible === false)
continue;
const near = instance.rowOffset ?? 0;
const far = near + instance.rowThickness;
const bandStart = host.edge === "top" || host.edge === "left" ? near : extent - far;
const bandEnd = host.edge === "top" || host.edge === "left" ? far : extent - near;
start = Math.min(start, bandStart);
end = Math.max(end, bandEnd);
}
return end > start ? {
"start": start,
"end": end
} : null;
}
function getActiveSurfaces() { function getActiveSurfaces() {
const results = []; const results = [];
// The host registry covers embedded, standalone, and free Islands with their live visual bounds.
for (const key of Object.keys(IslandHostRegistry.hosts || {})) {
const host = IslandHostRegistry.hosts[key];
const screen = host?.screen;
const controller = host?.islandController;
const snapVisible = controller?.expanded || (!host?.embedded && host?.motionRunning);
if (!screen?.name || !snapVisible || host.surfaceOpacity <= 0)
continue;
const rect = host.surfaceBodyRect?.();
if (!rect || rect.width <= 0 || rect.height <= 0)
continue;
let x = rect.x;
let y = rect.y;
let width = rect.width;
let height = rect.height;
const barBand = _islandBarBand(host, screen);
if (barBand) {
switch (host.edge) {
case "top":
if (y < barBand.end && y + height > barBand.start) {
const inset = Math.max(0, Math.min(height, barBand.end - y));
y += inset;
height -= inset;
}
break;
case "bottom":
if (y + height > barBand.start && y < barBand.end)
height = Math.max(0, barBand.start - y);
break;
case "left":
if (x < barBand.end && x + width > barBand.start) {
const inset = Math.max(0, Math.min(width, barBand.end - x));
x += inset;
width -= inset;
}
break;
case "right":
if (x + width > barBand.start && x < barBand.end)
width = Math.max(0, barBand.start - x);
break;
}
}
const left = Math.max(0, x);
const top = Math.max(0, y);
const right = Math.min(screen.width, x + width);
const bottom = Math.min(screen.height, y + height);
if (right <= left || bottom <= top)
continue;
results.push({
"name": "dms:dankisland-surface",
"type": "island",
"x": Math.round((screen.x ?? 0) + left),
"y": Math.round((screen.y ?? 0) + top),
"width": Math.round(right - left),
"height": Math.round(bottom - top)
});
}
for (const screenName in currentPopoutsByScreen) { for (const screenName in currentPopoutsByScreen) {
const popout = currentPopoutsByScreen[screenName]; const popout = currentPopoutsByScreen[screenName];
if (!popout || !popout.shouldBeVisible) if (!popout || !popout.shouldBeVisible)
+5 -52
View File
@@ -18,8 +18,6 @@ Singleton {
readonly property int sessionConfigVersion: 7 readonly property int sessionConfigVersion: 7
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
signal loaded signal loaded
signal brightnessDisplayHintChanged(string deviceName) signal brightnessDisplayHintChanged(string deviceName)
signal loadErrorOccurred(string file, string message) signal loadErrorOccurred(string file, string message)
@@ -326,11 +324,6 @@ Singleton {
_hasUnsavedChanges = false; _hasUnsavedChanges = false;
_pendingMigration = null; _pendingMigration = null;
if (isGreeterMode) {
parseSettings(greeterSessionFile.text());
return;
}
try { try {
const txt = settingsFile.text(); const txt = settingsFile.text();
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null; let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
@@ -369,7 +362,7 @@ Singleton {
_loadedSessionSnapshot = getCurrentSessionJson(); _loadedSessionSnapshot = getCurrentSessionJson();
_hasLoaded = true; _hasLoaded = true;
if (!isGreeterMode && typeof Theme !== "undefined") if (typeof Theme !== "undefined")
Theme.generateSystemThemesFromCurrentTheme(); Theme.generateSystemThemesFromCurrentTheme();
loaded(); loaded();
@@ -452,7 +445,7 @@ Singleton {
_loadedSessionSnapshot = getCurrentSessionJson(); _loadedSessionSnapshot = getCurrentSessionJson();
_hasLoaded = true; _hasLoaded = true;
if (!isGreeterMode && typeof Theme !== "undefined") if (typeof Theme !== "undefined")
Theme.generateSystemThemesFromCurrentTheme(); Theme.generateSystemThemesFromCurrentTheme();
loaded(); loaded();
@@ -485,7 +478,7 @@ Singleton {
} }
function saveSettings() { function saveSettings() {
if (isGreeterMode || _parseError || !_hasLoaded) if (_parseError || !_hasLoaded)
return; return;
settingsFile.setText(getCurrentSessionJson()); settingsFile.setText(getCurrentSessionJson());
if (_isReadOnly) if (_isReadOnly)
@@ -859,8 +852,6 @@ Singleton {
} }
function _screenShareShouldHold() { function _screenShareShouldHold() {
if (isGreeterMode)
return false;
if (typeof SettingsData === "undefined" || !SettingsData.notificationDndWhileScreenSharing) if (typeof SettingsData === "undefined" || !SettingsData.notificationDndWhileScreenSharing)
return false; return false;
if (typeof PrivacyService === "undefined" || !PrivacyService.screensharingActive) if (typeof PrivacyService === "undefined" || !PrivacyService.screensharingActive)
@@ -909,9 +900,6 @@ Singleton {
} }
function syncScreenShareDnd() { function syncScreenShareDnd() {
if (isGreeterMode)
return;
if (!_screenShareShouldHold()) { if (!_screenShareShouldHold()) {
if (!doNotDisturbHeldByScreenShare && !screenShareDndDismissed) if (!doNotDisturbHeldByScreenShare && !screenShareDndDismissed)
return; return;
@@ -1883,14 +1871,12 @@ Singleton {
FileView { FileView {
id: settingsFile id: settingsFile
path: isGreeterMode ? "" : StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/dms/session.json" path: StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/dms/session.json"
blockLoading: true blockLoading: true
blockWrites: true blockWrites: true
atomicWrites: true atomicWrites: true
watchChanges: !isGreeterMode watchChanges: true
onLoaded: { onLoaded: {
if (isGreeterMode)
return;
_hasUnsavedChanges = false; _hasUnsavedChanges = false;
parseSettings(settingsFile.text()); parseSettings(settingsFile.text());
} }
@@ -1900,39 +1886,6 @@ Singleton {
} }
} }
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterSessionBaseDir: root._greeterCacheDir
function setGreeterSessionBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterSessionBaseDir === next)
return;
greeterSessionBaseDir = next;
if (isGreeterMode)
greeterSessionFile.reload();
}
function resetGreeterSessionBaseDir() {
setGreeterSessionBaseDir(root._greeterCacheDir);
}
FileView {
id: greeterSessionFile
path: isGreeterMode && root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : ""
preload: isGreeterMode
blockLoading: false
blockWrites: true
watchChanges: false
printErrors: true
onLoaded: {
if (isGreeterMode) {
parseSettings(greeterSessionFile.text());
}
}
}
Process { Process {
id: sessionWritableCheckProcess id: sessionWritableCheckProcess
+14 -88
View File
@@ -25,8 +25,6 @@ Singleton {
readonly property int settingsConfigVersion: 42 readonly property int settingsConfigVersion: 42
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
enum Position { enum Position {
Top, Top,
Bottom, Bottom,
@@ -1128,6 +1126,7 @@ Singleton {
property string customPowerActionPowerOff: Spec.SPEC.customPowerActionPowerOff.def property string customPowerActionPowerOff: Spec.SPEC.customPowerActionPowerOff.def
property var customPowerButtons: Spec.SPEC.customPowerButtons.def property var customPowerButtons: Spec.SPEC.customPowerButtons.def
property var powerMenuBootEntries: Spec.SPEC.powerMenuBootEntries.def property var powerMenuBootEntries: Spec.SPEC.powerMenuBootEntries.def
property bool powerMenuFirmwareSetup: Spec.SPEC.powerMenuFirmwareSetup.def
property bool updaterCheckOnStart: Spec.SPEC.updaterCheckOnStart.def property bool updaterCheckOnStart: Spec.SPEC.updaterCheckOnStart.def
property bool updaterUseCustomCommand: Spec.SPEC.updaterUseCustomCommand.def property bool updaterUseCustomCommand: Spec.SPEC.updaterUseCustomCommand.def
@@ -1435,8 +1434,6 @@ Singleton {
signal notificationPopupsInvalidated signal notificationPopupsInvalidated
function refreshAuthAvailability() { function refreshAuthAvailability() {
if (isGreeterMode)
return;
Processes.detectAuthCapabilities(); Processes.detectAuthCapabilities();
} }
@@ -1506,8 +1503,6 @@ Singleton {
} }
function checkIconThemeDrift() { function checkIconThemeDrift() {
if (isGreeterMode)
return;
if (resolveIconTheme() === "System Default") if (resolveIconTheme() === "System Default")
return; return;
if (!SessionData.lastAppliedIconTheme) if (!SessionData.lastAppliedIconTheme)
@@ -1664,8 +1659,6 @@ Singleton {
} }
function scheduleAuthApply() { function scheduleAuthApply() {
if (isGreeterMode)
return;
Qt.callLater(() => { Qt.callLater(() => {
Processes.settingsRoot = root; Processes.settingsRoot = root;
Processes.scheduleAuthApply(); Processes.scheduleAuthApply();
@@ -1673,8 +1666,6 @@ Singleton {
} }
function scheduleGreeterAutoLoginSync() { function scheduleGreeterAutoLoginSync() {
if (isGreeterMode)
return;
Qt.callLater(() => { Qt.callLater(() => {
Processes.settingsRoot = root; Processes.settingsRoot = root;
Processes.scheduleGreeterAutoLoginSync(); Processes.scheduleGreeterAutoLoginSync();
@@ -1689,8 +1680,6 @@ Singleton {
} }
function markGreeterSyncPending(who, key, oldValue) { function markGreeterSyncPending(who, key, oldValue) {
if (isGreeterMode)
return;
if (!(key in SessionData.greeterSyncBaseline)) { if (!(key in SessionData.greeterSyncBaseline)) {
var baseline = Object.assign({}, SessionData.greeterSyncBaseline); var baseline = Object.assign({}, SessionData.greeterSyncBaseline);
baseline[key] = oldValue; baseline[key] = oldValue;
@@ -1840,7 +1829,7 @@ Singleton {
let loadedSettings = JSON.stringify(obj); let loadedSettings = JSON.stringify(obj);
const oldVersion = obj?.configVersion ?? 0; const oldVersion = obj?.configVersion ?? 0;
if (oldVersion > settingsConfigVersion && !isGreeterMode) { if (oldVersion > settingsConfigVersion) {
newerSettingsBackup.path = `${_configDir}/dms/settings.v${oldVersion}.json`; newerSettingsBackup.path = `${_configDir}/dms/settings.v${oldVersion}.json`;
newerSettingsBackup.setText(JSON.stringify(obj, null, 2)); newerSettingsBackup.setText(JSON.stringify(obj, null, 2));
} }
@@ -2063,16 +2052,14 @@ Singleton {
return splitSettings; return splitSettings;
} }
function saveSettings() { function saveSettings() {
if (isGreeterMode || _loading || !_hasLoaded) if (_loading || !_hasLoaded)
return; return;
unsavedUserChanges = true; unsavedUserChanges = true;
settingsSaveDebounce.restart(); settingsSaveDebounce.restart();
} }
function _saveSettings(userChanges) { function _saveSettings(userChanges) {
let reason = null; let reason = null;
if (isGreeterMode) { if (_loading) {
reason = "running in greeter mode.";
} else if (_loading) {
reason = "some files are being loaded"; reason = "some files are being loaded";
} else if (_parseError) { } else if (_parseError) {
reason = "failed to parse settings."; reason = "failed to parse settings.";
@@ -2092,7 +2079,7 @@ Singleton {
} }
function savePluginSettings() { function savePluginSettings() {
if (isGreeterMode || _pluginSettingsLoading || _pluginParseError) if (_pluginSettingsLoading || _pluginParseError)
return; return;
pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2)); pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2));
} }
@@ -2402,7 +2389,7 @@ Singleton {
let name = I18n.tr("Dock"); let name = I18n.tr("Dock");
let number = 2; let number = 2;
while (dockConfigs.some(config => config.name === name)) while (dockConfigs.some(config => config.name === name))
name = I18n.tr("Dock") + " " + number++; name = I18n.tr("Dock %1", "generated dock name, %1 is a number").arg(number++);
const config = DockConfig.create(id, name); const config = DockConfig.create(id, name);
const edge = firstFreeDockEdge(id, config.screenPreferences); const edge = firstFreeDockEdge(id, config.screenPreferences);
config.enabled = edge >= 0; config.enabled = edge >= 0;
@@ -2859,6 +2846,9 @@ Singleton {
id: "dot" + Date.now(), id: "dot" + Date.now(),
name: I18n.tr("Dot", "bar layout: free-floating dot that opens island activities"), name: I18n.tr("Dot", "bar layout: free-floating dot that opens island activities"),
enabled: true, enabled: true,
// A hidden or auto-hiding base bar would switch off the dot's OSD and notifications, and bar IPC refuses dot configs.
visible: true,
autoHide: false,
island: false, island: false,
dot: true, dot: true,
screenPreferences: ["all"], screenPreferences: ["all"],
@@ -3725,11 +3715,11 @@ Singleton {
property FileView fileView: FileView { property FileView fileView: FileView {
id: settingsFileView id: settingsFileView
path: isGreeterMode ? "" : filePath path: filePath
blockLoading: false blockLoading: false
blockWrites: true blockWrites: true
atomicWrites: true atomicWrites: true
watchChanges: !isGreeterMode watchChanges: true
onFileChanged: { onFileChanged: {
if (selfWrite) { if (selfWrite) {
selfWrite = false; selfWrite = false;
@@ -3740,9 +3730,6 @@ Singleton {
} }
} }
onLoaded: { onLoaded: {
if (isGreeterMode) {
return;
}
isLoading = true; isLoading = true;
_loading = true; _loading = true;
const hadParseFailed = hasParseFailed; const hadParseFailed = hasParseFailed;
@@ -3776,9 +3763,6 @@ Singleton {
} }
} }
onLoadFailed: error => { onLoadFailed: error => {
if (isGreeterMode) {
return;
}
isLoading = false; isLoading = false;
_loading = _anySettingsFile(file => file.isLoading); _loading = _anySettingsFile(file => file.isLoading);
if (error === FileViewError.FileNotFound) { if (error === FileViewError.FileNotFound) {
@@ -3922,7 +3906,7 @@ Singleton {
} }
property bool _settingsFolderExists: false property bool _settingsFolderExists: false
property bool _settingsFolderChecked: isGreeterMode property bool _settingsFolderChecked: false
function _syncSettingsFilesModels() { function _syncSettingsFilesModels() {
if (!_settingsFolderChecked) { if (!_settingsFolderChecked) {
settingsFilesModelSyncDebounce.restart(); settingsFilesModelSyncDebounce.restart();
@@ -3963,7 +3947,6 @@ Singleton {
} }
Loader { Loader {
id: settingsFolderLoader id: settingsFolderLoader
active: !isGreeterMode
function unloadModel() { function unloadModel() {
active = false; active = false;
} }
@@ -4012,59 +3995,6 @@ Singleton {
} }
} }
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterSettingsBaseDir: root._greeterCacheDir
function setGreeterSettingsBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterSettingsBaseDir === next)
return;
greeterSettingsBaseDir = next;
if (isGreeterMode)
greeterSettingsFile.reload();
}
function resetGreeterSettingsBaseDir() {
setGreeterSettingsBaseDir(root._greeterCacheDir);
}
function loadGreeterSettings(txt) {
_loading = true;
_hasLoaded = false;
try {
Store.parse(root, (txt && txt.trim()) ? JSON.parse(txt) : {});
_parseError = false;
} catch (e) {
_parseError = true;
log.error("Failed to parse greeter settings.json:", e.message);
Store.parse(root, {});
} finally {
_loading = false;
}
_hasLoaded = true;
applyStoredTheme();
}
FileView {
id: greeterSettingsFile
path: isGreeterMode && root.greeterSettingsBaseDir ? (root.greeterSettingsBaseDir + "/settings.json") : ""
preload: isGreeterMode
blockLoading: false
blockWrites: true
watchChanges: false
printErrors: false
onLoaded: {
if (isGreeterMode)
loadGreeterSettings(greeterSettingsFile.text());
}
onLoadFailed: {
if (isGreeterMode)
loadGreeterSettings("");
}
}
// Our first save drops the keys a newer shell wrote, so its settings are kept for going back. // Our first save drops the keys a newer shell wrote, so its settings are kept for going back.
FileView { FileView {
id: newerSettingsBackup id: newerSettingsBackup
@@ -4078,21 +4008,17 @@ Singleton {
FileView { FileView {
id: pluginSettingsFile id: pluginSettingsFile
path: isGreeterMode ? "" : pluginSettingsPath path: pluginSettingsPath
blockLoading: true blockLoading: true
blockWrites: true blockWrites: true
atomicWrites: true atomicWrites: true
printErrors: false printErrors: false
watchChanges: !isGreeterMode watchChanges: true
onLoaded: { onLoaded: {
if (isGreeterMode)
return;
pluginSettingsFileExists = true; pluginSettingsFileExists = true;
parsePluginSettings(pluginSettingsFile.text()); parsePluginSettings(pluginSettingsFile.text());
} }
onLoadFailed: error => { onLoadFailed: error => {
if (isGreeterMode)
return;
const msg = String(error || ""); const msg = String(error || "");
const missing = _isMissingPluginSettingsError(error); const missing = _isMissingPluginSettingsError(error);
pluginSettingsFileExists = !missing; pluginSettingsFileExists = !missing;
+3
View File
@@ -160,6 +160,7 @@ Singleton {
"children": [ "children": [
{ {
"id": "dock_general", "id": "dock_general",
"hidden": true,
"text": I18n.tr("General"), "text": I18n.tr("General"),
"icon": "tune", "icon": "tune",
"tabIndex": 5, "tabIndex": 5,
@@ -167,6 +168,7 @@ Singleton {
}, },
{ {
"id": "dock_widgets", "id": "dock_widgets",
"hidden": true,
"text": I18n.tr("Apps & widgets"), "text": I18n.tr("Apps & widgets"),
"icon": "widgets", "icon": "widgets",
"tabIndex": 57, "tabIndex": 57,
@@ -174,6 +176,7 @@ Singleton {
}, },
{ {
"id": "dock_appearance", "id": "dock_appearance",
"hidden": true,
"text": I18n.tr("Appearance"), "text": I18n.tr("Appearance"),
"icon": "palette", "icon": "palette",
"tabIndex": 58, "tabIndex": 58,
+41 -49
View File
@@ -13,7 +13,6 @@ import Quickshell.Io
import qs.Common import qs.Common
import qs.DCommon.Common as DCommon import qs.DCommon.Common as DCommon
import qs.Services import qs.Services
import qs.Modules.Greetd
import "StockThemes.js" as StockThemes import "StockThemes.js" as StockThemes
import "GSettings.js" as GSettings import "GSettings.js" as GSettings
@@ -1191,13 +1190,7 @@ Singleton {
readonly property real groupedListInnerRadius: cornerRadiusXS readonly property real groupedListInnerRadius: cornerRadiusXS
readonly property real groupedListOuterRadius: cornerRadiusL readonly property real groupedListOuterRadius: cornerRadiusL
property string fontFamily: { property string fontFamily: typeof SettingsData !== "undefined" ? resolvedFontFamily(SettingsData.fontFamily) : DCommon.Fonts.sans
if (typeof SettingsData === "undefined")
return DCommon.Fonts.sans;
if (SettingsData.isGreeterMode && SettingsData.lockScreenFontFamily !== "")
return resolvedFontFamily(SettingsData.lockScreenFontFamily);
return resolvedFontFamily(SettingsData.fontFamily);
}
property string monoFontFamily: typeof SettingsData !== "undefined" ? resolvedMonoFontFamily(SettingsData.monoFontFamily) : DCommon.Fonts.mono property string monoFontFamily: typeof SettingsData !== "undefined" ? resolvedMonoFontFamily(SettingsData.monoFontFamily) : DCommon.Fonts.mono
property string displayFontFamily: typeof SettingsData !== "undefined" ? resolvedDisplayFontFamily(SettingsData.displayFontFamily) : DCommon.Fonts.display property string displayFontFamily: typeof SettingsData !== "undefined" ? resolvedDisplayFontFamily(SettingsData.displayFontFamily) : DCommon.Fonts.display
@@ -1422,16 +1415,13 @@ Singleton {
currentThemeCategory = "generic"; currentThemeCategory = "generic";
} }
} }
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); if (savePrefs && typeof SettingsData !== "undefined") {
if (savePrefs && typeof SettingsData !== "undefined" && !isGreeterMode) {
SettingsData.set("currentThemeCategory", currentThemeCategory); SettingsData.set("currentThemeCategory", currentThemeCategory);
SettingsData.set("currentThemeName", currentTheme); SettingsData.set("currentThemeName", currentTheme);
} }
if (!isGreeterMode) {
generateSystemThemesFromCurrentTheme(); generateSystemThemesFromCurrentTheme();
} }
}
function setLightMode(light, savePrefs = true, enableTransition = false) { function setLightMode(light, savePrefs = true, enableTransition = false) {
if (typeof SettingsData !== "undefined" && SettingsData.matugenSmartMode) { if (typeof SettingsData !== "undefined" && SettingsData.matugenSmartMode) {
@@ -1447,18 +1437,15 @@ Singleton {
return; return;
} }
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); if (savePrefs && typeof SessionData !== "undefined") {
if (savePrefs && typeof SessionData !== "undefined" && !isGreeterMode) {
SessionData.setLightMode(light); SessionData.setLightMode(light);
} }
if (!isGreeterMode) {
if (typeof SettingsData !== "undefined") { if (typeof SettingsData !== "undefined") {
SettingsData.updateCosmicThemeMode(light); SettingsData.updateCosmicThemeMode(light);
} }
generateSystemThemesFromCurrentTheme(); generateSystemThemesFromCurrentTheme();
} }
}
function toggleLightMode(savePrefs = true) { function toggleLightMode(savePrefs = true) {
setLightMode(!isLightMode, savePrefs, true); setLightMode(!isLightMode, savePrefs, true);
@@ -1876,9 +1863,9 @@ Singleton {
} }
const runKey = Qt.md5(JSON.stringify(args)); const runKey = Qt.md5(JSON.stringify(args));
const skipUnchanged = !_matugenRunKey && !colorsFileLoadFailed && typeof SettingsData !== "undefined" && !SettingsData.generateThemeAtStartup; const startupRun = !_matugenRunKey && typeof SettingsData !== "undefined" && SettingsData.generateThemeAtStartup;
if (skipUnchanged && runKey === CacheData.matugenAppliedKey) { if (!startupRun && !colorsFileLoadFailed && runKey === CacheData.matugenAppliedKey) {
log.info("Theme inputs unchanged since the last run, skipping startup generation"); log.info("Theme inputs unchanged since the last run, skipping generation");
return; return;
} }
@@ -1938,8 +1925,7 @@ Singleton {
} }
function generateSystemThemesFromCurrentTheme() { function generateSystemThemesFromCurrentTheme() {
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); if (!matugenAvailable)
if (!matugenAvailable || isGreeterMode)
return; return;
_pendingGenerateParams = true; _pendingGenerateParams = true;
@@ -2144,7 +2130,7 @@ Singleton {
function applyGtkColors() { function applyGtkColors() {
if (!matugenAvailable) { if (!matugenAvailable) {
if (typeof ToastService !== "undefined") { if (typeof ToastService !== "undefined") {
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply %1 colors", "error toast, %1 is GTK or Qt").arg("GTK")); ToastService.showError(I18n.tr("%1 not available or disabled - cannot apply %2 colors", "error toast, %1 is matugen, %2 is GTK or Qt").arg("matugen").arg("GTK"));
} }
return; return;
} }
@@ -2166,7 +2152,7 @@ Singleton {
function applyQtColors() { function applyQtColors() {
if (!matugenAvailable) { if (!matugenAvailable) {
if (typeof ToastService !== "undefined") { if (typeof ToastService !== "undefined") {
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply %1 colors").arg("Qt")); ToastService.showError(I18n.tr("%1 not available or disabled - cannot apply %2 colors", "error toast, %1 is matugen, %2 is GTK or Qt").arg("matugen").arg("Qt"));
} }
return; return;
} }
@@ -2395,32 +2381,11 @@ Singleton {
} }
} }
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string greeterColorsBaseDir: root._greeterCacheDir
function setGreeterColorsBaseDir(dir) {
const next = dir || root._greeterCacheDir;
if (greeterColorsBaseDir === next)
return;
greeterColorsBaseDir = next;
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode)
dynamicColorsFileView.reload();
}
function resetGreeterColorsBaseDir() {
setGreeterColorsBaseDir(root._greeterCacheDir);
}
FileView { FileView {
id: dynamicColorsFileView id: dynamicColorsFileView
path: { path: stateDir + "/dms-colors.json"
if (SessionData.isGreeterMode)
return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : "";
return stateDir + "/dms-colors.json";
}
blockLoading: false blockLoading: false
watchChanges: !SessionData.isGreeterMode watchChanges: true
function parseAndLoadColors() { function parseAndLoadColors() {
try { try {
@@ -2462,9 +2427,6 @@ Singleton {
if (currentTheme !== dynamic) if (currentTheme !== dynamic)
return; return;
if (SessionData.isGreeterMode)
return;
if (workerRunning) { if (workerRunning) {
colorsReloadRetry.restart(); colorsReloadRetry.restart();
return; return;
@@ -2496,6 +2458,36 @@ Singleton {
onTriggered: dynamicColorsFileView.reload() onTriggered: dynamicColorsFileView.reload()
} }
readonly property bool publishTypography: typeof SettingsData !== "undefined" && SettingsData._hasLoaded
readonly property string typographyJson: JSON.stringify({
"fontFamily": fontFamily,
"monoFontFamily": monoFontFamily,
"displayFontFamily": displayFontFamily,
"fontWeight": fontWeight,
"fontScale": fontScale
}, null, 2)
onPublishTypographyChanged: typographyPublishDebounce.restart()
onTypographyJsonChanged: typographyPublishDebounce.restart()
FileView {
id: typographyFileView
path: root.publishTypography ? root.stateDir + "/dms-typography.json" : ""
blockLoading: false
printErrors: false
}
Timer {
id: typographyPublishDebounce
interval: 250
repeat: false
onTriggered: {
if (!root.publishTypography)
return;
typographyFileView.setText(root.typographyJson);
}
}
IpcHandler { IpcHandler {
target: "theme" target: "theme"
+1 -1
View File
@@ -439,7 +439,7 @@ function mangoActiveWorkspace(raw, screenName) {
function mangoWindowsOnWorkspace(windows, workspace) { function mangoWindowsOnWorkspace(windows, workspace) {
// mangoTags are 1-based; workspace.id is 0-based. // mangoTags are 1-based; workspace.id is 0-based.
return windows.filter(win => win && (win.mangoTags || []).includes(workspace.id + 1)); return windows.filter(win => win && win.mangoMonitor === workspace.output && (win.mangoTags || []).includes(workspace.id + 1));
} }
function i3Key(ws) { function i3Key(ws) {
@@ -8,6 +8,9 @@ var ISLAND_DEFAULTS = {
islandPlacement: "edge", islandPlacement: "edge",
islandFreeSize: 48, islandFreeSize: 48,
islandFreeIcon: "blur_on", islandFreeIcon: "blur_on",
islandFreeIconMode: "icon",
islandFreeIconPath: "",
islandFreeIconColor: "primary",
islandFreeEdgeMargin: 2, islandFreeEdgeMargin: 2,
islandFreeIdleDelay: 2500, islandFreeIdleDelay: 2500,
islandFreeIdleOpacity: 0.45, islandFreeIdleOpacity: 0.45,
+7 -7
View File
@@ -16,14 +16,14 @@ Singleton {
property var settingsRoot: null property var settingsRoot: null
onSettingsRootChanged: { onSettingsRootChanged: {
if (settingsRoot && !settingsRoot.isGreeterMode) if (settingsRoot)
consumeGreeterAutoLoginPendingSync(); consumeGreeterAutoLoginPendingSync();
} }
readonly property string greeterAutoLoginPendingSyncPath: (Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending" readonly property string greeterAutoLoginPendingSyncPath: (Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending"
function consumeGreeterAutoLoginPendingSync() { function consumeGreeterAutoLoginPendingSync() {
if (!settingsRoot || settingsRoot.isGreeterMode) if (!settingsRoot)
return; return;
greeterAutoLoginPendingCheckProcess.running = true; greeterAutoLoginPendingCheckProcess.running = true;
} }
@@ -292,7 +292,7 @@ Singleton {
property string authApplyTerminalFallbackStderr: "" property string authApplyTerminalFallbackStderr: ""
function scheduleAuthApply() { function scheduleAuthApply() {
if (!settingsRoot || settingsRoot.isGreeterMode) if (!settingsRoot)
return; return;
authApplyQueued = true; authApplyQueued = true;
@@ -305,7 +305,7 @@ Singleton {
} }
function beginAuthApply() { function beginAuthApply() {
if (!authApplyQueued || authApplyRunning || !settingsRoot || settingsRoot.isGreeterMode) if (!authApplyQueued || authApplyRunning || !settingsRoot)
return; return;
authApplyQueued = false; authApplyQueued = false;
@@ -344,7 +344,7 @@ Singleton {
property string greeterAutoLoginSyncStderr: "" property string greeterAutoLoginSyncStderr: ""
function scheduleGreeterAutoLoginSync() { function scheduleGreeterAutoLoginSync() {
if (!settingsRoot || settingsRoot.isGreeterMode) if (!settingsRoot)
return; return;
greeterAutoLoginSyncQueued = true; greeterAutoLoginSyncQueued = true;
@@ -357,7 +357,7 @@ Singleton {
} }
function beginGreeterAutoLoginSync() { function beginGreeterAutoLoginSync() {
if (!greeterAutoLoginSyncQueued || greeterAutoLoginSyncRunning || !settingsRoot || settingsRoot.isGreeterMode) if (!greeterAutoLoginSyncQueued || greeterAutoLoginSyncRunning || !settingsRoot)
return; return;
greeterAutoLoginSyncQueued = false; greeterAutoLoginSyncQueued = false;
@@ -533,7 +533,7 @@ Singleton {
root.toastRequested(0, message, "", "", "auth-sync"); root.toastRequested(0, message, "", "", "auth-sync");
} else { } else {
let details = (root.authApplyTerminalFallbackStderr || "").trim(); let details = (root.authApplyTerminalFallbackStderr || "").trim();
root.toastRequested(2, I18n.tr("Terminal fallback failed. Install a supported terminal emulator or run 'dms auth sync' manually.") + " (exit " + exitCode + ")", details, "", "auth-sync"); root.toastRequested(2, I18n.tr("Terminal fallback failed. Install one of the supported terminal emulators or run '%1' manually.", "terminal fallback status, %1 is a command").arg("dms auth sync") + " " + I18n.tr("(exit %1)", "process exit code").arg(exitCode), details, "", "auth-sync");
} }
root.finishAuthApply(); root.finishAuthApply();
} }
@@ -1445,6 +1445,9 @@ var LOCAL_SPEC = {
powerMenuBootEntries: { powerMenuBootEntries: {
def: [] def: []
}, },
powerMenuFirmwareSetup: {
def: false
},
updaterCheckOnStart: { updaterCheckOnStart: {
def: false def: false
}, },
-9
View File
@@ -1,9 +0,0 @@
import QtQuick
import Quickshell
import qs.Modules.Greetd
Scope {
id: root
GreeterSurface {}
}
+2
View File
@@ -1102,6 +1102,8 @@ Item {
} }
function set(key: string, value: string): string { function set(key: string, value: string): string {
if (AdminPolicy.disableSettingsIpcSet)
return "SETTINGS_SET_DISABLED";
if (!(key in SettingsData)) { if (!(key in SettingsData)) {
log.warn("Cannot set property, not found:", key); log.warn("Cannot set property, not found:", key);
return "SETTINGS_INVALID_KEY"; return "SETTINGS_INVALID_KEY";
+2 -3
View File
@@ -146,11 +146,10 @@ DModal {
}); });
} }
// NoDisplay entries are excluded from DesktopEntries.applications but // NoDisplay handlers stay hidden like in the launcher, except the shell's own (notepad)
// remain valid mime handlers; resolve them by id so they stay pickable
for (const rawId of mimeMatchedRawIds) { for (const rawId of mimeMatchedRawIds) {
const normId = _normAppId(rawId); const normId = _normAppId(rawId);
if (normId === "dms-open" || listedIds.has(normId)) if (!normId.startsWith("dev.duxlabs.shell.") || listedIds.has(normId))
continue; continue;
const desktopId = rawId.replace(/\.desktop$/, ""); const desktopId = rawId.replace(/\.desktop$/, "");
const entry = DesktopEntries.byId(desktopId) || DesktopEntries.heuristicLookup(desktopId); const entry = DesktopEntries.byId(desktopId) || DesktopEntries.heuristicLookup(desktopId);
+6 -6
View File
@@ -123,18 +123,18 @@ DModal {
text: { text: {
switch (requestType) { switch (requestType) {
case "confirm": case "confirm":
return I18n.tr("Confirm passkey for ") + deviceName; return I18n.tr("Confirm passkey for %1", "%1 is the device name").arg(deviceName);
case "display-passkey": case "display-passkey":
return I18n.tr("Enter this passkey on ") + deviceName; return I18n.tr("Enter this passkey on %1", "%1 is the device name").arg(deviceName);
case "authorize": case "authorize":
return I18n.tr("Authorize pairing with ") + deviceName; return I18n.tr("Authorize pairing with %1", "%1 is the device name").arg(deviceName);
case "pin": case "pin":
return I18n.tr("Enter PIN for ") + deviceName; return I18n.tr("Enter PIN for %1", "%1 is the device or network name").arg(deviceName);
case "passkey": case "passkey":
return I18n.tr("Enter passkey for ") + deviceName; return I18n.tr("Enter passkey for %1", "%1 is the device name").arg(deviceName);
default: default:
if (requestType.startsWith("authorize-service")) if (requestType.startsWith("authorize-service"))
return I18n.tr("Authorize service for ") + deviceName; return I18n.tr("Authorize service for %1", "%1 is the device name").arg(deviceName);
return deviceName; return deviceName;
} }
} }
@@ -188,7 +188,7 @@ Column {
} }
SettingsCard { SettingsCard {
title: "More in " + ChangelogService.currentVersion title: I18n.tr("More in %1", "changelog card title, %1 is a version").arg(ChangelogService.currentVersion)
Repeater { Repeater {
model: root.additions model: root.additions
@@ -258,7 +258,7 @@ Column {
StyledText { StyledText {
width: parent.width width: parent.width
text: "Plugin authors" text: I18n.tr("Plugin authors", "changelog section heading")
font.pixelSize: Theme.fontSizeLarge font.pixelSize: Theme.fontSizeLarge
font.weight: Theme.fontWeightBold font.weight: Theme.fontWeightBold
color: Theme.onTertiaryContainer color: Theme.onTertiaryContainer
@@ -634,7 +634,7 @@ Item {
switch (mode) { switch (mode) {
case "files": case "files":
if (!DSearchService.dsearchAvailable) if (!DSearchService.dsearchAvailable)
return I18n.tr("File search requires dsearch\nInstall from github.com/duxlabs-dev/dux-search"); return I18n.tr("File search requires %1\nInstall from %2", "launcher file search empty state, %1 is the dsearch binary, %2 is its repository").arg("dsearch").arg("github.com/duxlabs-dev/dux-search");
if (!hasQuery) if (!hasQuery)
return I18n.tr("Type to search files"); return I18n.tr("Type to search files");
if (root.controller.searchQuery.length < 2) if (root.controller.searchQuery.length < 2)
+1 -1
View File
@@ -187,7 +187,7 @@ DFloatingWindow {
} }
StyledText { StyledText {
text: I18n.tr("DMS_SOCKET not available") text: I18n.tr("%1 not available", "%1 is a tool or environment variable name").arg("DMS_SOCKET")
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
+1 -1
View File
@@ -190,7 +190,7 @@ DModal {
StyledText { StyledText {
width: parent.width width: parent.width
text: I18n.tr("To sign in as a different user, log out and pick the account from the greeter. Creating a fresh session in parallel needs a multi-session greeter (greetd-flexiserver / GDM / LightDM).") text: I18n.tr("To sign in as a different user, log out and pick the account from the greeter. Creating a fresh session in parallel needs a multi-session greeter (%1).", "switch user note, %1 lists multi-session greeters").arg("greetd-flexiserver / GDM / LightDM")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
+4 -4
View File
@@ -315,16 +315,16 @@ DModal {
} }
supportingText: { supportingText: {
if (promptReason === "pkcs11") if (promptReason === "pkcs11")
return I18n.tr("Enter PIN for ") + wifiPasswordSSID; return I18n.tr("Enter PIN for %1", "%1 is the device or network name").arg(wifiPasswordSSID);
if (isCertificatePrompt) if (isCertificatePrompt)
return wifiPasswordSSID; return wifiPasswordSSID;
if (fieldsInfo.length > 0) if (fieldsInfo.length > 0)
return I18n.tr("Enter credentials for ") + wifiPasswordSSID; return I18n.tr("Enter credentials for %1", "%1 is the network name").arg(wifiPasswordSSID);
if (isVpnPrompt) if (isVpnPrompt)
return I18n.tr("Enter password for ") + wifiPasswordSSID; return I18n.tr("Enter password for %1", "%1 is the network name").arg(wifiPasswordSSID);
if (isHiddenNetwork) if (isHiddenNetwork)
return I18n.tr("Enter network name and password"); return I18n.tr("Enter network name and password");
return (requiresEnterprise ? I18n.tr("Enter credentials for ") : I18n.tr("Enter password for ")) + wifiPasswordSSID; return (requiresEnterprise ? I18n.tr("Enter credentials for %1", "%1 is the network name") : I18n.tr("Enter password for %1", "%1 is the network name")).arg(wifiPasswordSSID);
} }
Rectangle { Rectangle {
+1 -1
View File
@@ -104,7 +104,7 @@ DModal {
width: parent.width width: parent.width
StyledText { StyledText {
text: I18n.tr("WiFi QR code for ") + theItem.wifiSSID text: I18n.tr("WiFi QR code for %1", "%1 is the network name").arg(theItem.wifiSSID)
font.pixelSize: Theme.fontSizeLarge font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText color: Theme.surfaceText
font.weight: Theme.fontWeightMedium font.weight: Theme.fontWeightMedium
@@ -46,7 +46,7 @@ Item {
visible: !DgopService.dgopAvailable || root.mounts.length === 0 visible: !DgopService.dgopAvailable || root.mounts.length === 0
iconName: DgopService.dgopAvailable ? "storage" : "error" iconName: DgopService.dgopAvailable ? "storage" : "error"
iconColor: DgopService.dgopAvailable ? Theme.primary : Theme.error iconColor: DgopService.dgopAvailable ? Theme.primary : Theme.error
title: DgopService.dgopAvailable ? I18n.tr("No disk data available") : I18n.tr("dgop not available") title: DgopService.dgopAvailable ? I18n.tr("No disk data available") : I18n.tr("%1 not available", "%1 is a tool or environment variable name").arg("dgop")
} }
CcGroup { CcGroup {
@@ -133,7 +133,7 @@ Item {
visible: !root.hasProfiles visible: !root.hasProfiles
iconName: "vpn_key_off" iconName: "vpn_key_off"
title: I18n.tr("No VPN profiles") title: I18n.tr("No VPN profiles")
subtitle: I18n.tr("Click Import to add a .ovpn or .conf") subtitle: I18n.tr("Click Import to add a %1 or %2 file", "VPN import hint, %1 and %2 are file extensions").arg(".ovpn").arg(".conf")
} }
DListView { DListView {
@@ -385,7 +385,7 @@ QtObject {
"type": "action", "type": "action",
"category": "system", "category": "system",
"enabled": DgopService.dgopAvailable, "enabled": DgopService.dgopAvailable,
"warning": !DgopService.dgopAvailable ? I18n.tr("Requires 'dgop' tool") : undefined, "warning": !DgopService.dgopAvailable ? I18n.tr("Requires %1", "missing program").arg("dgop") : undefined,
"allowMultiple": true "allowMultiple": true
}, },
{ {
@@ -17,6 +17,8 @@ Item {
property real columns: 4 property real columns: 4
property real rows: 1 property real rows: 1
property bool compact: columns <= 2 && rows === 1 property bool compact: columns <= 2 && rows === 1
property bool small: false
readonly property real bodyInset: small ? Math.max(0, (height - CcMetrics.smallRowHeight) / 2) : 0
property string iconName: "" property string iconName: ""
property string iconLabel: "" property string iconLabel: ""
property string iconTooltip: "" property string iconTooltip: ""
@@ -135,13 +137,13 @@ Item {
onSliderValueChanged: newValue => root.sliderValueChanged(newValue) onSliderValueChanged: newValue => root.sliderValueChanged(newValue)
Binding on trackHeight { Binding on trackHeight {
when: !root.tall when: !root.tall && !root.small
value: CcMetrics.stripTrackHeight value: CcMetrics.stripTrackHeight
restoreMode: Binding.RestoreBinding restoreMode: Binding.RestoreBinding
} }
Binding on handleHeight { Binding on handleHeight {
when: !root.tall when: !root.tall && !root.small
value: CcMetrics.stripHandleHeight value: CcMetrics.stripHandleHeight
restoreMode: Binding.RestoreBinding restoreMode: Binding.RestoreBinding
} }
@@ -46,7 +46,7 @@ CcTile {
if (enabled) if (enabled)
click(); click();
} }
Accessible.name: I18n.tr("Copy") + " " + modelData Accessible.name: I18n.tr("Copy %1", "%1 is a color value").arg(modelData)
onClicked: Quickshell.clipboardText = modelData onClicked: Quickshell.clipboardText = modelData
} }
} }
@@ -26,7 +26,7 @@ CcTile {
} }
subtitle: { subtitle: {
if (!DgopService.dgopAvailable) if (!DgopService.dgopAvailable)
return I18n.tr("DMS_SOCKET not available"); return I18n.tr("%1 not available", "%1 is a tool or environment variable name").arg("DMS_SOCKET");
if (!selectedMount) if (!selectedMount)
return I18n.tr("No disk data available"); return I18n.tr("No disk data available");
return `${selectedMount.used} / ${selectedMount.size} (${usagePercent.toFixed(0)}%)`; return `${selectedMount.used} / ${selectedMount.size} (${usagePercent.toFixed(0)}%)`;
@@ -17,7 +17,7 @@ function isSliderWidget(id) {
} }
function canShrink(id) { function canShrink(id) {
return id !== "user" && !isSliderWidget(id); return id !== "user";
} }
function isSmall(widget, rows) { function isSmall(widget, rows) {
@@ -25,7 +25,7 @@ function isSmall(widget, rows) {
} }
function canGroup(widget) { function canGroup(widget) {
return canShrink(widget?.id) && widget?.id !== GROUP_ID; return canShrink(widget?.id) && !isSliderWidget(widget?.id) && widget?.id !== GROUP_ID;
} }
// Members of a group that no longer exists fall back to the grid. // Members of a group that no longer exists fall back to the grid.
@@ -38,10 +38,12 @@ function inGroup(widget, ids) {
} }
function groupMembers(widgets, groupId) { function groupMembers(widgets, groupId) {
return widgets.reduce((members, widget, index) => widget?.group === groupId && isShown(widget) ? members.concat([{ return widgets.reduce((members, widget, index) => widget?.group === groupId && isShown(widget) ? members.concat([
{
"index": index, "index": index,
"widget": widget "widget": widget
}]) : members, []); }
]) : members, []);
} }
function isRemovable(widget) { function isRemovable(widget) {
+6 -2
View File
@@ -744,8 +744,12 @@ Item {
readonly property real barSurfaceThickness: Theme.px(effectiveBarThickness + surfaceSpacing + ((renderBarConfig?.gothCornersEnabled ?? false) && !hasMaximizedToplevel && spansEdge ? _wingR : 0), _dpr) + _shadowBuffer readonly property real barSurfaceThickness: Theme.px(effectiveBarThickness + surfaceSpacing + ((renderBarConfig?.gothCornersEnabled ?? false) && !hasMaximizedToplevel && spansEdge ? _wingR : 0), _dpr) + _shadowBuffer
readonly property bool islandSheetOut: islandHost?.sheetOut ?? false readonly property bool islandSheetOut: islandHost?.sheetOut ?? false
readonly property real islandSatelliteShadowBuffer: isIsland && islandSatellitesEnabled && !islandSatelliteBackground ? _shadowBuffer : 0 readonly property real islandSatelliteShadowBuffer: isIsland && islandSatellitesEnabled && !islandSatelliteBackground ? _shadowBuffer : 0
readonly property real hostThickness: isIsland ? Math.max(islandHost?.hostThickness ?? 0, islandStripThickness + islandSatelliteShadowBuffer) : Math.max(barSurfaceThickness, islandSheetOut ? islandHost.hostThickness : 0) // A grown layer surface jumps to its new origin before the client draws at the new size, so a far-edge host never grows for the sheet.
readonly property real hideSlideThickness: isIsland ? hostThickness : barSurfaceThickness readonly property bool islandReservesSheet: !!islandHost && (axis.edge === "bottom" || axis.edge === "right")
readonly property real hostThickness: isIsland ? Math.max(islandHost?.hostThickness ?? 0, islandStripThickness + islandSatelliteShadowBuffer) : Math.max(barSurfaceThickness, islandSheetOut || islandReservesSheet ? islandHost.hostThickness : 0)
// The host reserves the expanded island's height, so sliding by it would move a compact island off-screen within a frame.
readonly property real islandCompactThickness: Math.max(islandStripThickness + islandSatelliteShadowBuffer, (islandHost?.outerGap ?? 0) + (islandHost?.compactThickness ?? 0) + (islandHost?.shadowPadding ?? 0))
readonly property real hideSlideThickness: isIsland ? islandCompactThickness : barSurfaceThickness
readonly property real surfaceImplicitHeight: !isVertical ? hostThickness : 0 readonly property real surfaceImplicitHeight: !isVertical ? hostThickness : 0
readonly property real surfaceImplicitWidth: isVertical ? hostThickness : 0 readonly property real surfaceImplicitWidth: isVertical ? hostThickness : 0
+13 -19
View File
@@ -30,35 +30,29 @@ BasePill {
readonly property bool showPower: BatteryService.isCharging ? showPowerCharging : showPowerDischarging readonly property bool showPower: BatteryService.isCharging ? showPowerCharging : showPowerDischarging
readonly property bool critical: SettingsData.batteryCriticalAnimation && BatteryService.isCriticalBattery && !BatteryService.isCharging readonly property bool critical: SettingsData.batteryCriticalAnimation && BatteryService.isCriticalBattery && !BatteryService.isCharging
property real criticalPulse: 0 property real criticalPulse: 0
readonly property color criticalForeground: battery.mixColor(Theme.error, Theme.surface, criticalPulse) readonly property color criticalForeground: noBackground ? battery.mixColor(Theme.error, defaultContentColor, criticalPulse) : battery.mixColor(Theme.error, Theme.surface, criticalPulse)
fillColor: critical ? battery.mixColor(defaultFillColor, Theme.error, criticalPulse) : defaultFillColor fillColor: critical && !noBackground ? battery.mixColor(defaultFillColor, Theme.error, criticalPulse) : defaultFillColor
contentColor: critical ? battery.mixColor(defaultContentColor, Theme.surface, criticalPulse) : defaultContentColor contentColor: critical ? (noBackground ? criticalForeground : battery.mixColor(defaultContentColor, Theme.surface, criticalPulse)) : defaultContentColor
function mixColor(from, to, progress) { function mixColor(from, to, progress) {
return Qt.rgba(from.r + (to.r - from.r) * progress, from.g + (to.g - from.g) * progress, from.b + (to.b - from.b) * progress, from.a + (to.a - from.a) * progress); return Qt.rgba(from.r + (to.r - from.r) * progress, from.g + (to.g - from.g) * progress, from.b + (to.b - from.b) * progress, from.a + (to.a - from.a) * progress);
} }
SequentialAnimation on criticalPulse { property int criticalTick: 0
// Stepped on purpose: an Animation repaints the bar at the full refresh rate.
Timer {
interval: 50
repeat: true
running: battery.critical && battery.surfaceLive && !SettingsData.reduceMotion running: battery.critical && battery.surfaceLive && !SettingsData.reduceMotion
loops: Animation.Infinite
onRunningChanged: { onRunningChanged: {
if (!running) battery.criticalTick = 0;
battery.criticalPulse = 0; battery.criticalPulse = 0;
} }
onTriggered: {
NumberAnimation { battery.criticalTick = (battery.criticalTick + 1) % 40;
from: 0 battery.criticalPulse = 1 - Math.abs(20 - battery.criticalTick) / 20;
to: 1
duration: 1000
easing.type: Easing.Linear
}
NumberAnimation {
from: 1
to: 0
duration: 1000
easing.type: Easing.Linear
} }
} }
@@ -375,7 +375,7 @@ BasePill {
} }
property bool isOccupied: root.useNativeWorkspaces && CompositorService.workspaceOccupied(record) property bool isOccupied: root.useNativeWorkspaces && CompositorService.workspaceOccupied(record)
property bool isPlaceholder: !!(record && record.placeholder) property bool isPlaceholder: !!(record && record.placeholder)
property bool isHovered: mouseArea.containsMouse property bool isHovered: pillHover.hovered
property bool loadedIsUrgent: false property bool loadedIsUrgent: false
property bool isUrgent: root.useNativeWorkspaces ? CompositorService.workspaceUrgent(record, loadedIsUrgent) : (modelData?.urgent ?? false) property bool isUrgent: root.useNativeWorkspaces ? CompositorService.workspaceUrgent(record, loadedIsUrgent) : (modelData?.urgent ?? false)
@@ -615,6 +615,11 @@ BasePill {
} }
} }
HoverHandler {
id: pillHover
enabled: !isPlaceholder
}
MouseArea { MouseArea {
id: mouseArea id: mouseArea
anchors.fill: parent anchors.fill: parent
+4 -1
View File
@@ -680,6 +680,8 @@ DPopout {
readonly property bool ready: targetHeight >= 0 && settledHeight === targetHeight readonly property bool ready: targetHeight >= 0 && settledHeight === targetHeight
readonly property real availableHeight: root.screen ? root.screen.height - mainContainer.horizontalChromeHeight - DashMetrics.contentPadding * 2 - Theme.barHeight - Theme.spacingL * 2 : contentHeight readonly property real availableHeight: root.screen ? root.screen.height - mainContainer.horizontalChromeHeight - DashMetrics.contentPadding * 2 - Theme.barHeight - Theme.spacingL * 2 : contentHeight
readonly property int rowBudget: DashMetrics.rowCapFor(availableHeight) readonly property int rowBudget: DashMetrics.rowCapFor(availableHeight)
// Growing a panel not hung from a top bar moves the grid under a held card, which retargets the drop.
readonly property bool heightHeld: (currentItem?.cardInteracting ?? false) && root.effectiveBarPosition % 4 !== SettingsData.Top
x: (root.verticalNavigation && root.navigationEdge === SettingsData.Left ? root.navigationWidth : 0) - root.editGutter x: (root.verticalNavigation && root.navigationEdge === SettingsData.Left ? root.navigationWidth : 0) - root.editGutter
y: (!root.verticalNavigation && root.navigationEdge !== SettingsData.Bottom ? mainContainer.horizontalChromeHeight : 0) - root.editGutter y: (!root.verticalNavigation && root.navigationEdge !== SettingsData.Bottom ? mainContainer.horizontalChromeHeight : 0) - root.editGutter
@@ -691,12 +693,13 @@ DPopout {
clip: contentHeight > height clip: contentHeight > height
function updateContentHeight() { function updateContentHeight() {
if (targetHeight < 0) if (targetHeight < 0 || heightHeld)
return; return;
settledHeight = targetHeight; settledHeight = targetHeight;
} }
onTargetHeightChanged: Qt.callLater(updateContentHeight) onTargetHeightChanged: Qt.callLater(updateContentHeight)
onHeightHeldChanged: Qt.callLater(updateContentHeight)
Column { Column {
id: emptyContent id: emptyContent
+1
View File
@@ -214,6 +214,7 @@ Singleton {
readonly property int mediaLyricsRequestTimeout: 16000 readonly property int mediaLyricsRequestTimeout: 16000
readonly property int mediaLyricsLoadingDelay: 300 readonly property int mediaLyricsLoadingDelay: 300
readonly property real mediaLyricsPositionTolerance: 0.05 readonly property real mediaLyricsPositionTolerance: 0.05
readonly property real mediaLyricsWholeSecondSlack: 0.005
readonly property int mediaLyricsPositionProbeInterval: 5000 readonly property int mediaLyricsPositionProbeInterval: 5000
readonly property real mediaLyricsPositionProbeDrift: 1.5 readonly property real mediaLyricsPositionProbeDrift: 1.5
readonly property real mediaLyricsPositionProbeThreshold: 1 readonly property real mediaLyricsPositionProbeThreshold: 1
@@ -25,6 +25,7 @@ Item {
readonly property Item focusTarget: grid readonly property Item focusTarget: grid
readonly property bool blocksTabNavigation: editMode || addMenu.open || optionsSheet.shown readonly property bool blocksTabNavigation: editMode || addMenu.open || optionsSheet.shown
readonly property bool cardResizing: grid.sizePreview !== null readonly property bool cardResizing: grid.sizePreview !== null
readonly property bool cardInteracting: grid.interacting
implicitHeight: Math.max(DashMetrics.tabMinHeight, grid.implicitHeight) implicitHeight: Math.max(DashMetrics.tabMinHeight, grid.implicitHeight)
LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.enabled: I18n.isRtl
+1 -1
View File
@@ -123,7 +123,7 @@ Item {
interval: DashMetrics.mediaPositionPollInterval interval: DashMetrics.mediaPositionPollInterval
running: root.live && root.activePlayer?.playbackState === MprisPlaybackState.Playing && !root.isSeeking running: root.live && root.activePlayer?.playbackState === MprisPlaybackState.Playing && !root.isSeeking
repeat: true repeat: true
onTriggered: root.activePlayer?.positionChanged() onTriggered: MprisController.refreshPosition(root.activePlayer)
} }
function syncAudioRef(wanted) { function syncAudioRef(wanted) {
@@ -16,7 +16,7 @@ Card {
readonly property string levelText: Math.round(BatteryService.batteryLevel) + "%" readonly property string levelText: Math.round(BatteryService.batteryLevel) + "%"
readonly property string meterStyle: options.style ?? "solid" readonly property string meterStyle: options.style ?? "solid"
readonly property bool levelColors: options.color !== "theme" readonly property bool levelColors: options.color !== "theme"
readonly property string healthText: options.health === true && BatteryService.batteryHealth.endsWith("%") ? I18n.tr("Health") + " " + BatteryService.batteryHealth : "" readonly property string healthText: options.health === true && BatteryService.batteryHealth.endsWith("%") ? I18n.tr("Health %1", "battery health, %1 is a percentage").arg(BatteryService.batteryHealth) : ""
entryId: "battery" entryId: "battery"
tone: options.tone ?? "" tone: options.tone ?? ""
@@ -53,7 +53,7 @@ Card {
interval: DashMetrics.mediaPositionPollInterval interval: DashMetrics.mediaPositionPollInterval
running: root.live && root.visible && root.interactive && root.showSeekbar && root.playing && !root.isSeeking running: root.live && root.visible && root.interactive && root.showSeekbar && root.playing && !root.isSeeking
repeat: true repeat: true
onTriggered: root.activePlayer?.positionSupported && root.activePlayer.positionChanged() onTriggered: MprisController.refreshPosition(root.activePlayer)
} }
Loader { Loader {
+1
View File
@@ -26,6 +26,7 @@ FocusScope {
readonly property var addable: grid.addableEntries readonly property var addable: grid.addableEntries
readonly property int usedColumns: grid.usedColumns readonly property int usedColumns: grid.usedColumns
readonly property bool cardResizing: grid.sizePreview !== null readonly property bool cardResizing: grid.sizePreview !== null
readonly property bool cardInteracting: grid.interacting
readonly property bool blocksTabNavigation: grid.blocksTabNavigation || detailSheet.shown || editorSheet.shown || optionsSheet.shown || addMenu.open readonly property bool blocksTabNavigation: grid.blocksTabNavigation || detailSheet.shown || editorSheet.shown || optionsSheet.shown || addMenu.open
implicitWidth: DashMetrics.contentWidthFor(SettingsData.showWeekNumber) implicitWidth: DashMetrics.contentWidthFor(SettingsData.showWeekNumber)
+1
View File
@@ -36,6 +36,7 @@ Item {
} }
] ]
readonly property bool blocksTabNavigation: widgets.blocksTabNavigation readonly property bool blocksTabNavigation: widgets.blocksTabNavigation
readonly property bool cardInteracting: widgets.cardInteracting
readonly property var forecastOptions: [DashRegistry.choice("forecast", I18n.tr("Forecast", "weather widget option label for forecast display type"), "chart", [ readonly property var forecastOptions: [DashRegistry.choice("forecast", I18n.tr("Forecast", "weather widget option label for forecast display type"), "chart", [
{ {
"value": "chart", "value": "chart",
@@ -3,20 +3,52 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import qs.Common import qs.Common
import qs.DCommon.Widgets import qs.DCommon.Widgets
import qs.Widgets
Item { Item {
id: root id: root
required property var controller required property var controller
property string iconName: "blur_on" property string iconName: "blur_on"
property string iconMode: "icon"
property string iconPath: ""
property string iconColor: "primary"
readonly property real diameter: Math.min(width, height) readonly property real diameter: Math.min(width, height)
readonly property real faceSize: Math.max(Theme.fontSizeSmall, Math.round(root.diameter * 0.46))
readonly property color symbolColor: {
switch (root.iconColor) {
case "":
case "primary":
return Theme.primary;
case "surface":
return Theme.surfaceText;
default:
return root.iconColor;
}
}
Loader {
id: logoLoader
anchors.centerIn: parent
active: root.iconMode !== "icon"
sourceComponent: LauncherLogo {
mode: root.iconMode
size: root.faceSize
colorOverride: root.iconColor
customPath: root.iconPath
}
}
// A logo mode with nothing to show (unknown compositor, empty custom path) falls back to the symbol.
DIcon { DIcon {
anchors.centerIn: parent anchors.centerIn: parent
visible: !(logoLoader.item as LauncherLogo)?.resolvedMode
name: root.iconName name: root.iconName
size: Math.max(Theme.fontSizeSmall, Math.round(root.diameter * 0.46)) size: root.faceSize
color: Theme.primary color: root.symbolColor
} }
Rectangle { Rectangle {
@@ -31,8 +31,9 @@ Item {
property string requestedWindow: "" property string requestedWindow: ""
property bool freeMode: false property bool freeMode: false
property bool anchorSnaps: false property bool anchorSnaps: false
property real anchorX: 0 property point anchor
property real anchorY: 0 // Velocity the next applyTarget seeds the spring with; a released drag flings from it.
property var flingSeed: null
property real freeMargin: 8 property real freeMargin: 8
// Measured from the band centre, not the band start. // Measured from the band centre, not the band start.
property real anchorAlong: 0 property real anchorAlong: 0
@@ -220,8 +221,8 @@ Item {
} }
if (!root.freeMode) if (!root.freeMode)
return target; return target;
const x = root.clampFree(root.anchorX - target.width / 2, target.width, root.width); const x = root.clampFree(root.anchor.x - target.width / 2, target.width, root.width);
const y = root.clampFree(root.anchorY - target.height / 2, target.height, root.height); const y = root.clampFree(root.anchor.y - target.height / 2, target.height, root.height);
return Object.assign({}, target, { return Object.assign({}, target, {
"offsetAlong": root.isVertical ? y + target.height / 2 - root.height / 2 : x + target.width / 2 - root.width / 2, "offsetAlong": root.isVertical ? y + target.height / 2 - root.height / 2 : x + target.width / 2 - root.width / 2,
"offsetCross": root.isVertical ? x : y "offsetCross": root.isVertical ? x : y
@@ -238,7 +239,9 @@ Item {
fadeCompactCross = root.descriptorCross(controller.compactTarget); fadeCompactCross = root.descriptorCross(controller.compactTarget);
if (motion.running) if (motion.running)
root.unionMotionStartBounds(); root.unionMotionStartBounds();
motion.setTarget(root.resolveTarget(controller.targetDescriptor), seedVelocity); const seed = seedVelocity ?? root.flingSeed;
root.flingSeed = null;
motion.setTarget(root.resolveTarget(controller.targetDescriptor), seed);
if (!motion.running) if (!motion.running)
root.controller.releaseIdleVisuals(); root.controller.releaseIdleVisuals();
} }
@@ -261,8 +264,7 @@ Item {
root.applyTarget(); root.applyTarget();
} }
onAnchorXChanged: root.syncAnchor() onAnchorChanged: root.syncAnchor()
onAnchorYChanged: root.syncAnchor()
onAnchorAlongChanged: root.syncAnchor() onAnchorAlongChanged: root.syncAnchor()
onAlongExtentChanged: root.syncAnchor() onAlongExtentChanged: root.syncAnchor()
onBandAlongStartChanged: root.syncAnchor() onBandAlongStartChanged: root.syncAnchor()
+11 -6
View File
@@ -39,8 +39,7 @@ Item {
property var centerSectionRect: null property var centerSectionRect: null
property var trailingSectionRect: null property var trailingSectionRect: null
readonly property int hostLayer: root.hostWindow?.dBarLayer ?? WlrLayer.Top readonly property int hostLayer: root.hostWindow?.dBarLayer ?? WlrLayer.Top
property real anchorX: 0 property point anchor
property real anchorY: 0
property bool anchorSnaps: false property bool anchorSnaps: false
property real freeScale: 1 property real freeScale: 1
property real freeOpacity: 1 property real freeOpacity: 1
@@ -60,6 +59,7 @@ Item {
readonly property real currentVisualY: surface.currentVisualY readonly property real currentVisualY: surface.currentVisualY
readonly property real currentVisualWidth: surface.currentVisualWidth readonly property real currentVisualWidth: surface.currentVisualWidth
readonly property real currentVisualHeight: surface.currentVisualHeight readonly property real currentVisualHeight: surface.currentVisualHeight
readonly property real currentSurfaceRadius: surface.currentSurfaceRadius
readonly property color surfaceColor: surface.surfaceColor readonly property color surfaceColor: surface.surfaceColor
readonly property real surfaceOpacity: surface.surfaceOpacity readonly property real surfaceOpacity: surface.surfaceOpacity
readonly property bool inputSuspended: controller.inputSuspended readonly property bool inputSuspended: controller.inputSuspended
@@ -68,7 +68,9 @@ Item {
// Held from expand until the collapse spring settles, so compact-to-compact morphs never resize the bar window. // Held from expand until the collapse spring settles, so compact-to-compact morphs never resize the bar window.
property bool sheetOut: false property bool sheetOut: false
readonly property bool surfaceFits: (root.isVertical ? root.windowWidth : root.windowHeight) >= root.hostThickness - 1 readonly property bool surfaceFits: (root.isVertical ? root.windowWidth : root.windowHeight) >= root.hostThickness - 1
readonly property bool hostReady: !root.embedded || root.frameHosted || root.surfaceFits // A free host lifts to the whole output for the sheet; the expand waits for that size so no frame of the morph is clipped.
property bool hostLifted: true
readonly property bool hostReady: root.freeMode ? root.hostLifted : (!root.embedded || root.frameHosted || root.surfaceFits)
// Layer surfaces glitch when they resize under a moving sheet: edit room waits for a frame at the grown size, and the grown size outlives the shrink spring. // Layer surfaces glitch when they resize under a moving sheet: edit room waits for a frame at the grown size, and the grown size outlives the shrink spring.
readonly property bool surfaceResizes: !root.freeMode && !root.frameHosted readonly property bool surfaceResizes: !root.freeMode && !root.frameHosted
readonly property int editSurfaceHeight: controller.editSurfaceHeight readonly property int editSurfaceHeight: controller.editSurfaceHeight
@@ -433,7 +435,8 @@ Item {
homeBrightnessDisplay: SettingsData.islandLevelDisplay(root.barConfig, "islandHomeBrightnessDisplay") homeBrightnessDisplay: SettingsData.islandLevelDisplay(root.barConfig, "islandHomeBrightnessDisplay")
batteryStyle: root.setting("islandBatteryStyle") batteryStyle: root.setting("islandBatteryStyle")
mediaClockVisible: root.setting("islandMediaClockVisible") mediaClockVisible: root.setting("islandMediaClockVisible")
mediaAutoSwitch: root.setting("islandMediaAutoSwitch") // Home and media share the dot face; see mediaFaceToggleAvailable.
mediaAutoSwitch: !root.dotMode && root.setting("islandMediaAutoSwitch")
launcherCycleEnabled: SettingsData.launcherStyle === "island" launcherCycleEnabled: SettingsData.launcherStyle === "island"
dashboardAvailableWidth: Math.max(0, (root.screen?.width ?? root.referenceScreenWidth) - (root.isVertical ? root.crossInsetStart + root.crossInsetEnd + root.outerGap + root.nearInset : root.alongInset) - Theme.spacingL * 2) dashboardAvailableWidth: Math.max(0, (root.screen?.width ?? root.referenceScreenWidth) - (root.isVertical ? root.crossInsetStart + root.crossInsetEnd + root.outerGap + root.nearInset : root.alongInset) - Theme.spacingL * 2)
dashboardAvailableHeight: Math.max(0, (root.screen?.height ?? root.referenceScreenHeight) - (root.isVertical ? root.alongInset : root.crossInsetStart + root.crossInsetEnd + root.outerGap + root.nearInset) - Theme.spacingL * 2) dashboardAvailableHeight: Math.max(0, (root.screen?.height ?? root.referenceScreenHeight) - (root.isVertical ? root.alongInset : root.crossInsetStart + root.crossInsetEnd + root.outerGap + root.nearInset) - Theme.spacingL * 2)
@@ -493,8 +496,7 @@ Item {
hostOriginX: root.hostOriginX hostOriginX: root.hostOriginX
hostOriginY: root.hostOriginY hostOriginY: root.hostOriginY
freeMode: root.freeMode freeMode: root.freeMode
anchorX: root.anchorX anchor: root.anchor
anchorY: root.anchorY
anchorSnaps: root.anchorSnaps anchorSnaps: root.anchorSnaps
anchorAlong: root.anchorAlong anchorAlong: root.anchorAlong
nearInset: root.nearInset nearInset: root.nearInset
@@ -534,6 +536,9 @@ Item {
DotCompact { DotCompact {
controller: controller controller: controller
iconName: root.setting("islandFreeIcon") iconName: root.setting("islandFreeIcon")
iconMode: root.setting("islandFreeIconMode")
iconPath: root.setting("islandFreeIconPath")
iconColor: root.setting("islandFreeIconColor")
} }
} }
@@ -189,10 +189,11 @@ Item {
component CompactFace: Loader { component CompactFace: Loader {
required property string activity required property string activity
required property Component face required property Component face
readonly property var target: root.resolveTarget(root.controller.compactTargetFor(activity)) // A free face only centres in the island, so it skips the anchor-resolved slot every drag event would rebuild.
readonly property var target: root.freeMode ? root.controller.compactTargetFor(activity) : root.resolveTarget(root.controller.compactTargetFor(activity))
readonly property bool isVertical: root.controller.isVertical readonly property bool isVertical: root.controller.isVertical
readonly property real alongPos: !active ? 0 : Math.round(root.slotAlong(target)) - Math.round(isVertical ? root.islandY : root.islandX) readonly property real alongPos: !active || root.freeMode ? 0 : Math.round(root.slotAlong(target)) - Math.round(isVertical ? root.islandY : root.islandX)
readonly property real crossPos: !active ? 0 : Math.round(root.slotCross(target)) - Math.round(isVertical ? root.islandX : root.islandY) readonly property real crossPos: !active || root.freeMode ? 0 : Math.round(root.slotCross(target)) - Math.round(isVertical ? root.islandX : root.islandY)
x: root.freeMode ? Math.round((parent.width - width) / 2) : (isVertical ? crossPos : alongPos) x: root.freeMode ? Math.round((parent.width - width) / 2) : (isVertical ? crossPos : alongPos)
y: root.freeMode ? Math.round((parent.height - height) / 2) : (isVertical ? alongPos : crossPos) y: root.freeMode ? Math.round((parent.height - height) / 2) : (isVertical ? alongPos : crossPos)
@@ -209,11 +210,13 @@ Item {
component ExpandedFace: Loader { component ExpandedFace: Loader {
required property string activity required property string activity
readonly property var target: root.controller.expandedTargetFor(activity) readonly property var target: root.controller.expandedTargetFor(activity)
readonly property var slot: root.resolveTarget(target) // A hidden face stays put, so a kept-alive sheet is not re-laid out on every move of the pill.
readonly property bool placed: active && visible
readonly property var slot: placed ? root.resolveTarget(target) : target
readonly property bool isVertical: root.controller.isVertical readonly property bool isVertical: root.controller.isVertical
readonly property real inset: root.controller.farEdge ? 0 : root.expandedInset readonly property real inset: root.controller.farEdge ? 0 : root.expandedInset
readonly property real alongPos: !active ? 0 : Math.round(root.slotAlong(slot)) - Math.round(isVertical ? root.islandY : root.islandX) readonly property real alongPos: !placed ? 0 : Math.round(root.slotAlong(slot)) - Math.round(isVertical ? root.islandY : root.islandX)
readonly property real crossPos: !active ? 0 : Math.round(root.slotCross(slot)) - Math.round(isVertical ? root.islandX : root.islandY) + inset readonly property real crossPos: !placed ? 0 : Math.round(root.slotCross(slot)) - Math.round(isVertical ? root.islandX : root.islandY) + inset
x: isVertical ? crossPos : alongPos x: isVertical ? crossPos : alongPos
y: isVertical ? alongPos : crossPos y: isVertical ? alongPos : crossPos
@@ -618,7 +618,8 @@ QtObject {
return mediaAvailable && mediaPreferred ? "media" : "home"; return mediaAvailable && mediaPreferred ? "media" : "home";
} }
readonly property bool mediaFaceToggleAvailable: !expanded && mediaAvailable && (activeActivity === "home" || activeActivity === "media") // A dot draws the same face for home and media, so a toggle would only change what the next click opens.
readonly property bool mediaFaceToggleAvailable: !dotMode && !expanded && mediaAvailable && (activeActivity === "home" || activeActivity === "media")
function toggleMediaFace() { function toggleMediaFace() {
if (!mediaFaceToggleAvailable) if (!mediaFaceToggleAvailable)
+107 -27
View File
@@ -10,52 +10,126 @@ DragHandler {
readonly property real maxFlingSpeed: 4000 readonly property real maxFlingSpeed: 4000
readonly property real flingProjection: 0.09 readonly property real flingProjection: 0.09
property real startAnchorX: 0 // Single events are too noisy on high-rate mice, so the fling reads the stroke's last stretch.
property real startAnchorY: 0 readonly property int velocityWindow: 60
property point startAnchor
property point startPointer property point startPointer
property point startOrigin
property point lastPointer
property bool tracking: false property bool tracking: false
property real velocityX: 0 property bool movePending: false
property real velocityY: 0 property bool flingPending: false
property point releaseVelocity
property var samples: []
readonly property bool hostLifted: root.hostWindow.lifted
onHostLiftedChanged: {
if (root.hostLifted && root.flingPending)
root.fling();
}
// Scene positions are surface-local; events just after the lift can still carry the pre-lift origin, so the reading that continues the stroke wins.
function pointerPosition() { function pointerPosition() {
return root.surface.mapToGlobal(root.centroid.position.x, root.centroid.position.y); const p = root.centroid.scenePosition;
const current = Qt.point(p.x + root.hostWindow.frameLeft, p.y + root.hostWindow.frameTop);
if (!root.tracking)
return current;
const previous = Qt.point(p.x + root.startOrigin.x, p.y + root.startOrigin.y);
const last = root.lastPointer;
return Math.hypot(current.x - last.x, current.y - last.y) <= Math.hypot(previous.x - last.x, previous.y - last.y) ? current : previous;
}
function anchorFor(pointer) {
return Qt.point(root.startAnchor.x + pointer.x - root.startPointer.x, root.startAnchor.y + pointer.y - root.startPointer.y);
} }
function rememberPress() { function rememberPress() {
root.startAnchorX = root.surface.currentVisualX + root.surface.currentVisualWidth / 2; root.startAnchor = Qt.point(root.surface.currentVisualX + root.surface.currentVisualWidth / 2, root.surface.currentVisualY + root.surface.currentVisualHeight / 2);
root.startAnchorY = root.surface.currentVisualY + root.surface.currentVisualHeight / 2; root.startOrigin = Qt.point(root.hostWindow.frameLeft, root.hostWindow.frameTop);
root.startPointer = root.pointerPosition(); root.startPointer = root.pointerPosition();
root.velocityX = 0; root.lastPointer = root.startPointer;
root.velocityY = 0; root.samples = [];
root.tracking = true; root.tracking = true;
} }
function recordPointer() {
const now = Date.now();
root.lastPointer = root.pointerPosition();
while (root.samples.length > 1 && now - root.samples[0].time > root.velocityWindow)
root.samples.shift();
root.samples.push({
"time": now,
"x": root.lastPointer.x,
"y": root.lastPointer.y
});
root.movePending = true;
}
function flingVelocity() {
const now = Date.now();
const recent = root.samples.filter(sample => now - sample.time <= root.velocityWindow);
if (recent.length < 2)
return Qt.point(0, 0);
const first = recent[0];
const last = recent[recent.length - 1];
const seconds = (last.time - first.time) / 1000;
if (seconds < 0.008)
return Qt.point(0, 0);
const clamp = value => Math.max(-root.maxFlingSpeed, Math.min(root.maxFlingSpeed, value));
return Qt.point(clamp((last.x - first.x) / seconds), clamp((last.y - first.y) / seconds));
}
// Re-based at drag start: a press just after a settle can read the old origin, and a flying dot has moved since the press.
function beginDrag() { function beginDrag() {
if (!root.tracking) root.flingPending = false;
root.tracking = false;
root.rememberPress(); root.rememberPress();
root.hostWindow.dragging = true; root.hostWindow.dragging = true;
root.hostWindow.wake(); root.hostWindow.wake();
root.updateDrag(); root.recordPointer();
} }
function updateDrag() { function applyMove() {
const point = root.pointerPosition(); root.movePending = false;
root.velocityX = Math.max(-root.maxFlingSpeed, Math.min(root.maxFlingSpeed, root.centroid.velocity.x)); root.hostWindow.anchor = root.anchorFor(root.lastPointer);
root.velocityY = Math.max(-root.maxFlingSpeed, Math.min(root.maxFlingSpeed, root.centroid.velocity.y));
root.hostWindow.anchorX = root.startAnchorX + point.x - root.startPointer.x;
root.hostWindow.anchorY = root.startAnchorY + point.y - root.startPointer.y;
} }
function finishDrag() { function finishDrag() {
root.hostWindow.dragging = false; const host = root.hostWindow;
root.hostWindow.anchorX = root.hostWindow.clampAnchorX(root.hostWindow.anchorX + root.velocityX * root.flingProjection); root.releaseVelocity = root.flingVelocity();
root.hostWindow.anchorY = root.hostWindow.clampAnchorY(root.hostWindow.anchorY + root.velocityY * root.flingProjection); root.movePending = false;
root.surface.applyTarget({ root.tracking = false;
"offsetAlong": root.surface.isVertical ? root.velocityY : root.velocityX, // Raised before dragging drops, so the lift holds through the fling.
"offsetCross": root.surface.isVertical ? root.velocityX : root.velocityY host.flinging = true;
}); host.dragging = false;
root.hostWindow.storeAnchor(); // A release before the lift lands waits for it, or the fling would spring out of the old buffer.
root.hostWindow.wake(); if (host.lifted)
root.fling();
else
root.flingPending = true;
}
function fling() {
const host = root.hostWindow;
root.flingPending = false;
// An expand requested mid-drag disables this handler; the sheet must not open with the release velocity.
if (root.surface.controller.expanded) {
host.flinging = false;
host.wake();
return;
}
const velocity = root.releaseVelocity;
const anchor = root.anchorFor(root.lastPointer);
root.surface.flingSeed = {
"offsetAlong": root.surface.isVertical ? velocity.y : velocity.x,
"offsetCross": root.surface.isVertical ? velocity.x : velocity.y
};
host.anchor = Qt.point(host.clampAnchorX(anchor.x + velocity.x * root.flingProjection), host.clampAnchorY(anchor.y + velocity.y * root.flingProjection));
// An unchanged anchor never reaches applyTarget, so the seed is spent here.
if (root.surface.flingSeed)
root.surface.applyTarget();
host.flinging = root.surface.motionRunning;
host.wake();
} }
parent: root.surface parent: root.surface
@@ -76,6 +150,12 @@ DragHandler {
if (!root.tracking) if (!root.tracking)
root.rememberPress(); root.rememberPress();
if (active) if (active)
root.updateDrag(); root.recordPointer();
}
// One anchor write per frame however fast the mouse polls, and none before the lift lands.
property FrameAnimation moveFrame: FrameAnimation {
running: root.active && root.movePending && root.hostWindow.lifted
onTriggered: root.applyMove()
} }
} }
@@ -28,22 +28,43 @@ PanelWindow {
readonly property real screenHeight: root.screen?.height ?? 1080 readonly property real screenHeight: root.screen?.height ?? 1080
readonly property string positionKey: IslandHostRegistry.key(root.screen?.name, root.barId) readonly property string positionKey: IslandHostRegistry.key(root.screen?.name, root.barId)
readonly property var storedAnchor: SessionData.islandFreePositions[root.positionKey] ?? null readonly property var storedAnchor: SessionData.islandFreePositions[root.positionKey] ?? null
readonly property real storedAnchorX: root.clampAnchorX((typeof root.storedAnchor?.x === "number" ? root.storedAnchor.x : 0.5) * root.screenWidth) readonly property point storedPoint: Qt.point(root.clampAnchorX((typeof root.storedAnchor?.x === "number" ? root.storedAnchor.x : 0.5) * root.screenWidth), root.clampAnchorY((typeof root.storedAnchor?.y === "number" ? root.storedAnchor.y : 0.5) * root.screenHeight))
readonly property real storedAnchorY: root.clampAnchorY((typeof root.storedAnchor?.y === "number" ? root.storedAnchor.y : 0.5) * root.screenHeight) readonly property real anchorX: root.anchor.x
readonly property real anchorY: root.anchor.y
// Sized from the visual's size alone, so a fractional spring position never resizes the surface.
readonly property int surfaceX: Math.floor(body.currentVisualX - root.hitPadding - body.shadowPadding) readonly property int surfaceX: Math.floor(body.currentVisualX - root.hitPadding - body.shadowPadding)
readonly property int surfaceY: Math.floor(body.currentVisualY - root.hitPadding - body.shadowPadding) readonly property int surfaceY: Math.floor(body.currentVisualY - root.hitPadding - body.shadowPadding)
readonly property int surfaceWidth: Math.ceil(body.currentVisualWidth + 2 * (root.hitPadding + body.shadowPadding)) + 1
readonly property int surfaceHeight: Math.ceil(body.currentVisualHeight + 2 * (root.hitPadding + body.shadowPadding)) + 1
// Gestures and morphs run on a surface lifted to the whole output: per-frame margin commits skew pointer coordinates and relayout Hyprland.
property bool lifting: false
property bool liftForced: false
property point liftOrigin
// True from the unlift until the tight configure lands: a re-grab in that window still reads the output size.
property bool sizeStale: false
readonly property bool outputSized: root.width >= root.screenWidth - 2 && root.height >= root.screenHeight - 2
// Negative far margins grow the surface in place; the origin flips only once the buffer spans the output.
readonly property bool lifted: root.lifting && (root.liftForced || (!root.sizeStale && root.outputSized))
readonly property int frameLeft: root.lifted ? 0 : root.lifting ? root.liftOrigin.x : root.surfaceX
readonly property int frameTop: root.lifted ? 0 : root.lifting ? root.liftOrigin.y : root.surfaceY
readonly property int frameRight: root.lifting ? -root.frameLeft : Math.round(root.screenWidth) - root.surfaceX - root.surfaceWidth
readonly property int frameBottom: root.lifting ? -root.frameTop : Math.round(root.screenHeight) - root.surfaceY - root.surfaceHeight
readonly property bool expanded: body.expanded readonly property bool expanded: body.expanded
readonly property bool pointerInside: body.islandController.pointerInside readonly property bool pointerInside: body.islandController.pointerInside
readonly property string activeActivity: body.islandController.activeActivity readonly property string activeActivity: body.islandController.activeActivity
// An idle-shrunk nub keeps its full-size hit area; the drag area shares it or ring clicks would be lost. readonly property bool motionRunning: body.motionRunning
readonly property real hitPadding: root.idle ? Math.max(0, (root.dotSize - root.dotSize * root.idleScale) / 2) : 0 // A shrunk nub keeps the full dot's hit area and surface, so idling and waking never resize the window.
readonly property real hitPadding: root.dot ? Math.max(0, (root.dotSize - body.currentVisualWidth) / 2) : 0
// Blur regions are not antialiased and a small circle shows the jagged rim, so blur waits for the morph. // Blur regions are not antialiased and a small circle shows the jagged rim, so blur waits for the morph.
readonly property bool blurWanted: !root.dot || Math.min(body.currentVisualWidth, body.currentVisualHeight) > root.dotSize * 1.5 readonly property bool blurWanted: !root.dot || Math.min(body.currentVisualWidth, body.currentVisualHeight) > root.dotSize * 1.5
property real anchorX: 0 property point anchor
property real anchorY: 0
property bool dragging: false property bool dragging: false
property bool flinging: false
// sheetOut spans the expand through the collapse spring, so the whole morph draws into one static buffer.
readonly property bool liftWanted: root.dragging || root.flinging || body.sheetOut
property bool idle: false property bool idle: false
function anchorLimit(size, extent) { function anchorLimit(size, extent) {
@@ -64,16 +85,24 @@ PanelWindow {
} }
function storeAnchor() { function storeAnchor() {
SessionData.setIslandFreePosition(root.positionKey, root.anchorX / root.screenWidth, root.anchorY / root.screenHeight); SessionData.setIslandFreePosition(root.positionKey, root.anchor.x / root.screenWidth, root.anchor.y / root.screenHeight);
} }
function moveTo(x, y) { function moveTo(x, y) {
root.anchorX = root.clampAnchorX(x); root.anchor = Qt.point(root.clampAnchorX(x), root.clampAnchorY(y));
root.anchorY = root.clampAnchorY(y);
root.storeAnchor(); root.storeAnchor();
root.wake(); root.wake();
} }
function beginLift() {
if (root.lifting)
return;
root.liftOrigin = Qt.point(root.frameLeft, root.frameTop);
root.liftForced = false;
root.lifting = true;
liftTimeout.restart();
}
function wake() { function wake() {
root.idle = false; root.idle = false;
if (root.idleDelay > 0) if (root.idleDelay > 0)
@@ -90,17 +119,37 @@ PanelWindow {
return body.containsGlobalPoint(gx, gy, padding); return body.containsGlobalPoint(gx, gy, padding);
} }
onStoredAnchorXChanged: { onStoredPointChanged: {
if (!root.dragging) if (!root.liftWanted)
root.anchorX = root.storedAnchorX; root.anchor = root.storedPoint;
} }
onStoredAnchorYChanged: { // Stored once the fling settles; a save mid-gesture stalls the fling's first frame.
if (!root.dragging) onLiftWantedChanged: {
root.anchorY = root.storedAnchorY; if (root.liftWanted) {
root.beginLift();
return;
}
root.sizeStale = root.outputSized;
root.lifting = false;
liftTimeout.stop();
root.storeAnchor();
}
onOutputSizedChanged: {
if (!root.outputSized)
root.sizeStale = false;
}
onLiftedChanged: {
if (root.lifted)
liftTimeout.stop();
}
onMotionRunningChanged: {
if (!root.motionRunning)
root.flinging = false;
} }
onPointerInsideChanged: root.wake() onPointerInsideChanged: root.wake()
onExpandedChanged: root.wake() onExpandedChanged: root.wake()
onActiveActivityChanged: root.wake() onActiveActivityChanged: root.wake()
onIdleDelayChanged: root.wake()
color: "transparent" color: "transparent"
exclusiveZone: -1 exclusiveZone: -1
@@ -122,16 +171,19 @@ PanelWindow {
radius: body.currentSurfaceRadius > 0 ? Math.ceil(body.currentSurfaceRadius) + 1 : 0 radius: body.currentSurfaceRadius > 0 ? Math.ceil(body.currentSurfaceRadius) + 1 : 0
} }
// All four edges are anchored so the margins alone place and size the surface, letting a lift grow it past the output.
anchors { anchors {
top: true top: true
bottom: true
left: true left: true
right: true
} }
WlrLayershell.margins { WlrLayershell.margins {
left: root.surfaceX left: root.frameLeft
top: root.surfaceY top: root.frameTop
right: root.frameRight
bottom: root.frameBottom
} }
implicitWidth: Math.ceil(body.currentVisualX + body.currentVisualWidth + root.hitPadding + body.shadowPadding) - root.surfaceX
implicitHeight: Math.ceil(body.currentVisualY + body.currentVisualHeight + root.hitPadding + body.shadowPadding) - root.surfaceY
Component.onCompleted: { Component.onCompleted: {
root.wake(); root.wake();
@@ -150,24 +202,46 @@ PanelWindow {
Timer { Timer {
id: idleTimer id: idleTimer
interval: Math.max(1000, root.idleDelay) interval: root.idleDelay
onTriggered: { onTriggered: {
if (root.expanded || root.pointerInside || root.dragging || body.islandController.transientActive) if (root.expanded || root.pointerInside || hitHover.hovered || root.liftWanted || body.islandController.transientActive)
return; return;
root.idle = true; root.idle = true;
} }
} }
mask: Region { // A compositor that never sizes the lifted surface would leave every drag frozen.
Timer {
id: liftTimeout
interval: 250
onTriggered: root.liftForced = true
}
// A quick stroke outruns the dot's rectangle between frames and the pointer leave ends the drag, so a drag takes input everywhere.
mask: root.dragging && root.lifted ? null : hitRegion
Region {
id: hitRegion
item: body.inputSuspended ? null : hitTarget item: body.inputSuspended ? null : hitTarget
} }
Item { Item {
id: hitTarget id: hitTarget
anchors.fill: parent
anchors.margins: body.shadowPadding x: body.currentVisualX - root.hitPadding - root.frameLeft
y: body.currentVisualY - root.hitPadding - root.frameTop
width: body.currentVisualWidth + 2 * root.hitPadding
height: body.currentVisualHeight + 2 * root.hitPadding
// The idle ring around a shrunk nub takes clicks and drags, so hovering it wakes the dot too.
HoverHandler {
id: hitHover
onHoveredChanged: root.wake()
}
} }
// Keep the animated buffer tight; the unmoving input-only window catches outside clicks. // Catches outside clicks; the lifted surface's own mask stays the island rectangle.
PanelWindow { PanelWindow {
id: dismissWindow id: dismissWindow
screen: root.screen screen: root.screen
@@ -182,14 +256,17 @@ PanelWindow {
left: true left: true
right: true right: true
} }
// The frozen spring envelope, so this full-output surface is not re-committed every frame of a morph.
mask: Region { mask: Region {
width: dismissWindow.width width: dismissWindow.width
height: dismissWindow.height height: dismissWindow.height
Region { Region {
x: root.surfaceX + hitTarget.x readonly property Item envelope: body.inputMaskItem
y: root.surfaceY + hitTarget.y
width: hitTarget.width x: dismissWindow.visible ? envelope.x : 0
height: hitTarget.height y: dismissWindow.visible ? envelope.y : 0
width: dismissWindow.visible ? envelope.width : 0
height: dismissWindow.visible ? envelope.height : 0
intersection: Intersection.Subtract intersection: Intersection.Subtract
} }
} }
@@ -212,8 +289,8 @@ PanelWindow {
IslandBarHost { IslandBarHost {
id: body id: body
x: -root.surfaceX x: -root.frameLeft
y: -root.surfaceY y: -root.frameTop
width: root.screenWidth width: root.screenWidth
height: root.screenHeight height: root.screenHeight
originOffsetX: x originOffsetX: x
@@ -221,9 +298,9 @@ PanelWindow {
barConfig: root.barConfig barConfig: root.barConfig
screen: root.screen screen: root.screen
hostWindow: root hostWindow: root
hostLifted: root.lifted
barId: root.barId barId: root.barId
anchorX: root.anchorX anchor: root.anchor
anchorY: root.anchorY
anchorSnaps: root.dragging anchorSnaps: root.dragging
freeScale: root.idle ? root.idleScale : 1 freeScale: root.idle ? root.idleScale : 1
freeOpacity: root.idle ? root.idleOpacity : 1 freeOpacity: root.idle ? root.idleOpacity : 1
-20
View File
@@ -1,20 +0,0 @@
.pragma library
function readBoolOverride(envReader, names, fallbackValue) {
for (let i = 0; i < names.length; i++) {
const name = names[i];
const raw = envReader(name);
if (raw === undefined || raw === null || raw === "")
continue;
const normalized = String(raw).trim().toLowerCase();
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on")
return true;
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off")
return false;
console.warn("Invalid boolean override for", name + ":", raw, "- trying next override/fallback");
}
return fallbackValue;
}
-162
View File
@@ -1,162 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import "GreetdEnv.js" as GreetdEnv
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("GreetdMemory")
readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
readonly property string sessionConfigPath: greetCfgDir + "/session.json"
readonly property string memoryFile: greetCfgDir + "/.local/state/memory.json"
readonly property bool rememberLastSession: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_SESSION", "DMS_SAVE_SESSION"], true)
readonly property bool rememberLastUser: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_USER", "DMS_SAVE_USERNAME"], true)
property string lastSessionId: ""
property string lastSessionDesktopId: ""
property string lastSessionExec: ""
property string lastSuccessfulUser: ""
property bool memoryReady: false
property bool isLightMode: false
property bool nightModeEnabled: false
Component.onCompleted: {
loadMemory();
loadSessionConfig();
}
function loadMemory() {
parseMemory(memoryFileView.text());
}
function loadSessionConfig() {
parseSessionConfig(sessionConfigFileView.text());
}
function parseSessionConfig(content) {
try {
if (content && content.trim()) {
const config = JSON.parse(content);
isLightMode = config.isLightMode !== undefined ? config.isLightMode : false;
nightModeEnabled = config.nightModeEnabled !== undefined ? config.nightModeEnabled : false;
}
} catch (e) {
log.warn("Failed to parse greeter session config:", e);
}
}
function parseMemory(content) {
try {
if (!content || !content.trim())
return;
const memory = JSON.parse(content);
lastSessionId = rememberLastSession ? (memory.lastSessionId || "") : "";
lastSessionDesktopId = rememberLastSession ? (memory.lastSessionDesktopId || "") : "";
lastSessionExec = rememberLastSession ? (memory.lastSessionExec || "") : "";
lastSuccessfulUser = rememberLastUser ? (memory.lastSuccessfulUser || "") : "";
if (!rememberLastSession || !rememberLastUser)
saveMemory();
} catch (e) {
log.warn("Failed to parse greetd memory:", e);
}
}
function saveMemory() {
let memory = {};
if (rememberLastSession && lastSessionId)
memory.lastSessionId = lastSessionId;
if (rememberLastSession && lastSessionDesktopId)
memory.lastSessionDesktopId = lastSessionDesktopId;
if (rememberLastUser && lastSuccessfulUser)
memory.lastSuccessfulUser = lastSuccessfulUser;
memoryFileView.setText(JSON.stringify(memory, null, 2));
}
function setLastSession(id, desktopId) {
if (!rememberLastSession) {
if (lastSessionId !== "" || lastSessionDesktopId !== "" || lastSessionExec !== "") {
lastSessionId = "";
lastSessionDesktopId = "";
lastSessionExec = "";
saveMemory();
}
return;
}
lastSessionId = id || "";
lastSessionDesktopId = desktopId || "";
lastSessionExec = "";
if (!lastSessionId)
lastSessionDesktopId = "";
saveMemory();
}
function setLastSessionId(id) {
setLastSession(id, lastSessionDesktopId);
}
function setLastSessionDesktopId(id) {
setLastSession(lastSessionId, id);
}
function setLastSessionExec(exec) {
if (!rememberLastSession) {
if (lastSessionExec !== "") {
lastSessionExec = "";
saveMemory();
}
return;
}
if (lastSessionExec !== "") {
lastSessionExec = "";
saveMemory();
}
}
function setLastSuccessfulUser(username) {
if (!rememberLastUser) {
if (lastSuccessfulUser !== "") {
lastSuccessfulUser = "";
saveMemory();
}
return;
}
lastSuccessfulUser = username || "";
saveMemory();
}
FileView {
id: memoryFileView
path: root.memoryFile
blockLoading: false
blockWrites: false
atomicWrites: true
watchChanges: false
printErrors: false
onLoaded: {
parseMemory(memoryFileView.text());
root.memoryReady = true;
}
onLoadFailed: {
root.memoryReady = true;
}
}
FileView {
id: sessionConfigFileView
path: root.sessionConfigPath
blockLoading: false
blockWrites: true
atomicWrites: false
watchChanges: false
printErrors: false
onLoaded: {
parseSessionConfig(sessionConfigFileView.text());
}
onLoadFailed: {}
}
}
@@ -1,26 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import "GreetdEnv.js" as GreetdEnv
Singleton {
id: root
readonly property var _envRememberLastSession: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_SESSION", "DMS_SAVE_SESSION"], undefined)
readonly property var _envRememberLastUser: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_USER", "DMS_SAVE_USERNAME"], undefined)
readonly property bool settingsLoaded: SettingsData._hasLoaded
readonly property bool rememberLastSession: _envRememberLastSession !== undefined ? _envRememberLastSession : SettingsData.greeterRememberLastSession
readonly property bool rememberLastUser: _envRememberLastUser !== undefined ? _envRememberLastUser : SettingsData.greeterRememberLastUser
function setConfigBaseDir(dir) {
SettingsData.setGreeterSettingsBaseDir(dir);
}
function resetConfigBaseDir() {
SettingsData.resetGreeterSettingsBaseDir();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,36 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
Singleton {
id: root
property string passwordBuffer: ""
property string username: ""
property string usernameInput: ""
property bool showPasswordInput: false
property string selectedSession: ""
property string selectedSessionPath: ""
property string selectedSessionDesktopId: ""
property string pamState: ""
property bool unlocking: false
property var sessionList: []
property var sessionExecs: []
property var sessionPaths: []
property var sessionDesktopIds: []
property int currentSessionIndex: 0
property bool sessionManuallySelected: false
property var availableUsers: []
property int selectedUserIndex: -1
function reset() {
showPasswordInput = false;
username = "";
usernameInput = "";
passwordBuffer = "";
pamState = "";
selectedUserIndex = -1;
}
}
@@ -1,33 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
Variants {
model: Quickshell.screens
PanelWindow {
id: root
property var modelData
screen: modelData
anchors {
left: true
right: true
top: true
bottom: true
}
exclusionMode: ExclusionMode.Normal
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
color: "transparent"
GreeterContent {
anchors.fill: parent
screenName: root.screen?.name ?? ""
}
}
}
@@ -1,242 +0,0 @@
import QtQuick
import QtQuick.Layouts
import qs.Common
import qs.Services
import qs.DCommon.Widgets
Item {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property bool expanded: false
property int maxExpandedHeight: 400
property bool autoLoginVisible: false
property bool autoLoginChecked: false
property bool manualEntryVisible: false
signal userSelected(string username)
signal toggleRequested
signal autoLoginToggled
signal manualEntryRequested
readonly property int rowHeight: 52
readonly property int collapsedBarHeight: 36
readonly property int actionRowHeight: 44
readonly property int userListFullHeight: {
const count = GreeterUsersService.users.length;
if (count === 0)
return 0;
return count * rowHeight + Math.max(0, count - 1) * Theme.spacingXS;
}
readonly property int manualEntryBlockHeight: manualEntryVisible ? actionRowHeight + Theme.spacingXS : 0
readonly property int autoLoginBlockHeight: autoLoginVisible ? actionRowHeight + Theme.spacingXS : 0
readonly property int expandedContentHeight: {
if (!expanded)
return 0;
if (GreeterUsersService.users.length === 0 && !autoLoginVisible && !manualEntryVisible)
return 0;
return Math.min(maxExpandedHeight, userListFullHeight + manualEntryBlockHeight + autoLoginBlockHeight);
}
function encodeFileUrl(path) {
if (!path)
return "";
return "file://" + path.split("/").map(s => encodeURIComponent(s)).join("/");
}
function profileImageSource(username) {
const path = GreeterUsersService.profileImagePath(username);
if (path)
return encodeFileUrl(path);
return "";
}
implicitHeight: expanded ? expandedContentHeight : collapsedBarHeight
implicitWidth: parent ? parent.width : 320
Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: collapsedBarHeight
visible: !expanded
RowLayout {
anchors.fill: parent
spacing: Theme.spacingM
StyledText {
Layout.fillWidth: true
text: GreeterState.username ? GreeterUsersService.optionLabel(GreeterState.username) : I18n.tr("Select user...", "greeter user picker placeholder")
color: GreeterState.username ? Theme.surfaceText : Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeMedium
elide: Text.ElideRight
}
DIcon {
Layout.alignment: Qt.AlignVCenter
name: "expand_more"
size: 20
color: Theme.surfaceVariantText
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleRequested()
}
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: root.expandedContentHeight
visible: expanded
spacing: Theme.spacingXS
DListView {
id: userListView
width: parent.width
height: parent.height - root.manualEntryBlockHeight - root.autoLoginBlockHeight
clip: true
interactive: contentHeight > height
spacing: Theme.spacingXS
model: GreeterUsersService.users
delegate: Rectangle {
id: userRow
required property var modelData
required property int index
width: userListView.width
height: root.rowHeight
radius: Theme.cornerRadius
color: userRowMouse.containsMouse ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0)
border.color: GreeterState.username === userRow.modelData.username ? Theme.primary : Theme.withAlpha(Theme.primary, 0)
border.width: GreeterState.username === userRow.modelData.username ? 1 : 0
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingM
Item {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
DCircularImage {
anchors.fill: parent
ringWidth: Theme.avatarRingWidth
ringColor: Theme.avatarRingColor
imageSource: root.profileImageSource(userRow.modelData.username)
fallbackIcon: "material:person"
}
}
StyledText {
Layout.fillWidth: true
text: GreeterUsersService.optionLabel(userRow.modelData.username)
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
elide: Text.ElideRight
}
}
MouseArea {
id: userRowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.userSelected(userRow.modelData.username)
}
}
}
Rectangle {
width: parent.width
height: root.actionRowHeight
visible: root.manualEntryVisible
radius: Theme.cornerRadius
color: manualEntryRowMouse.containsMouse ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingM
DIcon {
Layout.alignment: Qt.AlignVCenter
name: "person_add"
size: 20
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: I18n.tr("Not listed?", "greeter link to switch to manual username entry")
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
elide: Text.ElideRight
}
}
MouseArea {
id: manualEntryRowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.manualEntryRequested()
}
}
Rectangle {
width: parent.width
height: root.actionRowHeight
visible: root.autoLoginVisible
radius: Theme.cornerRadius
color: autoLoginRowMouse.containsMouse ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingM
DIcon {
Layout.alignment: Qt.AlignVCenter
name: root.autoLoginChecked ? "check_box" : "check_box_outline_blank"
size: 20
color: root.autoLoginChecked ? Theme.primary : Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: I18n.tr("Auto-login")
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
elide: Text.ElideRight
}
}
MouseArea {
id: autoLoginRowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.autoLoginToggled()
}
}
}
}
@@ -1,46 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("GreeterUserTheme")
readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
property string activeUsername: ""
function userCacheDir(username) {
if (!username)
return "";
return greetCfgDir + "/users/" + username;
}
function applyForUser(username) {
const name = (username || "").trim();
activeUsername = name;
if (!name) {
applyDefault();
return;
}
const dir = userCacheDir(name);
if (typeof GreeterUsersService !== "undefined" && GreeterUsersService.hasSyncedTheme(name)) {
Theme.setGreeterColorsBaseDir(dir);
SessionData.setGreeterSessionBaseDir(dir);
GreetdSettings.setConfigBaseDir(dir);
return;
}
applyDefault();
}
function applyDefault() {
activeUsername = "";
Theme.resetGreeterColorsBaseDir();
SessionData.resetGreeterSessionBaseDir();
GreetdSettings.resetConfigBaseDir();
}
}
-7
View File
@@ -1,7 +0,0 @@
# Embedded Greetd module
TODO: remove when dank-greeter is available in the Arch extra repos.
The greeter moved to [dank-greeter](https://github.com/duxlabs-dev/dux-greeter). This embedded copy exists only for archinstall compatibility: the `niri - Dux Shell` archinstall profile writes an `/etc/greetd/config.toml` that launches `Modules/Greetd/assets/dms-greeter` from the packaged DMS tree, and dank-greeter is not installable from the official Arch repos yet.
Do not extend this module. New greeter work goes to dank-greeter. Installing greetd-dms-greeter-bin from the AUR and running `dms-greeter sync` migrates a system off this embedded copy.
@@ -1,506 +0,0 @@
#!/bin/bash
set -e
COMPOSITOR=""
COMPOSITOR_CONFIG=""
DMS_PATH="dms-greeter"
CACHE_DIR="/var/cache/dms-greeter"
REMEMBER_LAST_SESSION=""
REMEMBER_LAST_USER=""
DEBUG_MODE=0
show_help() {
cat << EOF
dms-greeter - Dux Shell greeter launcher
Usage: dms-greeter --command COMPOSITOR [OPTIONS]
Required:
--command COMPOSITOR Compositor to use (niri, hyprland, sway, scroll, miracle, mango, or labwc)
Options:
-C, --config PATH Custom compositor config file
-p, --path PATH DMS path (config name or absolute path)
(default: dms-greeter)
--cache-dir PATH Cache directory for greeter data
(default: /var/cache/dms-greeter)
--remember-last-session BOOL
Persist selected session to greeter memory
(BOOL: true/false, default: from settings.json)
--remember-last-user BOOL
Persist last successful username to greeter memory
(BOOL: true/false, default: from settings.json)
--no-save-session Alias for --remember-last-session false
--no-save-username Alias for --remember-last-user false
--debug Enable verbose startup logging to stderr
-h, --help Show this help message
Examples:
dms-greeter --command niri
dms-greeter --command hyprland -C /etc/greetd/custom-hypr.lua
dms-greeter --command sway -p /home/user/.config/quickshell/custom-dms
dms-greeter --command scroll -p /home/user/.config/quickshell/custom-dms
dms-greeter --command niri --cache-dir /tmp/dmsgreeter
dms-greeter --command niri --no-save-session --no-save-username
dms-greeter --command mango
dms-greeter --command labwc
EOF
}
require_command() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: required command '$cmd' was not found in PATH" >&2
exit 1
fi
}
normalize_bool_flag() {
local flag_name="$1"
local value="$2"
local normalized="${value,,}"
case "$normalized" in
1|true|yes|on)
echo "1"
;;
0|false|no|off)
echo "0"
;;
*)
echo "Error: $flag_name must be true/false (or 1/0, yes/no, on/off)" >&2
exit 1
;;
esac
}
is_hyprland_lua_config() {
local config_file="$1"
case "$config_file" in
*.lua)
return 0
;;
esac
if [[ -f "$config_file" ]] && grep -qE '(^|[^[:alnum:]_])hl\.' "$config_file"; then
return 0
fi
return 1
}
clear_vt() {
local clear_sequence=$'\033[2J\033[H\033[3J\033[?25l'
# Clear retained console text on the controlling VT.
if printf '%s' "$clear_sequence" >/dev/tty 2>/dev/null; then
return
fi
if [[ -t 1 ]]; then
printf '%s' "$clear_sequence"
fi
}
exec_compositor() {
local log_tag="$1"
shift
if [[ "$DEBUG_MODE" == "1" ]]; then
exec "$@"
fi
clear_vt
if command -v systemd-cat >/dev/null 2>&1; then
exec "$@" > >(systemd-cat -t "dms-greeter/$log_tag" -p info 2>/dev/null) 2>&1
fi
# Preserve diagnostics on systems without journald.
exec "$@" >>"$CACHE_DIR/$log_tag.log" 2>&1
}
while [[ $# -gt 0 ]]; do
case $1 in
--command)
COMPOSITOR="$2"
shift 2
;;
-C|--config)
COMPOSITOR_CONFIG="$2"
shift 2
;;
-p|--path)
DMS_PATH="$2"
shift 2
;;
--cache-dir)
CACHE_DIR="$2"
shift 2
;;
--remember-last-session)
REMEMBER_LAST_SESSION="$2"
shift 2
;;
--remember-last-user)
REMEMBER_LAST_USER="$2"
shift 2
;;
--no-save-session)
REMEMBER_LAST_SESSION="0"
shift
;;
--no-save-username)
REMEMBER_LAST_USER="0"
shift
;;
--debug)
DEBUG_MODE=1
shift
;;
-h|--help)
show_help
exit 0
;;
*)
echo "Unknown option: $1" >&2
show_help
exit 1
;;
esac
done
if [[ -z "$COMPOSITOR" ]]; then
echo "Error: --command COMPOSITOR is required" >&2
show_help
exit 1
fi
locate_dms_config() {
local config_name="$1"
local search_paths=()
local config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
search_paths+=("$config_home/quickshell/$config_name")
search_paths+=("/usr/share/quickshell/$config_name")
local config_dirs="${XDG_CONFIG_DIRS:-/etc/xdg}"
IFS=':' read -ra dirs <<< "$config_dirs"
for dir in "${dirs[@]}"; do
if [[ -n "$dir" ]]; then
search_paths+=("$dir/quickshell/$config_name")
fi
done
for path in "${search_paths[@]}"; do
if [[ -f "$path/shell.qml" ]]; then
echo "$path"
return 0
fi
done
return 1
}
export XDG_SESSION_TYPE=wayland
export QT_QPA_PLATFORM=wayland
export QT_WAYLAND_DISABLE_WINDOWDECORATION=1
export EGL_PLATFORM=gbm
export DMS_RUN_GREETER=1
if [[ ! -d "$CACHE_DIR" ]]; then
echo "Error: cache directory '$CACHE_DIR' does not exist." >&2
echo " Run 'dms greeter sync' to initialize it, or pass --cache-dir to an existing directory." >&2
exit 1
fi
export DMS_GREET_CFG_DIR="$CACHE_DIR"
if [[ -n "$REMEMBER_LAST_SESSION" ]]; then
DMS_GREET_REMEMBER_LAST_SESSION=$(normalize_bool_flag "--remember-last-session" "$REMEMBER_LAST_SESSION")
export DMS_GREET_REMEMBER_LAST_SESSION
if [[ "$DMS_GREET_REMEMBER_LAST_SESSION" == "1" ]]; then
DMS_SAVE_SESSION=true
else
DMS_SAVE_SESSION=false
fi
export DMS_SAVE_SESSION
fi
if [[ -n "$REMEMBER_LAST_USER" ]]; then
DMS_GREET_REMEMBER_LAST_USER=$(normalize_bool_flag "--remember-last-user" "$REMEMBER_LAST_USER")
export DMS_GREET_REMEMBER_LAST_USER
if [[ "$DMS_GREET_REMEMBER_LAST_USER" == "1" ]]; then
DMS_SAVE_USERNAME=true
else
DMS_SAVE_USERNAME=false
fi
export DMS_SAVE_USERNAME
fi
export HOME="$CACHE_DIR"
export XDG_STATE_HOME="$CACHE_DIR/.local/state"
export XDG_DATA_HOME="$CACHE_DIR/.local/share"
export XDG_CACHE_HOME="$CACHE_DIR/.cache"
# Fallback runtime dir for systems without logind/pam_rundir (e.g. Void+seatd),
# where Wayland compositors abort with "RuntimeDirNotSet". Guarded so logind
# systems keep their own.
if [[ -z "${XDG_RUNTIME_DIR:-}" || ! -d "${XDG_RUNTIME_DIR:-}" ]]; then
export XDG_RUNTIME_DIR="$CACHE_DIR/run"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
fi
# Keep greeter VT clean by default; callers can override via env or --debug.
if [[ -z "${RUST_LOG:-}" ]]; then
export RUST_LOG=warn
fi
if [[ -z "${NIRI_LOG:-}" ]]; then
export NIRI_LOG=warn
fi
# Export the user's configured cursor (from synced settings.json) for the
# compositor and greeter shell, but only if that theme is installed system-wide.
apply_user_cursor() {
command -v jq >/dev/null 2>&1 || return 0
local settings="$DMS_GREET_CFG_DIR/settings.json"
[[ -f "$settings" ]] || return 0
local theme size
theme=$(jq -r '.cursorSettings.theme // empty' "$settings" 2>/dev/null)
size=$(jq -r '.cursorSettings.size // empty' "$settings" 2>/dev/null)
[[ -n "$theme" && "$theme" != "System Default" ]] || return 0
local dirs=()
IFS=':' read -ra data_dirs <<< "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
local d
for d in "${data_dirs[@]}"; do
[[ -n "$d" ]] && dirs+=("$d/icons")
done
dirs+=(/run/current-system/sw/share/icons /usr/share/icons /usr/local/share/icons)
for d in "${dirs[@]}"; do
if [[ -d "$d/$theme/cursors" ]]; then
export XCURSOR_PATH="$d${XCURSOR_PATH:+:$XCURSOR_PATH}"
export XCURSOR_THEME="$theme"
[[ -n "$size" ]] && export XCURSOR_SIZE="$size"
return 0
fi
done
}
apply_user_cursor
if command -v qs >/dev/null 2>&1; then
QS_BIN="qs"
elif command -v quickshell >/dev/null 2>&1; then
QS_BIN="quickshell"
else
echo "Error: neither 'qs' nor 'quickshell' was found in PATH" >&2
exit 1
fi
QS_CMD="$QS_BIN"
if [[ "$DMS_PATH" == /* ]]; then
QS_CMD="$QS_BIN -p $DMS_PATH"
else
RESOLVED_PATH=$(locate_dms_config "$DMS_PATH")
if [[ $? -eq 0 && -n "$RESOLVED_PATH" ]]; then
if [[ "$DEBUG_MODE" == "1" ]]; then
echo "Located DMS config at: $RESOLVED_PATH" >&2
fi
QS_CMD="$QS_BIN -p $RESOLVED_PATH"
else
echo "Error: Could not find DMS config '$DMS_PATH' (shell.qml) in any valid config path" >&2
exit 1
fi
fi
case "$COMPOSITOR" in
niri)
require_command "niri"
if [[ -z "$COMPOSITOR_CONFIG" ]]; then
TEMP_CONFIG=$(mktemp)
# base/default config
cat > "$TEMP_CONFIG" << NIRI_EOF
hotkey-overlay {
skip-at-startup
}
environment {
DMS_RUN_GREETER "1"
}
gestures {
hot-corners {
off
}
}
layout {
background-color "#000000"
}
NIRI_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
else
TEMP_CONFIG=$(mktemp)
cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG"
fi
# Append includes if override_files exist
OVERRIDE_FILES=(
/usr/share/greetd/niri_overrides.kdl # for building into a system image
/etc/greetd/niri_overrides.kdl # for local overrides
)
for override_file in "${OVERRIDE_FILES[@]}"; do
if [[ -e "$override_file" ]]; then
# TODO: maybe move to optional=true when Niri Next drops, so we can avoid these checks ;-)
cat >> "$TEMP_CONFIG" << NIRI_EOF
include "$override_file"
NIRI_EOF
fi
done
# Append spawn command
cat >> "$TEMP_CONFIG" << NIRI_EOF
spawn-at-startup "sh" "-c" "$QS_CMD; niri msg action quit --skip-confirmation"
NIRI_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
exec_compositor "niri" niri -c "$COMPOSITOR_CONFIG"
;;
hyprland)
if ! command -v start-hyprland >/dev/null 2>&1 && ! command -v Hyprland >/dev/null 2>&1; then
echo "Error: neither 'start-hyprland' nor 'Hyprland' was found in PATH" >&2
exit 1
fi
if [[ -n "$COMPOSITOR_CONFIG" ]] && is_hyprland_lua_config "$COMPOSITOR_CONFIG"; then
TEMP_CONFIG=$(mktemp --suffix=.lua)
cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG"
cat >> "$TEMP_CONFIG" << HYPRLAND_LUA_EOF
hl.on("hyprland.start", function()
hl.exec_cmd("sh -c \"$QS_CMD; hyprctl dispatch 'hl.dsp.exit()'\"")
end)
HYPRLAND_LUA_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
elif [[ -z "$COMPOSITOR_CONFIG" ]]; then
TEMP_CONFIG=$(mktemp --suffix=.lua)
cat > "$TEMP_CONFIG" << HYPRLAND_LUA_EOF
hl.env("DMS_RUN_GREETER", "1")
hl.config({
misc = {
disable_hyprland_logo = true,
},
})
hl.on("hyprland.start", function()
hl.exec_cmd("sh -c \"$QS_CMD; hyprctl dispatch 'hl.dsp.exit()'\"")
end)
HYPRLAND_LUA_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
else
# hyprlang stays usable until 0.57 ships; legacy dispatch is the only form a .conf session accepts
TEMP_CONFIG=$(mktemp)
cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG"
cat >> "$TEMP_CONFIG" << HYPRLAND_EOF
exec-once = sh -c "$QS_CMD; hyprctl dispatch exit"
HYPRLAND_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
fi
if command -v start-hyprland >/dev/null 2>&1; then
exec_compositor "hyprland" start-hyprland -- --config "$COMPOSITOR_CONFIG"
else
exec_compositor "hyprland" Hyprland -c "$COMPOSITOR_CONFIG"
fi
;;
sway)
require_command "sway"
if [[ -z "$COMPOSITOR_CONFIG" ]]; then
TEMP_CONFIG=$(mktemp)
cat > "$TEMP_CONFIG" << SWAY_EOF
exec "$QS_CMD; swaymsg exit"
SWAY_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
else
TEMP_CONFIG=$(mktemp)
cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG"
cat >> "$TEMP_CONFIG" << SWAY_EOF
exec "$QS_CMD; swaymsg exit"
SWAY_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
fi
exec_compositor "sway" sway --unsupported-gpu -c "$COMPOSITOR_CONFIG"
;;
scroll)
require_command "scroll"
if [[ -z "$COMPOSITOR_CONFIG" ]]; then
TEMP_CONFIG=$(mktemp)
cat > "$TEMP_CONFIG" << SCROLL_EOF
exec "$QS_CMD; scrollmsg exit"
SCROLL_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
else
TEMP_CONFIG=$(mktemp)
cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG"
cat >> "$TEMP_CONFIG" << SCROLL_EOF
exec "$QS_CMD; scrollmsg exit"
SCROLL_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
fi
exec_compositor "scroll" scroll -c "$COMPOSITOR_CONFIG"
;;
miracle|miracle-wm)
require_command "miracle-wm"
if [[ -z "$COMPOSITOR_CONFIG" ]]; then
TEMP_CONFIG=$(mktemp)
cat > "$TEMP_CONFIG" << MIRACLE_EOF
exec "$QS_CMD; miraclemsg exit"
MIRACLE_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
else
TEMP_CONFIG=$(mktemp)
cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG"
cat >> "$TEMP_CONFIG" << MIRACLE_EOF
exec "$QS_CMD; miraclemsg exit"
MIRACLE_EOF
COMPOSITOR_CONFIG="$TEMP_CONFIG"
fi
exec_compositor "miracle" miracle-wm -c "$COMPOSITOR_CONFIG"
;;
labwc)
require_command "labwc"
if [[ -n "$COMPOSITOR_CONFIG" ]]; then
exec_compositor "labwc" labwc --config "$COMPOSITOR_CONFIG" --session "$QS_CMD"
else
exec_compositor "labwc" labwc --session "$QS_CMD"
fi
;;
mango|mangowc)
require_command "mango"
if [[ -n "$COMPOSITOR_CONFIG" ]]; then
exec_compositor "mango" mango -c "$COMPOSITOR_CONFIG" -s "$QS_CMD && mmsg dispatch quit"
else
# Without -c mango falls back to /etc/mango/config.conf, whose spawn/quit binds work at the login screen.
TEMP_CONFIG=$(mktemp)
exec_compositor "mango" mango -c "$TEMP_CONFIG" -s "$QS_CMD && mmsg dispatch quit"
fi
;;
*)
echo "Error: Unsupported compositor: $COMPOSITOR" >&2
echo "Supported compositors: niri, hyprland, sway, scroll, miracle, mango, labwc" >&2
exit 1
;;
esac

Some files were not shown because too many files have changed in this diff Show More