1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-08 14:38:30 -04:00

Compare commits

..

1 Commits

Author SHA1 Message Date
bbedward edcad60786 rebase KDE platform compat 2026-07-21 11:41:04 -04:00
332 changed files with 13314 additions and 28341 deletions
-6
View File
@@ -14,12 +14,6 @@ git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.
git submodule update --init
```
To have `git pull` keep the submodule in sync automatically (moving it to the commit this repo points at, no separate `git submodule update` step), set:
```bash
git config submodule.recurse true
```
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
```bash
+1 -5
View File
@@ -101,9 +101,7 @@ MPRIS player controls, calendar sync, weather widgets, and clipboard history wit
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter).
**Plugin System**
Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
`~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and
their exact Git commits, so the same plugins can be reproduced on another machine.
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
## Supported Compositors
@@ -122,8 +120,6 @@ dms ipc call audio setvolume 50
dms ipc call wallpaper set /path/to/image.jpg
dms brightness list # List available displays
dms plugins search # Browse plugin registry
dms plugins lock # Refresh the portable plugin lockfile
dms plugins restore ~/plugins.lock.json
```
[Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
+1 -1
View File
@@ -74,7 +74,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
- `dms run [-d]` - Start shell (optionally as daemon)
- `dms restart` / `dms kill` - Manage running processes
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles
- `dms plugins [install|browse|search]` - Plugin management
- `dms brightness [list|set]` - Control display/monitor brightness
- `dms color pick` - Native color picker (see below)
- `dms update` - Update DMS and dependencies (disabled in distro packages)
-67
View File
@@ -51,8 +51,6 @@ func init() {
})
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
pluginsLockCmd.Flags().StringP("output", "o", "", "Also write the lockfile to this path")
pluginsRestoreCmd.Flags().Bool("prune", false, "Remove managed plugins that are not in the lockfile")
}
var debugSrvCmd = &cobra.Command{
@@ -180,36 +178,6 @@ var pluginsUpdateCmd = &cobra.Command{
},
}
var pluginsLockCmd = &cobra.Command{
Use: "lock",
Short: "Record installed plugins and their exact revisions",
Long: "Write a portable plugins.lock.json containing every managed user plugin and its current Git commit.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
if err := lockPluginsCLI(output); err != nil {
log.Fatalf("Error writing plugin lockfile: %v", err)
}
},
}
var pluginsRestoreCmd = &cobra.Command{
Use: "restore [lockfile]",
Short: "Restore plugins from exact revisions in a lockfile",
Long: "Install or reset all plugins in a portable plugins.lock.json. Existing managed plugins are retained unless --prune is specified.",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
path := ""
if len(args) == 1 {
path = args[0]
}
prune, _ := cmd.Flags().GetBool("prune")
if err := restorePluginsCLI(path, prune); err != nil {
log.Fatalf("Error restoring plugins: %v", err)
}
},
}
func runVersion(cmd *cobra.Command, args []string) {
fmt.Printf("%s\n", formatVersion(Version))
}
@@ -465,40 +433,6 @@ func installPluginCLI(idOrName string) error {
return nil
}
func lockPluginsCLI(outputPath string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
warnings, err := manager.WriteCurrentLockfile(outputPath)
if err != nil {
return err
}
for _, warning := range warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
}
fmt.Printf("Plugin lockfile written: %s\n", manager.GetLockfilePath())
if outputPath != "" && outputPath != manager.GetLockfilePath() {
fmt.Printf("Plugin lockfile exported: %s\n", outputPath)
}
return nil
}
func restorePluginsCLI(path string, prune bool) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
if err := manager.RestoreFromLockfile(path, prune); err != nil {
return err
}
if path == "" {
path = manager.GetLockfilePath()
}
fmt.Printf("Plugins restored from lockfile: %s\n", path)
return nil
}
func getAvailablePluginIDs() []string {
registry, err := plugins.NewRegistry()
if err != nil {
@@ -770,7 +704,6 @@ func getCommonCommands() []*cobra.Command {
ipcCmd,
debugSrvCmd,
pluginsCmd,
registryCmd,
dank16Cmd,
brightnessCmd,
dpmsCmd,
-1
View File
@@ -40,7 +40,6 @@ var resolveIncludeCmd = &cobra.Command{
"layout.kdl",
"outputs.kdl",
"binds.kdl",
"input.kdl",
"cursor.conf",
"layout.conf",
"outputs.conf",
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
var registryCmd = &cobra.Command{
Use: "registry",
Short: "Manage plugin and theme registries",
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
}
var registryListCmd = &cobra.Command{
Use: "list",
Short: "List configured registries",
Run: func(cmd *cobra.Command, args []string) {
for _, s := range registries.Load(afero.NewOsFs()) {
suffix := ""
if s.Official() {
suffix = " (official)"
}
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
}
},
}
var registryAddCmd = &cobra.Command{
Use: "add <name> <url>",
Short: "Add a registry",
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
log.Fatalf("Error adding registry: %v", err)
}
fmt.Printf("Registry added: %s\n", args[0])
},
}
var registryRemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove a registry",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for _, s := range registries.Load(afero.NewOsFs()) {
if !s.Official() {
names = append(names, s.Name)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
log.Fatalf("Error removing registry: %v", err)
}
fmt.Printf("Registry removed: %s\n", args[0])
},
}
-4
View File
@@ -143,10 +143,6 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig },
},
"input": {
niriFile: "input.kdl",
niriContent: func(_ string) string { return config.NiriInputConfig },
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
+1 -2
View File
@@ -15,8 +15,7 @@ func init() {
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
+1 -1
View File
@@ -14,7 +14,7 @@ var Version = "dev"
func init() {
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
+1 -1
View File
@@ -69,7 +69,7 @@ require (
)
require (
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
+2 -2
View File
@@ -1,7 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b h1:UwX1H4BkzazL7ips9ljnHzBXGttYARcIi5Njj9aqIt4=
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b/go.mod h1:xt8RldAfti0QCWidwYIzsSSoJWsE61WgEhTu2H9UpD4=
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599 h1:hxy5SRGREsQ6v4xUOxWnWVPWD6ZU+H0jA+LHcTwXbCg=
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599/go.mod h1:7p7cfydr4WM1G6eOPFlANXF3IV5du3FoA4CbDPprHAo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
-1
View File
@@ -264,7 +264,6 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"layout.kdl", NiriLayoutConfig},
{"alttab.kdl", NiriAlttabConfig},
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"input.kdl", NiriInputConfig},
{"outputs.kdl", ""},
{"cursor.kdl", ""},
{"windowrules.kdl", ""},
@@ -1,15 +0,0 @@
// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
input {
mouse {
accel-speed 0.0
}
touchpad {
tap
accel-speed 0.0
natural-scroll
}
}
+25 -2
View File
@@ -34,6 +34,31 @@ input {
numlock
}
// Next sections include libinput settings.
// Omitting settings disables them, or leaves them at their default values.
// All commented-out settings here are examples, not defaults.
touchpad {
// off
tap
// dwt
// dwtp
// drag false
// drag-lock
natural-scroll
// accel-speed 0.2
// accel-profile "flat"
// scroll-method "two-finger"
// disabled-on-external-mouse
}
mouse {
// off
// natural-scroll
// accel-speed 0.2
// accel-profile "flat"
// scroll-method "no-scroll"
}
trackpoint {
// off
// natural-scroll
@@ -52,7 +77,6 @@ input {
// Setting max-scroll-amount="0%" makes it work only on windows already fully on screen.
// focus-follows-mouse max-scroll-amount="0%"
}
// You can configure outputs by their name, which you can find
// by running `niri msg outputs` while inside a niri instance.
// The built-in laptop monitor is usually called "eDP-1".
@@ -247,4 +271,3 @@ include optional=true "dms/alttab.kdl"
include optional=true "dms/binds.kdl"
include optional=true "dms/outputs.kdl"
include optional=true "dms/cursor.kdl"
include optional=true "dms/input.kdl"
-3
View File
@@ -16,6 +16,3 @@ var NiriAlttabConfig string
//go:embed embedded/niri-binds.kdl
var NiriBindsConfig string
//go:embed embedded/niri-input.kdl
var NiriInputConfig string
+7 -22
View File
@@ -313,7 +313,6 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
Lf, af, bf := cf.Lab()
Lf *= 100.0
dir := 1.0
if isLightMode {
@@ -342,7 +341,6 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
origL, af, bf := cf.Lab()
origL *= 100.0
var darkerResult, lighterResult string
darkerL, lighterL := origL, origL
@@ -422,24 +420,6 @@ func blendHue(base, target, factor float64) float64 {
return result
}
// color8 sits a fixed L* offset from the background so it keeps its dim role
// regardless of primary brightness (conventional palettes put ANSI bright
// black ~2-2.5:1 from the background, e.g. catppuccin-mocha #585b70 on #1e1e2e)
func DeriveDim(bgHex string, hue, sat float64, isLight bool) string {
offset := 22.0
if isLight {
offset = -offset
}
bgL := getLstar(bgHex)
targetL := math.Max(0, math.Min(100, bgL+offset))
tint := HSVToRGB(HSV{H: hue, S: sat, V: 0.5})
c := colorful.Color{R: tint.R, G: tint.G, B: tint.B}
_, af, bf := c.Lab()
return labToHex(targetL, af, bf)
}
func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
@@ -520,7 +500,10 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := baseVal * 0.28
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
gray8S := baseSat * 0.05
gray8V := baseVal * 0.85
dimTarget := secondaryTarget * 0.5
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
brightRedS := math.Min(baseSat*1.0, 1.0)
brightRedV := math.Min(baseVal*1.2, 1.0)
@@ -576,7 +559,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := math.Min(baseVal*1.05, 1.0)
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
gray8S := baseSat * 0.15
gray8V := baseVal * 0.65
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
brightRedS := math.Min(baseSat*0.75, 1.0)
brightRedV := math.Min(baseVal*1.35, 1.0)
-70
View File
@@ -679,73 +679,3 @@ func TestContrastAlgorithmComparison(t *testing.T) {
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
}
func TestEnsureContrastDPSLightModeStaysLight(t *testing.T) {
tests := []struct {
name string
result string
bg string
minLstar float64
}{
{
name: "bidirectional adjustment",
result: EnsureContrastDPSBidirectional("#d0ccc6", "#f8f8f8", 17.5, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
{
name: "lstar adjustment",
result: EnsureContrastDPSLstar("#c0c0c0", "#f8f8f8", 30.0, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lstar := getLstar(tt.result)
if lstar < tt.minLstar {
t.Errorf("result %s has L* %.2f on light bg %s, expected >= %.2f (collapsed to near-black)",
tt.result, lstar, tt.bg, tt.minLstar)
}
})
}
}
func TestGeneratePaletteColor8Dim(t *testing.T) {
hues := []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#ef4444"}
for _, base := range hues {
t.Run(base, func(t *testing.T) {
palette := GeneratePalette(base, PaletteOptions{IsLight: false, UseDPS: true})
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio < 1.5 || bgRatio > 3.0 {
t.Errorf("Color8 %s vs bg %s ratio %.2f, expected 1.5-3.0 (bright black stays near bg)",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
sepRatio := ContrastRatio(palette.Color4.Hex, palette.Color8.Hex)
if sepRatio < 2.0 {
t.Errorf("Color4 %s vs Color8 %s ratio %.2f, expected >= 2.0 (blue must not collide with bright black)",
palette.Color4.Hex, palette.Color8.Hex, sepRatio)
}
})
}
}
func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
palette := GeneratePalette("#f59e0b", PaletteOptions{IsLight: true, UseDPS: true})
lstar := getLstar(palette.Color8.Hex)
if lstar < 60.0 {
t.Errorf("light mode Color8 %s has L* %.2f, expected >= 60 (dim grey, not near-black)",
palette.Color8.Hex, lstar)
}
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio > 3.0 {
t.Errorf("light mode Color8 %s vs bg %s ratio %.2f, expected <= 3.0",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
}
+7 -9
View File
@@ -53,11 +53,6 @@ func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDi
}
}
func emergeInstallArgs(packages []string) []string {
args := []string{"emerge", "--ask=n", "--quiet", "--autounmask-continue=y", "--autounmask-keep-keywords=y", "--autounmask-keep-masks=y"}
return append(args, packages...)
}
func (g *GentooDistribution) getArchKeyword() string {
arch := runtime.GOARCH
switch arch {
@@ -324,7 +319,6 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
}
g.log("Portage tree synced successfully")
args := emergeInstallArgs(missingPkgs)
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
@@ -332,10 +326,12 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
@@ -525,7 +521,8 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
}
}
args := emergeInstallArgs(packageNames)
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, packageNames...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
@@ -716,7 +713,8 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
guruPackages[i] = pkg + "::guru"
}
args := emergeInstallArgs(guruPackages)
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, guruPackages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
+31 -1
View File
@@ -6,7 +6,37 @@ func NewClient() Client {
geoclueClient, err := newGeoClueClient()
if err != nil {
log.Warnf("GeoClue2 unavailable: %v", err)
return newIpClient()
return newSeededIpClient()
}
loc, _ := geoclueClient.GetLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
log.Info("Using GeoClue2 location")
return geoclueClient
}
log.Info("GeoClue2 has no fix yet, seeding with IP location")
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location seed failed: %v", err)
return geoclueClient
}
log.Info("Seeded GeoClue2 with IP location")
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
return geoclueClient
}
func newSeededIpClient() *IpClient {
client := newIpClient()
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location also failed: %v", err)
return client
}
log.Info("Using IP location")
client.currLocation.Latitude = ipLoc.Latitude
client.currLocation.Longitude = ipLoc.Longitude
return client
}
+6 -22
View File
@@ -34,7 +34,6 @@ const (
type GeoClueClient struct {
currLocation *Location
locationMutex sync.RWMutex
seedOnce sync.Once
dbusConn *dbus.Conn
clientPath dbus.ObjectPath
@@ -231,29 +230,14 @@ func (c *GeoClueClient) SeedLocation(loc Location) {
}
func (c *GeoClueClient) GetLocation() (Location, error) {
loc := c.currentLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
return loc, nil
}
c.seedOnce.Do(func() {
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("GeoClue2 has no fix, IP location seed failed: %v", err)
return
}
log.Info("Seeded GeoClue2 with IP location")
c.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
})
return c.currentLocation(), nil
}
func (c *GeoClueClient) currentLocation() Location {
c.locationMutex.RLock()
defer c.locationMutex.RUnlock()
if c.currLocation == nil {
return Location{}
return Location{
Latitude: 0.0,
Longitude: 0.0,
}, nil
}
return *c.currLocation
stateCopy := *c.currLocation
return stateCopy, nil
}
+5 -45
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -225,44 +224,6 @@ func (h *HyprlandProvider) validateAction(action string) error {
return nil
}
var luaExprActionPattern = regexp.MustCompile(`^(function\s*\(|hl\.)`)
// isRawLuaActionText reports that action is a Lua expression to re-emit
// verbatim rather than freeform dispatcher text to wrap for hyprctl. The
// balance check keeps malformed input from corrupting the generated file.
func isRawLuaActionText(action string) bool {
if !luaExprActionPattern.MatchString(action) {
return false
}
depth := 0
var quote byte
escaped := false
for i := 0; i < len(action); i++ {
c := action[i]
switch {
case escaped:
escaped = false
case quote != 0:
switch c {
case '\\':
escaped = true
case quote:
quote = 0
}
case c == '"' || c == '\'':
quote = c
case c == '(':
depth++
case c == ')':
depth--
if depth < 0 {
return false
}
}
}
return depth == 0 && quote == 0 && !escaped
}
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.ensureWritableConfig(); err != nil {
return err
@@ -293,12 +254,11 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
canonicalKey := canonicalHyprlandOverrideKey(key)
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: canonicalKey,
Action: action,
Description: description,
Flags: flags,
Options: options,
RawLuaAction: isRawLuaActionText(action),
Key: canonicalKey,
Action: action,
Description: description,
Flags: flags,
Options: options,
}
return h.writeOverrideBinds(existingBinds)
@@ -463,56 +463,6 @@ func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
}
}
func TestIsRawLuaActionText(t *testing.T) {
cases := []struct {
action string
want bool
}{
{`function() hl.plugin.scrolloverview.overview("toggle") end`, true},
{`hl.dsp.exec_cmd("foo")`, true},
{`hl.dsp.no_op()`, true},
{"workspace 3", false},
{"exec zeditor", false},
{`function() hl.foo( end`, false},
{`function() hl.foo("a) end`, false},
{`hl.foo()) hl.bar((`, false},
}
for _, tc := range cases {
if got := isRawLuaActionText(tc.action); got != tc.want {
t.Errorf("isRawLuaActionText(%q) = %v, want %v", tc.action, got, tc.want)
}
}
}
func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte("-- DMS user keybind overrides\n"), 0o644); err != nil {
t.Fatal(err)
}
provider := NewHyprlandProvider(tmpDir)
rawAction := `function() hl.plugin.scrolloverview.overview("toggle") end`
if err := provider.SetBind("SUPER + G", rawAction, "Toggle overview", nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
if err != nil {
t.Fatal(err)
}
got := string(data)
if !strings.Contains(got, `hl.bind("SUPER + G", `+rawAction) {
t.Fatalf("expected raw Lua action to be written verbatim, got:\n%s", got)
}
if strings.Contains(got, "hyprctl dispatch function") {
t.Fatalf("expected raw Lua action to not be wrapped in hyprctl dispatch, got:\n%s", got)
}
}
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
+26 -53
View File
@@ -386,8 +386,8 @@ func buildOnce(opts *Options) (bool, error) {
default:
syncAccentColor(primaryDark)
}
refreshGTKTheme(opts.Mode)
refreshGTKColorScheme()
refreshGTK(opts.Mode)
refreshGTK4()
}
if isDMSKDEColorSchemeActive(opts.ConfigDir) {
@@ -471,9 +471,12 @@ output_path = '%s'
case TemplateKindTerminal:
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
case TemplateKindVSCode:
for _, editor := range vscodeEditors {
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
}
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
@@ -630,23 +633,6 @@ func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []stri
return false
}
type vscodeEditor struct {
name string
dataDir string
}
var vscodeEditors = []vscodeEditor{
{"vscode", ".vscode"},
{"codium", ".vscode-oss"},
{"cursor", ".cursor"},
{"windsurf", ".windsurf"},
{"vscode-insiders", ".vscode-insiders"},
}
func (e vscodeEditor) extensionsDir(homeDir string) string {
return filepath.Join(homeDir, e.dataDir, "extensions")
}
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
matches, err := filepath.Glob(pattern)
@@ -925,7 +911,7 @@ func generateDank16Variants(primaryDark, primaryLight, surface string, mode Colo
}
func isDMSGTKActive(configDir string) bool {
gtkCSS := filepath.Join(configDir, "gtk-4.0", "gtk.css")
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
info, err := os.Lstat(gtkCSS)
if err != nil {
@@ -982,32 +968,11 @@ func applyKDEColorScheme(mode ColorMode) {
}
}
func gtkThemeInstalled(theme string) bool {
home, _ := os.UserHomeDir()
candidates := []string{
filepath.Join(home, ".local/share/themes", theme),
filepath.Join(home, ".themes", theme),
filepath.Join("/usr/share/themes", theme),
filepath.Join("/usr/local/share/themes", theme),
}
for _, dir := range candidates {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return true
}
}
return false
}
func refreshGTKTheme(mode ColorMode) {
theme := mode.GTKTheme()
if !gtkThemeInstalled(theme) {
log.Infof("Skipping gtk-theme refresh: %s is not installed", theme)
return
}
func refreshGTK(mode ColorMode) {
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil {
log.Warnf("Failed to reset gtk-theme: %v", err)
}
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", theme); err != nil {
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil {
log.Warnf("Failed to set gtk-theme: %v", err)
}
}
@@ -1028,8 +993,8 @@ func expectColorSchemeEcho(scheme string) {
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
// following the portal color-scheme (Chromium) can drop the restore signal
// mid-repaint and latch the wrong mode, so this is opt-in.
func refreshGTKColorScheme() {
if os.Getenv("DMS_ENABLE_GTK_REFRESH") != "1" {
func refreshGTK4() {
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
return
}
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
@@ -1047,13 +1012,13 @@ func refreshGTKColorScheme() {
expectColorSchemeEcho(toggle)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
log.Warnf("Failed to toggle color-scheme for GTK refresh: %v", err)
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
return
}
time.Sleep(50 * time.Millisecond)
time.Sleep(400 * time.Millisecond)
expectColorSchemeEcho(current)
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
log.Warnf("Failed to restore color-scheme for GTK refresh: %v", err)
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
}
}
@@ -1182,8 +1147,16 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
}
func checkVSCodeExtension(homeDir string) bool {
for _, editor := range vscodeEditors {
pattern := filepath.Join(editor.extensionsDir(homeDir), "danklinux.dms-theme-*")
extDirs := []string{
filepath.Join(homeDir, ".vscode/extensions"),
filepath.Join(homeDir, ".vscode-oss/extensions"),
filepath.Join(homeDir, ".config/Code - OSS/extensions"),
filepath.Join(homeDir, ".cursor/extensions"),
filepath.Join(homeDir, ".windsurf/extensions"),
}
for _, extDir := range extDirs {
pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
return true
}
-197
View File
@@ -1,197 +0,0 @@
package plugins
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/spf13/afero"
)
const pluginLockfileVersion = 1
var gitCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
var scpLikeRepoPattern = regexp.MustCompile(`^[^@:/\s]+@[^@:/\s]+:\S+$`)
type PluginLockfile struct {
LockfileVersion int `json:"lockfileVersion"`
Plugins map[string]LockedPlugin `json:"plugins"`
}
type LockedPlugin struct {
Repo string `json:"repo"`
Path string `json:"path,omitempty"`
Commit string `json:"commit"`
}
type LockStore struct {
fs afero.Fs
path string
}
func NewLockStore(fs afero.Fs, path string) *LockStore {
return &LockStore{fs: fs, path: path}
}
func NewPluginLockfile() PluginLockfile {
return PluginLockfile{
LockfileVersion: pluginLockfileVersion,
Plugins: make(map[string]LockedPlugin),
}
}
func (s *LockStore) Path() string {
return s.path
}
func (s *LockStore) Exists() (bool, error) {
return afero.Exists(s.fs, s.path)
}
func (s *LockStore) Load() (PluginLockfile, error) {
data, err := afero.ReadFile(s.fs, s.path)
if err != nil {
if os.IsNotExist(err) {
return NewPluginLockfile(), nil
}
return PluginLockfile{}, fmt.Errorf("failed to read plugin lockfile: %w", err)
}
return ParsePluginLockfile(data)
}
func ParsePluginLockfile(data []byte) (PluginLockfile, error) {
var lock PluginLockfile
if err := json.Unmarshal(data, &lock); err != nil {
return PluginLockfile{}, fmt.Errorf("failed to parse plugin lockfile: %w", err)
}
if lock.Plugins == nil {
lock.Plugins = make(map[string]LockedPlugin)
}
if err := lock.Validate(); err != nil {
return PluginLockfile{}, err
}
return lock, nil
}
func (s *LockStore) Write(lock PluginLockfile) error {
if err := lock.Validate(); err != nil {
return err
}
data, err := json.MarshalIndent(lock, "", " ")
if err != nil {
return fmt.Errorf("failed to encode plugin lockfile: %w", err)
}
data = append(data, '\n')
if err := s.fs.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("failed to create plugin lockfile directory: %w", err)
}
tmpPath := s.path + ".tmp"
if err := afero.WriteFile(s.fs, tmpPath, data, 0o644); err != nil {
return fmt.Errorf("failed to write temporary plugin lockfile: %w", err)
}
defer s.fs.Remove(tmpPath) //nolint:errcheck
if err := s.fs.Rename(tmpPath, s.path); err != nil {
return fmt.Errorf("failed to replace plugin lockfile: %w", err)
}
return nil
}
func (lock PluginLockfile) Validate() error {
if lock.LockfileVersion != pluginLockfileVersion {
return fmt.Errorf("unsupported plugin lockfile version %d (expected %d)", lock.LockfileVersion, pluginLockfileVersion)
}
repoCommits := make(map[string]string)
for id, plugin := range lock.Plugins {
if !isSafePluginPathComponent(id) {
return fmt.Errorf("invalid locked plugin id: %q", id)
}
if strings.TrimSpace(plugin.Repo) == "" {
return fmt.Errorf("locked plugin %q has no repository", id)
}
if err := validatePluginRepo(plugin.Repo); err != nil {
return fmt.Errorf("locked plugin %q: %w", id, err)
}
if !gitCommitPattern.MatchString(plugin.Commit) {
return fmt.Errorf("locked plugin %q has invalid commit %q", id, plugin.Commit)
}
if err := validatePluginRepoPath(plugin.Path); err != nil {
return fmt.Errorf("locked plugin %q: %w", id, err)
}
if commit, ok := repoCommits[plugin.Repo]; ok && !strings.EqualFold(commit, plugin.Commit) {
return fmt.Errorf("plugins from repository %q must use the same commit", plugin.Repo)
}
repoCommits[plugin.Repo] = plugin.Commit
}
return nil
}
func validatePluginRepo(repo string) error {
parsed, err := url.Parse(repo)
if err != nil {
if scpLikeRepoPattern.MatchString(repo) {
return nil
}
return fmt.Errorf("invalid repository URL %q", repo)
}
if parsed.User == nil {
return nil
}
if _, hasPassword := parsed.User.Password(); hasPassword {
return fmt.Errorf("repository URL must not contain credentials")
}
switch parsed.Scheme {
case "http", "https":
return fmt.Errorf("repository URL must not contain credentials")
}
return nil
}
func validatePluginRepoPath(path string) error {
if path == "" {
return nil
}
clean := filepath.Clean(path)
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return fmt.Errorf("invalid repository path %q", path)
}
return nil
}
func (lock PluginLockfile) IDs() []string {
ids := make([]string, 0, len(lock.Plugins))
for id := range lock.Plugins {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
func (lock *PluginLockfile) SetRepositoryCommit(repo, commit string) {
for id, plugin := range lock.Plugins {
if plugin.Repo != repo {
continue
}
plugin.Commit = commit
lock.Plugins[id] = plugin
}
}
func (lock PluginLockfile) Clone() PluginLockfile {
cloned := NewPluginLockfile()
for id, plugin := range lock.Plugins {
cloned.Plugins[id] = plugin
}
return cloned
}
-111
View File
@@ -1,111 +0,0 @@
package plugins
import (
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testCommit = "0123456789abcdef0123456789abcdef01234567"
func TestPluginLockfileRoundTrip(t *testing.T) {
fs := afero.NewMemMapFs()
store := NewLockStore(fs, "/config/plugins.lock.json")
lock := NewPluginLockfile()
lock.Plugins["weather"] = LockedPlugin{
Repo: "https://github.com/example/plugins.git",
Path: "plugins/weather",
Commit: testCommit,
}
require.NoError(t, store.Write(lock))
loaded, err := store.Load()
require.NoError(t, err)
assert.Equal(t, lock, loaded)
data, err := afero.ReadFile(fs, store.Path())
require.NoError(t, err)
assert.Equal(t, "{\n \"lockfileVersion\": 1,\n \"plugins\": {\n \"weather\": {\n \"repo\": \"https://github.com/example/plugins.git\",\n \"path\": \"plugins/weather\",\n \"commit\": \"0123456789abcdef0123456789abcdef01234567\"\n }\n }\n}\n", string(data))
}
func TestPluginLockfileValidation(t *testing.T) {
tests := []struct {
name string
lock PluginLockfile
want string
}{
{
name: "rejects unsupported version",
lock: PluginLockfile{LockfileVersion: 2, Plugins: map[string]LockedPlugin{}},
want: "unsupported plugin lockfile version",
},
{
name: "rejects unsafe id",
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"../bad": {Repo: "repo", Commit: testCommit}}},
want: "invalid locked plugin id",
},
{
name: "rejects unsafe repository path",
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "repo", Path: "../bad", Commit: testCommit}}},
want: "invalid repository path",
},
{
name: "rejects abbreviated commit",
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "repo", Commit: "abc123"}}},
want: "invalid commit",
},
{
name: "rejects repository credentials",
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "https://token@example.com/plugin.git", Commit: testCommit}}},
want: "must not contain credentials",
},
{
name: "rejects ssh password",
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "ssh://git:secret@example.com/plugin.git", Commit: testCommit}}},
want: "must not contain credentials",
},
{
name: "rejects conflicting monorepo commits",
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{
"one": {Repo: "repo", Commit: testCommit},
"two": {Repo: "repo", Commit: "1123456789abcdef0123456789abcdef01234567"},
}},
want: "must use the same commit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.lock.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
func TestPluginLockfileAcceptsSSHRemotes(t *testing.T) {
repos := []string{
"https://github.com/user/plugin.git",
"ssh://git@github.com/user/plugin.git",
"git@github.com:user/plugin.git",
}
for _, repo := range repos {
t.Run(repo, func(t *testing.T) {
lock := PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{
"plugin": {Repo: repo, Commit: testCommit},
}}
assert.NoError(t, lock.Validate())
})
}
}
func TestMissingPluginLockfileIsEmpty(t *testing.T) {
store := NewLockStore(afero.NewMemMapFs(), "/config/plugins.lock.json")
lock, err := store.Load()
require.NoError(t, err)
assert.Equal(t, pluginLockfileVersion, lock.LockfileVersion)
assert.Empty(t, lock.Plugins)
}
+88 -147
View File
@@ -16,7 +16,6 @@ import (
type Manager struct {
fs afero.Fs
pluginsDir string
lockPath string
gitClient GitClient
}
@@ -29,7 +28,6 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
return &Manager{
fs: fs,
pluginsDir: pluginsDir,
lockPath: getPluginLockPath(),
gitClient: &realGitClient{},
}, nil
}
@@ -43,15 +41,6 @@ func getPluginsDir() string {
return filepath.Join(configDir, "DankMaterialShell", "plugins")
}
func getPluginLockPath() string {
configDir, err := os.UserConfigDir()
if err != nil {
log.Error("failed to get user config dir", "err", err)
return ""
}
return filepath.Join(configDir, "DankMaterialShell", "plugins.lock.json")
}
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
path, err := m.findInstalledPath(plugin.ID)
if err != nil {
@@ -129,12 +118,7 @@ func (m *Manager) findInDir(dir, pluginID string) (string, error) {
}
func (m *Manager) Install(plugin Plugin) error {
if !isSafePluginPathComponent(plugin.ID) {
return fmt.Errorf("invalid plugin id: %q", plugin.ID)
}
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
repoPath := pluginPath
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
@@ -144,18 +128,6 @@ func (m *Manager) Install(plugin Plugin) error {
if exists {
return fmt.Errorf("plugin already installed: %s", plugin.Name)
}
if strings.TrimSpace(plugin.Repo) == "" {
return fmt.Errorf("plugin repository is required")
}
if err := validatePluginRepo(plugin.Repo); err != nil {
return err
}
if err := validatePluginRepoPath(plugin.Path); err != nil {
return err
}
if _, err := m.ensureLockfile(); err != nil {
return err
}
if err := m.fs.MkdirAll(m.pluginsDir, 0o755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
@@ -168,7 +140,7 @@ func (m *Manager) Install(plugin Plugin) error {
if plugin.Path != "" {
repoName := m.getRepoName(plugin.Repo)
repoPath = filepath.Join(reposDir, repoName)
repoPath := filepath.Join(reposDir, repoName)
repoExists, err := afero.DirExists(m.fs, repoPath)
if err != nil {
@@ -206,13 +178,12 @@ func (m *Manager) Install(plugin Plugin) error {
if err := m.createSymlink(sourcePath, pluginPath); err != nil {
return fmt.Errorf("failed to create symlink: %w", err)
}
metaPath := pluginPath + ".meta"
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil {
m.fs.Remove(pluginPath) //nolint:errcheck
return fmt.Errorf("failed to write plugin repository metadata: %w", err)
return fmt.Errorf("failed to write metadata: %w", err)
}
} else {
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
m.fs.RemoveAll(pluginPath) //nolint:errcheck
@@ -220,16 +191,6 @@ func (m *Manager) Install(plugin Plugin) error {
}
}
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
if plugin.Path != "" {
m.fs.Remove(pluginPath) //nolint:errcheck
m.fs.Remove(pluginPath + ".meta") //nolint:errcheck
} else {
m.fs.RemoveAll(pluginPath) //nolint:errcheck
}
return fmt.Errorf("failed to update plugin lockfile: %w", err)
}
return nil
}
@@ -246,15 +207,6 @@ func (m *Manager) createSymlink(source, dest string) error {
}
func (m *Manager) Update(plugin Plugin) error {
lock, err := m.ensureLockfile()
if err != nil {
return err
}
if locked, ok := lock.Plugins[plugin.ID]; ok {
plugin.Repo = locked.Repo
plugin.Path = locked.Path
}
pluginPath, err := m.findInstalledPath(plugin.ID)
if err != nil {
return fmt.Errorf("failed to find plugin: %w", err)
@@ -267,42 +219,46 @@ func (m *Manager) Update(plugin Plugin) error {
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
}
if plugin.Repo == "" {
plugin.Repo, err = m.gitClient.OriginURL(pluginPath)
if err != nil {
return fmt.Errorf("failed to read plugin origin: %w", err)
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return fmt.Errorf("failed to check metadata: %w", err)
}
if metaExists {
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := m.gitClient.Pull(repoPath); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := m.fs.RemoveAll(repoPath); err != nil {
return fmt.Errorf("failed to remove corrupted repository: %w", err)
}
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone repository: %w", err)
}
}
} else {
// Try to pull, if it fails, delete and re-clone
if err := m.gitClient.Pull(pluginPath); err != nil {
if err := m.fs.RemoveAll(pluginPath); err != nil {
return fmt.Errorf("failed to remove corrupted plugin: %w", err)
}
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone plugin: %w", err)
}
}
}
repoPath := m.repositoryPath(plugin.ID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
if err := m.gitClient.Pull(repoPath); err != nil {
if err := m.fs.RemoveAll(repoPath); err != nil {
return fmt.Errorf("failed to remove corrupted plugin repository: %w", err)
}
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone plugin repository: %w", err)
}
}
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
if locked, ok := lock.Plugins[plugin.ID]; ok {
m.gitClient.CheckoutRevision(repoPath, locked.Commit) //nolint:errcheck
}
return err
}
return nil
}
func (m *Manager) Uninstall(plugin Plugin) error {
lock, err := m.ensureLockfile()
if err != nil {
return err
}
if locked, ok := lock.Plugins[plugin.ID]; ok {
plugin.Repo = locked.Repo
plugin.Path = locked.Path
}
pluginPath, err := m.findInstalledPath(plugin.ID)
if err != nil {
return fmt.Errorf("failed to find plugin: %w", err)
@@ -315,64 +271,70 @@ func (m *Manager) Uninstall(plugin Plugin) error {
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
}
updatedLock := lock.Clone()
delete(updatedLock.Plugins, plugin.ID)
if err := m.lockStore().Write(updatedLock); err != nil {
return err
}
rollbackLock := func(err error) error {
if rollbackErr := m.lockStore().Write(lock); rollbackErr != nil {
return fmt.Errorf("%w (also failed to restore plugin lockfile: %v)", err, rollbackErr)
}
return err
}
metaPath := pluginPath + ".meta"
if plugin.Path != "" {
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return fmt.Errorf("failed to check metadata: %w", err)
}
if metaExists {
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
shouldCleanup, err := m.shouldCleanupRepo(plugin.Repo, plugin.ID)
shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID)
if err != nil {
return rollbackLock(fmt.Errorf("failed to check repo cleanup: %w", err))
return fmt.Errorf("failed to check repo cleanup: %w", err)
}
if err := m.fs.Remove(pluginPath); err != nil {
return rollbackLock(fmt.Errorf("failed to remove symlink: %w", err))
return fmt.Errorf("failed to remove symlink: %w", err)
}
if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists {
if err := m.fs.Remove(metaPath); err != nil {
return rollbackLock(fmt.Errorf("failed to remove metadata: %w", err))
}
if err := m.fs.Remove(metaPath); err != nil {
return fmt.Errorf("failed to remove metadata: %w", err)
}
if shouldCleanup {
if err := m.fs.RemoveAll(repoPath); err != nil {
return rollbackLock(fmt.Errorf("failed to cleanup repository: %w", err))
return fmt.Errorf("failed to cleanup repository: %w", err)
}
}
} else {
if err := m.fs.RemoveAll(pluginPath); err != nil {
return rollbackLock(fmt.Errorf("failed to remove plugin: %w", err))
return fmt.Errorf("failed to remove plugin: %w", err)
}
}
return nil
}
func (m *Manager) shouldCleanupRepo(repoURL, excludePlugin string) (bool, error) {
lock, err := m.ensureLockfile()
func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
installed, err := m.ListInstalled()
if err != nil {
return false, err
}
for id, plugin := range lock.Plugins {
registry, err := NewRegistry()
if err != nil {
return false, err
}
allPlugins, err := registry.List()
if err != nil {
return false, err
}
for _, id := range installed {
if id == excludePlugin {
continue
}
if plugin.Repo == repoURL && plugin.Path != "" {
return false, nil
for _, p := range allPlugins {
if p.ID == id && p.Repo == repoURL && p.Path != "" {
return false, nil
}
}
}
@@ -494,16 +456,6 @@ func (m *Manager) UninstallByIDOrName(idOrName string) error {
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return fmt.Errorf("cannot uninstall system plugin: %s", idOrName)
}
manifest := m.getPluginManifest(pluginPath)
if manifest != nil {
lock, err := m.ensureLockfile()
if err != nil {
return err
}
if locked, ok := lock.Plugins[manifest.ID]; ok {
return m.Uninstall(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path})
}
}
metaPath := pluginPath + ".meta"
metaExists, _ := afero.Exists(m.fs, metaPath)
@@ -536,16 +488,6 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return fmt.Errorf("cannot update system plugin: %s", idOrName)
}
manifest := m.getPluginManifest(pluginPath)
if manifest != nil {
lock, err := m.ensureLockfile()
if err != nil {
return err
}
if locked, ok := lock.Plugins[manifest.ID]; ok {
return m.Update(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path})
}
}
metaPath := pluginPath + ".meta"
metaExists, _ := afero.Exists(m.fs, metaPath)
@@ -560,14 +502,8 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
if err := m.gitClient.Pull(pluginPath); err != nil {
return fmt.Errorf("failed to update plugin: %w", err)
}
if manifest == nil {
return nil
}
repo, err := m.gitClient.OriginURL(pluginPath)
if err != nil {
return fmt.Errorf("failed to read plugin origin: %w", err)
}
return m.recordInstalledPlugin(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: repo}, pluginPath)
return nil
}
func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
@@ -636,15 +572,6 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
}
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
lock, err := m.ensureLockfile()
if err != nil {
return false, "", err
}
if locked, ok := lock.Plugins[pluginID]; ok {
plugin.Repo = locked.Repo
plugin.Path = locked.Path
}
pluginPath, err := m.findInstalledPath(pluginID)
if err != nil {
return false, "", fmt.Errorf("failed to find plugin: %w", err)
@@ -658,11 +585,25 @@ func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, d
return false, "", nil
}
repoPath := pluginPath
if plugin.Path != "" {
repoPath = m.repositoryPath(pluginID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return false, "", fmt.Errorf("failed to check metadata: %w", err)
}
var hasUp bool
var localHash, remoteHash string
if metaExists {
// Plugin is from a monorepo, check the repo directory
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
} else {
// Plugin is a standalone repo
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
}
hasUp, localHash, remoteHash, err := m.gitClient.HasUpdates(repoPath)
if err != nil {
return false, "", err
-299
View File
@@ -1,299 +0,0 @@
package plugins
import (
"fmt"
"path/filepath"
"strings"
"github.com/spf13/afero"
)
func (m *Manager) lockStore() *LockStore {
lockPath := m.lockPath
if lockPath == "" {
lockPath = filepath.Join(filepath.Dir(m.pluginsDir), "plugins.lock.json")
}
return NewLockStore(m.fs, lockPath)
}
func (m *Manager) GetLockfilePath() string {
return m.lockStore().Path()
}
func (m *Manager) ensureLockfile() (PluginLockfile, error) {
store := m.lockStore()
exists, err := store.Exists()
if err != nil {
return PluginLockfile{}, fmt.Errorf("failed to check plugin lockfile: %w", err)
}
if exists {
return store.Load()
}
lock, _, err := m.SnapshotLockfile()
if err != nil {
return PluginLockfile{}, err
}
if err := store.Write(lock); err != nil {
return PluginLockfile{}, err
}
return lock, nil
}
func (m *Manager) SnapshotLockfile() (PluginLockfile, []string, error) {
lock := NewPluginLockfile()
warnings := []string{}
exists, err := afero.DirExists(m.fs, m.pluginsDir)
if err != nil {
return PluginLockfile{}, nil, fmt.Errorf("failed to check plugins directory: %w", err)
}
if !exists {
return lock, warnings, nil
}
entries, err := afero.ReadDir(m.fs, m.pluginsDir)
if err != nil {
return PluginLockfile{}, nil, fmt.Errorf("failed to read plugins directory: %w", err)
}
for _, entry := range entries {
name := entry.Name()
if name == ".repos" || strings.HasSuffix(name, ".meta") {
continue
}
pluginPath := filepath.Join(m.pluginsDir, name)
manifest := m.getPluginManifest(pluginPath)
if manifest == nil || manifest.ID == "" {
continue
}
locked, repoPath, err := m.lockedPluginFromInstall(pluginPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", manifest.ID, err))
continue
}
commit, err := m.gitClient.CurrentRevision(repoPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: cannot read git revision: %v", manifest.ID, err))
continue
}
locked.Commit = commit
lock.SetRepositoryCommit(locked.Repo, commit)
lock.Plugins[manifest.ID] = locked
}
if err := lock.Validate(); err != nil {
return PluginLockfile{}, warnings, err
}
return lock, warnings, nil
}
func (m *Manager) lockedPluginFromInstall(pluginPath string) (LockedPlugin, string, error) {
metaPath := pluginPath + ".meta"
if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists {
data, err := afero.ReadFile(m.fs, metaPath)
if err != nil {
return LockedPlugin{}, "", fmt.Errorf("failed to read legacy metadata: %w", err)
}
metadata := parseLegacyPluginMetadata(string(data))
repo := metadata["repo"]
path := metadata["path"]
repoDir := metadata["repodir"]
if repo == "" || repoDir == "" {
return LockedPlugin{}, "", fmt.Errorf("legacy metadata is incomplete")
}
if err := validatePluginRepo(repo); err != nil {
return LockedPlugin{}, "", err
}
if !isSafePluginPathComponent(repoDir) {
return LockedPlugin{}, "", fmt.Errorf("legacy repository directory is invalid")
}
if err := validatePluginRepoPath(path); err != nil {
return LockedPlugin{}, "", err
}
return LockedPlugin{Repo: repo, Path: path}, filepath.Join(m.pluginsDir, ".repos", repoDir), nil
}
repo, err := m.gitClient.OriginURL(pluginPath)
if err != nil {
return LockedPlugin{}, "", fmt.Errorf("plugin is not a managed git checkout")
}
if err := validatePluginRepo(repo); err != nil {
return LockedPlugin{}, "", err
}
return LockedPlugin{Repo: repo}, pluginPath, nil
}
func parseLegacyPluginMetadata(data string) map[string]string {
metadata := make(map[string]string)
for _, line := range strings.Split(data, "\n") {
key, value, ok := strings.Cut(line, "=")
if ok {
metadata[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
return metadata
}
func (m *Manager) recordInstalledPlugin(plugin Plugin, repoPath string) error {
lock, err := m.ensureLockfile()
if err != nil {
return err
}
commit, err := m.gitClient.CurrentRevision(repoPath)
if err != nil {
return fmt.Errorf("failed to read installed plugin revision: %w", err)
}
lock.SetRepositoryCommit(plugin.Repo, commit)
lock.Plugins[plugin.ID] = LockedPlugin{
Repo: plugin.Repo,
Path: plugin.Path,
Commit: commit,
}
return m.lockStore().Write(lock)
}
func (m *Manager) WriteCurrentLockfile(outputPath string) ([]string, error) {
lock, warnings, err := m.SnapshotLockfile()
if err != nil {
return warnings, err
}
if err := m.lockStore().Write(lock); err != nil {
return warnings, err
}
if outputPath != "" && outputPath != m.GetLockfilePath() {
if err := NewLockStore(m.fs, outputPath).Write(lock); err != nil {
return warnings, err
}
}
return warnings, nil
}
func (m *Manager) RestoreFromLockfile(sourcePath string, prune bool) error {
if sourcePath == "" {
sourcePath = m.GetLockfilePath()
}
sourceStore := NewLockStore(m.fs, sourcePath)
exists, err := sourceStore.Exists()
if err != nil {
return fmt.Errorf("failed to check plugin lockfile: %w", err)
}
if !exists {
return fmt.Errorf("plugin lockfile not found: %s", sourcePath)
}
target, err := sourceStore.Load()
if err != nil {
return err
}
current, err := m.ensureLockfile()
if err != nil {
return err
}
for _, id := range target.IDs() {
wanted := target.Plugins[id]
installedPath, err := m.findInstalledPath(id)
if err != nil {
return err
}
if installedPath != "" {
existing, managed := current.Plugins[id]
if !managed {
return fmt.Errorf("plugin %q is already installed but is not managed by the lockfile", id)
}
if existing.Repo != wanted.Repo || existing.Path != wanted.Path {
if err := m.UninstallByIDOrName(id); err != nil {
return err
}
installedPath = ""
}
}
plugin := Plugin{ID: id, Name: id, Repo: wanted.Repo, Path: wanted.Path}
newlyInstalled := installedPath == ""
if installedPath == "" {
if err := m.Install(plugin); err != nil {
return fmt.Errorf("failed to restore plugin %q: %w", id, err)
}
}
repoPath := m.repositoryPath(id, wanted)
previousCommit, err := m.gitClient.CurrentRevision(repoPath)
if err != nil {
if newlyInstalled {
m.UninstallByIDOrName(id) //nolint:errcheck
}
return fmt.Errorf("failed to read current revision for plugin %q: %w", id, err)
}
if err := m.gitClient.CheckoutRevision(repoPath, wanted.Commit); err != nil {
if newlyInstalled {
if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil {
return fmt.Errorf("failed to restore plugin %q at %s: %w (also failed to remove the partial install: %v)", id, wanted.Commit, err, rollbackErr)
}
}
return fmt.Errorf("failed to restore plugin %q at %s: %w", id, wanted.Commit, err)
}
rollback := func(restoreErr error) error {
if newlyInstalled {
if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil {
return fmt.Errorf("%w (also failed to remove the partial install: %v)", restoreErr, rollbackErr)
}
return restoreErr
}
if rollbackErr := m.gitClient.CheckoutRevision(repoPath, previousCommit); rollbackErr != nil {
return fmt.Errorf("%w (also failed to restore revision %s: %v)", restoreErr, previousCommit, rollbackErr)
}
return restoreErr
}
pluginPath := installedPath
if pluginPath == "" {
pluginPath = filepath.Join(m.pluginsDir, id)
}
if actualID := m.getPluginID(pluginPath); actualID != id {
return rollback(fmt.Errorf("restored plugin %q has manifest id %q", id, actualID))
}
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
return rollback(err)
}
current, err = m.lockStore().Load()
if err != nil {
return err
}
}
if prune {
for _, id := range current.IDs() {
if _, wanted := target.Plugins[id]; wanted {
continue
}
if err := m.UninstallByIDOrName(id); err != nil {
return fmt.Errorf("failed to prune plugin %q: %w", id, err)
}
}
return m.lockStore().Write(target)
}
merged, err := m.lockStore().Load()
if err != nil {
return err
}
for id, plugin := range target.Plugins {
merged.SetRepositoryCommit(plugin.Repo, plugin.Commit)
merged.Plugins[id] = plugin
}
return m.lockStore().Write(merged)
}
func (m *Manager) repositoryPath(pluginID string, plugin LockedPlugin) string {
if plugin.Path != "" {
return filepath.Join(m.pluginsDir, ".repos", m.getRepoName(plugin.Repo))
}
if installedPath, err := m.findInDir(m.pluginsDir, pluginID); err == nil && installedPath != "" {
return installedPath
}
return filepath.Join(m.pluginsDir, pluginID)
}
-243
View File
@@ -1,7 +1,6 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"testing"
@@ -17,7 +16,6 @@ func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) {
manager := &Manager{
fs: fs,
pluginsDir: pluginsDir,
lockPath: "/config/plugins.lock.json",
gitClient: &mockGitClient{},
}
return manager, fs, pluginsDir
@@ -247,244 +245,3 @@ func TestManagerGetPluginsDir(t *testing.T) {
manager, _, pluginsDir := setupTestManager(t)
assert.Equal(t, pluginsDir, manager.GetPluginsDir())
}
func TestInstallUpdatesLockfile(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
manager.gitClient = &mockGitClient{
cloneFunc: func(path, _ string) error {
require.NoError(t, fs.MkdirAll(path, 0o755))
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644)
},
revisionFunc: func(string) (string, error) { return testCommit, nil },
}
require.NoError(t, manager.Install(plugin))
lock, err := manager.lockStore().Load()
require.NoError(t, err)
assert.Equal(t, LockedPlugin{Repo: plugin.Repo, Commit: testCommit}, lock.Plugins[plugin.ID])
exists, err := afero.Exists(fs, filepath.Join(pluginsDir, plugin.ID))
require.NoError(t, err)
assert.True(t, exists)
}
func TestUpdateRefreshesLockedCommit(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
require.NoError(t, manager.lockStore().Write(PluginLockfile{
LockfileVersion: 1,
Plugins: map[string]LockedPlugin{
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
},
}))
newCommit := "1123456789abcdef0123456789abcdef01234567"
manager.gitClient = &mockGitClient{
pullFunc: func(string) error { return nil },
revisionFunc: func(string) (string, error) { return newCommit, nil },
}
require.NoError(t, manager.Update(plugin))
lock, err := manager.lockStore().Load()
require.NoError(t, err)
assert.Equal(t, newCommit, lock.Plugins[plugin.ID].Commit)
}
func TestUpdateResolvesRenamedPluginDirectory(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
pluginPath := filepath.Join(pluginsDir, "renamed-dir")
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
require.NoError(t, manager.lockStore().Write(PluginLockfile{
LockfileVersion: 1,
Plugins: map[string]LockedPlugin{
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
},
}))
var pulledPath string
manager.gitClient = &mockGitClient{
pullFunc: func(path string) error {
pulledPath = path
return nil
},
cloneFunc: func(path, _ string) error {
t.Fatalf("unexpected clone to %s", path)
return nil
},
revisionFunc: func(string) (string, error) { return testCommit, nil },
}
require.NoError(t, manager.Update(plugin))
assert.Equal(t, pluginPath, pulledPath)
}
func TestUninstallRemovesLockedPlugin(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
require.NoError(t, manager.lockStore().Write(PluginLockfile{
LockfileVersion: 1,
Plugins: map[string]LockedPlugin{
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
},
}))
require.NoError(t, manager.Uninstall(plugin))
lock, err := manager.lockStore().Load()
require.NoError(t, err)
assert.NotContains(t, lock.Plugins, plugin.ID)
}
func TestRestoreFromLockfileInstallsExactCommit(t *testing.T) {
manager, fs, _ := setupTestManager(t)
wantedCommit := "2123456789abcdef0123456789abcdef01234567"
incoming := PluginLockfile{
LockfileVersion: 1,
Plugins: map[string]LockedPlugin{
"restored": {Repo: "https://github.com/test/restored.git", Commit: wantedCommit},
},
}
require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming))
currentCommit := testCommit
manager.gitClient = &mockGitClient{
cloneFunc: func(path, _ string) error {
require.NoError(t, fs.MkdirAll(path, 0o755))
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"restored"}`), 0o644)
},
revisionFunc: func(string) (string, error) { return currentCommit, nil },
checkoutFunc: func(_ string, revision string) error {
currentCommit = revision
return nil
},
}
require.NoError(t, manager.RestoreFromLockfile("/incoming.lock.json", false))
lock, err := manager.lockStore().Load()
require.NoError(t, err)
assert.Equal(t, wantedCommit, lock.Plugins["restored"].Commit)
}
func TestSnapshotLockfileMigratesLegacyMonorepoMetadata(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
pluginPath := filepath.Join(pluginsDir, "legacy")
repoPath := filepath.Join(pluginsDir, ".repos", "legacy-repo")
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
require.NoError(t, fs.MkdirAll(repoPath, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"legacy"}`), 0o644))
require.NoError(t, afero.WriteFile(fs, pluginPath+".meta", []byte("repo=https://github.com/test/suite.git\npath=plugins/legacy\nrepodir=legacy-repo"), 0o644))
manager.gitClient = &mockGitClient{
revisionFunc: func(path string) (string, error) {
assert.Equal(t, repoPath, path)
return testCommit, nil
},
}
lock, warnings, err := manager.SnapshotLockfile()
require.NoError(t, err)
assert.Empty(t, warnings)
assert.Equal(t, LockedPlugin{
Repo: "https://github.com/test/suite.git",
Path: "plugins/legacy",
Commit: testCommit,
}, lock.Plugins["legacy"])
}
func TestRecordInstalledPluginUpdatesSharedRepositoryCommit(t *testing.T) {
manager, _, _ := setupTestManager(t)
repo := "https://github.com/test/suite.git"
require.NoError(t, manager.lockStore().Write(PluginLockfile{
LockfileVersion: 1,
Plugins: map[string]LockedPlugin{
"one": {Repo: repo, Path: "plugins/one", Commit: testCommit},
"two": {Repo: repo, Path: "plugins/two", Commit: testCommit},
},
}))
newCommit := "3123456789abcdef0123456789abcdef01234567"
manager.gitClient = &mockGitClient{
revisionFunc: func(string) (string, error) { return newCommit, nil },
}
require.NoError(t, manager.recordInstalledPlugin(Plugin{ID: "one", Repo: repo, Path: "plugins/one"}, "/repo"))
lock, err := manager.lockStore().Load()
require.NoError(t, err)
assert.Equal(t, newCommit, lock.Plugins["one"].Commit)
assert.Equal(t, newCommit, lock.Plugins["two"].Commit)
}
func TestRestoreFromLockfilePrunesManagedPlugins(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
pluginPath := filepath.Join(pluginsDir, "old")
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"old","name":"Old"}`), 0o644))
require.NoError(t, manager.lockStore().Write(PluginLockfile{
LockfileVersion: 1,
Plugins: map[string]LockedPlugin{
"old": {Repo: "https://github.com/test/old.git", Commit: testCommit},
},
}))
require.NoError(t, NewLockStore(fs, "/empty.lock.json").Write(NewPluginLockfile()))
require.NoError(t, manager.RestoreFromLockfile("/empty.lock.json", true))
exists, err := afero.Exists(fs, pluginPath)
require.NoError(t, err)
assert.False(t, exists)
lock, err := manager.lockStore().Load()
require.NoError(t, err)
assert.Empty(t, lock.Plugins)
}
func TestRestoreMissingLockfileDoesNotPrune(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
pluginPath := filepath.Join(pluginsDir, "kept")
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"kept"}`), 0o644))
require.NoError(t, manager.lockStore().Write(PluginLockfile{
LockfileVersion: 1,
Plugins: map[string]LockedPlugin{
"kept": {Repo: "https://github.com/test/kept.git", Commit: testCommit},
},
}))
err := manager.RestoreFromLockfile("/missing.lock.json", true)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
exists, statErr := afero.Exists(fs, pluginPath)
require.NoError(t, statErr)
assert.True(t, exists)
}
func TestRestoreFailedRevisionRemovesPartialInstall(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
incoming := PluginLockfile{
LockfileVersion: 1,
Plugins: map[string]LockedPlugin{
"broken": {Repo: "https://github.com/test/broken.git", Commit: testCommit},
},
}
require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming))
manager.gitClient = &mockGitClient{
cloneFunc: func(path, _ string) error {
require.NoError(t, fs.MkdirAll(path, 0o755))
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"broken"}`), 0o644)
},
revisionFunc: func(string) (string, error) { return testCommit, nil },
checkoutFunc: func(string, string) error {
return errors.New("revision unavailable")
},
}
err := manager.RestoreFromLockfile("/incoming.lock.json", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "revision unavailable")
exists, statErr := afero.Exists(fs, filepath.Join(pluginsDir, "broken"))
require.NoError(t, statErr)
assert.False(t, exists)
lock, loadErr := manager.lockStore().Load()
require.NoError(t, loadErr)
assert.NotContains(t, lock.Plugins, "broken")
}
+44 -142
View File
@@ -2,18 +2,17 @@ package plugins
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/spf13/afero"
)
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type Plugin struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -34,10 +33,7 @@ type Plugin struct {
type GitClient interface {
PlainClone(path string, url string) error
Pull(path string) error
OriginURL(path string) (string, error)
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
CurrentRevision(path string) (string, error)
CheckoutRevision(path string, revision string) error
}
type realGitClient struct{}
@@ -69,22 +65,6 @@ func (g *realGitClient) Pull(path string) error {
return nil
}
func (g *realGitClient) OriginURL(path string) (string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return "", err
}
remote, err := repo.Remote("origin")
if err != nil {
return "", err
}
urls := remote.Config().URLs
if len(urls) == 0 {
return "", errors.New("origin remote has no URL")
}
return urls[0], nil
}
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
@@ -138,53 +118,11 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
return localHash != remoteHead, localHash, remoteHead, nil
}
func (g *realGitClient) CurrentRevision(path string) (string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return "", err
}
head, err := repo.Head()
if err != nil {
return "", err
}
return head.Hash().String(), nil
}
func (g *realGitClient) CheckoutRevision(path string, revision string) error {
if !gitCommitPattern.MatchString(revision) {
return fmt.Errorf("invalid git revision %q", revision)
}
repo, err := git.PlainOpen(path)
if err != nil {
return err
}
hash := plumbing.NewHash(revision)
if _, objectErr := repo.CommitObject(hash); objectErr != nil {
if _, remoteErr := repo.Remote("origin"); remoteErr != nil {
return fmt.Errorf("revision %s is not available in repository: %w", revision, objectErr)
}
fetchErr := repo.Fetch(&git.FetchOptions{})
if fetchErr != nil && fetchErr != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to fetch locked revision: %w", fetchErr)
}
}
if _, err := repo.CommitObject(hash); err != nil {
return fmt.Errorf("revision %s is not available in repository: %w", revision, err)
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
return worktree.Reset(&git.ResetOptions{Commit: hash, Mode: git.HardReset})
}
type Registry struct {
fs afero.Fs
cacheDir string
registries []registries.Source
plugins []Plugin
git GitClient
fs afero.Fs
cacheDir string
plugins []Plugin
git GitClient
}
func NewRegistry() (*Registry, error) {
@@ -192,63 +130,63 @@ func NewRegistry() (*Registry, error) {
}
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{
fs: fs,
cacheDir: getCacheDir(),
registries: registries.Load(fs),
git: &realGitClient{},
fs: fs,
cacheDir: cacheDir,
git: &realGitClient{},
}, nil
}
func (r *Registry) cacheDirFor(src registries.Source) string {
return filepath.Join(r.cacheDir, src.Name)
}
func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
}
// A cached clone is reused only when its origin still matches the configured
// URL; renamed or re-pointed registries re-clone instead of pulling from the
// stale remote.
func (r *Registry) updateOne(src registries.Source) error {
dir := r.cacheDirFor(src)
exists, err := afero.DirExists(r.fs, dir)
func (r *Registry) Update() error {
exists, err := afero.DirExists(r.fs, r.cacheDir)
if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err)
}
if exists {
origin, originErr := r.git.OriginURL(dir)
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
return nil
if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.fs.RemoveAll(dir); err != nil {
return fmt.Errorf("failed to remove stale registry cache: %w", err)
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to clone registry: %w", err)
}
} else {
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := r.git.Pull(r.cacheDir); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
}
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
}
}
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(dir, src.URL); err != nil {
return fmt.Errorf("failed to clone: %w", err)
}
return nil
return r.loadPlugins()
}
// A registry without a plugins/ directory is a valid themes-only registry.
func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
pluginsDir := filepath.Join(dir, "plugins")
func (r *Registry) loadPlugins() error {
pluginsDir := filepath.Join(r.cacheDir, "plugins")
entries, err := afero.ReadDir(r.fs, pluginsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
return fmt.Errorf("failed to read plugins directory: %w", err)
}
var plugins []Plugin
r.plugins = []Plugin{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
@@ -268,51 +206,15 @@ func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
}
plugins = append(plugins, plugin)
r.plugins = append(r.plugins, plugin)
}
return plugins, nil
}
// Pre-multi-registry caches were a single clone at the base dir; the per-name
// layout nests under it, so a leftover clone is deleted wholesale first.
func (r *Registry) resetLegacyCache() {
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
_ = r.fs.RemoveAll(r.cacheDir)
}
}
// Update refreshes every configured registry, aggregating plugins in
// declaration order (first occurrence of an ID wins). A failing registry is
// reported in the joined error but does not block the others.
func (r *Registry) Update() error {
r.resetLegacyCache()
r.plugins = []Plugin{}
seen := make(map[string]struct{})
var errs []error
for _, src := range r.registries {
if err := r.updateOne(src); err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
plugins, err := r.loadPluginsFrom(r.cacheDirFor(src))
if err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
for _, p := range plugins {
if _, dup := seen[p.ID]; dup {
continue
}
seen[p.ID] = struct{}{}
r.plugins = append(r.plugins, p)
}
}
return errors.Join(errs...)
return nil
}
func (r *Registry) List() ([]Plugin, error) {
if len(r.plugins) == 0 {
if err := r.Update(); err != nil && len(r.plugins) == 0 {
if err := r.Update(); err != nil {
return nil, err
}
}
+57 -287
View File
@@ -2,29 +2,18 @@ package plugins
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testRegistryURL = "https://example.com/test-registry.git"
type mockGitClient struct {
cloneFunc func(path string, url string) error
pullFunc func(path string) error
originFunc func(path string) (string, error)
hasUpdatesFunc func(path string) (bool, string, string, error)
revisionFunc func(path string) (string, error)
checkoutFunc func(path string, revision string) error
}
func (m *mockGitClient) PlainClone(path string, url string) error {
@@ -41,13 +30,6 @@ func (m *mockGitClient) Pull(path string) error {
return nil
}
func (m *mockGitClient) OriginURL(path string) (string, error) {
if m.originFunc != nil {
return m.originFunc(path)
}
return "https://github.com/test/plugin.git", nil
}
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path)
@@ -55,27 +37,11 @@ func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
return false, "", "", nil
}
func (m *mockGitClient) CurrentRevision(path string) (string, error) {
if m.revisionFunc != nil {
return m.revisionFunc(path)
}
return "0123456789abcdef0123456789abcdef01234567", nil
}
func (m *mockGitClient) CheckoutRevision(path string, revision string) error {
if m.checkoutFunc != nil {
return m.checkoutFunc(path, revision)
}
return nil
}
func TestNewRegistry(t *testing.T) {
registry, err := NewRegistry()
assert.NoError(t, err)
assert.NotNil(t, registry)
assert.NotEmpty(t, registry.cacheDir)
require.NotEmpty(t, registry.registries)
assert.Equal(t, registries.OfficialName, registry.registries[0].Name)
}
func TestGetCacheDir(t *testing.T) {
@@ -83,50 +49,14 @@ func TestGetCacheDir(t *testing.T) {
assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry")
}
func TestRealGitClientRevisionCheckout(t *testing.T) {
repoPath := t.TempDir()
repo, err := git.PlainInit(repoPath, false)
require.NoError(t, err)
worktree, err := repo.Worktree()
require.NoError(t, err)
filePath := filepath.Join(repoPath, "value.txt")
signature := &object.Signature{Name: "DMS Test", Email: "test@example.com", When: time.Unix(1, 0)}
require.NoError(t, os.WriteFile(filePath, []byte("one"), 0o644))
_, err = worktree.Add("value.txt")
require.NoError(t, err)
first, err := worktree.Commit("first", &git.CommitOptions{Author: signature})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filePath, []byte("two"), 0o644))
_, err = worktree.Add("value.txt")
require.NoError(t, err)
second, err := worktree.Commit("second", &git.CommitOptions{Author: signature})
require.NoError(t, err)
client := &realGitClient{}
revision, err := client.CurrentRevision(repoPath)
require.NoError(t, err)
assert.Equal(t, second.String(), revision)
require.NoError(t, client.CheckoutRevision(repoPath, first.String()))
revision, err = client.CurrentRevision(repoPath)
require.NoError(t, err)
assert.Equal(t, first.String(), revision)
data, err := os.ReadFile(filePath)
require.NoError(t, err)
assert.Equal(t, "one", string(data))
}
func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
fs := afero.NewMemMapFs()
tmpDir := "/test-cache"
registry := &Registry{
fs: fs,
cacheDir: tmpDir,
registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
plugins: []Plugin{},
git: &mockGitClient{},
fs: fs,
cacheDir: tmpDir,
plugins: []Plugin{},
git: &mockGitClient{},
}
return registry, fs, tmpDir
}
@@ -174,14 +104,14 @@ func TestLoadPlugins(t *testing.T) {
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
plugins, err := registry.loadPluginsFrom(tmpDir)
err := registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, plugins, 2)
assert.Len(t, registry.plugins, 2)
assert.Equal(t, "TestPlugin1", plugins[0].Name)
assert.Equal(t, "TestPlugin2", plugins[1].Name)
assert.Equal(t, []string{"dankbar-widget"}, plugins[0].Capabilities)
assert.Equal(t, []string{"dep1", "dep2"}, plugins[1].Dependencies)
assert.Equal(t, "TestPlugin1", registry.plugins[0].Name)
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name)
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities)
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies)
})
t.Run("skips non-json files", func(t *testing.T) {
@@ -206,10 +136,34 @@ func TestLoadPlugins(t *testing.T) {
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
plugins, err := registry.loadPluginsFrom(tmpDir)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
assert.Equal(t, "ValidPlugin", plugins[0].Name)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
})
t.Run("skips directories", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0o755)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
})
t.Run("skips invalid json files", func(t *testing.T) {
@@ -234,18 +188,18 @@ func TestLoadPlugins(t *testing.T) {
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
plugins, err := registry.loadPluginsFrom(tmpDir)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
assert.Equal(t, "ValidPlugin", plugins[0].Name)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
})
t.Run("missing plugins directory is a themes-only registry", func(t *testing.T) {
t.Run("returns error when plugins directory missing", func(t *testing.T) {
registry, _, _ := setupTestRegistry(t)
plugins, err := registry.loadPluginsFrom(registry.cacheDir)
assert.NoError(t, err)
assert.Empty(t, plugins)
err := registry.loadPlugins()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read plugins directory")
})
}
@@ -286,40 +240,19 @@ func TestList(t *testing.T) {
Distro: []string{"any"},
}
registry.git = &mockGitClient{
mockGit := &mockGitClient{
cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
assert.Equal(t, "NewPlugin", plugins[0].Name)
})
t.Run("partial registry failure still returns loaded plugins", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
registry.registries = []registries.Source{
{Name: "test", URL: testRegistryURL},
{Name: "broken", URL: "https://example.com/broken.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
if url != testRegistryURL {
return errors.New("clone failed")
}
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
})
}
func TestUpdate(t *testing.T) {
@@ -338,15 +271,16 @@ func TestUpdate(t *testing.T) {
}
cloneCalled := false
registry.git = &mockGitClient{
mockGit := &mockGitClient{
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, testRegistryURL, url)
assert.Equal(t, filepath.Join(tmpDir, "test"), path)
assert.Equal(t, registryRepo, url)
assert.Equal(t, tmpDir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
err := registry.Update()
assert.NoError(t, err)
@@ -355,7 +289,7 @@ func TestUpdate(t *testing.T) {
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
})
t.Run("pulls when cache exists with matching origin", func(t *testing.T) {
t.Run("pulls updates when cache exists", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
plugin := Plugin{
@@ -369,188 +303,24 @@ func TestUpdate(t *testing.T) {
Distro: []string{"any"},
}
subdir := filepath.Join(tmpDir, "test")
require.NoError(t, fs.MkdirAll(subdir, 0o755))
err := fs.MkdirAll(tmpDir, 0o755)
require.NoError(t, err)
pullCalled := false
registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return testRegistryURL, nil
},
mockGit := &mockGitClient{
pullFunc: func(path string) error {
pullCalled = true
assert.Equal(t, subdir, path)
assert.Equal(t, tmpDir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
err := registry.Update()
err = registry.Update()
assert.NoError(t, err)
assert.True(t, pullCalled)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
})
t.Run("re-clones when cached origin does not match configured URL", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
subdir := filepath.Join(tmpDir, "test")
require.NoError(t, fs.MkdirAll(subdir, 0o755))
require.NoError(t, afero.WriteFile(fs, filepath.Join(subdir, "stale"), []byte("x"), 0o644))
pullCalled := false
cloneCalled := false
registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return "https://example.com/old-origin.git", nil
},
pullFunc: func(path string) error {
pullCalled = true
return nil
},
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, testRegistryURL, url)
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.False(t, pullCalled, "stale origin must not be pulled")
assert.True(t, cloneCalled)
exists, _ := afero.Exists(fs, filepath.Join(subdir, "stale"))
assert.False(t, exists, "stale cache contents removed before re-clone")
})
t.Run("re-clones when pull fails", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
subdir := filepath.Join(tmpDir, "test")
require.NoError(t, fs.MkdirAll(subdir, 0o755))
cloneCalled := false
registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return testRegistryURL, nil
},
pullFunc: func(path string) error {
return errors.New("shallow clone corruption")
},
cloneFunc: func(path string, url string) error {
cloneCalled = true
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.True(t, cloneCalled)
})
t.Run("aggregates from multiple registries", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
pluginA := Plugin{ID: "a", Name: "PluginA", Compositors: []string{"niri"}, Distro: []string{"any"}}
pluginB := Plugin{ID: "b", Name: "PluginB", Compositors: []string{"niri"}, Distro: []string{"any"}}
registry.registries = []registries.Source{
{Name: "official", URL: testRegistryURL},
{Name: "louzt", URL: "https://example.com/louzt.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
switch filepath.Base(path) {
case "official":
createTestPlugin(t, fs, path, "x.json", pluginA)
case "louzt":
createTestPlugin(t, fs, path, "x.json", pluginB)
}
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 2)
assert.Equal(t, "a", registry.plugins[0].ID)
assert.Equal(t, "b", registry.plugins[1].ID)
})
t.Run("dedupes by ID with declaration order priority", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
pluginOfficial := Plugin{ID: "weather", Name: "OfficialWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
pluginLouzt := Plugin{ID: "weather", Name: "LouztWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
registry.registries = []registries.Source{
{Name: "official", URL: testRegistryURL},
{Name: "louzt", URL: "https://example.com/louzt.git"},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
switch filepath.Base(path) {
case "official":
createTestPlugin(t, fs, path, "x.json", pluginOfficial)
case "louzt":
createTestPlugin(t, fs, path, "x.json", pluginLouzt)
}
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "OfficialWeather", registry.plugins[0].Name)
})
t.Run("continues past failing registry and reports it", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
registry.registries = []registries.Source{
{Name: "broken", URL: "https://example.com/broken.git"},
{Name: "test", URL: testRegistryURL},
}
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
if url != testRegistryURL {
return errors.New("network unreachable")
}
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.Error(t, err)
assert.Contains(t, err.Error(), "registry broken")
assert.Len(t, registry.plugins, 1, "healthy registry still loads")
})
t.Run("removes legacy single-clone cache at base", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
require.NoError(t, fs.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755))
createTestPlugin(t, fs, tmpDir, "legacy.json", Plugin{ID: "legacy", Name: "Legacy"})
registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
return nil
},
}
err := registry.Update()
assert.NoError(t, err)
exists, _ := afero.DirExists(fs, filepath.Join(tmpDir, ".git"))
assert.False(t, exists, "legacy clone removed")
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "x", registry.plugins[0].ID)
})
}
@@ -22,16 +22,6 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
if !strings.Contains(content, "Keys.onPressed") || !strings.Contains(content, "event.text") {
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
}
// Wayland IMEs commit unconsumed printable keys as text-input text rather
// than forwarding raw keys, so the lock screen needs an IME commit sink
// alongside raw key handling.
if !strings.Contains(content, "id: imeCommitSink") {
t.Fatalf("passwordField must keep the imeCommitSink TextInput so IME-routed keyboards can type (#2950)")
}
if !strings.Contains(content, "Qt.ImhSensitiveData") {
t.Fatalf("imeCommitSink must advertise hidden-text hints so IMEs treat it as a password field")
}
}
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
-130
View File
@@ -1,130 +0,0 @@
package registries
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/afero"
)
const (
OfficialName = "official"
officialURL = "https://github.com/AvengeMedia/dms-plugin-registry.git"
)
// Source identifies a registry repository. Name doubles as the per-registry
// cache subdirectory, so it is restricted to a filesystem-safe slug.
type Source struct {
Name string `json:"name"`
URL string `json:"url"`
}
func (s Source) Official() bool {
return s.Name == OfficialName
}
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
func configPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config dir: %w", err)
}
return filepath.Join(configDir, "DankMaterialShell", "registries.json"), nil
}
// Load returns the official registry followed by any user-configured extras.
// A missing or unreadable config yields just the official registry.
func Load(fs afero.Fs) []Source {
sources := []Source{{Name: OfficialName, URL: officialURL}}
path, err := configPath()
if err != nil {
return sources
}
data, err := afero.ReadFile(fs, path)
if err != nil {
return sources
}
var extras []Source
if err := json.Unmarshal(data, &extras); err != nil {
return sources
}
for _, s := range extras {
if !nameRe.MatchString(s.Name) || s.Name == OfficialName || s.URL == "" {
continue
}
sources = append(sources, s)
}
return sources
}
func saveExtras(fs afero.Fs, extras []Source) error {
path, err := configPath()
if err != nil {
return err
}
if err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
data, err := json.MarshalIndent(extras, "", " ")
if err != nil {
return err
}
return afero.WriteFile(fs, path, append(data, '\n'), 0o644)
}
func loadExtras(fs afero.Fs) []Source {
sources := Load(fs)
return sources[1:]
}
func Add(fs afero.Fs, name, url string) error {
name = strings.TrimSpace(name)
url = strings.TrimSpace(url)
if !nameRe.MatchString(name) {
return fmt.Errorf("invalid registry name %q: use 1-32 lowercase letters, digits or hyphens", name)
}
if name == OfficialName {
return fmt.Errorf("registry name %q is reserved", OfficialName)
}
if url == "" {
return fmt.Errorf("registry URL is required")
}
extras := loadExtras(fs)
for _, s := range extras {
if s.Name == name {
return fmt.Errorf("registry %q already exists", name)
}
if s.URL == url {
return fmt.Errorf("registry %q already uses this URL", s.Name)
}
}
return saveExtras(fs, append(extras, Source{Name: name, URL: url}))
}
func Remove(fs afero.Fs, name string) error {
if name == OfficialName {
return fmt.Errorf("the official registry cannot be removed")
}
extras := loadExtras(fs)
kept := make([]Source, 0, len(extras))
for _, s := range extras {
if s.Name != name {
kept = append(kept, s)
}
}
if len(kept) == len(extras) {
return fmt.Errorf("registry %q not found", name)
}
return saveExtras(fs, kept)
}
@@ -1,79 +0,0 @@
package registries
import (
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupFs(t *testing.T) afero.Fs {
t.Setenv("XDG_CONFIG_HOME", "/xdg")
return afero.NewMemMapFs()
}
func TestLoadDefaults(t *testing.T) {
fs := setupFs(t)
sources := Load(fs)
require.Len(t, sources, 1)
assert.Equal(t, OfficialName, sources[0].Name)
assert.Equal(t, officialURL, sources[0].URL)
assert.True(t, sources[0].Official())
}
func TestAddAndLoad(t *testing.T) {
fs := setupFs(t)
require.NoError(t, Add(fs, "extra", "https://example.com/extra.git"))
require.NoError(t, Add(fs, "another", "https://example.com/another.git"))
sources := Load(fs)
require.Len(t, sources, 3)
assert.Equal(t, OfficialName, sources[0].Name)
assert.Equal(t, "extra", sources[1].Name)
assert.Equal(t, "another", sources[2].Name)
assert.False(t, sources[1].Official())
}
func TestAddValidation(t *testing.T) {
fs := setupFs(t)
assert.Error(t, Add(fs, "", "https://example.com/x.git"))
assert.Error(t, Add(fs, "Has Spaces", "https://example.com/x.git"))
assert.Error(t, Add(fs, "UPPER", "https://example.com/x.git"))
assert.Error(t, Add(fs, "../escape", "https://example.com/x.git"))
assert.Error(t, Add(fs, OfficialName, "https://example.com/x.git"))
assert.Error(t, Add(fs, "noname", ""))
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
assert.Error(t, Add(fs, "extra", "https://example.com/other.git"), "duplicate name rejected")
assert.Error(t, Add(fs, "extra2", "https://example.com/x.git"), "duplicate URL rejected")
}
func TestRemove(t *testing.T) {
fs := setupFs(t)
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
require.NoError(t, Remove(fs, "extra"))
assert.Len(t, Load(fs), 1)
assert.Error(t, Remove(fs, "extra"), "already removed")
assert.Error(t, Remove(fs, OfficialName), "official is not removable")
}
func TestLoadIgnoresInvalidConfig(t *testing.T) {
fs := setupFs(t)
require.NoError(t, fs.MkdirAll("/xdg/DankMaterialShell", 0o755))
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte("{not json"), 0o644))
assert.Len(t, Load(fs), 1)
entries := `[{"name":"ok","url":"https://example.com/ok.git"},{"name":"Bad Name","url":"https://example.com/bad.git"},{"name":"official","url":"https://example.com/spoof.git"},{"name":"nourl","url":""}]`
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte(entries), 0o644))
sources := Load(fs)
require.Len(t, sources, 2, "invalid entries dropped")
assert.Equal(t, "ok", sources[1].Name)
assert.Equal(t, officialURL, sources[0].URL, "official cannot be spoofed from config")
}
+6 -11
View File
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
m.promptBroker = broker
adapter, err := findAdapter(conn)
adapter, err := m.findAdapter()
if err != nil {
conn.Close()
return nil, err
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
}
m.adapterPath = adapter
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
return m, nil
}
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
return "", err
}
for path, interfaces := range objects {
@@ -89,7 +89,7 @@ func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
}
}
return "", ErrNoAdapter
return "", fmt.Errorf("no adapter found")
}
func (m *Manager) initialize() error {
@@ -487,11 +487,6 @@ func (m *Manager) StopDiscovery() error {
}
func (m *Manager) SetPowered(powered bool) error {
if powered {
if err := rfkillUnblockBluetooth(); err != nil {
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
}
}
obj := m.dbusConn.Object(bluezService, m.adapterPath)
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
}
@@ -1,5 +0,0 @@
package bluez
func rfkillUnblockBluetooth() error {
return nil
}
@@ -1,19 +0,0 @@
package bluez
import "os"
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
func rfkillUnblockBluetooth() error {
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
var event [8]byte
event[4] = 2
event[5] = 3
_, err = f.Write(event[:])
return err
}
-46
View File
@@ -1,46 +0,0 @@
package bluez
import (
"errors"
"fmt"
"github.com/godbus/dbus/v5"
)
var ErrNoAdapter = errors.New("no bluetooth adapter found")
func WaitForAdapter() error {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return err
}
defer conn.Close()
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(objectMgrIface),
dbus.WithMatchMember("InterfacesAdded"),
); err != nil {
return err
}
signals := make(chan *dbus.Signal, 64)
conn.Signal(signals)
if _, err := findAdapter(conn); err == nil {
return nil
}
for sig := range signals {
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
continue
}
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
if !ok {
continue
}
if _, ok := ifaces[adapter1Iface]; ok {
return nil
}
}
return fmt.Errorf("dbus signal stream closed")
}
+10 -23
View File
@@ -229,9 +229,6 @@ func (m *Manager) snapshotState() CUPSState {
func (m *Manager) Subscribe(id string) chan CUPSState {
ch := make(chan CUPSState, 64)
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
wasEmpty := true
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
wasEmpty = false
@@ -240,25 +237,19 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
m.subscribers.Store(id, ch)
if !wasEmpty || m.subscription == nil {
return ch
if wasEmpty && m.subscription != nil {
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
} else {
m.eventWG.Add(1)
go m.eventHandler()
}
}
if err := m.subscription.Start(); err != nil {
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
return ch
}
m.eventWG.Add(1)
go m.eventHandler()
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subLifecycleMu.Lock()
defer m.subLifecycleMu.Unlock()
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
@@ -269,22 +260,18 @@ func (m *Manager) Unsubscribe(id string) {
return false
})
if !isEmpty || m.subscription == nil {
return
if isEmpty && m.subscription != nil {
m.subscription.Stop()
m.eventWG.Wait()
}
m.subscription.Stop()
m.eventWG.Wait()
}
func (m *Manager) Close() {
close(m.stopChan)
m.subLifecycleMu.Lock()
if m.subscription != nil {
m.subscription.Stop()
}
m.subLifecycleMu.Unlock()
m.eventWG.Wait()
m.notifierWg.Wait()
-64
View File
@@ -1,9 +1,6 @@
package cups
import (
"errors"
"fmt"
"sync"
"testing"
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
@@ -78,67 +75,6 @@ func TestManager_Subscribe(t *testing.T) {
assert.Equal(t, 0, count)
}
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
// unsynchronized so overlapping Start/Stop trips the race detector
type stubSubscription struct {
mu sync.Mutex
events chan SubscriptionEvent
conn *int
running bool
}
func (s *stubSubscription) Start() error {
if s.running {
return errors.New("already running")
}
s.running = true
s.mu.Lock()
s.events = make(chan SubscriptionEvent)
s.mu.Unlock()
v := 0
s.conn = &v
*s.conn++
return nil
}
func (s *stubSubscription) Stop() {
if !s.running {
return
}
s.running = false
s.conn = nil
s.mu.Lock()
close(s.events)
s.mu.Unlock()
}
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
s.mu.Lock()
defer s.mu.Unlock()
return s.events
}
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
m.subscription = &stubSubscription{}
var wg sync.WaitGroup
for i := range 8 {
wg.Go(func() {
id := fmt.Sprintf("client-%d", i)
for range 50 {
m.Subscribe(id)
m.Unsubscribe(id)
}
})
}
wg.Wait()
}
func TestManager_Close(t *testing.T) {
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
-1
View File
@@ -79,7 +79,6 @@ type Manager struct {
client CUPSClientInterface
pkHelper PkHelper
subscription SubscriptionManagerInterface
subLifecycleMu sync.Mutex
stateMutex sync.RWMutex
subscribers syncmap.Map[string, chan CUPSState]
stopChan chan struct{}
+21 -25
View File
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
return nil, fmt.Errorf("failed to find keyboards: %w", err)
}
initialCapsLock, _ := capsLockFromDevices(devices)
initialCapsLock := readInitialCapsLockState(devices[0])
watcher, err := fsnotify.NewWatcher()
if err != nil {
@@ -85,21 +85,14 @@ func NewManager() (*Manager, error) {
return m, nil
}
func capsLockFromDevices(devices []EvdevDevice) (bool, bool) {
for _, device := range devices {
if device == nil {
continue
}
ledStates, err := device.State(evLedType)
if err != nil || len(ledStates) == 0 {
continue
}
return ledStates[ledCapslockKey], true
func readInitialCapsLockState(device EvdevDevice) bool {
ledStates, err := device.State(evLedType)
if err != nil {
log.Debugf("Could not read LED state: %v", err)
return false
}
return false, false
return ledStates[ledCapslockKey]
}
func findKeyboards() ([]EvdevDevice, error) {
@@ -304,22 +297,25 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
m.devicesMutex.RUnlock()
return
}
ordered := make([]EvdevDevice, 0, len(m.devices))
ordered = append(ordered, m.devices[deviceIndex])
for i, device := range m.devices {
if i == deviceIndex {
continue
}
ordered = append(ordered, device)
}
device := m.devices[deviceIndex]
m.devicesMutex.RUnlock()
capsLockState, ok := capsLockFromDevices(ordered)
if !ok {
log.Debug("No LED-capable device available for caps lock state")
ledStates, err := device.State(evLedType)
if err != nil {
log.Warnf("Failed to read LED state: %v", err)
return
}
if len(ledStates) == 0 {
log.Debug("No LED state available (empty map)")
// This means the device either:
// - doesn't support LED reporting at all, or
// - the kernel returned an empty state
return
}
capsLockState := ledStates[ledCapslockKey]
m.updateCapsLockStateDirect(capsLockState)
}
+4 -22
View File
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
m.Close()
}
func TestCapsLockFromDevices(t *testing.T) {
func TestReadInitialCapsLockState(t *testing.T) {
t.Run("caps lock is on", func(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{
@@ -314,8 +314,7 @@ func TestCapsLockFromDevices(t *testing.T) {
}
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.True(t, ok)
result := readInitialCapsLockState(mockDevice)
assert.True(t, result)
})
@@ -326,8 +325,7 @@ func TestCapsLockFromDevices(t *testing.T) {
}
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.True(t, ok)
result := readInitialCapsLockState(mockDevice)
assert.False(t, result)
})
@@ -335,25 +333,9 @@ func TestCapsLockFromDevices(t *testing.T) {
mockDevice := mocks.NewMockEvdevDevice(t)
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
assert.False(t, ok)
result := readInitialCapsLockState(mockDevice)
assert.False(t, result)
})
t.Run("falls back past device without LED state", func(t *testing.T) {
noLedDevice := mocks.NewMockEvdevDevice(t)
noLedDevice.EXPECT().State(evdev.EvType(evLedType)).Return(evdev.StateMap{}, nil).Once()
ledDevice := mocks.NewMockEvdevDevice(t)
ledState := evdev.StateMap{
ledCapslockKey: true,
}
ledDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
result, ok := capsLockFromDevices([]EvdevDevice{noLedDevice, nil, ledDevice})
assert.True(t, ok)
assert.True(t, result)
})
}
func TestHasInputGroupAccess(t *testing.T) {
+17 -20
View File
@@ -8,11 +8,24 @@ import (
)
func NewManager(client geolocation.Client) (*Manager, error) {
currLocation, err := client.GetLocation()
if err != nil {
log.Warnf("Failed to get initial location: %v", err)
}
m := &Manager{
client: client,
dirty: make(chan struct{}),
stopChan: make(chan struct{}),
state: &State{},
state: &State{
Latitude: currLocation.Latitude,
Longitude: currLocation.Longitude,
},
}
if err := m.startSignalPump(); err != nil {
return nil, err
}
m.notifierWg.Add(1)
@@ -21,22 +34,6 @@ func NewManager(client geolocation.Client) (*Manager, error) {
return m, nil
}
// The geolocation client may fetch IP location on first use, so nothing
// touches it until a consumer actually asks for location data.
func (m *Manager) ensureStarted() {
m.startOnce.Do(func() {
go func() {
currLocation, err := m.client.GetLocation()
if err != nil {
log.Warnf("Failed to get initial location: %v", err)
} else {
m.handleLocationChange(currLocation)
}
m.startSignalPump()
}()
})
}
func (m *Manager) Close() {
close(m.stopChan)
m.notifierWg.Wait()
@@ -51,7 +48,6 @@ func (m *Manager) Close() {
}
func (m *Manager) Subscribe(id string) chan State {
m.ensureStarted()
ch := make(chan State, 64)
m.subscribers.Store(id, ch)
return ch
@@ -63,7 +59,7 @@ func (m *Manager) Unsubscribe(id string) {
}
}
func (m *Manager) startSignalPump() {
func (m *Manager) startSignalPump() error {
m.sigWG.Add(1)
go func() {
defer m.sigWG.Done()
@@ -84,6 +80,8 @@ func (m *Manager) startSignalPump() {
}
}
}()
return nil
}
func (m *Manager) handleLocationChange(location geolocation.Location) {
@@ -104,7 +102,6 @@ func (m *Manager) notifySubscribers() {
}
func (m *Manager) GetState() State {
m.ensureStarted()
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
+1 -2
View File
@@ -16,8 +16,7 @@ type Manager struct {
state *State
stateMutex sync.RWMutex
client geolocation.Client
startOnce sync.Once
client geolocation.Client
stopChan chan struct{}
sigWG sync.WaitGroup
@@ -302,17 +302,17 @@ func (a *SecretAgent) GetSecrets(
}
a.backend.cachedVPNCredsMu.Unlock()
a.backend.cachedOpenConnectMu.Lock()
cachedOpenConnect := a.backend.cachedOpenConnectAuth
if cachedOpenConnect != nil && cachedOpenConnect.ConnectionUUID == connUuid {
a.backend.cachedOpenConnectAuth = nil
a.backend.cachedOpenConnectMu.Unlock()
a.backend.cachedGPSamlMu.Lock()
cachedGPSaml := a.backend.cachedGPSamlCookie
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
a.backend.cachedGPSamlCookie = nil
a.backend.cachedGPSamlMu.Unlock()
log.Infof("[SecretAgent] Using cached OpenConnect authentication for %s", connUuid)
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
return buildOpenConnectSecretsResponse(settingName, cachedOpenConnect.Cookie, cachedOpenConnect.Host, cachedOpenConnect.Fingerprint), nil
return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
}
a.backend.cachedOpenConnectMu.Unlock()
a.backend.cachedGPSamlMu.Unlock()
if len(fields) == 1 && fields[0] == "gp-saml" {
gateway := ""
@@ -347,17 +347,17 @@ func (a *SecretAgent) GetSecrets(
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
a.backend.cachedOpenConnectMu.Lock()
a.backend.cachedOpenConnectAuth = &cachedOpenConnectAuth{
a.backend.cachedGPSamlMu.Lock()
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
ConnectionUUID: connUuid,
Cookie: authResult.Cookie,
Host: authResult.Host,
User: authResult.User,
Fingerprint: authResult.Fingerprint,
}
a.backend.cachedOpenConnectMu.Unlock()
a.backend.cachedGPSamlMu.Unlock()
return buildOpenConnectSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
}
}
@@ -987,7 +987,7 @@ func buildWiFiSecretsResponse(settingName string, secrets map[string]string) nmS
return out
}
func buildOpenConnectSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
out := nmSettingMap{}
vpnSec := nmVariantMap{}
@@ -122,7 +122,7 @@ func TestNeedsExternalBrowserAuth(t *testing.T) {
}
}
func TestBuildOpenConnectSecretsResponse(t *testing.T) {
func TestBuildGPSamlSecretsResponse(t *testing.T) {
tests := []struct {
name string
settingName string
@@ -155,7 +155,7 @@ func TestBuildOpenConnectSecretsResponse(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildOpenConnectSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
assert.NotNil(t, result)
assert.Contains(t, result, tt.settingName)
@@ -80,16 +80,16 @@ type NetworkManagerBackend struct {
hotspotPendingDevice string
pendingVPNSave *pendingVPNCredentials
pendingVPNSaveMu sync.Mutex
cachedVPNCreds *cachedVPNCredentials
cachedVPNCredsMu sync.Mutex
cachedPKCS11PIN *cachedPKCS11PIN
cachedPKCS11Mu sync.Mutex
cachedOpenConnectAuth *cachedOpenConnectAuth
cachedOpenConnectMu sync.Mutex
cachedWiFiSecret *cachedWiFiSecret
cachedWiFiSecretMu sync.Mutex
pendingVPNSave *pendingVPNCredentials
pendingVPNSaveMu sync.Mutex
cachedVPNCreds *cachedVPNCredentials
cachedVPNCredsMu sync.Mutex
cachedPKCS11PIN *cachedPKCS11PIN
cachedPKCS11Mu sync.Mutex
cachedGPSamlCookie *cachedGPSamlCookie
cachedGPSamlMu sync.Mutex
cachedWiFiSecret *cachedWiFiSecret
cachedWiFiSecretMu sync.Mutex
onStateChange func()
}
@@ -100,9 +100,8 @@ type pendingVPNCredentials struct {
Password string
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
// falls back to Password under the "password" key when empty.
Secrets map[string]string
PersistentSecrets map[string]string
SavePassword bool
Secrets map[string]string
SavePassword bool
}
type cachedVPNCredentials struct {
@@ -125,7 +124,7 @@ type cachedWiFiSecret struct {
Secrets map[string]string
}
type cachedOpenConnectAuth struct {
type cachedGPSamlCookie struct {
ConnectionUUID string
Cookie string
Host string
@@ -3,7 +3,6 @@ package network
import (
"bufio"
"context"
"errors"
"fmt"
"os/exec"
"strings"
@@ -11,29 +10,16 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
type openConnectAuthResult struct {
type gpSamlAuthResult struct {
Cookie string
Host string
User string
Fingerprint string
}
type openConnectAuthError struct {
cause error
serverCert string
}
func (e *openConnectAuthError) Error() string {
return fmt.Sprintf("openconnect --authenticate failed: %v", e.cause)
}
func (e *openConnectAuthError) Unwrap() error {
return e.cause
}
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
// Only supports protocol=gp. Other protocols need their own implementations.
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*openConnectAuthResult, error) {
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
if gateway == "" {
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
}
@@ -77,7 +63,7 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
}
}()
result := &openConnectAuthResult{Host: gateway}
result := &gpSamlAuthResult{Host: gateway}
var allOutput []string
scanner := bufio.NewScanner(stdout)
@@ -131,8 +117,13 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
return result, nil
}
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*openConnectAuthResult, error) {
return runOpenConnectAuthenticate(ctx, []string{
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
ocPath, err := exec.LookPath("openconnect")
if err != nil {
return nil, fmt.Errorf("openconnect not found: %w", err)
}
args := []string{
"--protocol=gp",
"--usergroup=gateway:prelogin-cookie",
"--user=" + user,
@@ -140,83 +131,18 @@ func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user
"--allow-insecure-crypto",
"--authenticate",
gateway,
}, preloginCookie)
}
func runOpenConnectPasswordAuth(
ctx context.Context,
data map[string]string,
username, password, serverCert string,
) (*openConnectAuthResult, error) {
if data["protocol"] != "fortinet" {
return nil, fmt.Errorf("only Fortinet password authentication is supported")
}
gateway := data["gateway"]
if gateway == "" {
return nil, fmt.Errorf("OpenConnect gateway is empty")
}
if username == "" || password == "" {
return nil, fmt.Errorf("OpenConnect username and password are required")
}
args := []string{
"--protocol=fortinet",
"--user=" + username,
"--passwd-on-stdin",
"--non-inter",
}
if usergroup := data["usergroup"]; usergroup != "" {
args = append(args, "--usergroup="+usergroup)
}
if serverCert != "" {
args = append(args, "--servercert="+serverCert)
}
args = append(args, "--authenticate", gateway)
result, err := runOpenConnectAuthenticate(ctx, args, password)
if err == nil {
result.Host = gateway
if result.Fingerprint == "" {
result.Fingerprint = serverCert
}
}
return result, err
}
func runOpenConnectAuthenticate(ctx context.Context, args []string, secret string) (*openConnectAuthResult, error) {
ocPath, err := exec.LookPath("openconnect")
if err != nil {
return nil, fmt.Errorf("openconnect not found: %w", err)
}
cmd := exec.CommandContext(ctx, ocPath, args...)
cmd.Stdin = strings.NewReader(secret + "\n")
cmd.Stdin = strings.NewReader(preloginCookie)
output, err := cmd.CombinedOutput()
result := parseOpenConnectAuthenticateOutput(string(output))
serverCert := suggestedOpenConnectServerCert(string(output))
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("openconnect authentication timed out or was cancelled: %w", ctx.Err())
}
return nil, &openConnectAuthError{cause: err, serverCert: serverCert}
}
if result.Cookie == "" {
return nil, &openConnectAuthError{
cause: errors.New("no COOKIE in command output"),
serverCert: serverCert,
}
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
}
log.Infof("[OpenConnect] Authentication successful: cookie_len=%d, host=%s, has_fingerprint=%v",
len(result.Cookie), result.Host, result.Fingerprint != "")
return result, nil
}
func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
result := &openConnectAuthResult{}
for _, line := range strings.Split(output, "\n") {
result := &gpSamlAuthResult{}
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "COOKIE="):
@@ -232,7 +158,15 @@ func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
}
}
}
return result
if result.Cookie == "" {
return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
}
log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
len(result.Cookie), result.Host, result.Fingerprint)
return result, nil
}
func unshellQuote(s string) string {
@@ -245,7 +179,7 @@ func unshellQuote(s string) string {
return s
}
func parseGPSamlFromCommandLine(line string, result *openConnectAuthResult) {
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
if !strings.Contains(line, "openconnect") {
return
}
@@ -1,10 +1,6 @@
package network
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -75,7 +71,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
tests := []struct {
name string
line string
initialResult *openConnectAuthResult
initialResult *gpSamlAuthResult
expectedCookie string
expectedUser string
expectedFP string
@@ -83,7 +79,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "full openconnect command",
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "AUTH123",
expectedUser: "john",
expectedFP: "pin-sha256:ABC",
@@ -91,7 +87,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "with equals signs in cookie",
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "authcookie=xyz123&portal=GATE",
expectedUser: "jane",
expectedFP: "",
@@ -99,7 +95,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "non-openconnect line",
line: "some other output",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "",
expectedUser: "",
expectedFP: "",
@@ -107,7 +103,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "preserves existing values",
line: "openconnect --user=newuser",
initialResult: &openConnectAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
expectedCookie: "existing",
expectedUser: "newuser",
expectedFP: "existing-fp",
@@ -115,7 +111,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "only updates empty fields",
line: "openconnect --cookie=NEW --user=NEW",
initialResult: &openConnectAuthResult{Cookie: "OLD"},
initialResult: &gpSamlAuthResult{Cookie: "OLD"},
expectedCookie: "OLD",
expectedUser: "NEW",
expectedFP: "",
@@ -123,7 +119,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "real gp-saml-gui output",
line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "",
expectedUser: "john.doe@example.com",
expectedFP: "",
@@ -131,7 +127,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
{
name: "with server cert flag",
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
initialResult: &openConnectAuthResult{},
initialResult: &gpSamlAuthResult{},
expectedCookie: "",
expectedUser: "",
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
@@ -162,7 +158,7 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
"",
}
result := &openConnectAuthResult{}
result := &gpSamlAuthResult{}
for _, line := range lines {
parseGPSamlFromCommandLine(line, result)
}
@@ -171,19 +167,3 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
assert.Empty(t, result.Cookie, "cookie should not be parsed from command line")
assert.Empty(t, result.Fingerprint)
}
func TestRunOpenConnectAuthenticateSanitizesFailure(t *testing.T) {
binDir := t.TempDir()
openConnectPath := filepath.Join(binDir, "openconnect")
script := "#!/bin/sh\nprintf '%s\\n' 'Cookie: should-not-leak' 'Add --servercert pin-sha256:TEST-FINGERPRINT' >&2\nexit 1\n"
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
t.Setenv("PATH", binDir)
_, err := runOpenConnectAuthenticate(context.Background(), []string{"--authenticate", "vpn.example.test"}, "password")
assert.Error(t, err)
assert.NotContains(t, err.Error(), "should-not-leak")
var authErr *openConnectAuthError
assert.True(t, errors.As(err, &authErr))
assert.Equal(t, "pin-sha256:TEST-FINGERPRINT", authErr.serverCert)
}
@@ -326,7 +326,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
}
authAction := detectVPNAuthAction(vpnServiceType, vpnData)
var openConnectAuth *openConnectAuthResult
switch authAction {
case "openvpn_username":
@@ -336,19 +335,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
return err
}
case "openconnect_password":
if err := b.ensureOpenConnectAgentFlags(targetConn, vpnData); err != nil {
return fmt.Errorf("failed to prepare OpenConnect connection: %w", err)
}
authCtx, authCancel := context.WithTimeout(context.Background(), 5*time.Minute)
openConnectAuth, err = b.handleOpenConnectPasswordAuth(
authCtx, targetConn, connName, targetUUID, vpnServiceType, vpnData,
)
authCancel()
if err != nil {
return fmt.Errorf("OpenConnect authentication failed: %w", err)
}
case "gp_saml":
gateway := vpnData["gateway"]
protocol := vpnData["protocol"]
@@ -359,7 +345,7 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
openConnectAuth, err = b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
samlCancel()
if err != nil {
errMsg := err.Error()
@@ -377,6 +363,16 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
}
}
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = &cachedGPSamlCookie{
ConnectionUUID: targetUUID,
Cookie: authResult.Cookie,
Host: authResult.Host,
User: authResult.User,
Fingerprint: authResult.Fingerprint,
}
b.cachedGPSamlMu.Unlock()
if err := targetConn.ClearSecrets(); err != nil {
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
} else {
@@ -386,19 +382,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName)
}
if openConnectAuth != nil {
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = &cachedOpenConnectAuth{
ConnectionUUID: targetUUID,
Cookie: openConnectAuth.Cookie,
Host: openConnectAuth.Host,
User: openConnectAuth.User,
Fingerprint: openConnectAuth.Fingerprint,
}
b.cachedOpenConnectMu.Unlock()
log.Infof("[ConnectVPN] OpenConnect authentication cached for %s, proceeding with activation", connName)
}
b.stateMutex.Lock()
b.state.IsConnectingVPN = true
b.state.ConnectingVPNUUID = targetUUID
@@ -411,13 +394,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
nm := b.nmConn.(gonetworkmanager.NetworkManager)
_, err = nm.ActivateConnection(targetConn, nil, nil)
if err != nil {
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
b.stateMutex.Lock()
b.state.IsConnectingVPN = false
b.state.ConnectingVPNUUID = ""
@@ -449,9 +425,6 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol)
}
}
if protocol == "fortinet" && data["authtype"] == "password" {
return "openconnect_password"
}
case strings.Contains(serviceType, "openvpn"):
connType := data["connection-type"]
username := data["username"]
@@ -462,200 +435,6 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
return ""
}
func setOpenConnectAgentFlags(data map[string]string) bool {
changed := false
for _, field := range []string{"cookie", "gateway", "gwcert"} {
key := field + "-flags"
if data[key] != "2" {
data[key] = "2"
changed = true
}
}
return changed
}
func (b *NetworkManagerBackend) ensureOpenConnectAgentFlags(conn gonetworkmanager.Connection, data map[string]string) error {
if !setOpenConnectAgentFlags(data) {
return nil
}
if b.dbusConn == nil {
return fmt.Errorf("NetworkManager D-Bus connection is unavailable")
}
connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", conn.GetPath())
var existingSettings map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
return fmt.Errorf("failed to get connection settings: %w", err)
}
vpn, ok := existingSettings["vpn"]
if !ok {
return fmt.Errorf("VPN settings are missing")
}
vpn["data"] = dbus.MakeVariant(data)
var stored map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSecrets", 0, "vpn").Store(&stored); err != nil {
return fmt.Errorf("failed to preserve VPN secrets: %w", err)
}
if storedVPN, ok := stored["vpn"]; ok {
if secrets, ok := storedVPN["secrets"]; ok {
vpn["secrets"] = secrets
}
}
settings := map[string]map[string]dbus.Variant{"vpn": vpn}
if connection, ok := existingSettings["connection"]; ok {
settings["connection"] = connection
}
var result map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
return fmt.Errorf("failed to set NetworkManager secret-agent flags: %w", err)
}
return nil
}
func (b *NetworkManagerBackend) handleOpenConnectPasswordAuth(
ctx context.Context,
targetConn gonetworkmanager.Connection,
connName, targetUUID, vpnServiceType string,
data map[string]string,
) (*openConnectAuthResult, error) {
username := data["username"]
secrets := map[string]string{}
if stored, err := targetConn.GetSecrets("vpn"); err == nil {
if vpn, ok := stored["vpn"]; ok {
if saved, ok := vpn["secrets"].(map[string]string); ok {
secrets = saved
}
}
}
password := secrets["password"]
serverCert := secrets["certificate:"+data["gateway"]]
if serverCert == "" {
serverCert = secrets["gwcert"]
}
var reply PromptReply
if username == "" || password == "" {
if b.promptBroker == nil {
return nil, fmt.Errorf("password authentication requires an interactive prompt")
}
fields := []string{}
fieldsInfo := []FieldInfo{}
if username == "" {
fields = append(fields, "username")
fieldsInfo = append(fieldsInfo, FieldInfo{Name: "username", Label: "Username", IsSecret: false})
}
if password == "" {
fields = append(fields, "password")
fieldsInfo = append(fieldsInfo, FieldInfo{Name: "password", Label: "Password", IsSecret: true})
}
token, err := b.promptBroker.Ask(ctx, PromptRequest{
Name: connName,
ConnType: "vpn",
VpnService: vpnServiceType,
SettingName: "vpn",
Fields: fields,
FieldsInfo: fieldsInfo,
Reason: "required",
ConnectionId: connName,
ConnectionUuid: targetUUID,
ConnectionPath: string(targetConn.GetPath()),
})
if err != nil {
return nil, fmt.Errorf("failed to request credentials: %w", err)
}
reply, err = b.promptBroker.Wait(ctx, token)
if err != nil {
return nil, fmt.Errorf("credentials prompt failed: %w", err)
}
if username == "" {
username = reply.Secrets["username"]
}
if password == "" {
password = reply.Secrets["password"]
}
}
auth, err := runOpenConnectPasswordAuth(ctx, data, username, password, serverCert)
persistentSecrets := map[string]string{}
var authErr *openConnectAuthError
if err != nil && errors.As(err, &authErr) && authErr.serverCert != "" && authErr.serverCert != serverCert {
if b.promptBroker == nil {
return nil, fmt.Errorf("VPN server certificate is untrusted: %s", authErr.serverCert)
}
reason := "server-certificate"
if serverCert != "" {
reason = "server-certificate-changed"
}
token, promptErr := b.promptBroker.Ask(ctx, PromptRequest{
Name: connName,
ConnType: "vpn",
VpnService: vpnServiceType,
SettingName: "vpn",
Hints: []string{authErr.serverCert},
Reason: reason,
ConnectionId: connName,
ConnectionUuid: targetUUID,
ConnectionPath: string(targetConn.GetPath()),
})
if promptErr != nil {
return nil, fmt.Errorf("failed to request certificate confirmation: %w", promptErr)
}
if _, promptErr = b.promptBroker.Wait(ctx, token); promptErr != nil {
return nil, fmt.Errorf("certificate confirmation failed: %w", promptErr)
}
auth, err = runOpenConnectPasswordAuth(ctx, data, username, password, authErr.serverCert)
if err == nil {
persistentSecrets["certificate:"+data["gateway"]] = authErr.serverCert
}
}
if err != nil {
return nil, err
}
if len(reply.Secrets) > 0 || len(persistentSecrets) > 0 {
creds := &pendingVPNCredentials{
ConnectionPath: string(targetConn.GetPath()),
PersistentSecrets: persistentSecrets,
}
if _, ok := reply.Secrets["username"]; ok {
creds.Username = username
}
if reply.Save {
creds.Username = username
creds.Password = password
creds.Secrets = map[string]string{"password": password}
creds.SavePassword = true
}
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = creds
b.pendingVPNSaveMu.Unlock()
}
return auth, nil
}
func suggestedOpenConnectServerCert(output string) string {
for _, field := range strings.Fields(output) {
field = strings.Trim(field, "'\".,")
if strings.HasPrefix(field, "pin-sha256:") {
return field
}
}
return ""
}
func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error {
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
@@ -979,13 +758,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = ""
b.stateMutex.Unlock()
// Clear cached one-shot authentication values on success.
// Clear cached PKCS11 PIN and SAML cookie on success
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
b.pendingVPNSaveMu.Lock()
pending := b.pendingVPNSave
@@ -1008,16 +787,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = connectingVPNUUID
b.stateMutex.Unlock()
// Clear cached one-shot authentication values on failure.
// Clear cached PKCS11 PIN and SAML cookie on failure
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
return
}
}
@@ -1035,16 +811,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
b.state.VPNErrorUuid = connectingVPNUUID
b.stateMutex.Unlock()
// Clear cached one-shot authentication values.
// Clear cached PKCS11 PIN and SAML cookie
b.cachedPKCS11Mu.Lock()
b.cachedPKCS11PIN = nil
b.cachedPKCS11Mu.Unlock()
b.cachedOpenConnectMu.Lock()
b.cachedOpenConnectAuth = nil
b.cachedOpenConnectMu.Unlock()
b.pendingVPNSaveMu.Lock()
b.pendingVPNSave = nil
b.pendingVPNSaveMu.Unlock()
b.cachedGPSamlMu.Lock()
b.cachedGPSamlCookie = nil
b.cachedGPSamlMu.Unlock()
}
}
@@ -1090,40 +863,17 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
log.Infof("[saveVPNCredentials] Saving username")
}
secs := map[string]string{}
if len(creds.PersistentSecrets) > 0 {
var stored map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSecrets", 0, "vpn").Store(&stored); err != nil {
log.Warnf("[saveVPNCredentials] GetSecrets failed: %v", err)
return
}
if storedVPN, ok := stored["vpn"]; ok {
if storedSecrets, ok := storedVPN["secrets"]; ok {
saved, _ := storedSecrets.Value().(map[string]string)
for field, value := range saved {
secs[field] = value
}
}
}
for field, value := range creds.PersistentSecrets {
secs[field] = value
data[field+"-flags"] = "0"
}
}
// Save secrets if requested
if creds.SavePassword {
toSave := creds.Secrets
if len(toSave) == 0 {
toSave = map[string]string{"password": creds.Password}
secs := creds.Secrets
if len(secs) == 0 {
secs = map[string]string{"password": creds.Password}
}
for field, value := range toSave {
secs[field] = value
for field := range secs {
data[field+"-flags"] = "0"
}
}
if len(secs) > 0 {
vpn["secrets"] = dbus.MakeVariant(secs)
log.Infof("[saveVPNCredentials] Saving %d secret field(s)", len(secs))
log.Infof("[saveVPNCredentials] Saving %d secret field(s) with flags=0", len(secs))
}
vpn["data"] = dbus.MakeVariant(data)
@@ -1,14 +1,10 @@
package network
import (
"context"
"os"
"path/filepath"
"testing"
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
"github.com/Wifx/gonetworkmanager/v2"
"github.com/godbus/dbus/v5"
"github.com/stretchr/testify/assert"
)
@@ -140,137 +136,3 @@ func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T)
backend.updateVPNConnectionState()
})
}
func TestDetectVPNAuthAction_FortinetPasswordOnly(t *testing.T) {
service := "org.freedesktop.NetworkManager.openconnect"
assert.Equal(t, "openconnect_password", detectVPNAuthAction(service, map[string]string{
"protocol": "fortinet",
"authtype": "password",
}))
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
"protocol": "anyconnect",
"authtype": "password",
}))
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
"protocol": "fortinet",
"authtype": "saml",
}))
}
func TestEnsureOpenConnectAgentFlags(t *testing.T) {
data := map[string]string{"protocol": "fortinet"}
assert.True(t, setOpenConnectAgentFlags(data))
assert.Equal(t, "2", data["cookie-flags"])
assert.Equal(t, "2", data["gateway-flags"])
assert.Equal(t, "2", data["gwcert-flags"])
assert.False(t, setOpenConnectAgentFlags(data))
}
func TestOpenConnectCertificateConfirmation(t *testing.T) {
binDir := t.TempDir()
openConnectPath := filepath.Join(binDir, "openconnect")
script := `#!/bin/sh
case "$*" in
*--servercert=pin-sha256:TEST-FINGERPRINT*)
printf '%s\n' "COOKIE='SVPNCOOKIE=test'" "HOST='vpn.example.test'" "FINGERPRINT='pin-sha256:TEST-FINGERPRINT'"
exit 0
;;
esac
printf '%s\n' 'Add --servercert pin-sha256:TEST-FINGERPRINT' >&2
exit 1
`
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
t.Setenv("PATH", binDir)
conn := mock_gonetworkmanager.NewMockConnection(t)
connPath := dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/999")
conn.EXPECT().GetSecrets("vpn").Return(gonetworkmanager.ConnectionSettings{
"vpn": {"secrets": map[string]string{"password": "test-password"}},
}, nil)
conn.EXPECT().GetPath().Return(connPath).Twice()
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{},
}
backend := &NetworkManagerBackend{promptBroker: broker}
data := map[string]string{
"gateway": "vpn.example.test:443",
"protocol": "fortinet",
"authtype": "password",
"username": "test-user",
}
result, err := backend.handleOpenConnectPasswordAuth(
context.Background(), conn, "Test VPN", "test-uuid",
"org.freedesktop.NetworkManager.openconnect", data,
)
assert.NoError(t, err)
assert.Equal(t, "SVPNCOOKIE=test", result.Cookie)
assert.Equal(t, "vpn.example.test:443", result.Host)
prompt := <-broker.asked
assert.Equal(t, "server-certificate", prompt.Reason)
assert.Equal(t, []string{"pin-sha256:TEST-FINGERPRINT"}, prompt.Hints)
assert.Equal(t, map[string]string{
"certificate:vpn.example.test:443": "pin-sha256:TEST-FINGERPRINT",
}, backend.pendingVPNSave.PersistentSecrets)
}
func TestOpenConnectCertificateRotationReprompts(t *testing.T) {
binDir := t.TempDir()
openConnectPath := filepath.Join(binDir, "openconnect")
script := `#!/bin/sh
case "$*" in
*--servercert=pin-sha256:NEW-FINGERPRINT*)
printf '%s\n' "COOKIE='SVPNCOOKIE=test'" "HOST='vpn.example.test'" "FINGERPRINT='pin-sha256:NEW-FINGERPRINT'"
exit 0
;;
esac
printf '%s\n' 'Add --servercert pin-sha256:NEW-FINGERPRINT' >&2
exit 1
`
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
t.Setenv("PATH", binDir)
conn := mock_gonetworkmanager.NewMockConnection(t)
connPath := dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/999")
conn.EXPECT().GetSecrets("vpn").Return(gonetworkmanager.ConnectionSettings{
"vpn": {"secrets": map[string]string{
"password": "test-password",
"certificate:vpn.example.test:443": "pin-sha256:OLD-FINGERPRINT",
}},
}, nil)
conn.EXPECT().GetPath().Return(connPath).Twice()
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{},
}
backend := &NetworkManagerBackend{promptBroker: broker}
data := map[string]string{
"gateway": "vpn.example.test:443",
"protocol": "fortinet",
"authtype": "password",
"username": "test-user",
}
result, err := backend.handleOpenConnectPasswordAuth(
context.Background(), conn, "Test VPN", "test-uuid",
"org.freedesktop.NetworkManager.openconnect", data,
)
assert.NoError(t, err)
assert.Equal(t, "SVPNCOOKIE=test", result.Cookie)
prompt := <-broker.asked
assert.Equal(t, "server-certificate-changed", prompt.Reason)
assert.Equal(t, []string{"pin-sha256:NEW-FINGERPRINT"}, prompt.Hints)
assert.Equal(t, map[string]string{
"certificate:vpn.example.test:443": "pin-sha256:NEW-FINGERPRINT",
}, backend.pendingVPNSave.PersistentSecrets)
assert.False(t, backend.pendingVPNSave.SavePassword)
assert.Empty(t, backend.pendingVPNSave.Secrets)
}
-18
View File
@@ -43,8 +43,6 @@ func HandleRequest(conn *models.Conn, req models.Request, manager *Manager) {
handleGetNetworkQRCode(conn, req, manager)
case "network.qrcode-content":
handleGetNetworkQRCodeContent(conn, req, manager)
case "network.generate-qrcode":
handleGenerateQRCode(conn, req)
case "network.delete-qrcode":
handleDeleteQRCode(conn, req, manager)
case "network.ethernet.info":
@@ -367,22 +365,6 @@ func handleGetNetworkQRCodeContent(conn *models.Conn, req models.Request, manage
models.Respond(conn, req.ID, content)
}
func handleGenerateQRCode(conn *models.Conn, req models.Request) {
text, err := params.String(req.Params, "text")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
paths, err := generateTextQRCode(text)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, paths)
}
func handleDeleteQRCode(conn *models.Conn, req models.Request, _ *Manager) {
path, err := params.String(req.Params, "path")
if err != nil {
@@ -1,64 +0,0 @@
package network
import (
"crypto/sha256"
"fmt"
"os"
"time"
"github.com/yeqown/go-qrcode/v2"
"github.com/yeqown/go-qrcode/writer/standard"
)
const textQRCodeTmpPrefix = "/tmp/dank-text-qrcode-"
func generateTextQRCode(text string) ([2]string, error) {
qrc, err := qrcode.New(text)
if err != nil {
return [2]string{}, fmt.Errorf("failed to create QR code for text: %w", err)
}
pathThemed, pathNormal := textQRCodePaths(text)
if err := saveQRCodePNG(qrc, pathThemed, standard.WithBgTransparent(), standard.WithFgColorRGBHex("#ffffff")); err != nil {
return [2]string{}, err
}
if err := saveQRCodePNG(qrc, pathNormal); err != nil {
return [2]string{}, err
}
return [2]string{pathThemed, pathNormal}, nil
}
// Write to a temp file and rename into place so the shell's Image never
// observes a partially written PNG.
func saveQRCodePNG(qrc *qrcode.QRCode, path string, opts ...standard.ImageOption) error {
tmpPath := path + ".tmp"
opts = append(opts, standard.WithBuiltinImageEncoder(standard.PNG_FORMAT))
w, err := standard.New(tmpPath, opts...)
if err != nil {
return fmt.Errorf("failed to create QR code writer: %w", err)
}
if err := qrc.Save(w); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to save QR code: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to move QR code into place: %w", err)
}
return nil
}
// Paths are unique per generation, not per text: the library's mask selection
// is non-deterministic, so regenerating the same text produces different
// bytes, and reusing a path lets the shell's URL-keyed pixmap cache serve a
// stale pattern over the new file.
func textQRCodePaths(text string) (themed, normal string) {
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(text)))[:8]
nonce := time.Now().UnixNano()
themed = fmt.Sprintf("%s%s-%d-themed.png", textQRCodeTmpPrefix, hash, nonce)
normal = fmt.Sprintf("%s%s-%d-normal.png", textQRCodeTmpPrefix, hash, nonce)
return
}
+1 -1
View File
@@ -24,7 +24,7 @@ func qrCodePaths(ssid string) (themed, normal string) {
func isValidQRCodePath(path string) bool {
clean := filepath.Clean(path)
return (strings.HasPrefix(clean, qrCodeTmpPrefix) || strings.HasPrefix(clean, textQRCodeTmpPrefix)) && strings.HasSuffix(clean, ".png")
return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png")
}
var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
@@ -1,83 +0,0 @@
package registries
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/afero"
)
type RegistryInfo struct {
Name string `json:"name"`
URL string `json:"url"`
Official bool `json:"official"`
}
type SuccessResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func HandleRequest(conn *models.Conn, req models.Request) {
switch req.Method {
case "registries.list":
HandleList(conn, req)
case "registries.add":
HandleAdd(conn, req)
case "registries.remove":
HandleRemove(conn, req)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func HandleList(conn *models.Conn, req models.Request) {
sources := registries.Load(afero.NewOsFs())
result := make([]RegistryInfo, len(sources))
for i, s := range sources {
result[i] = RegistryInfo{Name: s.Name, URL: s.URL, Official: s.Official()}
}
models.Respond(conn, req.ID, result)
}
func HandleAdd(conn *models.Conn, req models.Request) {
name, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return
}
url, ok := models.Get[string](req, "url")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'url' parameter")
return
}
if err := registries.Add(afero.NewOsFs(), name, url); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{
Success: true,
Message: fmt.Sprintf("registry added: %s", name),
})
}
func HandleRemove(conn *models.Conn, req models.Request) {
name, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return
}
if err := registries.Remove(afero.NewOsFs(), name); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{
Success: true,
Message: fmt.Sprintf("registry removed: %s", name),
})
}
+2 -9
View File
@@ -18,7 +18,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
serverRegistries "github.com/AvengeMedia/DankMaterialShell/core/internal/server/registries"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
@@ -48,11 +47,6 @@ func RouteRequest(conn *models.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "registries.") {
serverRegistries.HandleRequest(conn, req)
return
}
if strings.HasPrefix(req.Method, "theme.auto.") {
if themeModeManager == nil {
models.RespondError(conn, req.ID, "theme mode manager not initialized")
@@ -122,12 +116,11 @@ func RouteRequest(conn *models.Conn, req models.Request) {
}
if strings.HasPrefix(req.Method, "cups.") {
mgr, err := ensureCupsManager()
if err != nil {
if cupsManager == nil {
models.RespondError(conn, req.ID, "CUPS manager not initialized")
return
}
cups.HandleRequest(conn, req, mgr)
cups.HandleRequest(conn, req, cupsManager)
return
}
+37 -113
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
@@ -36,7 +37,7 @@ import (
"github.com/AvengeMedia/dankgo/syncmap"
)
const APIVersion = 29
const APIVersion = 28
var CLIVersion = "dev"
@@ -79,8 +80,8 @@ var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client"
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
var cupsMu sync.Mutex
var cupsSubscriberCount int
var cupsSubscribers syncmap.Map[string, bool]
var cupsSubscriberCount atomic.Int32
var appPaths = paths.New("danklinux")
@@ -173,55 +174,17 @@ func InitializeAppPickerManager() error {
return nil
}
func initializeCupsManagerLocked() (bool, error) {
if cupsManager != nil {
return false, nil
}
func InitializeCupsManager() error {
manager, err := cups.NewManager()
if err != nil {
log.Warnf("Failed to initialize cups manager: %v", err)
return false, err
return err
}
cupsManager = manager
log.Info("CUPS manager initialized")
return true, nil
}
func ensureCupsManager() (*cups.Manager, error) {
cupsMu.Lock()
created, err := initializeCupsManagerLocked()
mgr := cupsManager
cupsMu.Unlock()
if err != nil {
return nil, err
}
if created {
notifyCapabilityChange()
}
return mgr, nil
}
func cupsAvailable() bool {
cupsMu.Lock()
defer cupsMu.Unlock()
return cupsManager != nil
}
func releaseCupsSubscriber() {
cupsMu.Lock()
cupsSubscriberCount--
var mgr *cups.Manager
if cupsSubscriberCount == 0 && cupsManager != nil {
mgr = cupsManager
cupsManager = nil
}
cupsMu.Unlock()
if mgr == nil {
return
}
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
mgr.Close()
notifyCapabilityChange()
return nil
}
func InitializeBrightnessManager() error {
@@ -416,7 +379,7 @@ func getCapabilities() Capabilities {
caps = append(caps, "browser")
}
if cupsAvailable() {
if cupsManager != nil {
caps = append(caps, "cups")
}
@@ -486,7 +449,7 @@ func getServerInfo() ServerInfo {
caps = append(caps, "browser")
}
if cupsAvailable() {
if cupsManager != nil {
caps = append(caps, "cups")
}
@@ -950,31 +913,38 @@ func handleSubscribe(conn *models.Conn, req models.Request) {
}
if shouldSubscribe("cups") {
cupsMu.Lock()
cupsSubscriberCount++
created, err := initializeCupsManagerLocked()
mgr := cupsManager
cupsMu.Unlock()
cupsSubscribers.Store(clientID+"-cups", true)
count := cupsSubscriberCount.Add(1)
if err != nil {
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
} else if created {
notifyCapabilityChange()
if count == 1 {
if err := InitializeCupsManager(); err != nil {
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
} else {
notifyCapabilityChange()
}
}
if mgr == nil {
releaseCupsSubscriber()
} else {
if cupsManager != nil {
wg.Add(1)
cupsChan := mgr.Subscribe(clientID + "-cups")
cupsChan := cupsManager.Subscribe(clientID + "-cups")
go func() {
defer wg.Done()
defer func() {
mgr.Unsubscribe(clientID + "-cups")
releaseCupsSubscriber()
cupsManager.Unsubscribe(clientID + "-cups")
cupsSubscribers.Delete(clientID + "-cups")
count := cupsSubscriberCount.Add(-1)
if count == 0 {
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
if cupsManager != nil {
cupsManager.Close()
cupsManager = nil
notifyCapabilityChange()
}
}
}()
initialState := mgr.GetState()
initialState := cupsManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
case <-stopChan:
@@ -1183,38 +1153,6 @@ func handleSubscribe(conn *models.Conn, req models.Request) {
}()
}
if shouldSubscribe("location") && locationManager != nil {
wg.Add(1)
locationChan := locationManager.Subscribe(clientID + "-location")
go func() {
defer wg.Done()
defer locationManager.Unsubscribe(clientID + "-location")
initialState := locationManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "location", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-locationChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "location", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
wg.Add(1)
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
@@ -1316,12 +1254,9 @@ func cleanupManagers() {
if appPickerManager != nil {
appPickerManager.Close()
}
cupsMu.Lock()
if cupsManager != nil {
cupsManager.Close()
cupsManager = nil
}
cupsMu.Unlock()
if brightnessManager != nil {
brightnessManager.Close()
}
@@ -1700,21 +1635,10 @@ func (s *Server) Serve(printDocs bool) error {
}()
go func() {
for {
err := InitializeBluezManager()
if err == nil {
notifyCapabilityChange()
return
}
if err := InitializeBluezManager(); err != nil {
log.Warnf("Bluez manager unavailable: %v", err)
if !errors.Is(err, bluez.ErrNoAdapter) {
return
}
if err := bluez.WaitForAdapter(); err != nil {
log.Warnf("Bluetooth adapter watch failed: %v", err)
return
}
log.Info("Bluetooth adapter appeared, initializing bluez manager")
} else {
notifyCapabilityChange()
}
}()
+1 -2
View File
@@ -143,8 +143,7 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
case "konsole":
argv = []string{term, "-p", "tabtitle=" + title}
case "gnome-terminal":
// --wait: the factory process otherwise returns immediately
argv = []string{term, "--wait", "--title=" + title}
argv = []string{term, "--title=" + title}
execFlag = "--"
default:
argv = []string{term}
+9 -14
View File
@@ -412,30 +412,25 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
m.failCustomUpgrade(ErrCodeTimeout, err)
return
code = ErrCodeTimeout
case errors.Is(ctx.Err(), context.Canceled):
m.failCustomUpgrade(ErrCodeCancelled, err)
return
code = ErrCodeCancelled
}
// exit status reflects the trailing `read`, not the update command
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
return
}
m.finishSuccessfulUpgrade(false)
m.runRefresh(context.Background(), false)
}
func (m *Manager) failCustomUpgrade(code ErrorCode, err error) {
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
m.appendLog("Upgrade complete.")
@@ -233,27 +233,3 @@ func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
}
}
func TestWrapInTerminal(t *testing.T) {
tests := []struct {
term string
wantPrefix []string
}{
{"kitty", []string{"kitty", "--class", "com.danklinux.dms", "-T", "Title"}},
{"gnome-terminal", []string{"gnome-terminal", "--wait", "--title=Title"}},
{"foot", []string{"foot", "--app-id=com.danklinux.dms", "--title=Title"}},
}
for _, tt := range tests {
got := wrapInTerminal(tt.term, "Title", "echo hi", nil)
if len(got) < len(tt.wantPrefix) || !reflect.DeepEqual(got[:len(tt.wantPrefix)], tt.wantPrefix) {
t.Errorf("wrapInTerminal(%q) = %#v, want prefix %#v", tt.term, got, tt.wantPrefix)
}
tail := got[len(got)-3:]
if tail[0] != "sh" || tail[1] != "-c" {
t.Errorf("wrapInTerminal(%q) tail = %#v, want [sh -c <cmd>]", tt.term, tail)
}
if !strings.Contains(tail[2], "echo hi") {
t.Errorf("wrapInTerminal(%q) command %q does not contain shell command", tt.term, tail[2])
}
}
}
+42 -37
View File
@@ -12,7 +12,6 @@ import (
"time"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"github.com/AvengeMedia/dankgo/boottimer"
"github.com/godbus/dbus/v5"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
@@ -24,6 +23,9 @@ import (
const animKelvinStep = 25
// Go timers freeze during suspend; cap sleeps so wall-clock deadlines can't be missed.
const maxScheduleWait = 5 * time.Minute
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
if err := config.Validate(); err != nil {
return nil, err
@@ -750,8 +752,7 @@ func (m *Manager) schedulerLoop() {
m.post(func() { m.applyCurrentTemp("startup") })
}
timer := boottimer.New(24 * time.Hour)
defer timer.Stop()
var timer *time.Timer
for {
m.configMutex.RLock()
enabled := m.config.Enabled
@@ -759,23 +760,32 @@ func (m *Manager) schedulerLoop() {
now := time.Now()
m.recalcSchedule(now)
// publish independent of output readiness so night status never
// presents a stale schedule while applies are blocked (#2967)
m.updateStateFromSchedule()
waitDur := 24 * time.Hour
var waitDur time.Duration
if enabled {
deadline := m.getNextDeadline(now)
if waitDur = time.Until(deadline); waitDur < time.Second {
waitDur = time.Until(deadline)
switch {
case waitDur < time.Second:
waitDur = time.Second
case waitDur > maxScheduleWait:
waitDur = maxScheduleWait
}
} else {
waitDur = 24 * time.Hour
}
timer.Reset(waitDur)
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(waitDur)
select {
case <-m.stopChan:
timer.Stop()
return
case <-m.updateTrigger:
timer.Stop()
m.scheduleMutex.Lock()
m.schedule.calcDay = time.Time{}
m.scheduleMutex.Unlock()
@@ -1076,11 +1086,14 @@ func (m *Manager) handleResume() {
}
// Compositor gamma state is unknown after resume; force a resend (#1235)
// and re-arm the scheduler timer, which froze during suspend.
m.outputs.Range(func(_ uint32, out *outputState) bool {
out.lastTemp = 0
return true
})
m.recalcSchedule(time.Now())
m.applyCurrentTemp("resume")
m.triggerUpdate()
}
func (m *Manager) triggerUpdate() {
@@ -1107,15 +1120,13 @@ func (m *Manager) SetTemperature(low, high int) error {
m.configMutex.Unlock()
return nil
}
updated := m.config
updated.LowTemp = low
updated.HighTemp = high
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
m.config.LowTemp = low
m.config.HighTemp = high
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1127,16 +1138,14 @@ func (m *Manager) SetLocation(lat, lon float64) error {
m.configMutex.Unlock()
return nil
}
updated := m.config
updated.Latitude = &lat
updated.Longitude = &lon
updated.UseIPLocation = false
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
m.config.Latitude = &lat
m.config.Longitude = &lon
m.config.UseIPLocation = false
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1171,15 +1180,13 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
m.configMutex.Unlock()
return nil
}
updated := m.config
updated.ManualSunrise = &sunrise
updated.ManualSunset = &sunset
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
m.config.ManualSunrise = &sunrise
m.config.ManualSunset = &sunset
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -1202,14 +1209,12 @@ func (m *Manager) SetGamma(gamma float64) error {
m.configMutex.Unlock()
return nil
}
updated := m.config
updated.Gamma = gamma
if err := updated.Validate(); err != nil {
m.configMutex.Unlock()
m.config.Gamma = gamma
err := m.config.Validate()
m.configMutex.Unlock()
if err != nil {
return err
}
m.config = updated
m.configMutex.Unlock()
m.triggerUpdate()
return nil
}
@@ -9,7 +9,6 @@ import (
"github.com/stretchr/testify/assert"
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
)
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
@@ -413,75 +412,3 @@ func TestNewManager_InvalidConfig(t *testing.T) {
_, err := NewManager(mockDisplay, config)
assert.Error(t, err)
}
func TestSetters_RejectedValuesLeaveConfigUntouched(t *testing.T) {
newManager := func() *Manager {
return &Manager{
config: DefaultConfig(),
updateTrigger: make(chan struct{}, 1),
}
}
t.Run("SetTemperature", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetTemperature(3200, 2500)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
t.Run("SetLocation", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetLocation(120.0, 10.0)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
t.Run("SetGamma", func(t *testing.T) {
m := newManager()
before := m.config
err := m.SetGamma(-1.0)
assert.Error(t, err)
assert.Equal(t, before, m.config)
assert.Empty(t, m.updateTrigger)
})
}
func TestSetters_ValidValuesCommitAndTrigger(t *testing.T) {
m := &Manager{
config: DefaultConfig(),
updateTrigger: make(chan struct{}, 1),
}
err := m.SetTemperature(3000, 6000)
assert.NoError(t, err)
assert.Equal(t, 3000, m.config.LowTemp)
assert.Equal(t, 6000, m.config.HighTemp)
assert.Len(t, m.updateTrigger, 1)
}
func TestApplyGamma_SkipsUnchangedTempAndGamma(t *testing.T) {
m := &Manager{config: DefaultConfig()}
m.controlsInitialized = true
out := &outputState{
id: 1,
rampSize: 256,
gammaControl: &wlr_gamma_control.ZwlrGammaControlV1{},
lastTemp: 5000,
lastGamma: m.config.Gamma,
}
m.outputs.Store(out.id, out)
m.applyGamma(5000)
assert.False(t, out.failed, "unchanged temp must not reach the compositor write path")
assert.Equal(t, 5000, out.lastTemp)
assert.Equal(t, uint32(256), out.rampSize)
}
+44 -113
View File
@@ -7,11 +7,12 @@ import (
"os"
"path/filepath"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6"
"github.com/spf13/afero"
)
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type ColorScheme struct {
Primary string `json:"primary,omitempty"`
PrimaryText string `json:"primaryText,omitempty"`
@@ -150,7 +151,6 @@ type Theme struct {
type GitClient interface {
PlainClone(path string, url string) error
Pull(path string) error
OriginURL(path string) (string, error)
}
type realGitClient struct{}
@@ -182,28 +182,11 @@ func (g *realGitClient) Pull(path string) error {
return nil
}
func (g *realGitClient) OriginURL(path string) (string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return "", err
}
remote, err := repo.Remote("origin")
if err != nil {
return "", err
}
urls := remote.Config().URLs
if len(urls) == 0 {
return "", errors.New("origin remote has no URL")
}
return urls[0], nil
}
type Registry struct {
fs afero.Fs
cacheDir string
registries []registries.Source
themes []Theme
git GitClient
fs afero.Fs
cacheDir string
themes []Theme
git GitClient
}
func NewRegistry() (*Registry, error) {
@@ -211,63 +194,61 @@ func NewRegistry() (*Registry, error) {
}
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{
fs: fs,
cacheDir: getCacheDir(),
registries: registries.Load(fs),
git: &realGitClient{},
fs: fs,
cacheDir: cacheDir,
git: &realGitClient{},
}, nil
}
func (r *Registry) cacheDirFor(src registries.Source) string {
return filepath.Join(r.cacheDir, src.Name)
}
func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
}
// A cached clone is reused only when its origin still matches the configured
// URL; renamed or re-pointed registries re-clone instead of pulling from the
// stale remote.
func (r *Registry) updateOne(src registries.Source) error {
dir := r.cacheDirFor(src)
exists, err := afero.DirExists(r.fs, dir)
func (r *Registry) Update() error {
exists, err := afero.DirExists(r.fs, r.cacheDir)
if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err)
}
if exists {
origin, originErr := r.git.OriginURL(dir)
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
return nil
if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.fs.RemoveAll(dir); err != nil {
return fmt.Errorf("failed to remove stale registry cache: %w", err)
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to clone registry: %w", err)
}
} else {
if err := r.git.Pull(r.cacheDir); err != nil {
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
}
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
}
}
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(dir, src.URL); err != nil {
return fmt.Errorf("failed to clone: %w", err)
}
return nil
return r.loadThemes()
}
// A registry without a themes/ directory is a valid plugins-only registry.
func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
themesDir := filepath.Join(dir, "themes")
func (r *Registry) loadThemes() error {
themesDir := filepath.Join(r.cacheDir, "themes")
entries, err := afero.ReadDir(r.fs, themesDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read themes directory: %w", err)
return fmt.Errorf("failed to read themes directory: %w", err)
}
var themes []Theme
r.themes = []Theme{}
for _, entry := range entries {
if !entry.IsDir() {
continue
@@ -297,46 +278,10 @@ func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
theme.PreviewPath = previewPath
}
themes = append(themes, theme)
r.themes = append(r.themes, theme)
}
return themes, nil
}
// Pre-multi-registry caches were a single clone at the base dir; the per-name
// layout nests under it, so a leftover clone is deleted wholesale first.
func (r *Registry) resetLegacyCache() {
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
_ = r.fs.RemoveAll(r.cacheDir)
}
}
// Update refreshes every configured registry, aggregating themes in
// declaration order (first occurrence of an ID wins). A failing registry is
// reported in the joined error but does not block the others.
func (r *Registry) Update() error {
r.resetLegacyCache()
r.themes = []Theme{}
seen := make(map[string]struct{})
var errs []error
for _, src := range r.registries {
if err := r.updateOne(src); err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
themes, err := r.loadThemesFrom(r.cacheDirFor(src))
if err != nil {
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
continue
}
for _, t := range themes {
if _, dup := seen[t.ID]; dup {
continue
}
seen[t.ID] = struct{}{}
r.themes = append(r.themes, t)
}
}
return errors.Join(errs...)
return nil
}
func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
@@ -355,7 +300,7 @@ func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
func (r *Registry) List() ([]Theme, error) {
if len(r.themes) == 0 {
if err := r.Update(); err != nil && len(r.themes) == 0 {
if err := r.Update(); err != nil {
return nil, err
}
}
@@ -398,25 +343,11 @@ func (r *Registry) Get(idOrName string) (*Theme, error) {
}
func (r *Registry) GetThemeSourcePath(themeID string) string {
// Themes may live under any registry's subdir. Search them all; first hit wins.
for _, cfg := range r.registries {
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID, "theme.json")
if exists, _ := afero.Exists(r.fs, candidate); exists {
return candidate
}
}
// Fallback to first registry (legacy path semantics).
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID, "theme.json")
return filepath.Join(r.cacheDir, "themes", themeID, "theme.json")
}
func (r *Registry) GetThemeDir(themeID string) string {
for _, cfg := range r.registries {
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID)
if exists, _ := afero.DirExists(r.fs, candidate); exists {
return candidate
}
}
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID)
return filepath.Join(r.cacheDir, "themes", themeID)
}
func SortByFirstParty(themes []Theme) []Theme {
-72
View File
@@ -1,10 +1,8 @@
package themes
import (
"os"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero"
)
@@ -66,73 +64,3 @@ func TestLoadThemeWCAGInvalidJSON(t *testing.T) {
t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag)
}
}
type stubGitClient struct {
cloneFunc func(path string, url string) error
}
func (s *stubGitClient) PlainClone(path string, url string) error {
if s.cloneFunc != nil {
return s.cloneFunc(path, url)
}
return nil
}
func (s *stubGitClient) Pull(path string) error { return nil }
func (s *stubGitClient) OriginURL(path string) (string, error) { return "", os.ErrNotExist }
func writeTestTheme(t *testing.T, fs afero.Fs, registryDir, themeID, name string) {
dir := registryDir + "/themes/" + themeID
if err := fs.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
themeJSON := `{"id":"` + themeID + `","name":"` + name + `","version":"1.0","author":"a","description":"d"}`
if err := afero.WriteFile(fs, dir+"/theme.json", []byte(themeJSON), 0o644); err != nil {
t.Fatal(err)
}
}
func TestUpdateMultiRegistry(t *testing.T) {
fs := afero.NewMemMapFs()
base := "/test-cache"
r := &Registry{
fs: fs,
cacheDir: base,
registries: []registries.Source{
{Name: "official", URL: "https://example.com/official.git"},
{Name: "extra", URL: "https://example.com/extra.git"},
},
themes: []Theme{},
}
r.git = &stubGitClient{
cloneFunc: func(path string, url string) error {
switch path {
case base + "/official":
writeTestTheme(t, fs, path, "shared", "OfficialShared")
writeTestTheme(t, fs, path, "one", "One")
case base + "/extra":
writeTestTheme(t, fs, path, "shared", "ExtraShared")
writeTestTheme(t, fs, path, "two", "Two")
}
return nil
},
}
if err := r.Update(); err != nil {
t.Fatalf("Update: %v", err)
}
if len(r.themes) != 3 {
t.Fatalf("expected 3 themes after dedupe, got %d", len(r.themes))
}
for _, theme := range r.themes {
if theme.ID == "shared" && theme.Name != "OfficialShared" {
t.Fatalf("first registry should win for duplicate ID, got %q", theme.Name)
}
}
if dir := r.GetThemeDir("two"); dir != base+"/extra/themes/two" {
t.Fatalf("expected theme dir under extra registry, got %q", dir)
}
if path := r.GetThemeSourcePath("one"); path != base+"/official/themes/one/theme.json" {
t.Fatalf("expected theme source under official registry, got %q", path)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ revision=1
create_wrksrc=yes
build_style=go
build_wrksrc="core"
hostmakedepends="make tar"
hostmakedepends="make"
go_import_path="github.com/AvengeMedia/DankMaterialShell/core"
go_package="${go_import_path}/cmd/dms"
go_build_tags="distro_binary withshell"
Generated
+3 -3
View File
@@ -3,11 +3,11 @@
"dank-qml-common": {
"flake": false,
"locked": {
"lastModified": 1786121412,
"narHash": "sha256-XHNpDNfQfjP6nIscd26GK6ydgBdVN5gknYGOVxXBWLw=",
"lastModified": 1784647395,
"narHash": "sha256-kcLSJC8XBzmWoSBc90Zn81qrNsqWMkwQ1m/zZixOw30=",
"owner": "AvengeMedia",
"repo": "dank-qml-common",
"rev": "28fde7311296cbd041e5b704e54a481082b92b18",
"rev": "1919595f0dde4f5bf5ac0baa1902ff6d070971d5",
"type": "github"
},
"original": {
+1 -1
View File
@@ -111,7 +111,7 @@
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-pjaRyB6E2TZvVd5a4xcdGSRVr9Dg9wEG/5e+HdtZJCg=";
vendorHash = "sha256-//ya3Yb25/kEBeT6M4bO8Egi+Tb7iiUwevXlzTyIgqA=";
subPackages = [ "cmd/dms" ];
+6 -64
View File
@@ -11,27 +11,16 @@ Singleton {
id: root
readonly property var log: Log.scoped("CacheData")
readonly property int cacheConfigVersion: 2
readonly property int cacheConfigVersion: 1
readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation)
readonly property string _stateDir: Paths.strip(_stateUrl)
property bool _loading: false
property bool _hasLoaded: false
property int _loadedCacheVersion: 0
readonly property var _pinKeys: ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"]
readonly property var _dataKeys: ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings"].concat(_pinKeys)
property string wallpaperLastPath: ""
property string profileLastPath: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
property var bluetoothDevicePins: ({})
property var audioInputDevicePins: ({})
property var audioOutputDevicePins: ({})
property var fileBrowserSettings: ({
"wallpaper": {
"lastPath": "",
@@ -89,46 +78,8 @@ Singleton {
function loadCache() {
_loading = true;
try {
parseCache(cacheFile.text());
} finally {
_loading = false;
_hasLoaded = true;
}
}
function set(key, value) {
if (_dataKeys.indexOf(key) < 0) {
log.warn("Unknown cache key:", key);
return;
}
root[key] = value;
saveCache();
}
function migratePins(pins) {
if (!pins)
return;
if (!_hasLoaded)
loadCache();
if (_loadedCacheVersion >= cacheConfigVersion)
return;
let migrated = false;
for (const key of _pinKeys) {
const legacy = pins[key];
if (!legacy || Object.keys(legacy).length === 0)
continue;
if (Object.keys(root[key] || {}).length > 0)
continue;
root[key] = legacy;
migrated = true;
}
if (!migrated)
return;
log.info("Migrated device pins from settings.json");
saveCache();
parseCache(cacheFile.text());
_loading = false;
}
function parseCache(content) {
@@ -136,7 +87,6 @@ Singleton {
try {
if (content && content.trim()) {
const cache = JSON.parse(content);
_loadedCacheVersion = cache.configVersion || 0;
wallpaperLastPath = cache.wallpaperLastPath !== undefined ? cache.wallpaperLastPath : "";
profileLastPath = cache.profileLastPath !== undefined ? cache.profileLastPath : "";
@@ -172,10 +122,6 @@ Singleton {
};
}
for (const key of _pinKeys) {
root[key] = cache[key] !== undefined ? cache[key] : {};
}
if (cache.configVersion === undefined) {
migrateFromUndefinedToV1(cache);
cleanupUnusedKeys();
@@ -192,16 +138,12 @@ Singleton {
function saveCache() {
if (_loading)
return;
const data = {
cacheFile.setText(JSON.stringify({
"wallpaperLastPath": wallpaperLastPath,
"profileLastPath": profileLastPath,
"fileBrowserSettings": fileBrowserSettings,
"configVersion": cacheConfigVersion
};
for (const key of _pinKeys) {
data[key] = root[key];
}
cacheFile.setText(JSON.stringify(data, null, 2));
}, null, 2));
}
function migrateFromUndefinedToV1(cache) {
@@ -209,7 +151,7 @@ Singleton {
}
function cleanupUnusedKeys() {
const validKeys = _dataKeys.concat(["configVersion"]);
const validKeys = ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings", "configVersion"];
try {
const content = cacheFile.text();
+29
View File
@@ -0,0 +1,29 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
Singleton {
id: root
function clearImageCache() {
Quickshell.execDetached(["rm", "-rf", Paths.stringify(Paths.imagecache)]);
Paths.mkdir(Paths.imagecache);
}
function clearOldCache(ageInMinutes) {
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", "*.png", "-mmin", `+${ageInMinutes}`, "-delete"]);
}
function clearCacheForSize(size) {
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", `*@${size}x${size}.png`, "-delete"]);
}
function getCacheSize(callback) {
Proc.runCommand("cache_size", ["du", "-sm", Paths.stringify(Paths.imagecache)], function (output, exitCode) {
const sizeMB = parseInt(output.split("\t")[0]) || 0;
callback(sizeMB);
});
}
}
@@ -114,6 +114,26 @@ function withRevision(descriptor, revision) {
return next;
}
function withAnimationOffset(descriptor, x, y) {
var next = normalize(descriptor);
next.animationOffset = {
"x": x === undefined ? next.animationOffset.x : _number(x, next.animationOffset.x),
"y": y === undefined ? next.animationOffset.y : _number(y, next.animationOffset.y)
};
return next;
}
function withBodyRect(descriptor, x, y, width, height) {
var next = normalize(descriptor);
next.bodyRect = {
"x": x === undefined ? next.bodyRect.x : _number(x, next.bodyRect.x),
"y": y === undefined ? next.bodyRect.y : _number(y, next.bodyRect.y),
"width": width === undefined ? next.bodyRect.width : Math.max(0, _number(width, next.bodyRect.width)),
"height": height === undefined ? next.bodyRect.height : Math.max(0, _number(height, next.bodyRect.height))
};
return next;
}
function same(a, b, threshold) {
if (!a || !b)
return false;
@@ -162,6 +162,21 @@ function fillBounds(rect, side, seamOverlap, dpr) {
};
}
function clipEnvelope(rect, side, radii, seamOverlap, dpr) {
var fill = fillBounds(rect, side, seamOverlap, dpr);
var chrome = chromeBounds(fill, side, radii.start, radii.end, radii.farExtent, dpr);
return {
"x": chrome.x,
"y": chrome.y,
"width": chrome.width,
"height": chrome.height,
"bodyX": snap(fill.x - chrome.x, dpr),
"bodyY": snap(fill.y - chrome.y, dpr),
"bodyWidth": fill.width,
"bodyHeight": fill.height
};
}
function blurRegions(descriptor, rect, radii, dpr) {
var side = descriptor.barSide;
var regions = [bodyRect(rect, dpr)];
@@ -204,3 +219,14 @@ function unionBounds(rects, padding, dpr) {
"height": Math.max(0, snap(maxY - minY + pad * 2, dpr))
};
}
function shadowSourceBounds(descriptor, rect, radii, padding, dpr) {
return unionBounds(blurRegions(descriptor, rect, radii, dpr), padding, dpr);
}
function stableEqual(a, b, dpr) {
if (!a || !b)
return false;
var threshold = 0.5 / (dpr || 1);
return Math.abs(a.x - b.x) < threshold && Math.abs(a.y - b.y) < threshold && Math.abs(a.width - b.width) < threshold && Math.abs(a.height - b.height) < threshold;
}
+10
View File
@@ -44,6 +44,16 @@ function connectorX(barSide, baseX, bodyWidth, placement, spacing, radius) {
return barSide === "left" ? s : s - w;
}
function connectorY(barSide, baseY, bodyHeight, placement, spacing, radius) {
var s = seamY(barSide, baseY, bodyHeight, placement);
var h = connectorHeight(barSide, spacing, radius);
if (barSide === "top")
return s;
if (barSide === "bottom")
return s - h;
return placement === "left" ? s - h : s;
}
// Which corner of the connector's bounding rect hosts the concave arc that
// carves into the body. Used for arc-sweep orientation.
function arcCorner(barSide, placement) {
-72
View File
@@ -1,72 +0,0 @@
.pragma library
function formatRate(bytesPerSec, gbDecimals) {
if (bytesPerSec < 1024)
return bytesPerSec.toFixed(0) + " B/s";
if (bytesPerSec < 1024 * 1024)
return (bytesPerSec / 1024).toFixed(1) + " KB/s";
if (bytesPerSec < 1024 * 1024 * 1024)
return (bytesPerSec / (1024 * 1024)).toFixed(1) + " MB/s";
return (bytesPerSec / (1024 * 1024 * 1024)).toFixed(gbDecimals ?? 2) + " GB/s";
}
function formatBytes(bytes) {
if (bytes < 1024)
return bytes.toFixed(0) + "B";
if (bytes < 1024 * 1024)
return (bytes / 1024).toFixed(0) + "K";
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(1) + "M";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + "G";
}
function formatIsoTime(isoString) {
if (!isoString)
return "";
try {
const date = new Date(isoString);
if (isNaN(date.getTime()))
return "";
return date.toLocaleTimeString(Qt.locale(), "HH:mm");
} catch (e) {
return "";
}
}
function formatRemaining(ms, zeroText, minText, hText, hmText) {
if (ms <= 0)
return zeroText;
const totalMinutes = Math.ceil(ms / 60000);
if (totalMinutes < 60)
return minText.arg(totalMinutes);
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes - hours * 60;
if (mins === 0)
return hText.arg(hours);
return hmText.arg(hours).arg(mins);
}
function pad2(n) {
return n < 10 ? "0" + n : "" + n;
}
function formatUntil(ts, use24h) {
if (!ts)
return "";
const d = new Date(ts);
const hours = d.getHours();
const minutes = d.getMinutes();
if (use24h)
return pad2(hours) + ":" + pad2(minutes);
const suffix = hours >= 12 ? "PM" : "AM";
const h12 = ((hours + 11) % 12) + 1;
return h12 + ":" + pad2(minutes) + " " + suffix;
}
function addToHistory(arr, val, max) {
const newArr = arr.slice();
newArr.push(val);
if (newArr.length > max)
newArr.shift();
return newArr;
}
-58
View File
@@ -271,61 +271,3 @@ function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) {
}
return conflicts;
}
function qtKeyFromName(name) {
var n = (name || "").toUpperCase();
if (n.length === 1 && n >= "A" && n <= "Z")
return Qt.Key_A + (n.charCodeAt(0) - 65);
if (n.length === 1 && n >= "0" && n <= "9")
return Qt.Key_0 + (n.charCodeAt(0) - 48);
if (n.length >= 2 && n[0] === "F") {
var f = parseInt(n.slice(1), 10);
if (f >= 1 && f <= 12)
return Qt.Key_F1 + (f - 1);
}
var named = {
"SPACE": Qt.Key_Space,
"TAB": Qt.Key_Tab,
"RETURN": Qt.Key_Return,
"ENTER": Qt.Key_Enter,
"BACKSPACE": Qt.Key_Backspace,
"DELETE": Qt.Key_Delete,
"HOME": Qt.Key_Home,
"END": Qt.Key_End,
"UP": Qt.Key_Up,
"DOWN": Qt.Key_Down,
"LEFT": Qt.Key_Left,
"RIGHT": Qt.Key_Right
};
return named[n] || 0;
}
function isModifierKey(qk) {
return qk === Qt.Key_Control || qk === Qt.Key_Shift || qk === Qt.Key_Alt || qk === Qt.Key_Meta
|| qk === Qt.Key_NumLock || qk === Qt.Key_CapsLock || qk === Qt.Key_ScrollLock;
}
function eventMatchesCombo(event, combo) {
if (!combo)
return false;
var parts = combo.split("+");
var keyName = parts[parts.length - 1].trim().toUpperCase();
var wantsShift = false;
var hasCtrl = false;
for (var i = 0; i < parts.length - 1; i++) {
var mod = parts[i].trim().toLowerCase();
if (mod === "shift")
wantsShift = true;
else if (mod === "ctrl" || mod === "control")
hasCtrl = true;
else
return false;
}
if (hasCtrl && !(event.modifiers & Qt.ControlModifier))
return false;
if (event.modifiers & (Qt.AltModifier | Qt.MetaModifier))
return false;
if (((event.modifiers & Qt.ShiftModifier) !== 0) !== wantsShift)
return false;
return event.key === qtKeyFromName(keyName);
}
+7
View File
@@ -1370,3 +1370,10 @@ function buildDmsAction(baseKey, args) {
return parts.join(" ");
}
function getScreenshotOptions() {
return [
{ id: "write-to-disk", label: "Save to disk", type: "bool" },
{ id: "show-pointer", label: "Show pointer", type: "bool" }
];
}
+2 -11
View File
@@ -15,11 +15,7 @@ Singleton {
function openModal(modal) {
PopoutManager.screenshotActive = false;
const screenName = modal.effectiveScreen?.name ?? "unknown";
var next = {};
for (var k in currentModalsByScreen)
next[k] = currentModalsByScreen[k];
next[screenName] = modal;
currentModalsByScreen = next;
currentModalsByScreen[screenName] = modal;
modalChanged();
Qt.callLater(() => {
if (!modal.allowStacking)
@@ -38,12 +34,7 @@ Singleton {
function closeModal(modal) {
const screenName = modal.effectiveScreen?.name ?? "unknown";
if (currentModalsByScreen[screenName] === modal) {
var next = {};
for (var k in currentModalsByScreen) {
if (k !== screenName)
next[k] = currentModalsByScreen[k];
}
currentModalsByScreen = next;
delete currentModalsByScreen[screenName];
modalChanged();
}
}
+5 -15
View File
@@ -22,10 +22,6 @@ Singleton {
readonly property url imagecache: `${cache}/imagecache`
property var iconResolver: null
property var desktopIconResolver: null
property var trashHandler: null
Component.onCompleted: mkdir(imagecache)
function stringify(path: url): string {
@@ -92,7 +88,7 @@ Singleton {
function themedIconPath(name: string): string {
if (!name)
return "";
const themed = iconResolver ? iconResolver(name) : "";
const themed = (typeof IconThemeService !== "undefined") ? IconThemeService.resolve(name) : "";
if (themed)
return themed;
return Quickshell.iconPath(name, true);
@@ -109,15 +105,11 @@ Singleton {
return moddedId;
return themedIconPath(moddedId);
}
if (!desktopIconResolver)
return themedIconPath(iconName);
return themedIconPath(iconName) || desktopIconResolver(iconName);
return themedIconPath(iconName) || DesktopService.resolveIconPath(iconName);
}
function trashPath(path: string, callback): void {
if (!trashHandler)
return;
trashHandler(path, callback);
TrashService.trashPath(path, callback);
}
function copyPathToClipboard(path: string): void {
@@ -133,7 +125,7 @@ Singleton {
return toFileUrl(expandTilde(target));
if (target.startsWith("file://"))
return target;
const themed = iconResolver ? iconResolver(target) : "";
const themed = (typeof IconThemeService !== "undefined") ? IconThemeService.resolve(target) : "";
if (themed)
return themed;
return "image://icon/" + target;
@@ -156,9 +148,7 @@ Singleton {
if (icon && icon !== "")
return icon;
if (!desktopIconResolver)
return "";
return desktopIconResolver(appId);
return DesktopService.resolveIconPath(appId);
}
function getAppName(appId: string, desktopEntry: var): string {
+2 -21
View File
@@ -65,35 +65,16 @@ Singleton {
function _openPopout(popout) {
if (popout.dashVisible !== undefined) {
let flagStayedTrue = popout.dashVisible === true;
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing) {
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing)
popout.dashVisible = false;
flagStayedTrue = false;
}
popout.dashVisible = true;
// Flag already true (e.g. retargeting to another monitor) won't re-fire
// the change handler, so drive the surface open explicitly.
if (flagStayedTrue)
_ensureSurfaceOpen(popout);
return;
}
if (popout.notificationHistoryVisible !== undefined) {
const flagStayedTrue = popout.notificationHistoryVisible === true;
popout.notificationHistoryVisible = true;
if (flagStayedTrue)
_ensureSurfaceOpen(popout);
return;
}
_ensureSurfaceOpen(popout);
}
function _ensureSurfaceOpen(popout) {
if (typeof popout.present === "function") {
popout.present();
return;
}
if (typeof popout.open === "function")
popout.open();
popout.open();
}
function _closePopout(popout) {
-27
View File
@@ -1,27 +0,0 @@
.pragma library
function findParentFlickable(item) {
while (item) {
if (item.hasOwnProperty("contentY") && item.hasOwnProperty("contentItem"))
return item;
item = item.parent;
}
return null;
}
function findSettings(item) {
while (item) {
if (item.saveValue !== undefined && item.loadValue !== undefined)
return item;
item = item.parent;
}
return null;
}
function normalizePinList(value) {
if (Array.isArray(value))
return value.filter(v => v);
if (typeof value === "string" && value.length > 0)
return [value];
return [];
}
+200 -48
View File
@@ -16,10 +16,6 @@ Singleton {
readonly property int sessionConfigVersion: 3
signal loaded
signal brightnessDisplayHintChanged(string deviceName)
signal loadErrorOccurred(string file, string message)
property bool _parseError: false
property bool _hasLoaded: false
property bool _isReadOnly: false
@@ -148,7 +144,6 @@ Singleton {
property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none")
property bool wallpaperCyclingEnabled: false
property bool wallpaperCyclingRandom: false
property string wallpaperCyclingMode: "interval"
property int wallpaperCyclingInterval: 300
property string wallpaperCyclingTime: "06:00"
@@ -229,7 +224,6 @@ Singleton {
property string niriOverviewLastMode: "apps"
property string settingsSidebarExpandedIds: ","
property string settingsSidebarCollapsedIds: ","
property bool showConfigReloadToast: true
Component.onCompleted: {
loadSettings();
@@ -281,14 +275,15 @@ Singleton {
if (typeof Theme !== "undefined")
Theme.generateSystemThemesFromCurrentTheme();
loaded();
if (typeof WallpaperCyclingService !== "undefined")
WallpaperCyclingService.updateCyclingState();
_checkSessionWritable();
} catch (e) {
_parseError = true;
const msg = e.message;
log.error("Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => loadErrorOccurred("session.json", msg));
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
}
}
@@ -362,12 +357,13 @@ Singleton {
if (typeof Theme !== "undefined")
Theme.generateSystemThemesFromCurrentTheme();
loaded();
if (typeof WallpaperCyclingService !== "undefined")
WallpaperCyclingService.updateCyclingState();
} catch (e) {
_parseError = true;
const msg = e.message;
log.error("Failed to parse session.json - file will not be overwritten.");
Qt.callLater(() => loadErrorOccurred("session.json", msg));
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
}
}
@@ -468,6 +464,11 @@ Singleton {
saveSettings();
}
function setWallpaperPath(path) {
wallpaperPath = path;
saveSettings();
}
function setWallpaper(imagePath) {
wallpaperPath = imagePath;
if (perModeWallpaper) {
@@ -647,11 +648,6 @@ Singleton {
saveSettings();
}
function setWallpaperCyclingRandom(random) {
wallpaperCyclingRandom = random;
saveSettings();
}
function setWallpaperCyclingMode(mode) {
wallpaperCyclingMode = mode;
saveSettings();
@@ -698,37 +694,6 @@ Singleton {
saveSettings();
}
function setMonitorCyclingRandom(screenName, random) {
var screen = null;
var screens = Quickshell.screens;
for (var i = 0; i < screens.length; i++) {
if (screens[i].name === screenName) {
screen = screens[i];
break;
}
}
if (!screen) {
log.warn("Screen not found");
return;
}
var identifier = typeof SettingsData !== "undefined" ? SettingsData.getScreenDisplayName(screen) : screen.name;
var newSettings = {};
for (var key in monitorCyclingSettings) {
var isThisScreen = key === screen.name || (screen.model && key === screen.model);
if (!isThisScreen) {
newSettings[key] = monitorCyclingSettings[key];
}
}
newSettings[identifier] = getMonitorCyclingSettings(screenName);
newSettings[identifier].random = random;
monitorCyclingSettings = newSettings;
saveSettings();
}
function setMonitorCyclingMode(screenName, mode) {
var screen = null;
var screens = Quickshell.screens;
@@ -882,6 +847,11 @@ Singleton {
saveSettings();
}
function setNightModeLocationProvider(provider) {
nightModeLocationProvider = provider;
saveSettings();
}
function setThemeModeAutoEnabled(enabled) {
themeModeAutoEnabled = enabled;
saveSettings();
@@ -970,6 +940,10 @@ Singleton {
setBarPinnedApps(currentPinned);
}
function isBarPinnedApp(appId) {
return appId && barPinnedApps.indexOf(appId) !== -1;
}
function hideTrayId(trayId) {
if (!trayId)
return;
@@ -1032,6 +1006,11 @@ Singleton {
saveSettings();
}
function setLaunchPrefix(prefix) {
launchPrefix = prefix;
saveSettings();
}
function setLastBrightnessDevice(device) {
lastBrightnessDevice = device;
saveSettings();
@@ -1046,7 +1025,10 @@ Singleton {
}
brightnessExponentialDevices = newSettings;
saveSettings();
brightnessDisplayHintChanged(deviceName);
if (typeof DisplayService !== "undefined") {
DisplayService.updateDeviceBrightnessDisplay(deviceName);
}
}
function getBrightnessExponential(deviceName) {
@@ -1060,6 +1042,10 @@ Singleton {
saveSettings();
}
function getBrightnessUserSetValue(deviceName) {
return brightnessUserSetValues[deviceName];
}
function clearBrightnessUserSetValue(deviceName) {
var newValues = Object.assign({}, brightnessUserSetValues);
delete newValues[deviceName];
@@ -1083,6 +1069,21 @@ Singleton {
return value !== undefined ? value : 1.2;
}
function setSelectedGpuIndex(index) {
selectedGpuIndex = index;
saveSettings();
}
function setNvidiaGpuTempEnabled(enabled) {
nvidiaGpuTempEnabled = enabled;
saveSettings();
}
function setNonNvidiaGpuTempEnabled(enabled) {
nonNvidiaGpuTempEnabled = enabled;
saveSettings();
}
function setEnabledGpuPciIds(pciIds) {
enabledGpuPciIds = pciIds;
saveSettings();
@@ -1198,6 +1199,15 @@ Singleton {
return deviceMaxVolumes[nodeName] ?? 100;
}
function removeDeviceMaxVolume(nodeName) {
if (!nodeName)
return;
const updated = Object.assign({}, deviceMaxVolumes);
delete updated[nodeName];
deviceMaxVolumes = updated;
saveSettings();
}
function updateLocale() {
if (!locale) {
I18n._pickTranslation();
@@ -1261,6 +1271,12 @@ Singleton {
saveSettings();
}
function clearLauncherHistory() {
launcherLastQuery = "";
launcherSearchHistory = [];
saveSettings();
}
function setAppDrawerLastMode(mode) {
appDrawerLastMode = mode;
saveSettings();
@@ -1359,7 +1375,6 @@ Singleton {
function getMonitorCyclingSettings(screenName) {
var defaults = {
"enabled": false,
"random": false,
"mode": "interval",
"interval": 300,
"time": "06:00"
@@ -1401,4 +1416,141 @@ Singleton {
}
}
}
IpcHandler {
target: "wallpaper"
function get(): string {
if (root.perMonitorWallpaper) {
return "ERROR: Per-monitor mode enabled. Use getFor(screenName) instead.";
}
return root.wallpaperPath || "";
}
function set(path: string): string {
if (root.perMonitorWallpaper) {
return "ERROR: Per-monitor mode enabled. Use setFor(screenName, path) instead.";
}
if (!path) {
return "ERROR: No path provided";
}
var absolutePath = path.startsWith("/") ? path : StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/" + path;
try {
root.setWallpaper(absolutePath);
return "SUCCESS: Wallpaper set to " + absolutePath;
} catch (e) {
return "ERROR: Failed to set wallpaper: " + e.toString();
}
}
function clear(): string {
root.setWallpaper("");
root.setPerMonitorWallpaper(false);
root.monitorWallpapers = {};
root.saveSettings();
return "SUCCESS: All wallpapers cleared";
}
function next(): string {
if (root.perMonitorWallpaper) {
return "ERROR: Per-monitor mode enabled. Use nextFor(screenName) instead.";
}
if (!root.wallpaperPath) {
return "ERROR: No wallpaper set";
}
try {
WallpaperCyclingService.cycleNextManually();
return "SUCCESS: Cycling to next wallpaper";
} catch (e) {
return "ERROR: Failed to cycle wallpaper: " + e.toString();
}
}
function prev(): string {
if (root.perMonitorWallpaper) {
return "ERROR: Per-monitor mode enabled. Use prevFor(screenName) instead.";
}
if (!root.wallpaperPath) {
return "ERROR: No wallpaper set";
}
try {
WallpaperCyclingService.cyclePrevManually();
return "SUCCESS: Cycling to previous wallpaper";
} catch (e) {
return "ERROR: Failed to cycle wallpaper: " + e.toString();
}
}
function getFor(screenName: string): string {
if (!screenName) {
return "ERROR: No screen name provided";
}
return root.getMonitorWallpaper(screenName) || "";
}
function setFor(screenName: string, path: string): string {
if (!screenName) {
return "ERROR: No screen name provided";
}
if (!path) {
return "ERROR: No path provided";
}
var absolutePath = path.startsWith("/") ? path : StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/" + path;
try {
if (!root.perMonitorWallpaper) {
root.setPerMonitorWallpaper(true);
}
root.setMonitorWallpaper(screenName, absolutePath);
return "SUCCESS: Wallpaper set for " + screenName + " to " + absolutePath;
} catch (e) {
return "ERROR: Failed to set wallpaper for " + screenName + ": " + e.toString();
}
}
function nextFor(screenName: string): string {
if (!screenName) {
return "ERROR: No screen name provided";
}
var currentWallpaper = root.getMonitorWallpaper(screenName);
if (!currentWallpaper) {
return "ERROR: No wallpaper set for " + screenName;
}
try {
WallpaperCyclingService.cycleNextForMonitor(screenName);
return "SUCCESS: Cycling to next wallpaper for " + screenName;
} catch (e) {
return "ERROR: Failed to cycle wallpaper for " + screenName + ": " + e.toString();
}
}
function prevFor(screenName: string): string {
if (!screenName) {
return "ERROR: No screen name provided";
}
var currentWallpaper = root.getMonitorWallpaper(screenName);
if (!currentWallpaper) {
return "ERROR: No wallpaper set for " + screenName;
}
try {
WallpaperCyclingService.cyclePrevForMonitor(screenName);
return "SUCCESS: Cycling to previous wallpaper for " + screenName;
} catch (e) {
return "ERROR: Failed to cycle wallpaper for " + screenName + ": " + e.toString();
}
}
}
}
+478 -112
View File
@@ -15,7 +15,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 13
readonly property int settingsConfigVersion: 12
enum Position {
Top,
@@ -164,11 +164,6 @@ Singleton {
property string matugenTargetMonitor: ""
property real popupTransparency: 1.0
property real dockTransparency: 1
property bool floatingWindowSyncGlobal: true
property real floatingWindowTransparency: 1.0
property bool floatingWindowForegroundLayers: true
property real floatingWindowForegroundTransparency: 1.0
property bool dmsWindowsFloating: true
property string widgetBackgroundColor: "sch"
property string widgetBackgroundCustomColor: "#6750A4"
property real widgetBackgroundCustomStrength: 0.50
@@ -189,34 +184,6 @@ Singleton {
property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1
property bool mangoTrackpadNaturalScrolling: true
property string mouseAccelProfile: "default"
property real mouseAccelSpeed: 0.0
property bool mouseLeftHanded: false
property bool mouseMiddleEmulation: false
property bool mouseNaturalScroll: false
property real mouseScrollFactor: 1.0
property string mouseScrollMethod: "default"
property string touchpadAccelProfile: "default"
property real touchpadAccelSpeed: 0.0
property bool touchpadDisableOnExternalMouse: false
property bool touchpadDisableWhileTyping: true
property bool touchpadDragLock: false
property bool touchpadMiddleEmulation: false
property bool touchpadNaturalScroll: true
property real touchpadScrollFactor: 1.0
property string touchpadScrollMethod: "default"
property bool touchpadTapAndDrag: true
property bool touchpadTapToClick: true
property string keyboardLayouts: ""
property string keyboardVariants: ""
property string keyboardModel: ""
property string keyboardOptions: ""
property string keyboardKeymapFile: ""
property string keyboardTrackLayout: ""
property int keyboardRepeatDelay: 0
property int keyboardRepeatRate: 0
property bool keyboardNumlock: false
property int firstDayOfWeek: -1
property bool showWeekNumber: false
@@ -269,7 +236,6 @@ Singleton {
onBlurEnabledChanged: saveSettings()
property bool blurForegroundLayers: true
onBlurForegroundLayersChanged: saveSettings()
property real foregroundLayerTransparency: 1.0
property real blurLayerOutlineOpacity: 0.12
onBlurLayerOutlineOpacityChanged: saveSettings()
property bool blurBorderEnabled: true
@@ -495,7 +461,6 @@ Singleton {
property bool mediaAdaptiveWidthEnabled: true
property bool audioVisualizerEnabled: true
property bool mediaUseAlbumArtAccent: false
property bool appleMusicAnimatedArtEnabled: false
property string audioScrollMode: "volume"
property int audioWheelScrollAmount: 5
property bool audioDeviceScrollVolumeEnabled: false
@@ -676,6 +641,9 @@ Singleton {
readonly property string iconTheme: resolveIconTheme()
property var availableIconThemes: ["System Default"]
property string systemDefaultIconTheme: ""
property bool qt5ctAvailable: false
property bool qt6ctAvailable: false
property bool gtkAvailable: false
property var cursorSettings: ({
"theme": "System Default",
@@ -726,23 +694,16 @@ Singleton {
property bool notepadUseCompositorGap: false
property int notepadEdgeGap: 0
property string activeCompositor: ""
// Compositor layout gap when enabled and available, else the manual value.
readonly property int notepadEffectiveEdgeGap: {
if (notepadUseCompositorGap) {
var g = -1;
switch (activeCompositor) {
case "niri":
if (CompositorService.isNiri)
g = niriLayoutGapsOverride;
break;
case "hyprland":
else if (CompositorService.isHyprland)
g = hyprlandLayoutGapsOverride;
break;
case "mango":
else if (CompositorService.isMango)
g = mangoLayoutGapsOverride;
break;
}
if (g >= 0)
return g;
}
@@ -811,7 +772,14 @@ Singleton {
property bool fadeToDpmsEnabled: true
property int fadeToDpmsGracePeriod: 5
property string launchPrefix: ""
property var brightnessDevicePins: ({})
property var wifiNetworkPins: ({})
property var bluetoothDevicePins: ({})
property var audioInputDevicePins: ({})
property var audioOutputDevicePins: ({})
property bool gtkThemingEnabled: false
property bool qtThemingEnabled: false
property bool syncModeWithPortal: true
property bool terminalsAlwaysDark: false
@@ -934,8 +902,6 @@ Singleton {
property bool lockPamInlineU2f: false
property bool lockPamExternallyManaged: false
property string lockU2fPamPath: ""
property string lockScreenSecurityKeyShortcut: "Ctrl+Q"
property bool lockScreenSecurityKeyShortcutEnabled: false
property bool greeterPamExternallyManaged: false
property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0
@@ -950,7 +916,6 @@ Singleton {
property int notificationTimeoutLow: 5000
property int notificationTimeoutNormal: 5000
property int notificationTimeoutCritical: 0
property bool notificationIgnoreAppTimeout: false
property bool notificationCompactMode: false
property bool notificationShowTimeoutBar: false
property bool notificationDedupeEnabled: true
@@ -990,7 +955,6 @@ Singleton {
property string customPowerActionHibernate: ""
property string customPowerActionReboot: ""
property string customPowerActionPowerOff: ""
property var customPowerButtons: []
property bool updaterHideWidget: false
property bool updaterCheckOnStart: false
@@ -1148,6 +1112,44 @@ Singleton {
saveSettings();
}
function getSystemMonitorVariants() {
return systemMonitorVariants || [];
}
function createSystemMonitorVariant(name, config) {
const id = "sysmon_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9);
const variant = {
id: id,
name: name,
config: config || getDefaultSystemMonitorConfig()
};
const variants = JSON.parse(JSON.stringify(systemMonitorVariants || []));
variants.push(variant);
systemMonitorVariants = variants;
saveSettings();
return variant;
}
function updateSystemMonitorVariant(variantId, updates) {
const variants = JSON.parse(JSON.stringify(systemMonitorVariants || []));
const idx = variants.findIndex(v => v.id === variantId);
if (idx === -1)
return;
Object.assign(variants[idx], updates);
systemMonitorVariants = variants;
saveSettings();
}
function removeSystemMonitorVariant(variantId) {
const variants = (systemMonitorVariants || []).filter(v => v.id !== variantId);
systemMonitorVariants = variants;
saveSettings();
}
function getSystemMonitorVariant(variantId) {
return (systemMonitorVariants || []).find(v => v.id === variantId) || null;
}
function getDefaultSystemMonitorConfig() {
return {
showHeader: true,
@@ -1288,6 +1290,70 @@ Singleton {
return (desktopWidgetInstances || []).find(inst => inst.id === instanceId) || null;
}
function getDesktopWidgetInstancesOfType(widgetType) {
return (desktopWidgetInstances || []).filter(inst => inst.widgetType === widgetType);
}
function getEnabledDesktopWidgetInstances() {
return (desktopWidgetInstances || []).filter(inst => inst.enabled);
}
function moveDesktopWidgetInstance(instanceId, direction) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const idx = instances.findIndex(inst => inst.id === instanceId);
if (idx === -1)
return false;
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= instances.length)
return false;
const temp = instances[idx];
instances[idx] = instances[targetIdx];
instances[targetIdx] = temp;
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function reorderDesktopWidgetInstance(instanceId, newIndex) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const idx = instances.findIndex(inst => inst.id === instanceId);
if (idx === -1 || newIndex < 0 || newIndex >= instances.length)
return false;
const [item] = instances.splice(idx, 1);
instances.splice(newIndex, 0, item);
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function reorderDesktopWidgetInstanceInGroup(instanceId, groupId, newIndexInGroup) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const groups = desktopWidgetGroups || [];
const groupMatches = inst => {
if (groupId === null)
return !inst.group || !groups.some(g => g.id === inst.group);
return inst.group === groupId;
};
const groupInstances = instances.filter(groupMatches);
const currentGroupIdx = groupInstances.findIndex(inst => inst.id === instanceId);
if (currentGroupIdx === -1 || currentGroupIdx === newIndexInGroup)
return false;
if (newIndexInGroup < 0 || newIndexInGroup >= groupInstances.length)
return false;
const globalIdx = instances.findIndex(inst => inst.id === instanceId);
if (globalIdx === -1)
return false;
const [item] = instances.splice(globalIdx, 1);
const targetInstance = groupInstances[newIndexInGroup];
let targetGlobalIdx = instances.findIndex(inst => inst.id === targetInstance.id);
if (newIndexInGroup > currentGroupIdx)
targetGlobalIdx++;
instances.splice(targetGlobalIdx, 0, item);
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function moveDesktopWidgetInstanceToGroup(instanceId, groupId, newIndexInGroup) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const groups = desktopWidgetGroups || [];
@@ -1353,14 +1419,22 @@ Singleton {
saveSettings();
}
function getDesktopWidgetGroup(groupId) {
return (desktopWidgetGroups || []).find(g => g.id === groupId) || null;
}
function getDesktopWidgetInstancesByGroup(groupId) {
return (desktopWidgetInstances || []).filter(inst => inst.group === groupId);
}
function getUngroupedDesktopWidgetInstances() {
return (desktopWidgetInstances || []).filter(inst => !inst.group);
}
signal forceDankBarLayoutRefresh
signal forceDockLayoutRefresh
signal widgetDataChanged
signal workspaceIconsUpdated
signal compositorLayoutRefreshNeeded(bool frame)
signal compositorInputRefreshNeeded
signal compositorCursorRefreshNeeded
signal notificationPopupsInvalidated
function refreshAuthAvailability() {
Processes.detectAuthCapabilities();
@@ -1395,15 +1469,24 @@ Singleton {
}
function updateCompositorLayout() {
compositorLayoutRefreshNeeded(false);
}
function updateCompositorInput() {
compositorInputRefreshNeeded();
if (typeof CompositorService === "undefined")
return;
if (CompositorService.isNiri && typeof NiriService !== "undefined")
NiriService.generateNiriLayoutConfig();
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined")
HyprlandService.generateLayoutConfig();
if (CompositorService.isMango && typeof MangoService !== "undefined")
MangoService.generateLayoutConfig();
}
function updateFrameCompositorLayout() {
compositorLayoutRefreshNeeded(true);
// Generate before begin() so compositor readiness is already pending at transitionRequested
if (typeof CompositorService !== "undefined") {
if (CompositorService.isNiri && typeof NiriService !== "undefined")
NiriService.generateNiriLayoutConfig(true);
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined")
HyprlandService.generateLayoutConfig(true);
}
FrameTransitionState.begin();
}
@@ -1624,7 +1707,6 @@ Singleton {
"applyStoredTheme": applyStoredTheme,
"regenSystemThemes": regenSystemThemes,
"updateCompositorLayout": updateCompositorLayout,
"updateCompositorInput": updateCompositorInput,
"applyStoredIconTheme": applyStoredIconTheme,
"updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor,
@@ -1648,7 +1730,6 @@ Singleton {
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
const oldVersion = obj?.configVersion ?? 0;
const legacyPins = oldVersion < 13 ? Store.extractPins(obj) : null;
if (oldVersion < settingsConfigVersion) {
const migrated = Store.migrateToVersion(obj, settingsConfigVersion);
if (migrated) {
@@ -1656,8 +1737,6 @@ Singleton {
obj = migrated;
}
}
if (legacyPins)
Qt.callLater(() => CacheData.migratePins(legacyPins));
if (obj?.lockScreenActiveMonitor !== undefined) {
var oldVal = obj.lockScreenActiveMonitor;
@@ -1698,6 +1777,7 @@ Singleton {
_hasLoaded = true;
applyStoredTheme();
updateCompositorCursor();
Processes.detectQtTools();
Qt.callLater(checkIconThemeDrift);
_checkSettingsWritable();
@@ -2055,6 +2135,14 @@ Singleton {
return showSeconds ? "h:mm:ss AP" : "h:mm AP";
}
function getEffectiveClockDateFormat() {
return clockDateFormat && clockDateFormat.length > 0 ? clockDateFormat : "ddd d";
}
function getEffectiveLockDateFormat() {
return lockDateFormat && lockDateFormat.length > 0 ? lockDateFormat : Locale.LongFormat;
}
function initializeListModels() {
const defaultBar = barConfigs[0] || getBarConfig("default");
if (defaultBar) {
@@ -2067,6 +2155,39 @@ Singleton {
widgetDataChanged();
}
function hasNamedWorkspaces() {
if (typeof NiriService === "undefined" || !CompositorService.isNiri)
return false;
for (var i = 0; i < NiriService.allWorkspaces.length; i++) {
var ws = NiriService.allWorkspaces[i];
if (ws.name && ws.name.trim() !== "")
return true;
}
return false;
}
function getNamedWorkspaces() {
var namedWorkspaces = [];
if (typeof NiriService === "undefined" || !CompositorService.isNiri)
return namedWorkspaces;
for (const ws of NiriService.allWorkspaces) {
if (ws.name && ws.name.trim() !== "") {
namedWorkspaces.push(ws.name);
}
}
return namedWorkspaces;
}
function getPopupYPosition(barHeight) {
const defaultBar = barConfigs[0] || getBarConfig("default");
const gothOffset = defaultBar?.gothCornersEnabled ? Theme.cornerRadius : 0;
const spacing = defaultBar?.spacing ?? 4;
const bottomGap = defaultBar?.bottomGap ?? 0;
return barHeight + spacing + bottomGap - gothOffset + Theme.popupDistance;
}
function getPopupTriggerPosition(pos, screen, barThickness, widgetWidth, barSpacing, barPosition, barConfig) {
const relativeX = pos.x;
const relativeY = pos.y;
@@ -2360,10 +2481,51 @@ Singleton {
updateBarConfigs();
if (positionChanged) {
notificationPopupsInvalidated();
NotificationService.dismissAllPopups();
}
}
function checkBarCollisions(barId) {
const bar = getBarConfig(barId);
if (!bar || !bar.enabled)
return [];
const conflicts = [];
const enabledBars = getEnabledBarConfigs();
for (var i = 0; i < enabledBars.length; i++) {
const other = enabledBars[i];
if (other.id === barId)
continue;
const samePosition = bar.position === other.position;
if (!samePosition)
continue;
const barScreens = bar.screenPreferences || ["all"];
const otherScreens = other.screenPreferences || ["all"];
const hasAll = barScreens.includes("all") || otherScreens.includes("all");
if (hasAll) {
conflicts.push({
"barId": other.id,
"barName": other.name,
"reason": "Same position on all screens"
});
continue;
}
const overlapping = barScreens.some(screen => otherScreens.includes(screen));
if (overlapping) {
conflicts.push({
"barId": other.id,
"barName": other.name,
"reason": "Same position on overlapping screens"
});
}
}
return conflicts;
}
function deleteBarConfig(barId) {
if (barId === "default")
return;
@@ -2408,7 +2570,8 @@ Singleton {
const bc = bars[i];
if (bc.position !== sidePos)
continue;
if (barConfigCoversScreen(bc, screen))
const prefs = bc.screenPreferences || ["all"];
if (prefs.includes("all") || isScreenInPreferences(screen, prefs))
return true;
}
return false;
@@ -2511,11 +2674,36 @@ Singleton {
return filtered;
}
function barConfigCoversScreen(bc, screen) {
var prefs = bc?.screenPreferences || ["all"];
if (prefs.includes("all") || isScreenInPreferences(screen, prefs))
return true;
return (bc?.showOnLastDisplay ?? false) && Quickshell.screens.length === 1;
function getFrameFilteredScreens() {
var prefs = frameScreenPreferences || ["all"];
if (!prefs || prefs.length === 0 || prefs.includes("all")) {
return Quickshell.screens;
}
return Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
}
function getActiveBarEdgeForScreen(screen) {
if (!screen)
return "";
for (var i = 0; i < barConfigs.length; i++) {
var bc = barConfigs[i];
if (!bc.enabled)
continue;
var prefs = bc.screenPreferences || ["all"];
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
continue;
switch (bc.position ?? 0) {
case SettingsData.Position.Top:
return "top";
case SettingsData.Position.Bottom:
return "bottom";
case SettingsData.Position.Left:
return "left";
case SettingsData.Position.Right:
return "right";
}
}
return "";
}
function getActiveBarEdgesForScreen(screen) {
@@ -2526,7 +2714,8 @@ Singleton {
var bc = barConfigs[i];
if (!bc.enabled)
continue;
if (!barConfigCoversScreen(bc, screen))
var prefs = bc.screenPreferences || ["all"];
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
continue;
switch (bc.position ?? 0) {
case SettingsData.Position.Top:
@@ -2546,47 +2735,51 @@ Singleton {
return edges;
}
function getOverlayBarEdgesForScreen(screen) {
if (!screen)
return [];
var edges = [];
for (var i = 0; i < barConfigs.length; i++) {
var bc = barConfigs[i];
if (!bc.enabled || !(bc.useOverlayLayer ?? false))
continue;
if (!barConfigCoversScreen(bc, screen))
continue;
switch (bc.position ?? 0) {
case SettingsData.Position.Top:
edges.push("top");
break;
case SettingsData.Position.Bottom:
edges.push("bottom");
break;
case SettingsData.Position.Left:
edges.push("left");
break;
case SettingsData.Position.Right:
edges.push("right");
break;
}
}
return edges;
}
readonly property real frameBarContentGap: frameBarInsetPadding < 0 ? frameThickness : frameBarInsetPadding
readonly property real frameBarContentGapExtra: Math.max(0, frameBarContentGap - frameThickness)
function frameEdgeReservation(screen, edge) {
if (!screen)
return 0;
return getActiveBarEdgesForScreen(screen).includes(edge) ? frameBarSize : frameThickness;
}
function frameEdgeInsetForSide(screen, side) {
if (!frameEnabled)
if (!frameEnabled || !screen)
return 0;
return frameEdgeReservation(screen, side);
const edges = getActiveBarEdgesForScreen(screen);
return edges.includes(side) ? frameBarSize : frameThickness;
}
function getActiveBarThicknessForScreen(screen) {
if (frameEnabled)
return frameBarSize;
if (!screen)
return frameThickness;
for (var i = 0; i < barConfigs.length; i++) {
var bc = barConfigs[i];
if (!bc.enabled)
continue;
var prefs = bc.screenPreferences || ["all"];
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
continue;
const innerPadding = bc.innerPadding ?? 4;
const barT = Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding));
const spacing = bc.spacing ?? 4;
const bottomGap = bc.bottomGap ?? 0;
return barT + spacing + bottomGap;
}
return frameThickness;
}
function sendTestNotifications() {
NotificationService.dismissAllPopups();
sendTestNotification(0);
testNotifTimer1.start();
testNotifTimer2.start();
}
function sendTestNotification(index) {
const notifications = [["Notification Position Test", "DMS test notification 1 of 3 ~ Hi there!", "preferences-system"], ["Second Test", "DMS Notification 2 of 3 ~ Check it out!", "applications-graphics"], ["Third Test", "DMS notification 3 of 3 ~ Enjoy!", "face-smile"]];
if (index < 0 || index >= notifications.length) {
return;
}
const notif = notifications[index];
testNotificationProcess.command = ["notify-send", "-h", "int:transient:1", "-a", "DMS", "-i", notif[2], notif[0], notif[1]];
testNotificationProcess.running = true;
}
function setMatugenScheme(scheme) {
@@ -2605,6 +2798,15 @@ Singleton {
set("matugenContrast", value);
}
function setRunUserMatugenTemplates(enabled) {
if (runUserMatugenTemplates === enabled)
return;
set("runUserMatugenTemplates", enabled);
if (typeof Theme !== "undefined") {
Theme.generateSystemThemesFromCurrentTheme();
}
}
function setMatugenTargetMonitor(monitorName) {
if (matugenTargetMonitor === monitorName)
return;
@@ -2623,6 +2825,11 @@ Singleton {
SessionData.setWeatherLocation(displayName, coordinates);
}
function setIconTheme(themeName) {
const light = iconThemePerMode && typeof SessionData !== "undefined" && SessionData.isLightMode;
setIconThemeForMode(themeName, light);
}
function setIconThemeForMode(themeName, light) {
if (light)
iconThemeLight = themeName;
@@ -2668,7 +2875,20 @@ Singleton {
// https://github.com/Supreeeme/xwayland-satellite/issues/104
// no idea if this matters on other compositors but we also set XCURSOR stuff in the launcher
function updateCompositorCursor() {
compositorCursorRefreshNeeded();
if (typeof CompositorService === "undefined")
return;
if (CompositorService.isNiri && typeof NiriService !== "undefined") {
NiriService.generateNiriCursorConfig();
return;
}
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined") {
HyprlandService.generateCursorConfig();
return;
}
if (CompositorService.isMango && typeof MangoService !== "undefined") {
MangoService.generateCursorConfig();
return;
}
}
function updateXResources() {
@@ -2736,6 +2956,20 @@ Singleton {
return env;
}
function setGtkThemingEnabled(enabled) {
set("gtkThemingEnabled", enabled);
if (enabled && typeof Theme !== "undefined") {
Theme.generateSystemThemesFromCurrentTheme();
}
}
function setQtThemingEnabled(enabled) {
set("qtThemingEnabled", enabled);
if (enabled && typeof Theme !== "undefined") {
Theme.generateSystemThemesFromCurrentTheme();
}
}
function setShowDock(enabled) {
showDock = enabled;
const defaultBar = barConfigs[0] || getBarConfig("default");
@@ -2781,6 +3015,16 @@ Singleton {
Qt.callLater(() => forceDockLayoutRefresh());
}
function setDankBarSpacing(spacing) {
const defaultBar = barConfigs[0] || getBarConfig("default");
if (defaultBar) {
updateBarConfig(defaultBar.id, {
"spacing": spacing
});
}
updateCompositorLayout();
}
function setDankBarPosition(position) {
const defaultBar = barConfigs[0] || getBarConfig("default");
if (!defaultBar)
@@ -2836,6 +3080,39 @@ Singleton {
}
}
function resetDankBarWidgetsToDefault() {
var defaultLeft = ["launcherButton", "workspaceSwitcher", "focusedWindow"];
var defaultCenter = ["music", "clock", "weather"];
var defaultRight = ["systemTray", "clipboard", "notificationButton", "battery", "controlCenterButton"];
const defaultBar = barConfigs[0] || getBarConfig("default");
if (defaultBar) {
updateBarConfig(defaultBar.id, {
"leftWidgets": defaultLeft,
"centerWidgets": defaultCenter,
"rightWidgets": defaultRight
});
}
updateListModel(leftWidgetsModel, defaultLeft);
updateListModel(centerWidgetsModel, defaultCenter);
updateListModel(rightWidgetsModel, defaultRight);
showLauncherButton = true;
showWorkspaceSwitcher = true;
showFocusedWindow = true;
showWeather = true;
showMusic = true;
showClipboard = true;
showCpuUsage = true;
showMemUsage = true;
showCpuTemp = true;
showGpuTemp = true;
showSystemTray = true;
showClock = true;
showNotificationButton = true;
showBattery = true;
showControlCenterButton = true;
showCapsLockIndicator = true;
}
function setWorkspaceNameIcon(workspaceName, iconData) {
var iconMap = JSON.parse(JSON.stringify(workspaceNameIcons));
iconMap[workspaceName] = iconData;
@@ -3117,6 +3394,15 @@ Singleton {
Theme.reloadCustomThemeVariant();
}
function toggleDankBarVisible() {
const defaultBar = barConfigs[0] || getBarConfig("default");
if (defaultBar) {
updateBarConfig(defaultBar.id, {
"visible": !defaultBar.visible
});
}
}
function toggleShowDock() {
setShowDock(!showDock);
}
@@ -3138,6 +3424,13 @@ Singleton {
savePluginSettings();
}
function removePluginSettings(pluginId) {
if (pluginSettings[pluginId]) {
delete pluginSettings[pluginId];
savePluginSettings();
}
}
function getPluginSettingsForPlugin(pluginId) {
const settings = pluginSettings[pluginId];
return settings ? JSON.parse(JSON.stringify(settings)) : {};
@@ -3163,6 +3456,22 @@ Singleton {
return settings ? JSON.parse(JSON.stringify(settings)) : {};
}
function setNiriOutputSettings(outputId, settings) {
const updated = JSON.parse(JSON.stringify(niriOutputSettings));
updated[outputId] = settings;
niriOutputSettings = updated;
saveSettings();
}
function removeNiriOutputSettings(outputId) {
if (!niriOutputSettings[outputId])
return;
const updated = JSON.parse(JSON.stringify(niriOutputSettings));
delete updated[outputId];
niriOutputSettings = updated;
saveSettings();
}
function getHyprlandOutputSetting(outputId, key, defaultValue) {
if (!hyprlandOutputSettings[outputId])
return defaultValue;
@@ -3187,6 +3496,40 @@ Singleton {
saveSettings();
}
function getHyprlandOutputSettings(outputId) {
const settings = hyprlandOutputSettings[outputId];
return settings ? JSON.parse(JSON.stringify(settings)) : {};
}
function setHyprlandOutputSettings(outputId, settings) {
const updated = JSON.parse(JSON.stringify(hyprlandOutputSettings));
updated[outputId] = settings;
hyprlandOutputSettings = updated;
saveSettings();
}
function removeHyprlandOutputSettings(outputId) {
if (!hyprlandOutputSettings[outputId])
return;
const updated = JSON.parse(JSON.stringify(hyprlandOutputSettings));
delete updated[outputId];
hyprlandOutputSettings = updated;
saveSettings();
}
function getDisplayProfiles(compositor) {
return displayProfiles[compositor] || {};
}
function setDisplayProfile(compositor, profileId, data) {
const updated = JSON.parse(JSON.stringify(displayProfiles));
if (!updated[compositor])
updated[compositor] = {};
updated[compositor][profileId] = data;
displayProfiles = updated;
saveSettings();
}
function removeDisplayProfile(compositor, profileId) {
if (!displayProfiles[compositor] || !displayProfiles[compositor][profileId])
return;
@@ -3240,6 +3583,29 @@ Singleton {
id: rightWidgetsModel
}
property Process testNotificationProcess
testNotificationProcess: Process {
command: []
running: false
}
property Timer testNotifTimer1
testNotifTimer1: Timer {
interval: 400
repeat: false
onTriggered: sendTestNotification(1)
}
property Timer testNotifTimer2
testNotifTimer2: Timer {
interval: 800
repeat: false
onTriggered: sendTestNotification(2)
}
property alias settingsFile: settingsFile
Timer {
+26
View File
@@ -416,6 +416,24 @@ const StockThemes = {
},
};
const ThemeCategories = {
GENERIC: {
name: "Generic",
variants: [
"blue",
"purple",
"green",
"orange",
"red",
"cyan",
"pink",
"amber",
"coral",
"monochrome",
],
},
};
const ThemeNames = {
BLUE: "blue",
PURPLE: "purple",
@@ -430,6 +448,10 @@ const ThemeNames = {
DYNAMIC: "dynamic",
};
function isStockTheme(themeName) {
return Object.keys(StockThemes.DARK).includes(themeName);
}
function getAvailableThemes(isLight = false) {
return isLight ? StockThemes.LIGHT : StockThemes.DARK;
}
@@ -442,3 +464,7 @@ function getThemeByName(themeName, isLight = false) {
function getAllThemeNames() {
return Object.keys(StockThemes.DARK);
}
function getThemeCategories() {
return ThemeCategories;
}
+609 -97
View File
@@ -96,6 +96,8 @@ Singleton {
}
property bool matugenAvailable: false
property bool gtkThemingEnabled: typeof SettingsData !== "undefined" ? SettingsData.gtkAvailable : false
property bool qtThemingEnabled: typeof SettingsData !== "undefined" ? (SettingsData.qt5ctAvailable || SettingsData.qt6ctAvailable) : false
property var workerRunning: false
property var pendingThemeRequest: null
@@ -105,11 +107,8 @@ Singleton {
property int _colorsRetryCount: 0
property double _lastGenerateMs: 0
property bool blurLayersActive: false
property bool matugenToastSuppressed: false
signal screenTransitionNeeded
signal themeGenerationStarting
property bool themeModeAutomationActive: false
property bool dmsServiceWasDisconnected: true
readonly property var dank16: {
const raw = matugenColors?.dank16;
@@ -145,7 +144,7 @@ Singleton {
Quickshell.execDetached(["mkdir", "-p", stateDir]);
// shellDir may be an embedded-UI extraction, which is read-only and
// unexecutable (dankgo shellapp/shellfs makeReadOnly chmods 0444)
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "assets", "", shellDir]);
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "", shellDir, "assets-only"]);
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
matugenAvailable = (code === 0) && !envDisableMatugen;
@@ -197,6 +196,235 @@ Singleton {
const currentIsLight = (typeof SessionData !== "undefined") ? SessionData.isLightMode : false;
SettingsData.updateCosmicThemeMode(currentIsLight);
}
if (typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled) {
startThemeModeAutomation();
}
}
Connections {
target: SessionData
enabled: typeof SessionData !== "undefined"
function onThemeModeAutoEnabledChanged() {
if (SessionData.themeModeAutoEnabled) {
root.startThemeModeAutomation();
} else {
root.stopThemeModeAutomation();
}
}
function onThemeModeAutoModeChanged() {
if (root.themeModeAutomationActive) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
}
}
function onThemeModeStartHourChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeStartMinuteChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeEndHourChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeEndMinuteChanged() {
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onThemeModeShareGammaSettingsChanged() {
if (root.themeModeAutomationActive) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
}
}
function onNightModeStartHourChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeStartMinuteChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeEndHourChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onNightModeEndMinuteChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
root.evaluateThemeMode();
root.syncTimeThemeSchedule();
}
}
function onLatitudeChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0 && typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
function onLongitudeChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0 && typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
function onNightModeUseIPLocationChanged() {
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
if (typeof DMSService !== "undefined") {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": SessionData.nightModeUseIPLocation
}, response => {
if (!response.error && !SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
});
}
root.evaluateThemeMode();
root.syncLocationThemeSchedule();
}
}
}
// React to gamma backend's isDay state changes for location-based mode
Connections {
target: DisplayService
enabled: typeof DisplayService !== "undefined" && typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location" && !themeAutoBackendAvailable()
function onGammaIsDayChanged() {
if (root.isLightMode !== DisplayService.gammaIsDay) {
root.setLightMode(DisplayService.gammaIsDay, true, true);
}
}
}
Connections {
target: DMSService
function onThemeAutoStateUpdate(data) {
if (!SessionData.themeModeAutoEnabled) {
return;
}
applyThemeAutoState(data);
}
function onConnectionStateChanged() {
if (DMSService.isConnected && SessionData.themeModeAutoMode === "time") {
root.syncTimeThemeSchedule();
}
if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") {
root.syncLocationThemeSchedule();
}
if (themeAutoBackendAvailable() && SessionData.themeModeAutoEnabled) {
DMSService.sendRequest("theme.auto.getState", null, response => {
if (response && response.result) {
applyThemeAutoState(response.result);
}
});
}
if (!SessionData.themeModeAutoEnabled) {
return;
}
if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") {
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": true
}, response => {
if (!response.error) {
log.info("Theme automation: IP location enabled after connection");
}
});
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": false
}, response => {
if (!response.error) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
}, locationResponse => {
if (locationResponse?.error) {
log.warn("Theme automation: Failed to set location", locationResponse.error);
}
});
}
});
} else {
log.warn("Theme automation: No location configured");
}
}
}
}
Connections {
target: SessionService
enabled: SessionData.themeModeAutoEnabled
function onSessionUnlocked() {
root.triggerThemeAutomationRefresh();
}
function onSessionResumed() {
root.triggerThemeAutomationRefresh();
}
}
function triggerThemeAutomationRefresh() {
if (!themeAutoBackendAvailable()) {
root.evaluateThemeMode();
return;
}
DMSService.sendRequest("theme.auto.trigger", {});
}
function getMatugenColor(path, fallback) {
@@ -343,30 +571,13 @@ Singleton {
property color surfaceVariantAlpha: withAlpha(surfaceVariant, 0.2)
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
readonly property bool blurForegroundLayers: blurLayersActive && foregroundLayers
readonly property bool transparentBlurLayers: blurLayersActive && !foregroundLayers
readonly property real foregroundLayerTransparency: typeof SettingsData === "undefined" ? 1.0 : (SettingsData.foregroundLayerTransparency ?? 1.0)
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color floatingSurfaceHigh: foregroundLayers ? withAlpha(surfaceContainerHigh, foregroundLayerTransparency) : withAlpha(surfaceContainerHigh, 0)
readonly property bool floatingWindowSynced: typeof SettingsData === "undefined" || (SettingsData.floatingWindowSyncGlobal ?? true)
readonly property real floatingWindowTransparency: {
if (typeof SettingsData === "undefined" || floatingWindowSynced)
return popupTransparency;
return SettingsData.floatingWindowTransparency ?? 1.0;
}
readonly property bool floatingWindowForegroundLayers: floatingWindowSynced ? foregroundLayers : (SettingsData.floatingWindowForegroundLayers ?? true)
readonly property real floatingWindowForegroundTransparency: {
if (typeof SettingsData === "undefined" || floatingWindowSynced)
return foregroundLayerTransparency;
return SettingsData.floatingWindowForegroundTransparency ?? 1.0;
}
readonly property color floatingWindowSurface: withAlpha(surfaceContainer, floatingWindowTransparency)
readonly property color floatingWindowSurfaceHigh: floatingWindowForegroundLayers ? withAlpha(surfaceContainerHigh, floatingWindowForegroundTransparency) : withAlpha(surfaceContainerHigh, 0)
readonly property color floatingWindowNestedSurface: floatingWindowSurfaceHigh
readonly property color notepadWindowSurface: withAlpha(surfaceContainer, notepadTransparency)
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
readonly property color nestedSurface: floatingSurfaceHigh
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
@@ -374,20 +585,6 @@ Singleton {
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
readonly property real floatingWindowFieldAlpha: floatingWindowTransparency
readonly property color floatingWindowFieldColor: withAlpha(surfaceContainerHigh, floatingWindowFieldAlpha)
readonly property real popupFieldAlpha: {
if (transparentBlurLayers)
return 0.28;
if (blurForegroundLayers)
return Math.max(foregroundLayerTransparency, 0.62);
return popupTransparency;
}
readonly property color popupFieldColor: withAlpha(surfaceContainerHigh, popupFieldAlpha)
readonly property color popupFieldBorderColor: withAlpha(outline, blurLayersActive ? 0.16 : layerOutlineOpacity)
readonly property color popupFieldFocusedBorderColor: withAlpha(primary, blurLayersActive ? 0.72 : 1.0)
readonly property color floatingWindowFieldBorderColor: popupFieldBorderColor
readonly property color floatingWindowFieldFocusedBorderColor: popupFieldFocusedBorderColor
property color surfaceTextHover: withAlpha(surfaceText, 0.08)
property color surfaceTextAlpha: withAlpha(surfaceText, 0.3)
@@ -461,7 +658,7 @@ Singleton {
}
}
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (foregroundLayers ? withAlpha(surfaceContainerHigh, blurLayersActive ? Math.min(popupTransparency, 0.24) : popupTransparency) : withAlpha(surfaceContainer, 0))
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (foregroundLayers ? withAlpha(surfaceContainerHigh, BlurService.enabled ? Math.min(popupTransparency, 0.24) : popupTransparency) : withAlpha(surfaceContainer, 0))
readonly property color ccPillInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.08) : nestedSurface
readonly property color ccPillInactiveHoverBg: transparentBlurLayers ? withAlpha(primary, 0.10) : primaryPressed
readonly property color ccSliderTrackColor: transparentBlurLayers ? surfaceText : surfaceContainerHigh
@@ -781,6 +978,22 @@ Singleton {
};
}
function elevationTintOpacity(level) {
if (!level)
return 0;
if (level === elevationLevel1)
return 0.05;
if (level === elevationLevel2)
return 0.08;
if (level === elevationLevel3)
return 0.11;
if (level === elevationLevel4)
return 0.12;
if (level === elevationLevel5)
return 0.14;
return 0.08;
}
readonly property var animationDurations: [
{
"shorter": 0,
@@ -1048,7 +1261,9 @@ Singleton {
}
function screenTransition() {
screenTransitionNeeded();
if (CompositorService.isNiri) {
NiriService.doScreenTransition();
}
}
function switchTheme(themeName, savePrefs = true, enableTransition = true) {
@@ -1100,6 +1315,7 @@ Singleton {
SessionData.setLightMode(light);
}
PortalService.setLightMode(light);
if (typeof SettingsData !== "undefined") {
SettingsData.updateCosmicThemeMode(light);
}
@@ -1110,6 +1326,22 @@ Singleton {
setLightMode(!isLightMode, savePrefs, true);
}
function forceGenerateSystemThemes() {
if (!matugenAvailable) {
return;
}
generateSystemThemesFromCurrentTheme();
}
function getAvailableThemes() {
return StockThemes.getAllThemeNames();
}
function getThemeDisplayName(themeName) {
const themeData = StockThemes.getThemeByName(themeName, isLightMode);
return themeData.name;
}
function getThemeColors(themeName) {
if (themeName === "custom" && customThemeData) {
return customThemeData;
@@ -1225,7 +1457,11 @@ Singleton {
readonly property var _availableThemeNames: StockThemes.getAllThemeNames()
property string currentThemeName: currentTheme
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : floatingWindowTransparency
function panelBackground() {
return withAlpha(surfaceContainer, panelTransparency);
}
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency
property bool widgetBackgroundHasAlpha: {
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
@@ -1301,6 +1537,10 @@ Singleton {
}
}
function isColorDark(c) {
return (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) < 0.5;
}
function barIconSize(barThickness, offset, maximizeIcon, iconScale) {
const defaultOffset = offset !== undefined ? offset : -6;
const size = (maximizeIcon ?? false) ? iconSizeLarge : iconSize;
@@ -1383,6 +1623,19 @@ Singleton {
}
}
function getPowerProfileDescription(profile) {
switch (profile) {
case 0:
return I18n.tr("Extend battery life", "power profile description");
case 1:
return I18n.tr("Balance power and performance", "power profile description");
case 2:
return I18n.tr("Prioritize performance", "power profile description");
default:
return I18n.tr("Custom power profile", "power profile description");
}
}
function onLightModeChanged() {
if (currentTheme === "custom" && customThemeFileView.path) {
customThemeFileView.reload();
@@ -1410,7 +1663,9 @@ Singleton {
log.info("Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
themeGenerationStarting();
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
NiriService.suppressNextToast();
}
const desired = {
"kind": kind,
@@ -1522,7 +1777,8 @@ Singleton {
if (currentTheme === dynamic) {
if (!rawWallpaperPath) {
log.warn("Auto theme has no wallpaper - skipping matugen");
log.warn("Auto theme has no wallpaper - skipping matugen, syncing portal mode only");
PortalService.setLightMode(isLight);
return;
}
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
@@ -1670,47 +1926,6 @@ Singleton {
return colors;
}
function refreshGtkTheme() {
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
const schema = "org.gnome.desktop.interface";
const key = "gtk-theme";
const makeCmd = (tool, schema, val) => {
if (tool === "gsettings") {
return `gsettings set ${schema} ${key} '' && gsettings set ${schema} ${key} ${val}`;
} else {
const dconfPath = `/${schema.replace(/\./g, "/")}`;
return `dconf write ${dconfPath}/${key} "''" && dconf write ${dconfPath}/${key} "'${val}'"`;
}
};
Proc.runCommand("gtkRefresher", ["sh", "-c", makeCmd("gsettings", schema, theme)], (output, exitCode) => {
if (exitCode !== 0) {
Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => {
if (exitCode !== 0) {
log.warn("Failed to refresh gtk-theme");
}
});
}
});
}
function patchGtk3colors() {
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight, shellDir], (output, exitCode) => {
switch (exitCode) {
case 0:
refreshGtkTheme();
break;
case 2:
break;
default:
log.warn(`Failed to patch GTK3 colors: ${output}`);
}
});
}
function applyGtkColors() {
if (!matugenAvailable) {
if (typeof ToastService !== "undefined") {
@@ -1720,9 +1935,9 @@ Singleton {
}
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "true" : "false";
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight, shellDir], (output, exitCode) => {
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, isLight, shellDir], (output, exitCode) => {
if (exitCode === 0) {
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
if (typeof ToastService !== "undefined" && typeof NiriService !== "undefined" && !NiriService.matugenSuppression) {
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
}
} else {
@@ -1760,17 +1975,18 @@ Singleton {
return Qt.rgba(c.r, c.g, c.b, a);
}
function popupLayerColor(baseColor) {
if (isConnectedEffect)
return connectedSurfaceColor;
return withAlpha(baseColor, popupTransparency);
}
function blendAlpha(c, a) {
if (!c || c.r === undefined)
return Qt.rgba(0, 0, 0, 0);
return Qt.rgba(c.r, c.g, c.b, c.a * a);
}
function hoverTint(base) {
const factor = 1.2;
return isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
}
function blend(c1, c2, r) {
return Qt.rgba(c1.r * (1 - r) + c2.r * r, c1.g * (1 - r) + c2.g * r, c1.b * (1 - r) + c2.b * r, c1.a * (1 - r) + c2.a * r);
}
@@ -1915,11 +2131,8 @@ Singleton {
root.matugenCompleted(currentMode, "error");
}
if (!pendingThemeRequest) {
if (SettingsData.matugenTemplateGtk)
patchGtk3colors();
if (!pendingThemeRequest)
return;
}
const req = pendingThemeRequest;
pendingThemeRequest = null;
@@ -2087,4 +2300,303 @@ Singleton {
root.switchTheme(defaultTheme, true, false);
}
}
// Theme mode automation functions
function themeAutoBackendAvailable() {
return typeof DMSService !== "undefined" && DMSService.isConnected && Array.isArray(DMSService.capabilities) && DMSService.capabilities.includes("theme.auto");
}
function applyThemeAutoState(state) {
if (!state) {
return;
}
if (state.config && state.config.mode && state.config.mode !== SessionData.themeModeAutoMode) {
return;
}
if (typeof SessionData !== "undefined" && state.nextTransition !== undefined) {
SessionData.themeModeNextTransition = state.nextTransition || "";
}
if (state.isLight !== undefined && root.isLightMode !== state.isLight) {
root.setLightMode(state.isLight, true, true);
}
}
function syncTimeThemeSchedule() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return;
}
if (!DMSService.isConnected) {
return;
}
const timeModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "time";
if (!timeModeActive) {
return;
}
DMSService.sendRequest("theme.auto.setMode", {
"mode": "time"
});
const shareSettings = SessionData.themeModeShareGammaSettings;
const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour;
const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute;
const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour;
const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute;
DMSService.sendRequest("theme.auto.setSchedule", {
"startHour": startHour,
"startMinute": startMinute,
"endHour": endHour,
"endMinute": endMinute
}, response => {
if (response && response.error) {
log.error("Theme automation: Failed to sync time schedule:", response.error);
}
});
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": true
});
DMSService.sendRequest("theme.auto.trigger", {});
}
function syncLocationThemeSchedule() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return;
}
if (!DMSService.isConnected) {
return;
}
const locationModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location";
if (!locationModeActive) {
return;
}
DMSService.sendRequest("theme.auto.setMode", {
"mode": "location"
});
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("theme.auto.setUseIPLocation", {
"use": true
});
} else {
DMSService.sendRequest("theme.auto.setUseIPLocation", {
"use": false
});
if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("theme.auto.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
});
}
}
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": true
});
DMSService.sendRequest("theme.auto.trigger", {});
}
function evaluateThemeMode() {
if (typeof SessionData === "undefined" || !SessionData.themeModeAutoEnabled) {
return;
}
if (themeAutoBackendAvailable()) {
DMSService.sendRequest("theme.auto.getState", null, response => {
if (response && response.result) {
applyThemeAutoState(response.result);
}
});
return;
}
const mode = SessionData.themeModeAutoMode;
if (mode === "location") {
evaluateLocationBasedThemeMode();
} else {
evaluateTimeBasedThemeMode();
}
}
function evaluateLocationBasedThemeMode() {
if (typeof DisplayService !== "undefined") {
const shouldBeLight = DisplayService.gammaIsDay;
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
return;
}
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
const shouldBeLight = calculateIsDaytime(SessionData.latitude, SessionData.longitude);
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
return;
}
if (root.themeModeAutomationActive) {
if (SessionData.nightModeUseIPLocation) {
log.warn("Theme automation: Waiting for IP location from backend");
} else {
log.warn("Theme automation: Location mode requires coordinates");
}
}
}
function evaluateTimeBasedThemeMode() {
const shareSettings = SessionData.themeModeShareGammaSettings;
const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour;
const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute;
const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour;
const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute;
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const startMinutes = startHour * 60 + startMinute;
const endMinutes = endHour * 60 + endMinute;
let shouldBeLight;
if (startMinutes < endMinutes) {
shouldBeLight = currentMinutes < startMinutes || currentMinutes >= endMinutes;
} else {
shouldBeLight = currentMinutes >= endMinutes && currentMinutes < startMinutes;
}
if (root.isLightMode !== shouldBeLight) {
root.setLightMode(shouldBeLight, true, true);
}
}
function calculateIsDaytime(lat, lng) {
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const diff = now - start;
const dayOfYear = Math.floor(diff / 86400000);
const latRad = lat * Math.PI / 180;
const declination = 23.45 * Math.sin((360 / 365) * (dayOfYear - 81) * Math.PI / 180);
const declinationRad = declination * Math.PI / 180;
const cosHourAngle = -Math.tan(latRad) * Math.tan(declinationRad);
if (cosHourAngle > 1) {
return false; // Polar night
}
if (cosHourAngle < -1) {
return true; // Midnight sun
}
const hourAngle = Math.acos(cosHourAngle);
const hourAngleDeg = hourAngle * 180 / Math.PI;
const sunriseHour = 12 - hourAngleDeg / 15;
const sunsetHour = 12 + hourAngleDeg / 15;
const timeZoneOffset = now.getTimezoneOffset() / 60;
const localSunrise = sunriseHour - lng / 15 - timeZoneOffset;
const localSunset = sunsetHour - lng / 15 - timeZoneOffset;
const currentHour = now.getHours() + now.getMinutes() / 60;
const normalizeSunrise = ((localSunrise % 24) + 24) % 24;
const normalizeSunset = ((localSunset % 24) + 24) % 24;
return currentHour >= normalizeSunrise && currentHour < normalizeSunset;
}
// Helper function to send location to backend
function sendLocationToBackend() {
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
return false;
}
if (!DMSService.isConnected) {
return false;
}
if (SessionData.nightModeUseIPLocation) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": true
}, response => {
if (response?.error) {
log.warn("Theme automation: Failed to enable IP location", response.error);
}
});
return true;
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
"use": false
}, response => {
if (!response.error) {
DMSService.sendRequest("wayland.gamma.setLocation", {
"latitude": SessionData.latitude,
"longitude": SessionData.longitude
}, locResp => {
if (locResp?.error) {
log.warn("Theme automation: Failed to set location", locResp.error);
}
});
}
});
return true;
}
return false;
}
Timer {
id: locationRetryTimer
interval: 1000
repeat: true
running: false
property int retryCount: 0
onTriggered: {
if (root.sendLocationToBackend()) {
stop();
retryCount = 0;
root.evaluateThemeMode();
} else {
retryCount++;
if (retryCount >= 10) {
stop();
retryCount = 0;
}
}
}
}
function startThemeModeAutomation() {
root.themeModeAutomationActive = true;
root.syncTimeThemeSchedule();
root.syncLocationThemeSchedule();
const sent = root.sendLocationToBackend();
if (!sent && typeof SessionData !== "undefined" && SessionData.themeModeAutoMode === "location") {
locationRetryTimer.start();
} else {
root.evaluateThemeMode();
}
}
function stopThemeModeAutomation() {
root.themeModeAutomationActive = false;
if (typeof DMSService !== "undefined" && DMSService.isConnected) {
DMSService.sendRequest("theme.auto.setEnabled", {
"enabled": false
});
}
}
}
+10 -61
View File
@@ -1,69 +1,31 @@
pragma Singleton
import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
Singleton {
id: root
property var activeTrayMenus: ({})
property var _pendingMenuRequest: null
signal openTrayMenuRequested
function requestOpenMenu(itemId, screenName) {
_pendingMenuRequest = {
"itemId": itemId,
"screenName": screenName
};
openTrayMenuRequested();
}
// Every SystemTrayBar instance receives the signal; the claim ensures
// exactly one opens the menu, preferring the requested screen
function claimMenuRequest(instanceScreenName) {
if (!_pendingMenuRequest)
return null;
if (_pendingMenuRequest.screenName && _pendingMenuRequest.screenName !== instanceScreenName)
return null;
const request = _pendingMenuRequest;
_pendingMenuRequest = null;
return request;
}
function findTrayItem(itemId: string): var {
if (!itemId)
return null;
return SystemTray.items.values.find(item => {
const id = item?.id || "";
const title = item?.tooltipTitle || "";
const fullKey = title ? `${id}::${title}` : id;
return fullKey === itemId || id === itemId;
});
}
function registerMenu(screenName, menu) {
if (!screenName || !menu)
return;
const newMenus = Object.assign({}, activeTrayMenus);
newMenus[screenName] = menu;
activeTrayMenus = newMenus;
if (!screenName || !menu) return
const newMenus = Object.assign({}, activeTrayMenus)
newMenus[screenName] = menu
activeTrayMenus = newMenus
}
function unregisterMenu(screenName) {
if (!screenName)
return;
const newMenus = Object.assign({}, activeTrayMenus);
delete newMenus[screenName];
activeTrayMenus = newMenus;
if (!screenName) return
const newMenus = Object.assign({}, activeTrayMenus)
delete newMenus[screenName]
activeTrayMenus = newMenus
}
function closeHoverMenus() {
function closeAllMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName]
if (!menu || menu.openedByHover !== true) continue
if (!menu) continue
if (typeof menu.close === "function") {
menu.close()
} else if (menu.showMenu !== undefined) {
@@ -71,17 +33,4 @@ Singleton {
}
}
}
function closeAllMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName];
if (!menu)
continue;
if (typeof menu.close === "function") {
menu.close();
} else if (menu.showMenu !== undefined) {
menu.showMenu = false;
}
}
}
}
+21 -2
View File
@@ -4,13 +4,17 @@
function markdownToHtml(text) {
if (!text) return "";
// Store code blocks and inline code to protect them from further processing
const codeBlocks = [];
const inlineCode = [];
let blockIndex = 0;
let inlineIndex = 0;
// First, extract and replace code blocks with placeholders
let html = text.replace(/```([\s\S]*?)```/g, (match, code) => {
// Trim leading and trailing blank lines only
const trimmedCode = code.replace(/^\n+|\n+$/g, '');
// Escape HTML entities in code
const escapedCode = trimmedCode.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
@@ -18,7 +22,9 @@ function markdownToHtml(text) {
return `\x00CODEBLOCK${blockIndex++}\x00`;
});
// Extract and replace inline code
html = html.replace(/`([^`]+)`/g, (match, code) => {
// Escape HTML entities in code
const escapedCode = code.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
@@ -34,10 +40,12 @@ function markdownToHtml(text) {
return prefix + `\x00URL${urlIndex++}\x00`;
});
// Escape HTML entities (but not in code blocks or URLs)
html = html.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Headers
html = html.replace(/^### (.*?)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.*?)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.*?)$/gm, '<h1>$1</h1>');
@@ -50,16 +58,20 @@ function markdownToHtml(text) {
html = html.replace(/__(.*?)__/g, '<b>$1</b>');
html = html.replace(/_(.*?)_/g, '<i>$1</i>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Lists
html = html.replace(/^\* (.*?)$/gm, '<li>$1</li>');
html = html.replace(/^- (.*?)$/gm, '<li>$1</li>');
html = html.replace(/^\d+\. (.*?)$/gm, '<li>$1</li>');
// Wrap consecutive list items in ul/ol tags
html = html.replace(/(<li>[\s\S]*?<\/li>\s*)+/g, function(match) {
return '<ul>' + match + '</ul>';
});
// Restore extracted URLs as anchor tags (preserves raw & in href)
html = html.replace(/\x00URL(\d+)\x00/g, (_, index) => {
const url = urls[parseInt(index)];
const display = url.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -75,23 +87,30 @@ function markdownToHtml(text) {
return inlineCode[parseInt(index)];
});
// Line breaks (after code blocks are restored)
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br/>');
// Wrap in paragraph tags if not already wrapped
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
// Clean up the final HTML
// Remove <br/> tags immediately before block elements
html = html.replace(/<br\/>\s*<pre>/g, '<pre>');
html = html.replace(/<br\/>\s*<ul>/g, '<ul>');
html = html.replace(/<br\/>\s*<h[1-6]>/g, '<h$1>');
// Remove empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '');
html = html.replace(/<p>\s*<br\/>\s*<\/p>/g, '');
html = html.replace(/(<br\/>){3,}/g, '<br/><br/>');
html = html.replace(/(<\/p>)\s*(<p>)/g, '$1$2');
// Remove excessive line breaks
html = html.replace(/(<br\/>){3,}/g, '<br/><br/>'); // Max 2 consecutive line breaks
html = html.replace(/(<\/p>)\s*(<p>)/g, '$1$2'); // Remove whitespace between paragraphs
// Remove leading/trailing whitespace
html = html.trim();
return html;
+44 -15
View File
@@ -5,13 +5,11 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
Singleton {
id: root
signal toastRequested(int severity, string title, string body, string command, string category)
signal toastCategoryDismissed(string category)
property var settingsRoot: null
onSettingsRootChanged: {
@@ -319,7 +317,7 @@ Singleton {
function launchAuthApplyTerminalFallback(fromPrecheck, details) {
authApplyTerminalFallbackFromPrecheck = fromPrecheck;
if (details && details !== "")
toastRequested(0, I18n.tr("Authentication changes need sudo. Opening terminal so you can use password or fingerprint."), details, "", "auth-sync");
ToastService.showInfo(I18n.tr("Authentication changes need sudo. Opening terminal so you can use password or fingerprint."), details, "", "auth-sync");
authApplyTerminalFallbackStderr = "";
authApplyTerminalFallbackProcess.running = true;
}
@@ -366,21 +364,21 @@ Singleton {
}
function deferGreeterAutoLoginSyncToPill(details) {
toastCategoryDismissed("greeter-autologin-sync");
ToastService.dismissCategory("greeter-autologin-sync");
if (settingsRoot)
settingsRoot.set("greeterSyncPending", true);
toastRequested(1, I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms-greeter sync --autologin", "greeter-autologin-sync");
ToastService.showWarning(I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms-greeter sync --autologin", "greeter-autologin-sync");
finishGreeterAutoLoginSync();
}
function greeterAutoLoginSyncSuccessToast(details) {
const enabling = settingsRoot && settingsRoot.greeterAutoLogin;
// Clear the sticky in-progress toast, then confirm with an auto-dismissing toast.
toastCategoryDismissed("greeter-autologin-sync");
ToastService.dismissCategory("greeter-autologin-sync");
if (enabling) {
toastRequested(1, I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""), "", "");
ToastService.showWarning(I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""));
} else {
toastRequested(0, I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""), "", "");
ToastService.showInfo(I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""));
}
}
@@ -487,10 +485,41 @@ Singleton {
return pamFprintDetected ? "probe_failed" : "missing_pam_support";
}
// --- Qt tools detection ---
function detectQtTools() {
qtToolsDetectionProcess.running = true;
}
function checkPluginSettings() {
pluginSettingsCheckProcess.running = true;
}
property var qtToolsDetectionProcess: Process {
command: ["sh", "-c", "echo -n 'qt5ct:'; command -v qt5ct >/dev/null && echo 'true' || echo 'false'; echo -n 'qt6ct:'; command -v qt6ct >/dev/null && echo 'true' || echo 'false'; echo -n 'gtk:'; (command -v gsettings >/dev/null || command -v dconf >/dev/null) && echo 'true' || echo 'false'"]
running: false
stdout: StdioCollector {
onStreamFinished: {
if (!settingsRoot)
return;
if (text && text.trim()) {
const lines = text.trim().split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("qt5ct:")) {
settingsRoot.qt5ctAvailable = line.split(":")[1] === "true";
} else if (line.startsWith("qt6ct:")) {
settingsRoot.qt6ctAvailable = line.split(":")[1] === "true";
} else if (line.startsWith("gtk:")) {
settingsRoot.gtkAvailable = line.split(":")[1] === "true";
}
}
}
}
}
}
Timer {
id: authApplyDebounce
interval: 300
@@ -546,7 +575,7 @@ Singleton {
onExited: exitCode => {
const enabling = root.settingsRoot && root.settingsRoot.greeterAutoLogin;
if (exitCode === 0) {
root.toastRequested(1, enabling ? I18n.tr("Applying auto-login on startup...") : I18n.tr("Disabling auto-login on startup..."), "", "dms-greeter sync --autologin", "greeter-autologin-sync");
ToastService.showWarning(enabling ? I18n.tr("Applying auto-login on startup...") : I18n.tr("Disabling auto-login on startup..."), "", "dms-greeter sync --autologin", "greeter-autologin-sync");
root.greeterAutoLoginSyncProcess.running = true;
return;
}
@@ -575,7 +604,7 @@ Singleton {
let details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.toastRequested(0, I18n.tr("Authentication changes applied"), details, "", "auth-sync");
ToastService.showInfo(I18n.tr("Authentication changes applied"), details, "", "auth-sync");
root.detectAuthCapabilities();
root.finishAuthApply();
return;
@@ -586,7 +615,7 @@ Singleton {
details = out;
if (err !== "")
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
root.toastRequested(1, I18n.tr("Background authentication sync failed. Trying terminal mode."), details, "", "auth-sync");
ToastService.showWarning(I18n.tr("Background authentication sync failed. Trying terminal mode."), details, "", "auth-sync");
root.launchAuthApplyTerminalFallback(false, "");
}
}
@@ -602,7 +631,7 @@ Singleton {
onExited: exitCode => {
const err = (root.authApplySudoProbeStderr || "").trim();
if (exitCode === 0) {
root.toastRequested(0, I18n.tr("Applying authentication changes..."), "", "", "auth-sync");
ToastService.showInfo(I18n.tr("Applying authentication changes..."), "", "", "auth-sync");
root.authApplyProcess.running = true;
return;
}
@@ -622,10 +651,10 @@ Singleton {
onExited: exitCode => {
if (exitCode === 0) {
const message = root.authApplyTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication there; it will close automatically when done.");
root.toastRequested(0, message, "", "", "auth-sync");
ToastService.showInfo(message, "", "", "auth-sync");
} else {
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");
ToastService.showError(I18n.tr("Terminal fallback failed. Install a supported terminal emulator or run 'dms auth sync' manually.") + " (exit " + exitCode + ")", details, "", "auth-sync");
}
root.finishAuthApply();
}
+1 -3
View File
@@ -19,7 +19,6 @@ var SPEC = {
includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] },
wallpaperCyclingEnabled: { def: false },
wallpaperCyclingRandom: { def: false },
wallpaperCyclingMode: { def: "interval" },
wallpaperCyclingInterval: { def: 300 },
wallpaperCyclingTime: { def: "06:00" },
@@ -99,8 +98,7 @@ var SPEC = {
niriOverviewLastMode: { def: "apps" },
settingsSidebarExpandedIds: { def: "," },
settingsSidebarCollapsedIds: { def: "," },
showConfigReloadToast: { def: true }
settingsSidebarCollapsedIds: { def: "," }
};
function getValidKeys() {
@@ -75,3 +75,26 @@ function migrateToVersion(obj, targetVersion, settingsData) {
return session;
}
function cleanup(fileText) {
var getValidKeys = SpecModule.getValidKeys;
if (!fileText || !fileText.trim()) return null;
try {
var session = JSON.parse(fileText);
var validKeys = getValidKeys();
var needsSave = false;
for (var key in session) {
if (validKeys.indexOf(key) < 0) {
delete session[key];
needsSave = true;
}
}
return needsSave ? JSON.stringify(session, null, 2) : null;
} catch (e) {
console.warn("SessionData: Failed to cleanup unused keys:", e.message);
return null;
}
}
+10 -39
View File
@@ -17,11 +17,6 @@ var SPEC = {
popupTransparency: { def: 1.0, coerce: percentToUnit },
dockTransparency: { def: 1.0, coerce: percentToUnit },
floatingWindowSyncGlobal: { def: true },
floatingWindowTransparency: { def: 1.0, coerce: percentToUnit },
floatingWindowForegroundLayers: { def: true },
floatingWindowForegroundTransparency: { def: 1.0, coerce: percentToUnit },
dmsWindowsFloating: { def: true },
widgetBackgroundColor: { def: "sch" },
widgetBackgroundCustomColor: { def: "#6750A4" },
@@ -43,34 +38,6 @@ var SPEC = {
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
mouseAccelProfile: { def: "default", onChange: "updateCompositorInput" },
mouseAccelSpeed: { def: 0.0, onChange: "updateCompositorInput" },
mouseLeftHanded: { def: false, onChange: "updateCompositorInput" },
mouseMiddleEmulation: { def: false, onChange: "updateCompositorInput" },
mouseNaturalScroll: { def: false, onChange: "updateCompositorInput" },
mouseScrollFactor: { def: 1.0, onChange: "updateCompositorInput" },
mouseScrollMethod: { def: "default", onChange: "updateCompositorInput" },
touchpadAccelProfile: { def: "default", onChange: "updateCompositorInput" },
touchpadAccelSpeed: { def: 0.0, onChange: "updateCompositorInput" },
touchpadDisableOnExternalMouse: { def: false, onChange: "updateCompositorInput" },
touchpadDisableWhileTyping: { def: true, onChange: "updateCompositorInput" },
touchpadDragLock: { def: false, onChange: "updateCompositorInput" },
touchpadMiddleEmulation: { def: false, onChange: "updateCompositorInput" },
touchpadNaturalScroll: { def: true, onChange: "updateCompositorInput" },
touchpadScrollFactor: { def: 1.0, onChange: "updateCompositorInput" },
touchpadScrollMethod: { def: "default", onChange: "updateCompositorInput" },
touchpadTapAndDrag: { def: true, onChange: "updateCompositorInput" },
touchpadTapToClick: { def: true, onChange: "updateCompositorInput" },
keyboardLayouts: { def: "", onChange: "updateCompositorInput" },
keyboardVariants: { def: "", onChange: "updateCompositorInput" },
keyboardModel: { def: "", onChange: "updateCompositorInput" },
keyboardOptions: { def: "", onChange: "updateCompositorInput" },
keyboardKeymapFile: { def: "", onChange: "updateCompositorInput" },
keyboardTrackLayout: { def: "", onChange: "updateCompositorInput" },
keyboardRepeatDelay: { def: 0, onChange: "updateCompositorInput" },
keyboardRepeatRate: { def: 0, onChange: "updateCompositorInput" },
keyboardNumlock: { def: false, onChange: "updateCompositorInput" },
firstDayOfWeek: { def: -1 },
showWeekNumber: { def: false },
@@ -102,7 +69,6 @@ var SPEC = {
barElevationEnabled: { def: true },
blurEnabled: { def: false },
blurForegroundLayers: { def: true },
foregroundLayerTransparency: { def: 1.0, coerce: percentToUnit },
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
blurBorderEnabled: { def: true },
blurBorderColor: { def: "outline" },
@@ -221,7 +187,6 @@ var SPEC = {
mediaAdaptiveWidthEnabled: { def: true },
audioVisualizerEnabled: { def: true },
mediaUseAlbumArtAccent: { def: false },
appleMusicAnimatedArtEnabled: { def: false },
audioScrollMode: { def: "volume" },
audioWheelScrollAmount: { def: 5 },
audioDeviceScrollVolumeEnabled: { def: false },
@@ -315,6 +280,9 @@ var SPEC = {
lastAppliedIconTheme: { def: "" },
availableIconThemes: { def: ["System Default"], persist: false },
systemDefaultIconTheme: { def: "", persist: false },
qt5ctAvailable: { def: false, persist: false },
qt6ctAvailable: { def: false, persist: false },
gtkAvailable: { def: false, persist: false },
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 }, mango: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
availableCursorThemes: { def: ["System Default"], persist: false },
@@ -387,7 +355,14 @@ var SPEC = {
fadeToDpmsEnabled: { def: true },
fadeToDpmsGracePeriod: { def: 5 },
launchPrefix: { def: "" },
brightnessDevicePins: { def: {} },
wifiNetworkPins: { def: {} },
bluetoothDevicePins: { def: {} },
audioInputDevicePins: { def: {} },
audioOutputDevicePins: { def: {} },
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
syncModeWithPortal: { def: true },
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
@@ -485,8 +460,6 @@ var SPEC = {
enableU2f: { def: false, onChange: "scheduleAuthApply" },
u2fMode: { def: "or" },
lockPamPath: { def: "" },
lockScreenSecurityKeyShortcut: { def: "Ctrl+Q" },
lockScreenSecurityKeyShortcutEnabled: { def: false },
lockPamInlineFprint: { def: false },
lockPamInlineU2f: { def: false },
lockPamExternallyManaged: { def: false },
@@ -504,7 +477,6 @@ var SPEC = {
notificationTimeoutLow: { def: 5000 },
notificationTimeoutNormal: { def: 5000 },
notificationTimeoutCritical: { def: 0 },
notificationIgnoreAppTimeout: { def: false },
notificationCompactMode: { def: false },
notificationShowTimeoutBar: { def: false },
notificationDedupeEnabled: { def: true },
@@ -543,7 +515,6 @@ var SPEC = {
customPowerActionHibernate: { def: "" },
customPowerActionReboot: { def: "" },
customPowerActionPowerOff: { def: "" },
customPowerButtons: { def: [] },
updaterHideWidget: { def: false },
updaterCheckOnStart: { def: false },
+23 -26
View File
@@ -2,21 +2,6 @@
.import "./SettingsSpec.js" as SpecModule
var PIN_KEYS = ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"];
function extractPins(obj) {
if (!obj) return null;
var pins = null;
for (var i = 0; i < PIN_KEYS.length; i++) {
var value = obj[PIN_KEYS[i]];
if (!value || Object.keys(value).length === 0) continue;
if (!pins) pins = {};
pins[PIN_KEYS[i]] = value;
}
return pins;
}
function parse(root, jsonObj) {
var SPEC = SpecModule.SPEC;
@@ -278,16 +263,28 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 12;
}
if (currentVersion < 13) {
console.info("Migrating settings from version", currentVersion, "to version 13");
console.info("Moving device and network pins to cache.json");
for (var p = 0; p < PIN_KEYS.length; p++) {
delete settings[PIN_KEYS[p]];
}
settings.configVersion = 13;
}
return settings;
}
function cleanup(fileText) {
var getValidKeys = SpecModule.getValidKeys;
if (!fileText || !fileText.trim()) return;
try {
var settings = JSON.parse(fileText);
var validKeys = getValidKeys();
var needsSave = false;
for (var key in settings) {
if (validKeys.indexOf(key) < 0) {
delete settings[key];
needsSave = true;
}
}
return needsSave ? JSON.stringify(settings, null, 2) : null;
} catch (e) {
console.warn("SettingsData: Failed to cleanup unused keys:", e.message);
return null;
}
}
+65
View File
@@ -122,6 +122,11 @@ const times = [
// adds a custom time to the times config
function addTime(angle, riseName, setName) {
times.push([angle, riseName, setName]);
};
// calculations for sun times
const J0 = 0.0009;
@@ -241,3 +246,63 @@ function getMoonIllumination(date) {
angle
};
};
function hoursLater(date, h) {
return new Date(date.valueOf() + h * dayMs / 24);
}
// calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article
function getMoonTimes(date, lat, lng, inUTC) {
const t = new Date(date);
if (inUTC) t.setUTCHours(0, 0, 0, 0);
else t.setHours(0, 0, 0, 0);
const hc = 0.133 * rad;
let h0 = getMoonPosition(t, lat, lng).altitude - hc,
rise, set, ye;
// go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set)
for (let i = 1; i <= 24; i += 2) {
const h1 = getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc;
const h2 = getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc;
const a = (h0 + h2) / 2 - h1;
const b = (h2 - h0) / 2;
const xe = -b / (2 * a);
const d = b * b - 4 * a * h1;
let roots = 0, x1 = 0, x2 = 0;
ye = (a * xe + b) * xe + h1;
if (d >= 0) {
const dx = Math.sqrt(d) / (Math.abs(a) * 2);
x1 = xe - dx;
x2 = xe + dx;
if (Math.abs(x1) <= 1) roots++;
if (Math.abs(x2) <= 1) roots++;
if (x1 < -1) x1 = x2;
}
if (roots === 1) {
if (h0 < 0) rise = i + x1;
else set = i + x1;
} else if (roots === 2) {
rise = i + (ye < 0 ? x2 : x1);
set = i + (ye < 0 ? x1 : x2);
}
if (rise && set) break;
h0 = h2;
}
const result = {};
if (rise) result.rise = hoursLater(t, rise);
if (set) result.set = hoursLater(t, set);
if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true;
return result;
};
+1 -40
View File
@@ -31,8 +31,6 @@ Item {
id: root
readonly property var log: Log.scoped("DMSShell")
readonly property var _sessionsServiceRef: SessionsService
// Keep the optional integration service alive
readonly property var _dankConnectServiceRef: DankConnectService
property var core: null
@@ -235,11 +233,6 @@ Item {
PolkitService.polkitAvailable;
DisplayConfigState.hasOutputBackend;
PortalService.systemColorScheme;
IconThemeService.revision;
DesktopService.isSystemd;
TrashService.count;
WallpaperCyclingService.cyclingActive;
ThemeAutoService.active;
}
Loader {
@@ -321,17 +314,6 @@ Item {
}
}
Binding {
target: BarWidgetService
property: "dockContextMenu"
value: dockContextMenuLoader.item
}
Binding {
target: BarWidgetService
property: "dockTrashContextMenu"
value: dockTrashContextMenuLoader.item
}
Connections {
target: SettingsData
function onDockShowTrashChanged() {
@@ -447,23 +429,6 @@ Item {
}
}
LazyLoader {
id: qrGeneratorModalLoader
active: false
Component.onCompleted: {
PopoutService.qrGeneratorModalLoader = qrGeneratorModalLoader;
}
QRGeneratorModal {
id: qrGeneratorModalItem
Component.onCompleted: {
PopoutService.qrGeneratorModal = qrGeneratorModalItem;
}
}
}
LazyLoader {
id: polkitAuthModalLoader
active: false
@@ -637,7 +602,7 @@ Item {
if (visible) {
wasShown = true;
} else if (wasShown) {
Qt.callLater(() => PopoutService.unloadSettingsNow());
PopoutService.unloadSettings();
}
}
}
@@ -942,7 +907,6 @@ Item {
expandedWidthValue: 960
edgeGap: SettingsData.notepadEffectiveEdgeGap
slideEdge: SettingsData.notepadSlideoutSide
customTransparency: Theme.notepadTransparency
onIsVisibleChanged: {
if (isVisible)
@@ -1018,9 +982,6 @@ Item {
case "reboot":
SessionService.reboot();
break;
case "softreboot":
SessionService.softReboot();
break;
case "poweroff":
SessionService.poweroff();
break;
+60 -89
View File
@@ -25,30 +25,32 @@ Item {
required property var windowRuleModalLoader
function getPreferredBar(refPropertyName) {
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
return null;
const focusedScreenName = BarWidgetService.getFocusedScreenName();
const bars = [];
if (root.dankBarRepeater) {
for (let i = 0; i < root.dankBarRepeater.count; i++)
bars.push(...(root.dankBarRepeater.itemAt(i)?.item?.barVariants?.instances || []));
}
const frameBars = BarWidgetService.frameHostedBars;
for (const screenName in frameBars)
bars.push(frameBars[screenName]);
const loaders = Array.from({
length: root.dankBarRepeater.count
}, (_, i) => root.dankBarRepeater.itemAt(i));
let currentBar = null;
for (const bar of bars) {
if (!bar)
continue;
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
const hasRef = !refPropertyName || !!bar[refPropertyName];
for (const loader of loaders) {
const instances = loader?.item?.barVariants?.instances || [];
for (const bar of instances) {
if (!bar)
continue;
if (hasRef) {
currentBar = bar;
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
const hasRef = !refPropertyName || !!bar[refPropertyName];
if (onFocusedScreen)
break;
if (hasRef) {
currentBar = bar;
if (onFocusedScreen)
break;
}
}
}
@@ -283,66 +285,28 @@ Item {
}
}
function _resolvePosition(position) {
switch ((position || "").toLowerCase()) {
case "left":
return "left";
case "center":
return "center";
case "right":
return "right";
default:
return "";
}
}
function _dashBar(position) {
if (position)
return root.getPreferredBar();
return root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
}
function _openDash(tab, position) {
const bar = _dashBar(position);
if (!bar)
return false;
const tabId = _resolveTabId(tab);
const dash = root.dankDashPopoutLoader.item;
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
if (position && bar.positionDash)
bar.positionDash(dash, position);
dash.requestTab(tabId);
if (dash.updateSurfacePosition)
dash.updateSurfacePosition();
return true;
}
return bar.triggerDashTab(tabId, position);
}
function _toggleDash(tab, position) {
if (root.dankDashPopoutLoader.item?.dashVisible) {
root.dankDashPopoutLoader.item.dashVisible = false;
return true;
}
const bar = _dashBar(position);
if (!bar)
return false;
return bar.triggerDashTab(_resolveTabId(tab), position);
}
function resolveTabIndex(tab: string): int {
return SettingsData.dashTabIndexForId(_resolveTabId(tab));
}
function open(tab: string): string {
return _openDash(tab, "") ? "DASH_OPEN_SUCCESS" : "DASH_OPEN_FAILED";
}
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
if (!bar)
return "DASH_OPEN_FAILED";
function openAt(tab: string, position: string): string {
return _openDash(tab, _resolvePosition(position)) ? "DASH_OPEN_SUCCESS" : "DASH_OPEN_FAILED";
const tabId = _resolveTabId(tab);
const dash = root.dankDashPopoutLoader.item;
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
dash.requestTab(tabId);
if (dash.updateSurfacePosition)
dash.updateSurfacePosition();
return "DASH_OPEN_SUCCESS";
}
if (!bar.triggerDashTab(tabId))
return "DASH_OPEN_FAILED";
return "DASH_OPEN_SUCCESS";
}
function close(): string {
@@ -354,11 +318,18 @@ Item {
}
function toggle(tab: string): string {
return _toggleDash(tab, "") ? "DASH_TOGGLE_SUCCESS" : "DASH_TOGGLE_FAILED";
}
if (root.dankDashPopoutLoader.item?.dashVisible) {
root.dankDashPopoutLoader.item.dashVisible = false;
return "DASH_TOGGLE_SUCCESS";
}
function toggleAt(tab: string, position: string): string {
return _toggleDash(tab, _resolvePosition(position)) ? "DASH_TOGGLE_SUCCESS" : "DASH_TOGGLE_FAILED";
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
if (bar) {
if (!bar.triggerDashTab(_resolveTabId(tab)))
return "DASH_TOGGLE_FAILED";
return "DASH_TOGGLE_SUCCESS";
}
return "DASH_TOGGLE_FAILED";
}
target: "dash"
@@ -2023,6 +1994,18 @@ Item {
}
IpcHandler {
function findTrayItem(itemId: string): var {
if (!itemId)
return null;
return SystemTray.items.values.find(item => {
const id = item?.id || "";
const title = item?.tooltipTitle || "";
const fullKey = title ? `${id}::${title}` : id;
return fullKey === itemId || id === itemId;
});
}
function list(): string {
const items = SystemTray.items.values;
if (items.length === 0)
@@ -2038,7 +2021,7 @@ Item {
}
function activate(itemId: string): string {
const item = TrayMenuManager.findTrayItem(itemId);
const item = findTrayItem(itemId);
if (!item)
return `ERROR: Tray item not found: ${itemId}`;
@@ -2046,20 +2029,8 @@ Item {
return `SUCCESS: Activated ${itemId}`;
}
function menu(itemId: string): string {
const item = TrayMenuManager.findTrayItem(itemId);
if (!item)
return `ERROR: Tray item not found: ${itemId}`;
if (!item.hasMenu)
return `ERROR: Tray item has no menu: ${itemId}`;
TrayMenuManager.requestOpenMenu(itemId, BarWidgetService.getFocusedScreenName());
return `SUCCESS: Requested menu ${itemId}`;
}
function status(itemId: string): string {
const item = TrayMenuManager.findTrayItem(itemId);
const item = findTrayItem(itemId);
if (!item)
return `ERROR: Tray item not found: ${itemId}`;
+4
View File
@@ -318,6 +318,10 @@ DankModal {
width: parent.width - Theme.spacingS * 2
anchors.horizontalCenter: parent.horizontalCenter
height: 52
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
@@ -15,7 +15,7 @@ Rectangle {
height: Math.round(Theme.fontSizeMedium * 4.2)
radius: Theme.cornerRadius
color: Theme.floatingWindowNestedSurface
color: Theme.surfaceContainerHigh
Rectangle {
anchors.fill: parent
@@ -4,9 +4,10 @@ import qs.Common
import qs.Services
import qs.Widgets
DankFloatingWindow {
FloatingWindow {
id: root
property bool disablePopupTransparency: true
readonly property int modalWidth: 680
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
@@ -20,6 +21,7 @@ DankFloatingWindow {
title: i18n("What's New")
minimumSize: Qt.size(modalWidth, modalHeight)
maximumSize: Qt.size(modalWidth, modalHeight)
color: Theme.surfaceContainer
visible: false
onClosed: visible = false
@@ -110,7 +112,7 @@ DankFloatingWindow {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.round(Theme.fontSizeMedium * 4.5)
color: Theme.floatingWindowNestedSurface
color: Theme.surfaceContainerHigh
Rectangle {
anchors.top: parent.top
@@ -314,20 +314,26 @@ Item {
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
function _frameEdgeInset(side) {
if (!effectiveScreen)
return 0;
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
}
readonly property real _connectedAlignedX: {
switch (resolvedConnectedBarSide) {
case "top":
case "bottom":
{
const insetL = SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
const insetR = SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
const insetL = _frameEdgeInset("left");
const insetR = _frameEdgeInset("right");
const usable = Math.max(0, screenWidth - insetL - insetR);
return insetL + Math.max(0, (usable - alignedWidth) / 2);
}
case "left":
return SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
return _frameEdgeInset("left");
case "right":
return screenWidth - alignedWidth - SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
return screenWidth - alignedWidth - _frameEdgeInset("right");
}
return 0;
}
@@ -335,14 +341,14 @@ Item {
readonly property real _connectedAlignedY: {
switch (resolvedConnectedBarSide) {
case "top":
return SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
return _frameEdgeInset("top");
case "bottom":
return screenHeight - alignedHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
return screenHeight - alignedHeight - _frameEdgeInset("bottom");
case "left":
case "right":
{
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const usable = Math.max(0, screenHeight - insetT - insetB);
return insetT + Math.max(0, (usable - alignedHeight) / 2);
}
+11 -19
View File
@@ -819,24 +819,16 @@ Item {
}
if (isCategoryFiltered) {
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
if (categoryPluginId) {
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
for (var i = 0; i < pluginCategoryItems.length; i++) {
allItems.push(pluginCategoryItems[i]);
}
} else {
var rawApps = AppSearchService.getAppsInCategory(appCategory);
for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
// Also include core apps (DMS Settings etc.) that match this category
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
}
var rawApps = AppSearchService.getAppsInCategory(appCategory);
for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
// Also include core apps (DMS Settings etc.) that match this category
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
}
} else {
var apps = searchApps(searchQuery);
@@ -847,7 +839,7 @@ Item {
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
var newSections = Scorer.groupBySection(scoredItems, buildDynamicSectionDefs(allItems), sortAlpha, searchQuery ? 50 : 500);
var newSections = Scorer.groupBySection(scoredItems, sectionDefinitions, sortAlpha, searchQuery ? 50 : 500);
for (var sid in collapsedSections) {
for (var i = 0; i < newSections.length; i++) {
@@ -105,6 +105,12 @@ Item {
}
readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide)
function _frameEdgeInset(side) {
if (!effectiveScreen)
return 0;
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
}
readonly property var _connectedModalPos: {
const fallback = {
"x": (screenWidth - modalWidth) / 2,
@@ -114,10 +120,10 @@ Item {
case "top":
case "bottom":
{
const insetL = SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
const insetR = SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
const insetL = _frameEdgeInset("left");
const insetR = _frameEdgeInset("right");
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const usable = Math.max(0, screenWidth - insetL - insetR);
const usableH = Math.max(0, screenHeight - insetT - insetB);
return {
@@ -128,11 +134,11 @@ Item {
case "left":
case "right":
{
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const usable = Math.max(0, screenHeight - insetT - insetB);
return {
"x": resolvedConnectedBarSide === "left" ? SettingsData.frameEdgeInsetForSide(effectiveScreen, "left") : screenWidth - modalWidth - SettingsData.frameEdgeInsetForSide(effectiveScreen, "right"),
"x": resolvedConnectedBarSide === "left" ? _frameEdgeInset("left") : screenWidth - modalWidth - _frameEdgeInset("right"),
"y": insetT + Math.max(0, (usable - modalHeight) / 2)
};
}
@@ -182,16 +188,16 @@ Item {
readonly property real _connectedChromeY: {
if (!launcherArcExtenderActive)
return alignedY;
return resolvedConnectedBarSide === "top" ? Theme.snap(SettingsData.frameEdgeInsetForSide(effectiveScreen, "top"), dpr) : alignedY;
return resolvedConnectedBarSide === "top" ? Theme.snap(_frameEdgeInset("top"), dpr) : alignedY;
}
readonly property real _connectedChromeWidth: alignedWidth
readonly property real _connectedChromeHeight: {
if (!launcherArcExtenderActive)
return alignedHeight;
if (resolvedConnectedBarSide === "top")
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "top")), dpr);
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - _frameEdgeInset("top")), dpr);
if (resolvedConnectedBarSide === "bottom")
return Theme.snap(Math.max(alignedHeight, screenHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom") - alignedY), dpr);
return Theme.snap(Math.max(alignedHeight, screenHeight - _frameEdgeInset("bottom") - alignedY), dpr);
return alignedHeight;
}
readonly property real contentSurfaceHeight: launcherArcExtenderActive ? _connectedChromeHeight : alignedHeight

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