mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-08 14:38:30 -04:00
Compare commits
45 Commits
e54be7d12d
...
alpha
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d44d42066 | |||
| fc90bddad1 | |||
| a713485599 | |||
| f16bdda969 | |||
| cf4f48069f | |||
| 38cca3195f | |||
| 40a9ca348c | |||
| a1ba5cf9ad | |||
| 915db2dce7 | |||
| 1dc0ceea1f | |||
| 1a621c3f88 | |||
| b7ec57a534 | |||
| a575363f89 | |||
| 3f6cd0b579 | |||
| 630e9bd3cd | |||
| 9013464312 | |||
| dade416ca0 | |||
| 401872881e | |||
| 7cb97f4462 | |||
| 82b1b36b1a | |||
| e203e76ccc | |||
| adcafbcc8e | |||
| 9981fcf529 | |||
| 2baf048293 | |||
| cffc33e14a | |||
| 0c811e3417 | |||
| 6ad46cf2c2 | |||
| 34626070af | |||
| 80b27b9a6a | |||
| 49f968d26b | |||
| 99b0dc596d | |||
| 158c0c12d8 | |||
| e089948225 | |||
| 7f2ba56e06 | |||
| 6de5593216 | |||
| 365474b0d9 | |||
| dc8a47644a | |||
| 27483e68dc | |||
| 400a18a8ed | |||
| 32ddf614c3 | |||
| 19d919ed5c | |||
| 594a2cde19 | |||
| 11287459c3 | |||
| ef191babb7 | |||
| fe64a342f9 |
@@ -101,7 +101,9 @@ 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).
|
||||
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.
|
||||
|
||||
## Supported Compositors
|
||||
|
||||
@@ -120,6 +122,8 @@ 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
@@ -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]` - Plugin management
|
||||
- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles
|
||||
- `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)
|
||||
|
||||
@@ -51,6 +51,8 @@ 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{
|
||||
@@ -178,6 +180,36 @@ 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))
|
||||
}
|
||||
@@ -433,6 +465,40 @@ 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 {
|
||||
@@ -704,6 +770,7 @@ func getCommonCommands() []*cobra.Command {
|
||||
ipcCmd,
|
||||
debugSrvCmd,
|
||||
pluginsCmd,
|
||||
registryCmd,
|
||||
dank16Cmd,
|
||||
brightnessCmd,
|
||||
dpmsCmd,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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])
|
||||
},
|
||||
}
|
||||
@@ -15,7 +15,8 @@ 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)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
||||
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
@@ -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)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
|
||||
@@ -313,6 +313,7 @@ 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 {
|
||||
@@ -341,6 +342,7 @@ 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
|
||||
@@ -420,6 +422,24 @@ 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)
|
||||
@@ -500,10 +520,7 @@ 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))
|
||||
|
||||
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))
|
||||
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
|
||||
|
||||
brightRedS := math.Min(baseSat*1.0, 1.0)
|
||||
brightRedV := math.Min(baseVal*1.2, 1.0)
|
||||
@@ -559,9 +576,7 @@ 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))
|
||||
|
||||
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))
|
||||
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
|
||||
|
||||
brightRedS := math.Min(baseSat*0.75, 1.0)
|
||||
brightRedV := math.Min(baseVal*1.35, 1.0)
|
||||
|
||||
@@ -679,3 +679,73 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ 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 {
|
||||
@@ -319,6 +324,7 @@ 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,
|
||||
@@ -326,12 +332,10 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
|
||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||
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 {
|
||||
@@ -521,8 +525,7 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||
args = append(args, packageNames...)
|
||||
args := emergeInstallArgs(packageNames)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
@@ -713,8 +716,7 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
|
||||
guruPackages[i] = pkg + "::guru"
|
||||
}
|
||||
|
||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||
args = append(args, guruPackages...)
|
||||
args := emergeInstallArgs(guruPackages)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseAURPackages,
|
||||
|
||||
@@ -6,37 +6,7 @@ func NewClient() Client {
|
||||
geoclueClient, err := newGeoClueClient()
|
||||
if err != nil {
|
||||
log.Warnf("GeoClue2 unavailable: %v", err)
|
||||
return newSeededIpClient()
|
||||
return newIpClient()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
type GeoClueClient struct {
|
||||
currLocation *Location
|
||||
locationMutex sync.RWMutex
|
||||
seedOnce sync.Once
|
||||
|
||||
dbusConn *dbus.Conn
|
||||
clientPath dbus.ObjectPath
|
||||
@@ -230,14 +231,29 @@ 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{
|
||||
Latitude: 0.0,
|
||||
Longitude: 0.0,
|
||||
}, nil
|
||||
return Location{}
|
||||
}
|
||||
stateCopy := *c.currLocation
|
||||
return stateCopy, nil
|
||||
return *c.currLocation
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
type Manager struct {
|
||||
fs afero.Fs
|
||||
pluginsDir string
|
||||
lockPath string
|
||||
gitClient GitClient
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
|
||||
return &Manager{
|
||||
fs: fs,
|
||||
pluginsDir: pluginsDir,
|
||||
lockPath: getPluginLockPath(),
|
||||
gitClient: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
@@ -41,6 +43,15 @@ 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 {
|
||||
@@ -118,7 +129,12 @@ 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 {
|
||||
@@ -128,6 +144,18 @@ 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)
|
||||
@@ -140,7 +168,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 {
|
||||
@@ -178,12 +206,13 @@ 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 {
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
m.fs.Remove(pluginPath) //nolint:errcheck
|
||||
return fmt.Errorf("failed to write plugin repository metadata: %w", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||
m.fs.RemoveAll(pluginPath) //nolint:errcheck
|
||||
@@ -191,6 +220,16 @@ 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
|
||||
}
|
||||
|
||||
@@ -207,6 +246,15 @@ 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)
|
||||
@@ -219,46 +267,42 @@ 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if plugin.Repo == "" {
|
||||
plugin.Repo, err = m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugin origin: %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)
|
||||
@@ -271,70 +315,64 @@ 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)
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check metadata: %w", err)
|
||||
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
|
||||
}
|
||||
|
||||
if metaExists {
|
||||
metaPath := pluginPath + ".meta"
|
||||
if plugin.Path != "" {
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID)
|
||||
shouldCleanup, err := m.shouldCleanupRepo(plugin.Repo, plugin.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check repo cleanup: %w", err)
|
||||
return rollbackLock(fmt.Errorf("failed to check repo cleanup: %w", err))
|
||||
}
|
||||
|
||||
if err := m.fs.Remove(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
||||
return rollbackLock(fmt.Errorf("failed to remove symlink: %w", err))
|
||||
}
|
||||
|
||||
if err := m.fs.Remove(metaPath); err != nil {
|
||||
return fmt.Errorf("failed to remove metadata: %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 shouldCleanup {
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return fmt.Errorf("failed to cleanup repository: %w", err)
|
||||
return rollbackLock(fmt.Errorf("failed to cleanup repository: %w", err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to remove plugin: %w", err)
|
||||
return rollbackLock(fmt.Errorf("failed to remove plugin: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
|
||||
installed, err := m.ListInstalled()
|
||||
func (m *Manager) shouldCleanupRepo(repoURL, excludePlugin string) (bool, error) {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
registry, err := NewRegistry()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
allPlugins, err := registry.List()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, id := range installed {
|
||||
for id, plugin := range lock.Plugins {
|
||||
if id == excludePlugin {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range allPlugins {
|
||||
if p.ID == id && p.Repo == repoURL && p.Path != "" {
|
||||
return false, nil
|
||||
}
|
||||
if plugin.Repo == repoURL && plugin.Path != "" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +494,16 @@ 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)
|
||||
@@ -488,6 +536,16 @@ 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)
|
||||
@@ -502,8 +560,14 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
|
||||
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to update plugin: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
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)
|
||||
}
|
||||
|
||||
func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
|
||||
@@ -572,6 +636,15 @@ 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)
|
||||
@@ -585,25 +658,11 @@ func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, d
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
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)
|
||||
repoPath := pluginPath
|
||||
if plugin.Path != "" {
|
||||
repoPath = m.repositoryPath(pluginID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
||||
}
|
||||
hasUp, localHash, remoteHash, err := m.gitClient.HasUpdates(repoPath)
|
||||
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -16,6 +17,7 @@ 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
|
||||
@@ -245,3 +247,244 @@ 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")
|
||||
}
|
||||
|
||||
@@ -2,17 +2,18 @@ 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"`
|
||||
@@ -33,7 +34,10 @@ 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{}
|
||||
@@ -65,6 +69,22 @@ 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 {
|
||||
@@ -118,11 +138,53 @@ 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
|
||||
plugins []Plugin
|
||||
git GitClient
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
registries []registries.Source
|
||||
plugins []Plugin
|
||||
git GitClient
|
||||
}
|
||||
|
||||
func NewRegistry() (*Registry, error) {
|
||||
@@ -130,63 +192,63 @@ func NewRegistry() (*Registry, error) {
|
||||
}
|
||||
|
||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||
cacheDir := getCacheDir()
|
||||
return &Registry{
|
||||
fs: fs,
|
||||
cacheDir: cacheDir,
|
||||
git: &realGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: getCacheDir(),
|
||||
registries: registries.Load(fs),
|
||||
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")
|
||||
}
|
||||
|
||||
func (r *Registry) Update() error {
|
||||
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check cache directory: %w", err)
|
||||
}
|
||||
|
||||
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 exists {
|
||||
origin, originErr := r.git.OriginURL(dir)
|
||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return r.loadPlugins()
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Registry) loadPlugins() error {
|
||||
pluginsDir := filepath.Join(r.cacheDir, "plugins")
|
||||
|
||||
// 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")
|
||||
entries, err := afero.ReadDir(r.fs, pluginsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
}
|
||||
|
||||
r.plugins = []Plugin{}
|
||||
|
||||
var plugins []Plugin
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
@@ -206,15 +268,51 @@ func (r *Registry) loadPlugins() error {
|
||||
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
|
||||
}
|
||||
|
||||
r.plugins = append(r.plugins, plugin)
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
return 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...)
|
||||
}
|
||||
|
||||
func (r *Registry) List() ([]Plugin, error) {
|
||||
if len(r.plugins) == 0 {
|
||||
if err := r.Update(); err != nil {
|
||||
if err := r.Update(); err != nil && len(r.plugins) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,29 @@ 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 {
|
||||
@@ -30,6 +41,13 @@ 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)
|
||||
@@ -37,11 +55,27 @@ 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) {
|
||||
@@ -49,14 +83,50 @@ 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,
|
||||
plugins: []Plugin{},
|
||||
git: &mockGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: tmpDir,
|
||||
registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
|
||||
plugins: []Plugin{},
|
||||
git: &mockGitClient{},
|
||||
}
|
||||
return registry, fs, tmpDir
|
||||
}
|
||||
@@ -104,14 +174,14 @@ func TestLoadPlugins(t *testing.T) {
|
||||
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
|
||||
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
|
||||
|
||||
err := registry.loadPlugins()
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 2)
|
||||
assert.Len(t, plugins, 2)
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("skips non-json files", func(t *testing.T) {
|
||||
@@ -136,34 +206,10 @@ func TestLoadPlugins(t *testing.T) {
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
err = registry.loadPlugins()
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
assert.NoError(t, err)
|
||||
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)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("skips invalid json files", func(t *testing.T) {
|
||||
@@ -188,18 +234,18 @@ func TestLoadPlugins(t *testing.T) {
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
err = registry.loadPlugins()
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("returns error when plugins directory missing", func(t *testing.T) {
|
||||
t.Run("missing plugins directory is a themes-only registry", func(t *testing.T) {
|
||||
registry, _, _ := setupTestRegistry(t)
|
||||
|
||||
err := registry.loadPlugins()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to read plugins directory")
|
||||
plugins, err := registry.loadPluginsFrom(registry.cacheDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, plugins)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,19 +286,40 @@ func TestList(t *testing.T) {
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
|
||||
mockGit := &mockGitClient{
|
||||
registry.git = &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) {
|
||||
@@ -271,16 +338,15 @@ func TestUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
cloneCalled := false
|
||||
mockGit := &mockGitClient{
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
assert.Equal(t, registryRepo, url)
|
||||
assert.Equal(t, tmpDir, path)
|
||||
assert.Equal(t, testRegistryURL, url)
|
||||
assert.Equal(t, filepath.Join(tmpDir, "test"), path)
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
@@ -289,7 +355,7 @@ func TestUpdate(t *testing.T) {
|
||||
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("pulls updates when cache exists", func(t *testing.T) {
|
||||
t.Run("pulls when cache exists with matching origin", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
plugin := Plugin{
|
||||
@@ -303,24 +369,188 @@ func TestUpdate(t *testing.T) {
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
|
||||
err := fs.MkdirAll(tmpDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
|
||||
pullCalled := false
|
||||
mockGit := &mockGitClient{
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return testRegistryURL, nil
|
||||
},
|
||||
pullFunc: func(path string) error {
|
||||
pullCalled = true
|
||||
assert.Equal(t, tmpDir, path)
|
||||
assert.Equal(t, subdir, 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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")
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
|
||||
return nil, fmt.Errorf("failed to find keyboards: %w", err)
|
||||
}
|
||||
|
||||
initialCapsLock := readInitialCapsLockState(devices[0])
|
||||
initialCapsLock, _ := capsLockFromDevices(devices)
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
@@ -85,14 +85,21 @@ func NewManager() (*Manager, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func readInitialCapsLockState(device EvdevDevice) bool {
|
||||
ledStates, err := device.State(evLedType)
|
||||
if err != nil {
|
||||
log.Debugf("Could not read LED state: %v", err)
|
||||
return false
|
||||
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
|
||||
}
|
||||
|
||||
return ledStates[ledCapslockKey]
|
||||
return false, false
|
||||
}
|
||||
|
||||
func findKeyboards() ([]EvdevDevice, error) {
|
||||
@@ -297,25 +304,22 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
|
||||
m.devicesMutex.RUnlock()
|
||||
return
|
||||
}
|
||||
device := m.devices[deviceIndex]
|
||||
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)
|
||||
}
|
||||
m.devicesMutex.RUnlock()
|
||||
|
||||
ledStates, err := device.State(evLedType)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to read LED state: %v", err)
|
||||
capsLockState, ok := capsLockFromDevices(ordered)
|
||||
if !ok {
|
||||
log.Debug("No LED-capable device available for caps lock state")
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func TestReadInitialCapsLockState(t *testing.T) {
|
||||
func TestCapsLockFromDevices(t *testing.T) {
|
||||
t.Run("caps lock is on", func(t *testing.T) {
|
||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||
ledState := evdev.StateMap{
|
||||
@@ -314,7 +314,8 @@ func TestReadInitialCapsLockState(t *testing.T) {
|
||||
}
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.True(t, ok)
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
@@ -325,7 +326,8 @@ func TestReadInitialCapsLockState(t *testing.T) {
|
||||
}
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.True(t, ok)
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
@@ -333,9 +335,25 @@ func TestReadInitialCapsLockState(t *testing.T) {
|
||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
|
||||
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.False(t, ok)
|
||||
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) {
|
||||
|
||||
@@ -8,24 +8,11 @@ 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{
|
||||
Latitude: currLocation.Latitude,
|
||||
Longitude: currLocation.Longitude,
|
||||
},
|
||||
}
|
||||
|
||||
if err := m.startSignalPump(); err != nil {
|
||||
return nil, err
|
||||
state: &State{},
|
||||
}
|
||||
|
||||
m.notifierWg.Add(1)
|
||||
@@ -34,6 +21,22 @@ 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()
|
||||
@@ -48,6 +51,7 @@ 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
|
||||
@@ -59,7 +63,7 @@ func (m *Manager) Unsubscribe(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) startSignalPump() error {
|
||||
func (m *Manager) startSignalPump() {
|
||||
m.sigWG.Add(1)
|
||||
go func() {
|
||||
defer m.sigWG.Done()
|
||||
@@ -80,8 +84,6 @@ func (m *Manager) startSignalPump() error {
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleLocationChange(location geolocation.Location) {
|
||||
@@ -102,6 +104,7 @@ func (m *Manager) notifySubscribers() {
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
m.ensureStarted()
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
if m.state == nil {
|
||||
|
||||
@@ -16,7 +16,8 @@ type Manager struct {
|
||||
state *State
|
||||
stateMutex sync.RWMutex
|
||||
|
||||
client geolocation.Client
|
||||
client geolocation.Client
|
||||
startOnce sync.Once
|
||||
|
||||
stopChan chan struct{}
|
||||
sigWG sync.WaitGroup
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
@@ -47,6 +48,11 @@ 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")
|
||||
|
||||
@@ -36,7 +36,7 @@ import (
|
||||
"github.com/AvengeMedia/dankgo/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 28
|
||||
const APIVersion = 29
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
@@ -1183,6 +1183,38 @@ 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")
|
||||
|
||||
@@ -759,6 +759,9 @@ 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
|
||||
if enabled {
|
||||
@@ -1104,13 +1107,15 @@ func (m *Manager) SetTemperature(low, high int) error {
|
||||
m.configMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.config.LowTemp = low
|
||||
m.config.HighTemp = high
|
||||
err := m.config.Validate()
|
||||
m.configMutex.Unlock()
|
||||
if err != nil {
|
||||
updated := m.config
|
||||
updated.LowTemp = low
|
||||
updated.HighTemp = high
|
||||
if err := updated.Validate(); err != nil {
|
||||
m.configMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
m.config = updated
|
||||
m.configMutex.Unlock()
|
||||
m.triggerUpdate()
|
||||
return nil
|
||||
}
|
||||
@@ -1122,14 +1127,16 @@ func (m *Manager) SetLocation(lat, lon float64) error {
|
||||
m.configMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.config.Latitude = &lat
|
||||
m.config.Longitude = &lon
|
||||
m.config.UseIPLocation = false
|
||||
err := m.config.Validate()
|
||||
m.configMutex.Unlock()
|
||||
if err != nil {
|
||||
updated := m.config
|
||||
updated.Latitude = &lat
|
||||
updated.Longitude = &lon
|
||||
updated.UseIPLocation = false
|
||||
if err := updated.Validate(); err != nil {
|
||||
m.configMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
m.config = updated
|
||||
m.configMutex.Unlock()
|
||||
m.triggerUpdate()
|
||||
return nil
|
||||
}
|
||||
@@ -1164,13 +1171,15 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
|
||||
m.configMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.config.ManualSunrise = &sunrise
|
||||
m.config.ManualSunset = &sunset
|
||||
err := m.config.Validate()
|
||||
m.configMutex.Unlock()
|
||||
if err != nil {
|
||||
updated := m.config
|
||||
updated.ManualSunrise = &sunrise
|
||||
updated.ManualSunset = &sunset
|
||||
if err := updated.Validate(); err != nil {
|
||||
m.configMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
m.config = updated
|
||||
m.configMutex.Unlock()
|
||||
m.triggerUpdate()
|
||||
return nil
|
||||
}
|
||||
@@ -1193,12 +1202,14 @@ func (m *Manager) SetGamma(gamma float64) error {
|
||||
m.configMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
m.config.Gamma = gamma
|
||||
err := m.config.Validate()
|
||||
m.configMutex.Unlock()
|
||||
if err != nil {
|
||||
updated := m.config
|
||||
updated.Gamma = gamma
|
||||
if err := updated.Validate(); err != nil {
|
||||
m.configMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
m.config = updated
|
||||
m.configMutex.Unlock()
|
||||
m.triggerUpdate()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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) {
|
||||
@@ -412,3 +413,75 @@ 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)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ 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"`
|
||||
@@ -151,6 +150,7 @@ 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,11 +182,28 @@ 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
|
||||
themes []Theme
|
||||
git GitClient
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
registries []registries.Source
|
||||
themes []Theme
|
||||
git GitClient
|
||||
}
|
||||
|
||||
func NewRegistry() (*Registry, error) {
|
||||
@@ -194,61 +211,63 @@ func NewRegistry() (*Registry, error) {
|
||||
}
|
||||
|
||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||
cacheDir := getCacheDir()
|
||||
return &Registry{
|
||||
fs: fs,
|
||||
cacheDir: cacheDir,
|
||||
git: &realGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: getCacheDir(),
|
||||
registries: registries.Load(fs),
|
||||
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")
|
||||
}
|
||||
|
||||
func (r *Registry) Update() error {
|
||||
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check cache directory: %w", err)
|
||||
}
|
||||
|
||||
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 exists {
|
||||
origin, originErr := r.git.OriginURL(dir)
|
||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return r.loadThemes()
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Registry) loadThemes() error {
|
||||
themesDir := filepath.Join(r.cacheDir, "themes")
|
||||
|
||||
// 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")
|
||||
entries, err := afero.ReadDir(r.fs, themesDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read themes directory: %w", err)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read themes directory: %w", err)
|
||||
}
|
||||
|
||||
r.themes = []Theme{}
|
||||
|
||||
var themes []Theme
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
@@ -278,10 +297,46 @@ func (r *Registry) loadThemes() error {
|
||||
theme.PreviewPath = previewPath
|
||||
}
|
||||
|
||||
r.themes = append(r.themes, theme)
|
||||
themes = append(themes, theme)
|
||||
}
|
||||
return themes, nil
|
||||
}
|
||||
|
||||
return 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...)
|
||||
}
|
||||
|
||||
func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
|
||||
@@ -300,7 +355,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 {
|
||||
if err := r.Update(); err != nil && len(r.themes) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -343,11 +398,25 @@ func (r *Registry) Get(idOrName string) (*Theme, error) {
|
||||
}
|
||||
|
||||
func (r *Registry) GetThemeSourcePath(themeID string) string {
|
||||
return filepath.Join(r.cacheDir, "themes", themeID, "theme.json")
|
||||
// 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")
|
||||
}
|
||||
|
||||
func (r *Registry) GetThemeDir(themeID string) string {
|
||||
return filepath.Join(r.cacheDir, "themes", themeID)
|
||||
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)
|
||||
}
|
||||
|
||||
func SortByFirstParty(themes []Theme) []Theme {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package themes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -64,3 +66,73 @@ 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
Submodule dank-qml-common updated: 3b06bd9372...28fde73112
Generated
+3
-3
@@ -3,11 +3,11 @@
|
||||
"dank-qml-common": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1785445867,
|
||||
"narHash": "sha256-l9IRsLIVZ7bV+KMuTdCga89tGt4lpdMXhQR7f/2qoe4=",
|
||||
"lastModified": 1786121412,
|
||||
"narHash": "sha256-XHNpDNfQfjP6nIscd26GK6ydgBdVN5gknYGOVxXBWLw=",
|
||||
"owner": "AvengeMedia",
|
||||
"repo": "dank-qml-common",
|
||||
"rev": "3b06bd9372e18bc8086cc3958d4f677d57fbfdd8",
|
||||
"rev": "28fde7311296cbd041e5b704e54a481082b92b18",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -271,3 +271,61 @@ 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);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ Singleton {
|
||||
function openModal(modal) {
|
||||
PopoutManager.screenshotActive = false;
|
||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||
currentModalsByScreen[screenName] = modal;
|
||||
var next = {};
|
||||
for (var k in currentModalsByScreen)
|
||||
next[k] = currentModalsByScreen[k];
|
||||
next[screenName] = modal;
|
||||
currentModalsByScreen = next;
|
||||
modalChanged();
|
||||
Qt.callLater(() => {
|
||||
if (!modal.allowStacking)
|
||||
@@ -34,7 +38,12 @@ Singleton {
|
||||
function closeModal(modal) {
|
||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||
if (currentModalsByScreen[screenName] === modal) {
|
||||
delete currentModalsByScreen[screenName];
|
||||
var next = {};
|
||||
for (var k in currentModalsByScreen) {
|
||||
if (k !== screenName)
|
||||
next[k] = currentModalsByScreen[k];
|
||||
}
|
||||
currentModalsByScreen = next;
|
||||
modalChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ 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"
|
||||
@@ -646,6 +647,11 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setWallpaperCyclingRandom(random) {
|
||||
wallpaperCyclingRandom = random;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setWallpaperCyclingMode(mode) {
|
||||
wallpaperCyclingMode = mode;
|
||||
saveSettings();
|
||||
@@ -692,6 +698,37 @@ 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;
|
||||
@@ -1322,6 +1359,7 @@ Singleton {
|
||||
function getMonitorCyclingSettings(screenName) {
|
||||
var defaults = {
|
||||
"enabled": false,
|
||||
"random": false,
|
||||
"mode": "interval",
|
||||
"interval": 300,
|
||||
"time": "06:00"
|
||||
|
||||
@@ -164,6 +164,11 @@ 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
|
||||
@@ -203,6 +208,16 @@ Singleton {
|
||||
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
|
||||
property string calendarBackend: "auto"
|
||||
@@ -254,6 +269,7 @@ 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
|
||||
@@ -479,6 +495,7 @@ 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
|
||||
@@ -659,9 +676,6 @@ 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",
|
||||
@@ -798,8 +812,6 @@ Singleton {
|
||||
property int fadeToDpmsGracePeriod: 5
|
||||
property string launchPrefix: ""
|
||||
|
||||
property bool gtkThemingEnabled: false
|
||||
property bool qtThemingEnabled: false
|
||||
property bool syncModeWithPortal: true
|
||||
property bool terminalsAlwaysDark: false
|
||||
|
||||
@@ -922,6 +934,8 @@ 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
|
||||
@@ -976,6 +990,7 @@ Singleton {
|
||||
property string customPowerActionHibernate: ""
|
||||
property string customPowerActionReboot: ""
|
||||
property string customPowerActionPowerOff: ""
|
||||
property var customPowerButtons: []
|
||||
|
||||
property bool updaterHideWidget: false
|
||||
property bool updaterCheckOnStart: false
|
||||
@@ -1683,7 +1698,6 @@ Singleton {
|
||||
_hasLoaded = true;
|
||||
applyStoredTheme();
|
||||
updateCompositorCursor();
|
||||
Processes.detectQtTools();
|
||||
Qt.callLater(checkIconThemeDrift);
|
||||
|
||||
_checkSettingsWritable();
|
||||
@@ -2394,8 +2408,7 @@ Singleton {
|
||||
const bc = bars[i];
|
||||
if (bc.position !== sidePos)
|
||||
continue;
|
||||
const prefs = bc.screenPreferences || ["all"];
|
||||
if (prefs.includes("all") || isScreenInPreferences(screen, prefs))
|
||||
if (barConfigCoversScreen(bc, screen))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -2498,6 +2511,13 @@ 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 getActiveBarEdgesForScreen(screen) {
|
||||
if (!screen)
|
||||
return [];
|
||||
@@ -2506,8 +2526,7 @@ Singleton {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled)
|
||||
continue;
|
||||
var prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||
if (!barConfigCoversScreen(bc, screen))
|
||||
continue;
|
||||
switch (bc.position ?? 0) {
|
||||
case SettingsData.Position.Top:
|
||||
@@ -2535,8 +2554,7 @@ Singleton {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled || !(bc.useOverlayLayer ?? false))
|
||||
continue;
|
||||
var prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||
if (!barConfigCoversScreen(bc, screen))
|
||||
continue;
|
||||
switch (bc.position ?? 0) {
|
||||
case SettingsData.Position.Top:
|
||||
|
||||
@@ -96,8 +96,6 @@ 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
|
||||
|
||||
@@ -347,11 +345,28 @@ Singleton {
|
||||
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 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 ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 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 nestedSurface: floatingSurfaceHigh
|
||||
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||
@@ -359,6 +374,20 @@ 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)
|
||||
|
||||
@@ -1196,7 +1225,7 @@ Singleton {
|
||||
readonly property var _availableThemeNames: StockThemes.getAllThemeNames()
|
||||
property string currentThemeName: currentTheme
|
||||
|
||||
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency
|
||||
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : floatingWindowTransparency
|
||||
|
||||
property bool widgetBackgroundHasAlpha: {
|
||||
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
|
||||
|
||||
@@ -1,31 +1,69 @@
|
||||
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 closeAllMenus() {
|
||||
function closeHoverMenus() {
|
||||
for (const screenName in activeTrayMenus) {
|
||||
const menu = activeTrayMenus[screenName]
|
||||
if (!menu) continue
|
||||
if (!menu || menu.openedByHover !== true) continue
|
||||
if (typeof menu.close === "function") {
|
||||
menu.close()
|
||||
} else if (menu.showMenu !== undefined) {
|
||||
@@ -33,4 +71,17 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,41 +487,10 @@ 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
|
||||
|
||||
@@ -19,6 +19,7 @@ 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" },
|
||||
|
||||
@@ -17,6 +17,11 @@ 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" },
|
||||
@@ -57,6 +62,16 @@ var SPEC = {
|
||||
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 },
|
||||
calendarBackend: { def: "auto" },
|
||||
@@ -87,6 +102,7 @@ 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" },
|
||||
@@ -205,6 +221,7 @@ var SPEC = {
|
||||
mediaAdaptiveWidthEnabled: { def: true },
|
||||
audioVisualizerEnabled: { def: true },
|
||||
mediaUseAlbumArtAccent: { def: false },
|
||||
appleMusicAnimatedArtEnabled: { def: false },
|
||||
audioScrollMode: { def: "volume" },
|
||||
audioWheelScrollAmount: { def: 5 },
|
||||
audioDeviceScrollVolumeEnabled: { def: false },
|
||||
@@ -298,9 +315,6 @@ 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 },
|
||||
@@ -374,8 +388,6 @@ var SPEC = {
|
||||
fadeToDpmsGracePeriod: { def: 5 },
|
||||
launchPrefix: { def: "" },
|
||||
|
||||
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
||||
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
||||
syncModeWithPortal: { def: true },
|
||||
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
|
||||
|
||||
@@ -473,6 +485,8 @@ 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 },
|
||||
@@ -529,6 +543,7 @@ var SPEC = {
|
||||
customPowerActionHibernate: { def: "" },
|
||||
customPowerActionReboot: { def: "" },
|
||||
customPowerActionPowerOff: { def: "" },
|
||||
customPowerButtons: { def: [] },
|
||||
|
||||
updaterHideWidget: { def: false },
|
||||
updaterCheckOnStart: { def: false },
|
||||
|
||||
@@ -31,6 +31,8 @@ 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
|
||||
|
||||
@@ -635,7 +637,7 @@ Item {
|
||||
if (visible) {
|
||||
wasShown = true;
|
||||
} else if (wasShown) {
|
||||
PopoutService.unloadSettings();
|
||||
Qt.callLater(() => PopoutService.unloadSettingsNow());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -940,6 +942,7 @@ Item {
|
||||
expandedWidthValue: 960
|
||||
edgeGap: SettingsData.notepadEffectiveEdgeGap
|
||||
slideEdge: SettingsData.notepadSlideoutSide
|
||||
customTransparency: Theme.notepadTransparency
|
||||
|
||||
onIsVisibleChanged: {
|
||||
if (isVisible)
|
||||
@@ -1015,6 +1018,9 @@ Item {
|
||||
case "reboot":
|
||||
SessionService.reboot();
|
||||
break;
|
||||
case "softreboot":
|
||||
SessionService.softReboot();
|
||||
break;
|
||||
case "poweroff":
|
||||
SessionService.poweroff();
|
||||
break;
|
||||
|
||||
+14
-14
@@ -2023,18 +2023,6 @@ 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)
|
||||
@@ -2050,7 +2038,7 @@ Item {
|
||||
}
|
||||
|
||||
function activate(itemId: string): string {
|
||||
const item = findTrayItem(itemId);
|
||||
const item = TrayMenuManager.findTrayItem(itemId);
|
||||
if (!item)
|
||||
return `ERROR: Tray item not found: ${itemId}`;
|
||||
|
||||
@@ -2058,8 +2046,20 @@ 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 = findTrayItem(itemId);
|
||||
const item = TrayMenuManager.findTrayItem(itemId);
|
||||
if (!item)
|
||||
return `ERROR: Tray item not found: ${itemId}`;
|
||||
|
||||
|
||||
@@ -318,10 +318,6 @@ 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.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -4,10 +4,9 @@ import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: root
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
readonly property int modalWidth: 680
|
||||
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
|
||||
|
||||
@@ -21,7 +20,6 @@ FloatingWindow {
|
||||
title: i18n("What's New")
|
||||
minimumSize: Qt.size(modalWidth, modalHeight)
|
||||
maximumSize: Qt.size(modalWidth, modalHeight)
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: visible = false
|
||||
@@ -112,7 +110,7 @@ FloatingWindow {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: Math.round(Theme.fontSizeMedium * 4.5)
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
|
||||
@@ -819,16 +819,24 @@ Item {
|
||||
}
|
||||
|
||||
if (isCategoryFiltered) {
|
||||
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 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]));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var apps = searchApps(searchQuery);
|
||||
@@ -839,7 +847,7 @@ Item {
|
||||
|
||||
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
|
||||
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
|
||||
var newSections = Scorer.groupBySection(scoredItems, sectionDefinitions, sortAlpha, searchQuery ? 50 : 500);
|
||||
var newSections = Scorer.groupBySection(scoredItems, buildDynamicSectionDefs(allItems), sortAlpha, searchQuery ? 50 : 500);
|
||||
|
||||
for (var sid in collapsedSections) {
|
||||
for (var i = 0; i < newSections.length; i++) {
|
||||
|
||||
@@ -23,17 +23,6 @@ FocusScope {
|
||||
property bool editMode: false
|
||||
property var editingApp: null
|
||||
property string editAppId: ""
|
||||
readonly property bool _blurActive: Theme.blurForegroundLayers || Theme.transparentBlurLayers
|
||||
readonly property real _launcherFieldAlpha: {
|
||||
if (Theme.transparentBlurLayers)
|
||||
return 0.28;
|
||||
if (Theme.blurForegroundLayers)
|
||||
return Math.max(Theme.popupTransparency, 0.62);
|
||||
return Theme.popupTransparency;
|
||||
}
|
||||
readonly property color _launcherSearchFieldColor: Theme.withAlpha(Theme.surfaceContainerHigh, _launcherFieldAlpha)
|
||||
readonly property color _launcherSearchBorderColor: Theme.withAlpha(Theme.outline, _blurActive ? 0.16 : Theme.layerOutlineOpacity)
|
||||
readonly property color _launcherSearchFocusedBorderColor: Theme.withAlpha(Theme.primary, _blurActive ? 0.72 : 1.0)
|
||||
|
||||
function resetScroll() {
|
||||
resultsList.resetScroll();
|
||||
@@ -314,7 +303,7 @@ FocusScope {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: -Theme.cornerRadius
|
||||
// In connected mode the launcher provides the surface so update the toolbar for arcs
|
||||
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !root._blurActive
|
||||
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !Theme.blurLayersActive
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
radius: Theme.cornerRadius
|
||||
}
|
||||
@@ -472,12 +461,6 @@ FocusScope {
|
||||
DankTextField {
|
||||
id: searchField
|
||||
width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0)
|
||||
cornerRadius: Theme.cornerRadius
|
||||
backgroundColor: root._launcherSearchFieldColor
|
||||
normalBorderColor: root._launcherSearchBorderColor
|
||||
focusedBorderColor: root._launcherSearchFocusedBorderColor
|
||||
borderWidth: 1
|
||||
focusedBorderWidth: 2
|
||||
leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search"
|
||||
leftIconSize: Theme.iconSize
|
||||
leftIconColor: Theme.surfaceVariantText
|
||||
|
||||
@@ -7,6 +7,7 @@ Item {
|
||||
|
||||
property string source: ""
|
||||
property int glyphSize: 14
|
||||
property bool badgeVisible: true
|
||||
|
||||
readonly property var sourceAsset: ({
|
||||
"flatpak": "../../assets/package-sources/flatpak.svg",
|
||||
@@ -17,7 +18,7 @@ Item {
|
||||
|
||||
readonly property string assetPath: sourceAsset[source] || ""
|
||||
|
||||
visible: SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
|
||||
visible: badgeVisible && SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
|
||||
implicitWidth: glyphSize
|
||||
implicitHeight: glyphSize
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ Rectangle {
|
||||
anchors.margins: Theme.spacingXS
|
||||
source: root.item?.type === "app" ? (root.item.source || "") : ""
|
||||
glyphSize: 16
|
||||
visible: !root.isSelected && !!source
|
||||
badgeVisible: !root.isSelected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ Item {
|
||||
width: parent.width
|
||||
height: keybindsGrid.height + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
readonly property bool useTwoColumns: width > 500
|
||||
readonly property int columnCount: useTwoColumns ? 2 : 1
|
||||
@@ -240,7 +240,7 @@ Item {
|
||||
width: parent.width
|
||||
height: noKeybindsColumn.height + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
visible: !root.hasKeybinds
|
||||
|
||||
Column {
|
||||
|
||||
@@ -291,7 +291,7 @@ Item {
|
||||
anchors.leftMargin: Theme.spacingXL
|
||||
anchors.rightMargin: Theme.spacingXL
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
|
||||
@@ -15,7 +15,7 @@ Rectangle {
|
||||
|
||||
height: Math.round(Theme.fontSizeMedium * 6.4)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -5,11 +5,10 @@ import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("GreeterModal")
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property int currentPage: 0
|
||||
readonly property int totalPages: 3
|
||||
readonly property var pageComponents: [welcomePage, doctorPage, completePage]
|
||||
@@ -90,7 +89,6 @@ FloatingWindow {
|
||||
title: I18n.tr("Welcome", "greeter modal window title")
|
||||
minimumSize: Qt.size(modalWidth, modalHeight)
|
||||
maximumSize: Qt.size(modalWidth, modalHeight)
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: visible = false
|
||||
@@ -173,7 +171,7 @@ FloatingWindow {
|
||||
width: pageIndicatorRow.width + Theme.spacingM * 2
|
||||
height: indicatorHeight
|
||||
radius: indicatorHeight / 2
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
Row {
|
||||
id: pageIndicatorRow
|
||||
@@ -259,7 +257,7 @@ FloatingWindow {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: Math.round(Theme.fontSizeMedium * 4.5)
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
|
||||
@@ -13,7 +13,7 @@ Rectangle {
|
||||
|
||||
height: Math.round(Theme.fontSizeMedium * 3.1)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -15,7 +15,7 @@ Rectangle {
|
||||
|
||||
height: Math.round(Theme.fontSizeMedium * 4.5)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -9,7 +9,7 @@ Rectangle {
|
||||
property string label: ""
|
||||
property string iconName: ""
|
||||
property color iconColor: Theme.surfaceText
|
||||
property color bgColor: Theme.surfaceContainerHigh
|
||||
property color bgColor: Theme.floatingWindowNestedSurface
|
||||
property bool selected: false
|
||||
|
||||
signal clicked
|
||||
|
||||
@@ -5,10 +5,9 @@ import qs.Modals
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: win
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property alias shouldBeVisible: win.visible
|
||||
|
||||
signal floatingToggleRequested
|
||||
@@ -30,7 +29,6 @@ FloatingWindow {
|
||||
minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height))
|
||||
implicitWidth: 1000
|
||||
implicitHeight: screen ? Math.min(820, screen.height - 100) : 820
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onVisibleChanged: {
|
||||
@@ -61,12 +59,6 @@ FloatingWindow {
|
||||
onDoubleClicked: windowControls.tryToggleMaximize()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.surfaceContainer
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingL
|
||||
|
||||
@@ -313,10 +313,6 @@ DankModal {
|
||||
|
||||
width: parent.width
|
||||
height: 48
|
||||
cornerRadius: Theme.cornerRadius
|
||||
backgroundColor: Theme.surfaceContainerHigh
|
||||
normalBorderColor: Theme.outlineMedium
|
||||
focusedBorderColor: Theme.primary
|
||||
leftIconName: "search"
|
||||
leftIconSize: Theme.iconSize
|
||||
leftIconColor: Theme.surfaceVariantText
|
||||
|
||||
@@ -202,7 +202,7 @@ FocusScope {
|
||||
StyledText {
|
||||
text: root.currentFlow?.message ?? ""
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceTextMedium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
@@ -272,11 +272,6 @@ FocusScope {
|
||||
|
||||
width: parent.width
|
||||
height: root.inputFieldHeight
|
||||
backgroundColor: Theme.surfaceHover
|
||||
normalBorderColor: Theme.outlineStrong
|
||||
focusedBorderColor: Theme.primary
|
||||
borderWidth: 1
|
||||
focusedBorderWidth: 2
|
||||
leftIconName: root.polkitPamHasFprint ? "fingerprint" : ""
|
||||
leftIconSize: 20
|
||||
leftIconColor: Theme.primary
|
||||
@@ -352,7 +347,7 @@ FocusScope {
|
||||
anchors.centerIn: parent
|
||||
text: I18n.tr("Authenticate")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.background
|
||||
color: Theme.primaryText
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@ import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: root
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
|
||||
function show() {
|
||||
if (contentLoader.item)
|
||||
contentLoader.item.reset();
|
||||
@@ -29,7 +27,6 @@ FloatingWindow {
|
||||
title: I18n.tr("Authentication")
|
||||
minimumSize: Qt.size(460, 220)
|
||||
maximumSize: Qt.size(460, 220)
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: hide()
|
||||
|
||||
@@ -129,6 +129,13 @@ DankModal {
|
||||
switchUserRequested();
|
||||
return;
|
||||
}
|
||||
if (action.startsWith("custom:")) {
|
||||
const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)];
|
||||
close();
|
||||
if (button?.command)
|
||||
Quickshell.execDetached(["sh", "-c", button.command]);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
root.powerActionRequested(action, "", "");
|
||||
}
|
||||
@@ -172,11 +179,14 @@ DankModal {
|
||||
|
||||
function updateVisibleActions() {
|
||||
const allActions = SettingsData.powerMenuActions || ["reboot", "logout", "poweroff", "lock", "suspend", "restart"];
|
||||
const customButtons = SettingsData.customPowerButtons || [];
|
||||
visibleActions = allActions.filter(action => {
|
||||
if (action === "hibernate" && !SessionService.hibernateSupported)
|
||||
return false;
|
||||
if (action === "softreboot" && !SessionService.softRebootSupported)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}).concat(customButtons.map((button, i) => "custom:" + i));
|
||||
|
||||
if (!SettingsData.powerMenuGridLayout)
|
||||
return;
|
||||
@@ -216,6 +226,14 @@ DankModal {
|
||||
}
|
||||
|
||||
function getActionData(action) {
|
||||
if (action.startsWith("custom:")) {
|
||||
const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)];
|
||||
return {
|
||||
"icon": button?.icon || "terminal",
|
||||
"label": button?.label || button?.command || "",
|
||||
"key": ""
|
||||
};
|
||||
}
|
||||
switch (action) {
|
||||
case "reboot":
|
||||
return {
|
||||
@@ -223,6 +241,12 @@ DankModal {
|
||||
"label": I18n.tr("Reboot"),
|
||||
"key": "R"
|
||||
};
|
||||
case "softreboot":
|
||||
return {
|
||||
"icon": "autorenew",
|
||||
"label": I18n.tr("Soft Reboot"),
|
||||
"key": "B"
|
||||
};
|
||||
case "logout":
|
||||
return {
|
||||
"icon": "logout",
|
||||
@@ -370,7 +394,7 @@ DankModal {
|
||||
|
||||
function handleListNavigation(event, isPressed) {
|
||||
if (!isPressed) {
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
||||
cancelHold();
|
||||
event.accepted = true;
|
||||
}
|
||||
@@ -429,6 +453,12 @@ DankModal {
|
||||
event.accepted = true;
|
||||
}
|
||||
break;
|
||||
case Qt.Key_B:
|
||||
if (visibleActions.includes("softreboot")) {
|
||||
startHold("softreboot", visibleActions.indexOf("softreboot"));
|
||||
event.accepted = true;
|
||||
}
|
||||
break;
|
||||
case Qt.Key_X:
|
||||
if (visibleActions.includes("logout")) {
|
||||
startHold("logout", visibleActions.indexOf("logout"));
|
||||
@@ -464,7 +494,7 @@ DankModal {
|
||||
|
||||
function handleGridNavigation(event, isPressed) {
|
||||
if (!isPressed) {
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
||||
cancelHold();
|
||||
event.accepted = true;
|
||||
}
|
||||
@@ -539,6 +569,12 @@ DankModal {
|
||||
event.accepted = true;
|
||||
}
|
||||
break;
|
||||
case Qt.Key_B:
|
||||
if (visibleActions.includes("softreboot")) {
|
||||
startHold("softreboot", visibleActions.indexOf("softreboot"));
|
||||
event.accepted = true;
|
||||
}
|
||||
break;
|
||||
case Qt.Key_X:
|
||||
if (visibleActions.includes("logout")) {
|
||||
startHold("logout", visibleActions.indexOf("logout"));
|
||||
@@ -597,7 +633,7 @@ DankModal {
|
||||
|
||||
readonly property var actionData: root.getActionData(modelData)
|
||||
readonly property bool isSelected: root.selectedIndex === index
|
||||
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff"
|
||||
readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
|
||||
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
|
||||
|
||||
width: (root.modalWidth - Theme.spacingL * 2 - Theme.spacingS * (root.gridColumns - 1)) / root.gridColumns
|
||||
@@ -627,7 +663,7 @@ DankModal {
|
||||
color: {
|
||||
if (gridButtonRect.modelData === "poweroff")
|
||||
return Theme.errorSelected;
|
||||
if (gridButtonRect.modelData === "reboot")
|
||||
if (gridButtonRect.modelData === "reboot" || gridButtonRect.modelData === "softreboot")
|
||||
return Theme.withAlpha(Theme.warning, 0.3);
|
||||
return Theme.primarySelected;
|
||||
}
|
||||
@@ -668,6 +704,7 @@ DankModal {
|
||||
height: 16
|
||||
radius: 4
|
||||
color: Theme.onSurface_12
|
||||
visible: gridButtonRect.actionData.key !== ""
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
StyledText {
|
||||
@@ -722,7 +759,7 @@ DankModal {
|
||||
|
||||
readonly property var actionData: root.getActionData(modelData)
|
||||
readonly property bool isSelected: root.selectedIndex === index
|
||||
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff"
|
||||
readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
|
||||
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
|
||||
|
||||
width: parent.width
|
||||
@@ -752,7 +789,7 @@ DankModal {
|
||||
color: {
|
||||
if (listButtonRect.modelData === "poweroff")
|
||||
return Theme.errorSelected;
|
||||
if (listButtonRect.modelData === "reboot")
|
||||
if (listButtonRect.modelData === "reboot" || listButtonRect.modelData === "softreboot")
|
||||
return Theme.withAlpha(Theme.warning, 0.3);
|
||||
return Theme.primarySelected;
|
||||
}
|
||||
@@ -800,6 +837,7 @@ DankModal {
|
||||
height: 20
|
||||
radius: 4
|
||||
color: Theme.onSurface_12
|
||||
visible: listButtonRect.actionData.key !== ""
|
||||
anchors {
|
||||
right: parent.right
|
||||
rightMargin: Theme.spacingM
|
||||
|
||||
@@ -8,11 +8,10 @@ import qs.Services
|
||||
import qs.Widgets
|
||||
import "../Common/Format.js" as Format
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: processListModal
|
||||
readonly property var log: Log.scoped("ProcessListModal")
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property int currentTab: 0
|
||||
property string searchText: ""
|
||||
property string expandedPid: ""
|
||||
@@ -81,7 +80,6 @@ FloatingWindow {
|
||||
minimumSize: Qt.size(Math.min(Math.round(Theme.fontSizeMedium * 48), Screen.width), Math.min(Math.round(Theme.fontSizeMedium * 34), Screen.height))
|
||||
implicitWidth: Math.round(Theme.fontSizeMedium * 71)
|
||||
implicitHeight: Math.round(Theme.fontSizeMedium * 51)
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: hide()
|
||||
@@ -420,7 +418,7 @@ FloatingWindow {
|
||||
Layout.margins: Theme.spacingL
|
||||
Layout.topMargin: Theme.spacingM
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
border.color: Theme.outlineLight
|
||||
border.width: 1
|
||||
clip: true
|
||||
|
||||
@@ -733,5 +733,20 @@ FocusScope {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: keyboardLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 45
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: KeyboardTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import qs.Modals.FileBrowser
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: settingsModal
|
||||
|
||||
property var profileBrowser: profileBrowserLoader.item
|
||||
@@ -95,7 +95,6 @@ FloatingWindow {
|
||||
minimumSize: Qt.size(500, 400)
|
||||
implicitWidth: 900
|
||||
implicitHeight: screen ? Math.min(940, screen.height - 100) : 940
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: hide()
|
||||
@@ -180,8 +179,6 @@ FloatingWindow {
|
||||
FocusScope {
|
||||
id: contentFocusScope
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
@@ -203,12 +200,6 @@ FloatingWindow {
|
||||
onDoubleClicked: windowControls.tryToggleMaximize()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.surfaceContainer
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingL
|
||||
@@ -276,7 +267,7 @@ FloatingWindow {
|
||||
|
||||
width: parent.width
|
||||
height: showBanner ? bannerContent.implicitHeight + Theme.spacingM * 2 : 0
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
visible: showBanner
|
||||
clip: true
|
||||
|
||||
|
||||
@@ -326,6 +326,13 @@ Rectangle {
|
||||
"tabIndex": 44,
|
||||
"niriOnly": true
|
||||
},
|
||||
{
|
||||
"id": "keyboard",
|
||||
"text": I18n.tr("Keyboard"),
|
||||
"icon": "keyboard",
|
||||
"tabIndex": 45,
|
||||
"niriOnly": true
|
||||
},
|
||||
{
|
||||
"id": "locale",
|
||||
"text": I18n.tr("Locale"),
|
||||
@@ -641,7 +648,7 @@ Rectangle {
|
||||
implicitWidth: __calculatedWidth
|
||||
width: __calculatedWidth
|
||||
height: parent.height
|
||||
color: Theme.surfaceContainer
|
||||
color: "transparent"
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
Component.onCompleted: {
|
||||
@@ -742,12 +749,9 @@ Rectangle {
|
||||
id: searchField
|
||||
width: parent.width - parent.leftPadding - parent.rightPadding
|
||||
placeholderText: I18n.tr("Search...")
|
||||
normalBorderColor: Theme.outlineMedium
|
||||
focusedBorderColor: Theme.primary
|
||||
leftIconName: "search"
|
||||
leftIconSize: Theme.iconSize - 4
|
||||
showClearButton: text.length > 0
|
||||
usePopupTransparency: false
|
||||
onTextChanged: {
|
||||
SettingsSearchService.search(text);
|
||||
root.searchSelectedIndex = 0;
|
||||
|
||||
@@ -4,10 +4,9 @@ import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: root
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property var editingRule: null
|
||||
property bool isEditMode: editingRule !== null
|
||||
property bool isNiri: CompositorService.isNiri
|
||||
@@ -29,7 +28,6 @@ FloatingWindow {
|
||||
title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule")
|
||||
minimumSize: Qt.size(500, 600)
|
||||
maximumSize: Qt.size(500, 600)
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: hide()
|
||||
|
||||
@@ -4,18 +4,16 @@ import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("WorkspaceRenameModal")
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
||||
|
||||
objectName: "workspaceRenameModal"
|
||||
title: I18n.tr("Rename Workspace")
|
||||
minimumSize: Qt.size(400, 160)
|
||||
maximumSize: Qt.size(400, 160)
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: hide()
|
||||
|
||||
@@ -469,6 +469,8 @@ PluginComponent {
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: peerMouseArea
|
||||
|
||||
z: -1
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
@@ -41,7 +41,11 @@ Rectangle {
|
||||
property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0
|
||||
property bool hasBothConnectionTypes: hasEthernetAvailable && hasWifiAvailable
|
||||
property int maxPinnedNetworks: 3
|
||||
readonly property int hotspotContentHeight: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable ? 56 + Theme.spacingS : 0
|
||||
// Hosting on the only wifi adapter with no ethernet uplink just drops connectivity,
|
||||
// so the hotspot row only shows where sharing can actually work (or is already on).
|
||||
readonly property bool hotspotRelevant: NetworkService.hotspotEnabled || NetworkService.hotspotActivating || NetworkService.hotspotBusy || NetworkService.ethernetConnected || (NetworkService.wifiDevices?.length ?? 0) > 1
|
||||
readonly property bool showHotspotRow: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable && hotspotRelevant
|
||||
readonly property int hotspotContentHeight: showHotspotRow ? 56 + Theme.spacingS : 0
|
||||
|
||||
property var hotspotStartConfirm: ConfirmModal {}
|
||||
|
||||
@@ -184,7 +188,7 @@ Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Theme.spacingM
|
||||
anchors.topMargin: Theme.spacingM
|
||||
visible: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable
|
||||
visible: root.showHotspotRow
|
||||
height: visible ? 56 : 0
|
||||
|
||||
Rectangle {
|
||||
|
||||
@@ -201,7 +201,16 @@ Item {
|
||||
direction: root.effectiveShadowDirection
|
||||
fallbackOffset: 4
|
||||
targetRadius: root.rt
|
||||
targetColor: barWindow._bgColor
|
||||
// wing-side body corners are square where the gothic fillets attach;
|
||||
// rounding the shadow there leaves a shadow-filled notch under the
|
||||
// translucent bar fill (#2975)
|
||||
topLeftRadius: root.gothEnabled && (root.isBottom || root.isRight) ? 0 : root.rt
|
||||
topRightRadius: root.gothEnabled && (root.isBottom || root.isLeft) ? 0 : root.rt
|
||||
bottomLeftRadius: root.gothEnabled && (root.isTop || root.isRight) ? 0 : root.rt
|
||||
bottomRightRadius: root.gothEnabled && (root.isTop || root.isLeft) ? 0 : root.rt
|
||||
// barShape below is the sole painter of the bar; a fill here doubles
|
||||
// the alpha of translucent bars while the wings stay single-painted
|
||||
targetColor: "transparent"
|
||||
|
||||
shadowBlurPx: root.shadowBlurPx
|
||||
shadowOffsetX: root.shadowOffsetX
|
||||
|
||||
@@ -163,16 +163,7 @@ Item {
|
||||
Variants {
|
||||
id: barVariants
|
||||
model: {
|
||||
const prefs = root.barConfig?.screenPreferences || ["all"];
|
||||
const wantsAll = prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all");
|
||||
let base;
|
||||
if (wantsAll) {
|
||||
base = Quickshell.screens;
|
||||
} else {
|
||||
base = Quickshell.screens.filter(screen => SettingsData.isScreenInPreferences(screen, prefs));
|
||||
if (base.length === 0 && root.barConfig?.showOnLastDisplay && Quickshell.screens.length === 1)
|
||||
base = Quickshell.screens;
|
||||
}
|
||||
const base = Quickshell.screens.filter(screen => SettingsData.barConfigCoversScreen(root.barConfig, screen));
|
||||
// Connected frame mode renders the bar inside the frame surface; skip the standalone window there
|
||||
// unless this bar wants the overlay layer, which the frame surface cannot provide.
|
||||
return base.filter(screen => !CompositorService.frameHostsBarForConfig(screen, root.barConfig));
|
||||
|
||||
@@ -609,7 +609,7 @@ Item {
|
||||
_closeHoverNotepad();
|
||||
activeHoverTrigger = "";
|
||||
PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen);
|
||||
TrayMenuManager.closeAllMenus();
|
||||
TrayMenuManager.closeHoverMenus();
|
||||
}
|
||||
|
||||
function _beginSupersededCloseForActive() {
|
||||
|
||||
@@ -67,6 +67,7 @@ BasePill {
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.MiddleButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPressed: mouse => {
|
||||
root.triggerRipple(this, mouse.x, mouse.y);
|
||||
SessionData.setDoNotDisturb(!SessionData.doNotDisturb);
|
||||
|
||||
@@ -83,6 +83,22 @@ BasePill {
|
||||
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: TrayMenuManager
|
||||
|
||||
function onOpenTrayMenuRequested() {
|
||||
const request = TrayMenuManager.claimMenuRequest(root.parentScreen?.name);
|
||||
if (!request)
|
||||
return;
|
||||
|
||||
const item = TrayMenuManager.findTrayItem(request.itemId);
|
||||
if (!item || !item.hasMenu)
|
||||
return;
|
||||
|
||||
root.showForTrayItem(item, root, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
}
|
||||
|
||||
function openInlineTrayContextMenu(trayItem, areaItem, mouse, anchorItem) {
|
||||
if (!trayItem) {
|
||||
return;
|
||||
@@ -1484,6 +1500,7 @@ BasePill {
|
||||
property bool isVertical: false
|
||||
property var axis: null
|
||||
property bool showMenu: false
|
||||
property bool openedByHover: false
|
||||
property var menuHandle: null
|
||||
|
||||
ListModel {
|
||||
@@ -1493,7 +1510,8 @@ BasePill {
|
||||
return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null;
|
||||
}
|
||||
|
||||
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
|
||||
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
|
||||
openedByHover = byHover === true;
|
||||
trayItem = item;
|
||||
anchorItem = anchor;
|
||||
parentScreen = screen;
|
||||
@@ -2084,7 +2102,7 @@ BasePill {
|
||||
}
|
||||
}
|
||||
|
||||
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
|
||||
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
|
||||
if (!screen)
|
||||
return;
|
||||
if (currentTrayMenu) {
|
||||
@@ -2099,7 +2117,7 @@ BasePill {
|
||||
currentTrayMenu = trayMenuComponent.createObject(null);
|
||||
if (!currentTrayMenu)
|
||||
return;
|
||||
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj);
|
||||
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj, byHover === true);
|
||||
}
|
||||
|
||||
function _trayLayoutRoot() {
|
||||
@@ -2147,7 +2165,7 @@ BasePill {
|
||||
if (!hit?.trayItem?.hasMenu)
|
||||
return false;
|
||||
const anchor = hit.children?.length > 0 ? hit.children[0] : hit;
|
||||
showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis);
|
||||
showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,7 +493,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: activePlayer?.trackAlbum || ""
|
||||
text: MprisController.stableAlbum
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextSecondary
|
||||
width: parent.width
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Card {
|
||||
id: root
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
readonly property var device: DankConnectService.primaryDevice
|
||||
readonly property var pairingRequest: DankConnectService.pairingRequest
|
||||
readonly property bool hasBattery: !!device && typeof device.batteryCharge === "number" && device.batteryCharge >= 0
|
||||
|
||||
function statusText() {
|
||||
if (!DankConnectService.connected)
|
||||
return I18n.tr("Disconnected");
|
||||
if (!device)
|
||||
return I18n.tr("No devices");
|
||||
const connection = device.isReachable ? I18n.tr("Connected") : I18n.tr("Disconnected");
|
||||
const pairing = device.isPaired ? I18n.tr("Paired") : I18n.tr("Not paired");
|
||||
return connection + " • " + pairing;
|
||||
}
|
||||
|
||||
function batteryText() {
|
||||
if (!hasBattery)
|
||||
return "";
|
||||
const charge = Math.round(device.batteryCharge) + "%";
|
||||
return device.batteryCharging ? charge + " • " + I18n.tr("Charging") : charge;
|
||||
}
|
||||
|
||||
function reportError(title, response) {
|
||||
if (response?.error)
|
||||
ToastService.showError(title, response.error);
|
||||
}
|
||||
|
||||
Row {
|
||||
id: contentRow
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: Theme.spacingL
|
||||
|
||||
Item {
|
||||
width: Math.max(180, contentRow.width - actionRow.width - trailingPanel.width - contentRow.spacing * 2)
|
||||
height: parent.height
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: "smartphone"
|
||||
size: 36
|
||||
color: root.device?.isReachable ? Theme.primary : Theme.surfaceTextSecondary
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 36 - parent.spacing
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: root.device?.name || "DankConnect"
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: root.statusText()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
visible: root.hasBattery
|
||||
text: root.batteryText()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actionRow
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: implicitWidth
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankActionButton {
|
||||
buttonSize: 34
|
||||
iconSize: 17
|
||||
iconName: "ring_volume"
|
||||
iconColor: Theme.primary
|
||||
tooltipText: I18n.tr("Ring")
|
||||
enabled: DankConnectService.canRing(root.device)
|
||||
onClicked: DankConnectService.ringDevice(root.device.id, response => root.reportError(I18n.tr("Ring"), response))
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
buttonSize: 34
|
||||
iconSize: 17
|
||||
iconName: "network_ping"
|
||||
iconColor: Theme.primary
|
||||
tooltipText: I18n.tr("Ping")
|
||||
enabled: DankConnectService.canPing(root.device)
|
||||
onClicked: DankConnectService.pingDevice(root.device.id, response => root.reportError(I18n.tr("Ping"), response))
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
buttonSize: 34
|
||||
iconSize: 17
|
||||
iconName: "content_paste_go"
|
||||
iconColor: Theme.primary
|
||||
tooltipText: I18n.tr("Send Clipboard")
|
||||
enabled: DankConnectService.canSendClipboard(root.device)
|
||||
onClicked: DankConnectService.sendClipboard(root.device.id, response => root.reportError(I18n.tr("Send Clipboard"), response))
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
buttonSize: 34
|
||||
iconSize: 17
|
||||
iconName: "open_in_new"
|
||||
iconColor: Theme.primary
|
||||
tooltipText: I18n.tr("Open App")
|
||||
onClicked: DankConnectService.openApp()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: trailingPanel
|
||||
|
||||
width: root.pairingRequest ? 270 : 52
|
||||
height: parent.height
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: !root.pairingRequest && root.hasBattery
|
||||
name: root.hasBattery ? Theme.getBatteryIcon(root.device.batteryCharge, root.device.batteryCharging, true) : "battery_std"
|
||||
size: 28
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: root.pairingRequest !== null
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Pairing request from %1").arg(root.pairingRequest?.name || I18n.tr("Device"))
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: root.pairingRequest?.verificationKey || I18n.tr("Unknown")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Bold
|
||||
color: Theme.primary
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankButton {
|
||||
text: I18n.tr("Reject")
|
||||
buttonHeight: 30
|
||||
horizontalPadding: Theme.spacingM
|
||||
onClicked: DankConnectService.rejectPairing(root.pairingRequest.deviceId, response => root.reportError(I18n.tr("Failed to reject pairing"), response))
|
||||
}
|
||||
|
||||
DankButton {
|
||||
text: I18n.tr("Accept")
|
||||
buttonHeight: 30
|
||||
horizontalPadding: Theme.spacingM
|
||||
backgroundColor: Theme.primary
|
||||
textColor: Theme.primaryText
|
||||
onClicked: DankConnectService.acceptPairing(root.pairingRequest.deviceId, response => root.reportError(I18n.tr("Failed to accept pairing"), response))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,11 @@ Item {
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
readonly property int overviewGridHeight: 410
|
||||
readonly property int dankConnectCardHeight: 124
|
||||
|
||||
implicitWidth: SettingsData.showWeekNumber ? 736 : 700
|
||||
implicitHeight: 410
|
||||
implicitHeight: overviewGridHeight + Theme.spacingM + dankConnectCardHeight
|
||||
|
||||
signal switchToWeatherTab
|
||||
signal switchToMediaTab
|
||||
@@ -89,5 +92,12 @@ Item {
|
||||
|
||||
onClicked: root.switchToMediaTab()
|
||||
}
|
||||
|
||||
DankConnectOverviewCard {
|
||||
x: 0
|
||||
y: root.overviewGridHeight + Theme.spacingM
|
||||
width: parent.width
|
||||
height: root.dankConnectCardHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import qs.Widgets
|
||||
Variants {
|
||||
id: dockVariants
|
||||
// Connected frame mode renders the dock inside the frame surface; skip the standalone window there.
|
||||
model: SettingsData.getFilteredScreens("dock").filter(screen => !CompositorService.frameHostsDockForScreen(screen))
|
||||
model: SettingsData.showDock ? SettingsData.getFilteredScreens("dock").filter(screen => !CompositorService.frameHostsDockForScreen(screen)) : []
|
||||
|
||||
property var contextMenu
|
||||
property var trashContextMenu
|
||||
|
||||
@@ -25,8 +25,7 @@ Item {
|
||||
const bc = configs[i];
|
||||
if (!bc.enabled || (bc.useOverlayLayer ?? false))
|
||||
continue;
|
||||
const prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !SettingsData.isScreenInPreferences(host.targetScreen, prefs))
|
||||
if (!SettingsData.barConfigCoversScreen(bc, host.targetScreen))
|
||||
continue;
|
||||
let edge = "top";
|
||||
switch (bc.position ?? 0) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.DankCommon.Session
|
||||
import "../../DankCommon/Common/LayoutCodes.js" as LayoutCodes
|
||||
import "../../Common/KeyUtils.js" as KeyUtils
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -98,6 +99,18 @@ Item {
|
||||
return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !root.unlocking;
|
||||
}
|
||||
|
||||
function triggerSecurityKeyUnlock() {
|
||||
if (!canStartSecurityKeyUnlock())
|
||||
return;
|
||||
passwordField.clear();
|
||||
pam.u2f.startForAlternativeAuth();
|
||||
}
|
||||
|
||||
function securityKeyShortcutMatches(event) {
|
||||
return SettingsData.lockScreenSecurityKeyShortcutEnabled
|
||||
&& KeyUtils.eventMatchesCombo(event, SettingsData.lockScreenSecurityKeyShortcut);
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
WeatherService.addRef();
|
||||
UserInfoService.getUserInfo();
|
||||
@@ -962,6 +975,12 @@ Item {
|
||||
}
|
||||
|
||||
if ((event.modifiers & Qt.ControlModifier) && !(event.modifiers & (Qt.AltModifier | Qt.MetaModifier))) {
|
||||
if (securityKeyShortcutMatches(event) && canStartSecurityKeyUnlock()) {
|
||||
triggerSecurityKeyUnlock();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case Qt.Key_A:
|
||||
cursorPosition = 0;
|
||||
@@ -1238,10 +1257,8 @@ Item {
|
||||
buttonSize: 32
|
||||
visible: root.canStartSecurityKeyUnlock()
|
||||
enabled: visible
|
||||
onClicked: {
|
||||
passwordField.clear();
|
||||
pam.u2f.startForAlternativeAuth();
|
||||
}
|
||||
tooltipText: SettingsData.lockScreenSecurityKeyShortcutEnabled ? I18n.tr("Security key (%1)", "lock screen security key button tooltip with shortcut").arg(SettingsData.lockScreenSecurityKeyShortcut) : I18n.tr("Security key", "lock screen security key button tooltip")
|
||||
onClicked: root.triggerSecurityKeyUnlock()
|
||||
}
|
||||
DankActionButton {
|
||||
id: virtualKeyboardButton
|
||||
@@ -1329,6 +1346,7 @@ Item {
|
||||
width: parent.width
|
||||
height: parent.height / 2
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: -1
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, 0.9)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Notepad
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: win
|
||||
|
||||
property alias shouldBeVisible: win.visible
|
||||
@@ -27,7 +27,7 @@ FloatingWindow {
|
||||
minimumSize: Qt.size(360, 320)
|
||||
implicitWidth: 640
|
||||
implicitHeight: 760
|
||||
color: Theme.surfaceContainer
|
||||
surfaceColor: Theme.notepadWindowSurface
|
||||
visible: false
|
||||
|
||||
onVisibleChanged: {
|
||||
@@ -38,7 +38,6 @@ FloatingWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// A compositor close (e.g. niri close-window)
|
||||
onClosed: win.visible = false
|
||||
|
||||
Item {
|
||||
@@ -58,12 +57,6 @@ FloatingWindow {
|
||||
onDoubleClicked: windowControls.tryToggleMaximize()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.surfaceContainerHigh
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
|
||||
@@ -353,7 +353,7 @@ Item {
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Surface Opacity")
|
||||
description: I18n.tr("Override global transparency for Notepad")
|
||||
description: I18n.tr("Override floating window transparency for Notepad")
|
||||
checked: SettingsData.notepadTransparencyOverride >= 0
|
||||
onToggled: checked => {
|
||||
if (checked) {
|
||||
@@ -370,7 +370,7 @@ Item {
|
||||
width: parent.width + Theme.spacingM
|
||||
height: 24
|
||||
visible: SettingsData.notepadTransparencyOverride >= 0
|
||||
value: Math.round((SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : SettingsData.popupTransparency) * 100)
|
||||
value: Math.round(Theme.notepadTransparency * 100)
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
unit: ""
|
||||
|
||||
@@ -364,7 +364,7 @@ Column {
|
||||
height: 48
|
||||
visible: searchVisible
|
||||
opacity: searchVisible ? 1 : 0
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
border.color: searchField.activeFocus ? Theme.primary : Theme.outlineMedium
|
||||
border.width: searchField.activeFocus ? 2 : 1
|
||||
radius: Theme.cornerRadius
|
||||
@@ -941,7 +941,7 @@ Column {
|
||||
width: Math.min(root.width, 360)
|
||||
height: pathInfoRow.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: 1
|
||||
z: 10
|
||||
|
||||
@@ -156,9 +156,10 @@ DankOSD {
|
||||
if (MprisController.isFirefoxYoutubeHoverPreview(player))
|
||||
return;
|
||||
|
||||
const newTitle = player.trackTitle || "";
|
||||
const newArtist = player.trackArtist || "";
|
||||
const newAlbum = player.trackAlbum || "";
|
||||
const metaPlayer = MprisController.bestMetadataPlayer(player);
|
||||
const newTitle = MprisController.displayTrackTitle(metaPlayer);
|
||||
const newArtist = metaPlayer.trackArtist || "";
|
||||
const newAlbum = metaPlayer.trackAlbum || "";
|
||||
const trackChanged = newTitle !== root._displayTitle || newArtist !== root._displayArtist || newAlbum !== root._displayAlbum;
|
||||
|
||||
root._displayTitle = newTitle;
|
||||
@@ -263,37 +264,102 @@ DankOSD {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: Theme.iconSize
|
||||
height: Theme.iconSize
|
||||
radius: Theme.iconSize / 2
|
||||
color: "transparent"
|
||||
Row {
|
||||
id: transportControls
|
||||
|
||||
x: parent.gap
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXXS
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: root._displayIcon
|
||||
size: Theme.iconSize
|
||||
color: playPauseButton.containsMouse ? Theme.primary : Theme.surfaceText
|
||||
Rectangle {
|
||||
width: Theme.iconSize - 4
|
||||
height: Theme.iconSize - 4
|
||||
radius: (Theme.iconSize - 4) / 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: prevButton.containsMouse ? Theme.surfaceTextHover : "transparent"
|
||||
opacity: (root.player?.canGoPrevious ?? false) ? 1 : 0.3
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "skip_previous"
|
||||
size: Theme.iconSize - 10
|
||||
color: prevButton.containsMouse ? Theme.primary : Theme.surfaceText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: prevButton
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: root.player?.canGoPrevious ?? false
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
MprisController.previousOrRewind();
|
||||
root.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: playPauseButton
|
||||
Rectangle {
|
||||
width: Theme.iconSize
|
||||
height: Theme.iconSize
|
||||
radius: Theme.iconSize / 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: "transparent"
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
togglePlaying();
|
||||
root.hide();
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: root._displayIcon
|
||||
size: Theme.iconSize
|
||||
color: playPauseButton.containsMouse ? Theme.primary : Theme.surfaceText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: playPauseButton
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
togglePlaying();
|
||||
root.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: Theme.iconSize - 4
|
||||
height: Theme.iconSize - 4
|
||||
radius: (Theme.iconSize - 4) / 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: nextButton.containsMouse ? Theme.surfaceTextHover : "transparent"
|
||||
opacity: (root.player?.canGoNext ?? false) ? 1 : 0.3
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "skip_next"
|
||||
size: Theme.iconSize - 10
|
||||
color: nextButton.containsMouse ? Theme.primary : Theme.surfaceText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: nextButton
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: root.player?.canGoNext ?? false
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
MprisController.next();
|
||||
root.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
x: parent.gap * 2 + Theme.iconSize
|
||||
width: parent.width - Theme.iconSize - parent.gap * 3
|
||||
x: parent.gap * 2 + transportControls.width
|
||||
width: parent.width - transportControls.width - parent.gap * 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXXS
|
||||
|
||||
|
||||
@@ -578,7 +578,7 @@ Item {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr('dms is a highly customizable, modern desktop shell with a <a href="https://m3.material.io/" style="text-decoration:none; color:%1;">material 3 inspired</a> design.<br /><br/>It is built with <a href="https://quickshell.org" style="text-decoration:none; color:%1;">Quickshell</a>, a QT6 framework for building desktop shells, and <a href="https://go.dev" style="text-decoration:none; color:%1;">Go</a>, a statically typed, compiled programming language.').arg(Theme.primary)
|
||||
text: I18n.tr('DMS is a highly customizable modern desktop shell with a %1 inspired design.<br/><br/>It is built with %2, a QT6 framework for building desktop shells, and %3, a statically typed, compiled programming language.').arg(`<a href="https://m3.material.io/" style="text-decoration:none; color:${Theme.primary};">Material Design 3</a>`).arg(`<a href="https://quickshell.org" style="text-decoration:none; color:${Theme.primary};">Quickshell</a>`).arg(`<a href="https://go.dev" style="text-decoration:none; color:${Theme.primary};">Go</a>`)
|
||||
textFormat: Text.RichText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
linkColor: Theme.primary
|
||||
@@ -819,7 +819,7 @@ Item {
|
||||
|
||||
StyledText {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: I18n.tr('<a href="https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE" style="text-decoration:none; color:%1;">MIT License</a>').arg(Theme.surfaceVariantText)
|
||||
text: `<a href="https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE" style="text-decoration:none; color:${Theme.surfaceVariantText};">${I18n.tr('MIT License')}</a>`
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
textFormat: Text.RichText
|
||||
|
||||
@@ -118,7 +118,9 @@ Column {
|
||||
width: parent.width
|
||||
height: 56
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowFieldColor
|
||||
border.color: Theme.floatingWindowFieldBorderColor
|
||||
border.width: 1
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -6,10 +6,9 @@ import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: root
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property string searchQuery: ""
|
||||
property var filteredWidgets: []
|
||||
property int selectedIndex: -1
|
||||
@@ -107,7 +106,6 @@ FloatingWindow {
|
||||
minimumSize: Qt.size(400, 350)
|
||||
implicitWidth: 500
|
||||
implicitHeight: 550
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: hide()
|
||||
@@ -276,10 +274,6 @@ FloatingWindow {
|
||||
id: searchField
|
||||
width: parent.width
|
||||
height: 48
|
||||
cornerRadius: Theme.cornerRadius
|
||||
backgroundColor: Theme.surfaceContainerHigh
|
||||
normalBorderColor: Theme.outlineMedium
|
||||
focusedBorderColor: Theme.primary
|
||||
leftIconName: "search"
|
||||
leftIconSize: Theme.iconSize
|
||||
leftIconColor: Theme.surfaceVariantText
|
||||
|
||||
@@ -320,17 +320,22 @@ Singleton {
|
||||
function findConfigEntryByFingerprint(data, outputIdentifiers, autoOnly) {
|
||||
const targetKey = outputSetFingerprint(outputIdentifiers);
|
||||
const configs = data.configurations || [];
|
||||
let firstUnnamed = null;
|
||||
for (let i = 0; i < configs.length; i++) {
|
||||
if (configFingerprint(configs[i]) === targetKey) {
|
||||
if (autoOnly && configs[i].name)
|
||||
continue;
|
||||
if (configFingerprint(configs[i]) !== targetKey)
|
||||
continue;
|
||||
if (configs[i].name && !autoOnly)
|
||||
return {
|
||||
entry: configs[i],
|
||||
index: i
|
||||
};
|
||||
}
|
||||
if (!configs[i].name && !firstUnnamed)
|
||||
firstUnnamed = {
|
||||
entry: configs[i],
|
||||
index: i
|
||||
};
|
||||
}
|
||||
return null;
|
||||
return firstUnnamed;
|
||||
}
|
||||
|
||||
function getProfileMonitorInclusion(profileId) {
|
||||
@@ -766,7 +771,7 @@ Singleton {
|
||||
return;
|
||||
|
||||
readMonitorsJson(data => {
|
||||
const match = findConfigEntryByFingerprint(data, currentOutputSet, true);
|
||||
const match = findConfigEntryByFingerprint(data, currentOutputSet, false);
|
||||
if (match) {
|
||||
if (configEntryMatchesLiveLayout(match.entry)) {
|
||||
SettingsData.setActiveDisplayProfile(CompositorService.compositor, match.entry.id);
|
||||
@@ -1111,15 +1116,50 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function extractNiriOutputBlocks(content) {
|
||||
const blocks = [];
|
||||
const headerRegex = /output\s+"([^"]+)"\s*\{/g;
|
||||
let match;
|
||||
while ((match = headerRegex.exec(content)) !== null) {
|
||||
const start = headerRegex.lastIndex;
|
||||
let depth = 1;
|
||||
let i = start;
|
||||
while (i < content.length && depth > 0) {
|
||||
const ch = content[i];
|
||||
if (ch === '{')
|
||||
depth++;
|
||||
else if (ch === '}')
|
||||
depth--;
|
||||
i++;
|
||||
}
|
||||
blocks.push({
|
||||
"name": match[1],
|
||||
"body": content.slice(start, i - 1)
|
||||
});
|
||||
headerRegex.lastIndex = i;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function stripNestedBlocks(body) {
|
||||
let stripped = body;
|
||||
let prev;
|
||||
do {
|
||||
prev = stripped;
|
||||
stripped = stripped.replace(/[\w-]+\s*\{[^{}]*\}/g, "");
|
||||
} while (stripped !== prev)
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function parseNiriOutputs(content) {
|
||||
const result = {};
|
||||
const outputRegex = /output\s+"([^"]+)"\s*\{([^}]*)\}/g;
|
||||
let match;
|
||||
while ((match = outputRegex.exec(content)) !== null) {
|
||||
const name = match[1];
|
||||
const body = match[2];
|
||||
for (const block of extractNiriOutputBlocks(content)) {
|
||||
const name = block.name;
|
||||
const body = block.body;
|
||||
|
||||
const disabled = /^\s*off\s*$/m.test(body);
|
||||
// off marks the output disabled only at the top level of its block;
|
||||
// nested sections like hot-corners { off } must not count (#2966)
|
||||
const disabled = /^\s*off\s*$/m.test(stripNestedBlocks(body));
|
||||
const modeMatch = body.match(/mode\s+"(\d+)x(\d+)@([\d.]+)"/);
|
||||
const posMatch = body.match(/position\s+x=(-?\d+)\s+y=(-?\d+)/);
|
||||
const scaleMatch = body.match(/scale\s+([\d.]+)/);
|
||||
|
||||
@@ -320,100 +320,10 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
property bool isLocationMode: SessionData.nightModeAutoMode === "location"
|
||||
visible: isLocationMode
|
||||
spacing: Theme.spacingM
|
||||
SettingsLocationSection {
|
||||
width: parent.width
|
||||
|
||||
DankToggle {
|
||||
id: ipLocationToggle
|
||||
width: parent.width
|
||||
text: I18n.tr("Use IP Location")
|
||||
description: I18n.tr("Automatically detect location based on IP address")
|
||||
checked: SessionData.nightModeUseIPLocation || false
|
||||
onToggled: checked => {
|
||||
SessionData.setNightModeUseIPLocation(checked);
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onNightModeUseIPLocationChanged() {
|
||||
ipLocationToggle.checked = SessionData.nightModeUseIPLocation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
leftPadding: Theme.spacingM
|
||||
visible: !SessionData.nightModeUseIPLocation
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Manual Coordinates")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingL
|
||||
|
||||
Column {
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Latitude")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: 120
|
||||
height: 40
|
||||
text: SessionData.latitude.toString()
|
||||
placeholderText: "0.0"
|
||||
onEditingFinished: {
|
||||
const lat = parseFloat(text);
|
||||
if (!isNaN(lat) && lat >= -90 && lat <= 90 && lat !== SessionData.latitude) {
|
||||
SessionData.setLatitude(lat);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Longitude")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: 120
|
||||
height: 40
|
||||
text: SessionData.longitude.toString()
|
||||
placeholderText: "0.0"
|
||||
onEditingFinished: {
|
||||
const lon = parseFloat(text);
|
||||
if (!isNaN(lon) && lon >= -180 && lon <= 180 && lon !== SessionData.longitude) {
|
||||
SessionData.setLongitude(lon);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Uses sunrise/sunset times to automatically adjust night mode based on your location.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width - parent.leftPadding
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
visible: SessionData.nightModeAutoMode === "location"
|
||||
description: I18n.tr("Uses sunrise/sunset times to automatically adjust night mode based on your location.")
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Settings.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentHeight: settingsColumn.height + Theme.spacingXL
|
||||
contentWidth: width
|
||||
|
||||
Column {
|
||||
id: settingsColumn
|
||||
|
||||
topPadding: 4
|
||||
width: Math.min(550, parent.width - Theme.spacingL * 2)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
NiriInputSetupBanner {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
tags: ["keyboard", "layout", "language", "input", "xkb"]
|
||||
title: I18n.tr("Keyboard Layouts")
|
||||
settingKey: "keyboardLayoutsSettings"
|
||||
iconName: "keyboard"
|
||||
|
||||
Column {
|
||||
width: parent?.width ?? 0
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Keyboard Layouts")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Comma-separated list of layout names. Leave empty to use the system keyboard settings.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
text: SettingsData.keyboardLayouts
|
||||
placeholderText: "us,de"
|
||||
onTextEdited: SettingsData.set("keyboardLayouts", text)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
tags: ["keyboard", "layout", "switch", "shortcut", "xkb"]
|
||||
settingKey: "keyboardOptions"
|
||||
text: I18n.tr("Switch Layout Shortcut")
|
||||
description: I18n.tr("Choose a shortcut key to cycle between keyboard layouts")
|
||||
options: [I18n.tr("Alt + Shift"), I18n.tr("Ctrl + Shift"), I18n.tr("Caps Lock"), I18n.tr("Super + Space"), I18n.tr("Custom / None")]
|
||||
currentValue: {
|
||||
const opt = SettingsData.keyboardOptions;
|
||||
if (opt.includes("grp:alt_shift_toggle"))
|
||||
return options[0];
|
||||
if (opt.includes("grp:ctrl_shift_toggle"))
|
||||
return options[1];
|
||||
if (opt.includes("grp:caps_toggle"))
|
||||
return options[2];
|
||||
if (opt.includes("grp:win_space_toggle"))
|
||||
return options[3];
|
||||
return options[4];
|
||||
}
|
||||
onValueChanged: value => {
|
||||
const idx = options.indexOf(value);
|
||||
let opt = SettingsData.keyboardOptions.split(",").filter(o => !o.startsWith("grp:")).join(",");
|
||||
|
||||
let newGrp = "";
|
||||
switch (idx) {
|
||||
case 0:
|
||||
newGrp = "grp:alt_shift_toggle";
|
||||
break;
|
||||
case 1:
|
||||
newGrp = "grp:ctrl_shift_toggle";
|
||||
break;
|
||||
case 2:
|
||||
newGrp = "grp:caps_toggle";
|
||||
break;
|
||||
case 3:
|
||||
newGrp = "grp:win_space_toggle";
|
||||
break;
|
||||
}
|
||||
|
||||
if (newGrp)
|
||||
opt = opt ? opt + "," + newGrp : newGrp;
|
||||
SettingsData.set("keyboardOptions", opt);
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent?.width ?? 0
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("XKB Options")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Advanced comma-separated libxkbcommon options.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
text: SettingsData.keyboardOptions
|
||||
placeholderText: "compose:ralt,ctrl:nocaps"
|
||||
onTextEdited: SettingsData.set("keyboardOptions", text)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent?.width ?? 0
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Keyboard Variant")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
text: SettingsData.keyboardVariants
|
||||
placeholderText: "colemak"
|
||||
onTextEdited: SettingsData.set("keyboardVariants", text)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent?.width ?? 0
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Keyboard Model")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
text: SettingsData.keyboardModel
|
||||
placeholderText: "pc104"
|
||||
onTextEdited: SettingsData.set("keyboardModel", text)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent?.width ?? 0
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Keymap File Path")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Direct path to a .xkb keymap file. Overrides layouts/variants above.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
text: SettingsData.keyboardKeymapFile
|
||||
placeholderText: "~/.config/keymap.xkb"
|
||||
onTextEdited: SettingsData.set("keyboardKeymapFile", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
tags: ["keyboard", "repeat", "delay", "rate", "numlock", "behavior"]
|
||||
title: I18n.tr("Keyboard Behavior")
|
||||
settingKey: "keyboardBehaviorSettings"
|
||||
iconName: "settings"
|
||||
|
||||
SettingsButtonGroupRow {
|
||||
tags: ["keyboard", "track", "layout"]
|
||||
settingKey: "keyboardTrackLayout"
|
||||
text: I18n.tr("Remember Layout")
|
||||
description: I18n.tr("How layout changes are remembered across apps")
|
||||
model: [I18n.tr("Globally"), I18n.tr("Per Window")]
|
||||
currentIndex: SettingsData.keyboardTrackLayout === "window" ? 1 : 0
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (!selected)
|
||||
return;
|
||||
SettingsData.set("keyboardTrackLayout", index === 1 ? "window" : "global");
|
||||
}
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
tags: ["keyboard", "numlock", "startup"]
|
||||
settingKey: "keyboardNumlock"
|
||||
text: I18n.tr("Enable Num Lock")
|
||||
description: I18n.tr("Automatically turn on Num Lock at startup")
|
||||
checked: SettingsData.keyboardNumlock
|
||||
onToggled: checked => SettingsData.set("keyboardNumlock", checked)
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
tags: ["keyboard", "repeat", "delay", "speed"]
|
||||
settingKey: "keyboardRepeatDelay"
|
||||
text: I18n.tr("Repeat Delay")
|
||||
description: I18n.tr("Delay before characters start repeating")
|
||||
value: SettingsData.keyboardRepeatDelay || 600
|
||||
minimum: 100
|
||||
maximum: 2000
|
||||
step: 50
|
||||
defaultValue: 600
|
||||
unit: "ms"
|
||||
onSliderValueChanged: newValue => SettingsData.set("keyboardRepeatDelay", newValue)
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
tags: ["keyboard", "repeat", "rate", "speed"]
|
||||
settingKey: "keyboardRepeatRate"
|
||||
text: I18n.tr("Repeat Rate")
|
||||
description: I18n.tr("Characters per second while holding down key")
|
||||
value: SettingsData.keyboardRepeatRate || 25
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
step: 1
|
||||
defaultValue: 25
|
||||
unit: ""
|
||||
onSliderValueChanged: newValue => SettingsData.set("keyboardRepeatRate", newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import qs.Modals.FileBrowser
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Settings.Widgets
|
||||
import "../../Common/KeyUtils.js" as KeyUtils
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -518,6 +519,117 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "lockScreenSecurityKeyShortcutEnabled"
|
||||
tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "shortcut", "keybind", "authentication"]
|
||||
text: I18n.tr("Security key shortcut", "lock screen security key shortcut toggle")
|
||||
description: I18n.tr("Keyboard shortcut to start security key unlock", "lock screen security key shortcut setting")
|
||||
checked: SettingsData.lockScreenSecurityKeyShortcutEnabled
|
||||
visible: SettingsData.enableU2f && SettingsData.u2fMode === "or" && !root.lockU2fControlledByPrimary
|
||||
onToggled: checked => SettingsData.set("lockScreenSecurityKeyShortcutEnabled", checked)
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width - Theme.spacingM * 2
|
||||
x: Theme.spacingM
|
||||
spacing: Theme.spacingM
|
||||
visible: SettingsData.lockScreenSecurityKeyShortcutEnabled && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !root.lockU2fControlledByPrimary
|
||||
|
||||
Column {
|
||||
width: parent.width - securityKeyCapture.width - parent.spacing
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Key combination", "lock screen security key shortcut key combination setting")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: securityKeyCapture.captureError !== "" ? securityKeyCapture.captureError : I18n.tr("Press Ctrl+key to set. Esc cancels.", "lock screen security key shortcut key combination capture hint")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: securityKeyCapture.captureError !== "" ? Theme.warning : Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: securityKeyCapture
|
||||
width: 200
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
focus: capturing
|
||||
text: capturing ? I18n.tr("Press key...", "lock screen security key shortcut key combination capture prompt") : SettingsData.lockScreenSecurityKeyShortcut
|
||||
backgroundColor: capturing ? Theme.primaryContainer : Theme.surfaceContainer
|
||||
textColor: Theme.surfaceText
|
||||
|
||||
property bool capturing: false
|
||||
property string captureError: ""
|
||||
readonly property var reservedKeys: ["A", "E", "B", "F", "U", "K", "W", "H", "D"]
|
||||
|
||||
function startCapture() {
|
||||
captureError = "";
|
||||
capturing = true;
|
||||
securityKeyCapture.forceActiveFocus();
|
||||
}
|
||||
|
||||
function stopCapture() {
|
||||
capturing = false;
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (capturing)
|
||||
stopCapture();
|
||||
else
|
||||
startCapture();
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (!securityKeyCapture.capturing)
|
||||
return;
|
||||
|
||||
if (KeyUtils.isModifierKey(event.key))
|
||||
return;
|
||||
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
securityKeyCapture.stopCapture();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const mods = KeyUtils.modsFromEvent(event.modifiers);
|
||||
const hasCtrl = mods.includes("Ctrl");
|
||||
const hasAlt = mods.includes("Alt") || mods.includes("Super");
|
||||
if (!hasCtrl || hasAlt)
|
||||
return;
|
||||
|
||||
const hasShift = mods.includes("Shift");
|
||||
const key = KeyUtils.xkbKeyFromQtKey(event.key, !!(event.modifiers & Qt.KeypadModifier), hasShift);
|
||||
if (!key)
|
||||
return;
|
||||
|
||||
if (!KeyUtils.qtKeyFromName(key))
|
||||
return;
|
||||
|
||||
if (!hasShift && securityKeyCapture.reservedKeys.indexOf(key.toUpperCase()) !== -1) {
|
||||
securityKeyCapture.captureError = I18n.tr("Ctrl+%1 is used for password editing", "lock screen security key shortcut reserved key warning").arg(key.toUpperCase());
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
SettingsData.set("lockScreenSecurityKeyShortcut", KeyUtils.formatToken(mods, key));
|
||||
securityKeyCapture.captureError = "";
|
||||
securityKeyCapture.stopCapture();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
settingKey: "lockU2fPamPath"
|
||||
tags: ["lock", "screen", "pam", "u2f", "security", "key", "source", "service"]
|
||||
|
||||
@@ -82,6 +82,15 @@ Item {
|
||||
onToggled: checked => SettingsData.set("mediaUseAlbumArtAccent", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "appleMusicAnimatedArtEnabled"
|
||||
tags: ["apple", "animated", "video", "artwork", "cover"]
|
||||
text: I18n.tr("Apple Music animated covers")
|
||||
description: I18n.tr("Show looping video album artwork from Apple Music when available. Sends the playing artist and album name to Apple.")
|
||||
checked: SettingsData.appleMusicAnimatedArtEnabled
|
||||
onToggled: checked => SettingsData.set("appleMusicAnimatedArtEnabled", checked)
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
property var scrollOptsInternal: ["volume", "song", "nothing"]
|
||||
property var scrollOptsDisplay: [I18n.tr("Change Volume", "media scroll wheel option"), I18n.tr("Change Song", "media scroll wheel option"), I18n.tr("Nothing", "media scroll wheel option")]
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Settings.Widgets
|
||||
import "../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -12,93 +10,6 @@ Item {
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
property var inputIncludeStatus: ({
|
||||
"exists": false,
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
})
|
||||
property bool checkingInclude: false
|
||||
property bool fixingInclude: false
|
||||
|
||||
function getInputConfigPaths() {
|
||||
const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
|
||||
if (CompositorService.compositor !== "niri")
|
||||
return null;
|
||||
return {
|
||||
"configFile": configDir + "/niri/config.kdl",
|
||||
"layoutFile": configDir + "/niri/dms/input.kdl",
|
||||
"grepPattern": 'include.*"dms/input.kdl"',
|
||||
"includeLine": 'include "dms/input.kdl"'
|
||||
};
|
||||
}
|
||||
|
||||
function checkInputIncludeStatus() {
|
||||
if (CompositorService.compositor !== "niri") {
|
||||
inputIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
checkingInclude = true;
|
||||
Proc.runCommand("check-input-include", [Proc.dmsBin, "config", "resolve-include", "niri", "input.kdl"], (output, exitCode) => {
|
||||
checkingInclude = false;
|
||||
if (exitCode !== 0) {
|
||||
inputIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
inputIncludeStatus = JSON.parse(output.trim());
|
||||
} catch (e) {
|
||||
inputIncludeStatus = {
|
||||
"exists": false,
|
||||
"included": false,
|
||||
"configFormat": "",
|
||||
"readOnly": false
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fixInputInclude() {
|
||||
const paths = getInputConfigPaths();
|
||||
if (!paths)
|
||||
return;
|
||||
|
||||
fixingInclude = true;
|
||||
const unixTime = Math.floor(Date.now() / 1000);
|
||||
const backupFile = paths.configFile + ".backup" + unixTime;
|
||||
const script = ConfigIncludeResolve.buildRepairScript({
|
||||
configFile: paths.configFile,
|
||||
backupFile: backupFile,
|
||||
fragmentFile: paths.layoutFile,
|
||||
grepPattern: paths.grepPattern,
|
||||
includeLine: paths.includeLine
|
||||
});
|
||||
Proc.runCommand("fix-input-include", ["sh", "-c", script], (output, exitCode) => {
|
||||
fixingInclude = false;
|
||||
if (exitCode !== 0)
|
||||
return;
|
||||
checkInputIncludeStatus();
|
||||
SettingsData.updateCompositorInput();
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (CompositorService.isNiri) {
|
||||
checkInputIncludeStatus();
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
@@ -113,67 +24,8 @@ Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
StyledRect {
|
||||
id: warningBox
|
||||
NiriInputSetupBanner {
|
||||
width: parent.width
|
||||
height: warningContent.implicitHeight + Theme.spacingL * 2
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
readonly property bool showSetup: !root.inputIncludeStatus.included
|
||||
|
||||
color: showSetup ? Theme.withAlpha(Theme.primary, 0.15) : Theme.withAlpha(Theme.primary, 0)
|
||||
border.color: showSetup ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.primary, 0)
|
||||
border.width: 1
|
||||
visible: showSetup && !root.checkingInclude && CompositorService.isNiri
|
||||
|
||||
Row {
|
||||
id: warningContent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
name: "warning"
|
||||
size: Theme.iconSize
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - Theme.iconSize - (fixButton.visible ? fixButton.width + Theme.spacingM : 0) - Theme.spacingM
|
||||
spacing: Theme.spacingXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("First Time Setup")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.primary
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg("dms/input")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: fixButton
|
||||
visible: warningBox.showSetup
|
||||
text: root.fixingInclude ? I18n.tr("Setting up...") : I18n.tr("Setup")
|
||||
backgroundColor: Theme.primary
|
||||
textColor: Theme.primaryText
|
||||
enabled: !root.fixingInclude
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: root.fixInputInclude()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -212,12 +64,15 @@ Item {
|
||||
description: I18n.tr("Flat uses constant speed; Adaptive scales with movement speed")
|
||||
model: [I18n.tr("Default"), I18n.tr("Flat"), I18n.tr("Adaptive")]
|
||||
currentIndex: {
|
||||
if (SettingsData.mouseAccelProfile === "flat") return 1;
|
||||
if (SettingsData.mouseAccelProfile === "adaptive") return 2;
|
||||
if (SettingsData.mouseAccelProfile === "flat")
|
||||
return 1;
|
||||
if (SettingsData.mouseAccelProfile === "adaptive")
|
||||
return 2;
|
||||
return 0;
|
||||
}
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (!selected) return;
|
||||
if (!selected)
|
||||
return;
|
||||
const profiles = ["default", "flat", "adaptive"];
|
||||
SettingsData.set("mouseAccelProfile", profiles[index]);
|
||||
}
|
||||
@@ -252,12 +107,15 @@ Item {
|
||||
description: I18n.tr("Choose when to generate scrolling events")
|
||||
model: [I18n.tr("Default"), I18n.tr("No Scroll"), I18n.tr("On Button Down")]
|
||||
currentIndex: {
|
||||
if (SettingsData.mouseScrollMethod === "no-scroll") return 1;
|
||||
if (SettingsData.mouseScrollMethod === "on-button-down") return 2;
|
||||
if (SettingsData.mouseScrollMethod === "no-scroll")
|
||||
return 1;
|
||||
if (SettingsData.mouseScrollMethod === "on-button-down")
|
||||
return 2;
|
||||
return 0;
|
||||
}
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (!selected) return;
|
||||
if (!selected)
|
||||
return;
|
||||
const methods = ["default", "no-scroll", "on-button-down"];
|
||||
SettingsData.set("mouseScrollMethod", methods[index]);
|
||||
}
|
||||
@@ -327,12 +185,15 @@ Item {
|
||||
description: I18n.tr("Flat uses constant speed; Adaptive scales with movement speed")
|
||||
model: [I18n.tr("Default"), I18n.tr("Flat"), I18n.tr("Adaptive")]
|
||||
currentIndex: {
|
||||
if (SettingsData.touchpadAccelProfile === "flat") return 1;
|
||||
if (SettingsData.touchpadAccelProfile === "adaptive") return 2;
|
||||
if (SettingsData.touchpadAccelProfile === "flat")
|
||||
return 1;
|
||||
if (SettingsData.touchpadAccelProfile === "adaptive")
|
||||
return 2;
|
||||
return 0;
|
||||
}
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (!selected) return;
|
||||
if (!selected)
|
||||
return;
|
||||
const profiles = ["default", "flat", "adaptive"];
|
||||
SettingsData.set("touchpadAccelProfile", profiles[index]);
|
||||
}
|
||||
@@ -367,14 +228,19 @@ Item {
|
||||
description: I18n.tr("Choose when to generate scrolling events")
|
||||
model: [I18n.tr("Default"), I18n.tr("Two Finger"), I18n.tr("Edge"), I18n.tr("No Scroll"), I18n.tr("On Button Down")]
|
||||
currentIndex: {
|
||||
if (SettingsData.touchpadScrollMethod === "two-finger") return 1;
|
||||
if (SettingsData.touchpadScrollMethod === "edge") return 2;
|
||||
if (SettingsData.touchpadScrollMethod === "no-scroll") return 3;
|
||||
if (SettingsData.touchpadScrollMethod === "on-button-down") return 4;
|
||||
if (SettingsData.touchpadScrollMethod === "two-finger")
|
||||
return 1;
|
||||
if (SettingsData.touchpadScrollMethod === "edge")
|
||||
return 2;
|
||||
if (SettingsData.touchpadScrollMethod === "no-scroll")
|
||||
return 3;
|
||||
if (SettingsData.touchpadScrollMethod === "on-button-down")
|
||||
return 4;
|
||||
return 0;
|
||||
}
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (!selected) return;
|
||||
if (!selected)
|
||||
return;
|
||||
const methods = ["default", "two-finger", "edge", "no-scroll", "on-button-down"];
|
||||
SettingsData.set("touchpadScrollMethod", methods[index]);
|
||||
}
|
||||
|
||||
@@ -465,7 +465,6 @@ Item {
|
||||
iconName: "restart_alt"
|
||||
iconSize: 20
|
||||
visible: JSON.stringify(SettingsData.notificationRules) !== JSON.stringify(SettingsData.getDefaultNotificationRules())
|
||||
backgroundColor: Theme.surfaceContainer
|
||||
iconColor: Theme.surfaceVariantText
|
||||
onClicked: SettingsData.resetNotificationRules()
|
||||
},
|
||||
@@ -473,7 +472,6 @@ Item {
|
||||
buttonSize: 36
|
||||
iconName: "add"
|
||||
iconSize: 20
|
||||
backgroundColor: Theme.surfaceContainer
|
||||
iconColor: Theme.primary
|
||||
onClicked: {
|
||||
SettingsData.addNotificationRule();
|
||||
@@ -723,7 +721,7 @@ Item {
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Math.max(0, parent.width - parent.spacing - mutedAppLabel.width - unmuteBtn.width - deleteBtn.width - Theme.spacingS * 5)
|
||||
width: Math.max(0, parent.width - parent.spacing - mutedAppLabel.width - unmuteBtn.width - mutedDeleteBtn.width - Theme.spacingS * 5)
|
||||
height: 1
|
||||
}
|
||||
|
||||
@@ -736,7 +734,7 @@ Item {
|
||||
}
|
||||
|
||||
Item {
|
||||
id: deleteBtn
|
||||
id: mutedDeleteBtn
|
||||
width: 28
|
||||
height: 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -744,18 +742,18 @@ Item {
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: deleteArea.containsMouse ? Theme.withAlpha(Theme.error, 0.2) : Theme.withAlpha(Theme.error, 0)
|
||||
color: mutedDeleteArea.containsMouse ? Theme.withAlpha(Theme.error, 0.2) : Theme.withAlpha(Theme.error, 0)
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "delete"
|
||||
size: 18
|
||||
color: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText
|
||||
color: mutedDeleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: deleteArea
|
||||
id: mutedDeleteArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
@@ -7,10 +7,9 @@ import qs.Modals.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: root
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property var allPlugins: []
|
||||
property string searchQuery: ""
|
||||
property var filteredPlugins: []
|
||||
@@ -608,7 +607,6 @@ FloatingWindow {
|
||||
return Math.round(Math.min(maxHeight, Math.max(540, parentModal.height * 0.8)));
|
||||
return Math.min(maxHeight, 760);
|
||||
}
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: hide()
|
||||
@@ -838,10 +836,6 @@ FloatingWindow {
|
||||
anchors.top: headerArea.bottom
|
||||
anchors.topMargin: Theme.spacingM
|
||||
height: 48
|
||||
cornerRadius: Theme.cornerRadius
|
||||
backgroundColor: Theme.surfaceContainerHigh
|
||||
normalBorderColor: Theme.outlineMedium
|
||||
focusedBorderColor: Theme.primary
|
||||
leftIconName: "search"
|
||||
leftIconSize: Theme.iconSize
|
||||
leftIconColor: Theme.surfaceVariantText
|
||||
@@ -1102,7 +1096,7 @@ FloatingWindow {
|
||||
ClippingRectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius - 2
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
|
||||
CachingImage {
|
||||
id: cardPreview
|
||||
@@ -1362,7 +1356,7 @@ FloatingWindow {
|
||||
anchors.topMargin: Theme.spacingM
|
||||
anchors.bottom: parent.bottom
|
||||
z: 10
|
||||
color: Theme.surfaceContainer
|
||||
color: Theme.floatingWindowSurface
|
||||
opacity: root.detailPluginId !== "" ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
@@ -1555,7 +1549,7 @@ FloatingWindow {
|
||||
width: parent.width
|
||||
height: Math.round(width * 0.52)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
border.color: Theme.withAlpha(Theme.outline, 0.2)
|
||||
border.width: 1
|
||||
|
||||
@@ -1829,10 +1823,9 @@ FloatingWindow {
|
||||
id: thirdPartyConfirmLoader
|
||||
active: false
|
||||
|
||||
FloatingWindow {
|
||||
DankFloatingWindow {
|
||||
id: thirdPartyConfirmModal
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
parentWindow: root
|
||||
|
||||
function show() {
|
||||
@@ -1847,7 +1840,6 @@ FloatingWindow {
|
||||
title: I18n.tr("Third-Party Plugin Warning")
|
||||
implicitWidth: 500
|
||||
implicitHeight: 350
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
FocusScope {
|
||||
|
||||
@@ -328,6 +328,134 @@ FocusScope {
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: registriesColumn.implicitHeight + Theme.spacingL * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
border.width: 0
|
||||
visible: DMSService.dmsAvailable && DMSService.apiVersion >= 29
|
||||
|
||||
Column {
|
||||
id: registriesColumn
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Registries")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Sources for plugins and themes. Registries are git repositories with a plugins/ or themes/ directory.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: DMSService.registries
|
||||
|
||||
Item {
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
height: registryInfo.implicitHeight + Theme.spacingXS
|
||||
|
||||
Column {
|
||||
id: registryInfo
|
||||
anchors.left: parent.left
|
||||
anchors.right: removeRegistryBtn.left
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: modelData.name
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("official")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
visible: modelData.official
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.url
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
font.family: "monospace"
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: removeRegistryBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "delete"
|
||||
iconSize: 18
|
||||
visible: !modelData.official
|
||||
onClicked: DMSService.removeRegistry(modelData.name, response => {
|
||||
if (response.error)
|
||||
ToastService.showError(response.error);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankTextField {
|
||||
id: registryNameField
|
||||
width: 140
|
||||
placeholderText: I18n.tr("Name")
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: registryUrlField
|
||||
width: parent.width - 140 - addRegistryBtn.width - Theme.spacingM * 2
|
||||
placeholderText: "https://github.com/user/registry.git"
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: addRegistryBtn
|
||||
text: I18n.tr("Add")
|
||||
enabled: registryNameField.text.trim() !== "" && registryUrlField.text.trim() !== ""
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: DMSService.addRegistry(registryNameField.text.trim(), registryUrlField.text.trim(), response => {
|
||||
if (response.error) {
|
||||
ToastService.showError(response.error);
|
||||
return;
|
||||
}
|
||||
registryNameField.text = "";
|
||||
registryUrlField.text = "";
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: Math.max(200, availableColumn.implicitHeight + Theme.spacingL * 2)
|
||||
@@ -517,12 +645,18 @@ FocusScope {
|
||||
function onOperationError(error) {
|
||||
ToastService.showError(error);
|
||||
}
|
||||
function onDmsAvailableChanged() {
|
||||
if (DMSService.dmsAvailable && DMSService.apiVersion >= 29)
|
||||
DMSService.listRegistries();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
updateFilteredPlugins();
|
||||
if (DMSService.dmsAvailable && DMSService.apiVersion >= 8)
|
||||
DMSService.listInstalled();
|
||||
if (DMSService.dmsAvailable && DMSService.apiVersion >= 29)
|
||||
DMSService.listRegistries();
|
||||
if (PopoutService.pendingPluginInstall)
|
||||
Qt.callLater(showPluginBrowser);
|
||||
}
|
||||
|
||||
@@ -410,8 +410,8 @@ Item {
|
||||
settingKey: "powerMenuDefaultAction"
|
||||
tags: ["power", "menu", "default", "action", "reboot", "logout", "shutdown"]
|
||||
text: I18n.tr("Default selected action")
|
||||
options: [I18n.tr("Reboot"), I18n.tr("Log Out"), I18n.tr("Power Off"), I18n.tr("Lock"), I18n.tr("Suspend"), I18n.tr("Restart DMS"), I18n.tr("Hibernate")]
|
||||
property var actionValues: ["reboot", "logout", "poweroff", "lock", "suspend", "restart", "hibernate"]
|
||||
options: [I18n.tr("Reboot"), I18n.tr("Log Out"), I18n.tr("Power Off"), I18n.tr("Lock"), I18n.tr("Suspend"), I18n.tr("Restart DMS"), I18n.tr("Hibernate"), I18n.tr("Soft Reboot")]
|
||||
property var actionValues: ["reboot", "logout", "poweroff", "lock", "suspend", "restart", "hibernate", "softreboot"]
|
||||
|
||||
Component.onCompleted: {
|
||||
const currentAction = SettingsData.powerMenuDefaultAction || "logout";
|
||||
@@ -475,6 +475,12 @@ Item {
|
||||
label: I18n.tr("Show Hibernate"),
|
||||
desc: I18n.tr("Only visible if hibernate is supported by your system"),
|
||||
hibernate: true
|
||||
},
|
||||
{
|
||||
key: "softreboot",
|
||||
label: I18n.tr("Show Soft Reboot"),
|
||||
desc: I18n.tr("Restart userspace without rebooting the kernel, requires systemd"),
|
||||
softreboot: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -484,7 +490,13 @@ Item {
|
||||
tags: ["power", "menu", "action", "show", modelData.key]
|
||||
text: modelData.label
|
||||
description: modelData.desc || ""
|
||||
visible: !modelData.hibernate || SessionService.hibernateSupported
|
||||
visible: {
|
||||
if (modelData.hibernate)
|
||||
return SessionService.hibernateSupported;
|
||||
if (modelData.softreboot)
|
||||
return SessionService.softRebootSupported;
|
||||
return true;
|
||||
}
|
||||
checked: SettingsData.powerMenuActions.includes(modelData.key)
|
||||
onToggled: checked => {
|
||||
let actions = [...SettingsData.powerMenuActions];
|
||||
|
||||
@@ -32,7 +32,6 @@ Item {
|
||||
iconName: "restart_alt"
|
||||
iconSize: 20
|
||||
visible: JSON.stringify(SettingsData.appIdSubstitutions) !== JSON.stringify(SettingsData.getDefaultAppIdSubstitutions())
|
||||
backgroundColor: Theme.surfaceContainer
|
||||
iconColor: Theme.surfaceVariantText
|
||||
onClicked: SettingsData.resetAppIdSubstitutions()
|
||||
},
|
||||
@@ -40,7 +39,6 @@ Item {
|
||||
buttonSize: 36
|
||||
iconName: "add"
|
||||
iconSize: 20
|
||||
backgroundColor: Theme.surfaceContainer
|
||||
iconColor: Theme.primary
|
||||
onClicked: SettingsData.addAppIdSubstitution("", "", "exact")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user