mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-08 14:38:30 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edcad60786 |
@@ -14,12 +14,6 @@ git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.
|
||||
git submodule update --init
|
||||
```
|
||||
|
||||
To have `git pull` keep the submodule in sync automatically (moving it to the commit this repo points at, no separate `git submodule update` step), set:
|
||||
|
||||
```bash
|
||||
git config submodule.recurse true
|
||||
```
|
||||
|
||||
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -101,9 +101,7 @@ MPRIS player controls, calendar sync, weather widgets, and clipboard history wit
|
||||
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter).
|
||||
|
||||
**Plugin System**
|
||||
Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
|
||||
`~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and
|
||||
their exact Git commits, so the same plugins can be reproduced on another machine.
|
||||
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
||||
|
||||
## Supported Compositors
|
||||
|
||||
@@ -122,8 +120,6 @@ dms ipc call audio setvolume 50
|
||||
dms ipc call wallpaper set /path/to/image.jpg
|
||||
dms brightness list # List available displays
|
||||
dms plugins search # Browse plugin registry
|
||||
dms plugins lock # Refresh the portable plugin lockfile
|
||||
dms plugins restore ~/plugins.lock.json
|
||||
```
|
||||
|
||||
[Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
|
||||
- `dms run [-d]` - Start shell (optionally as daemon)
|
||||
- `dms restart` / `dms kill` - Manage running processes
|
||||
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
|
||||
- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles
|
||||
- `dms plugins [install|browse|search]` - Plugin management
|
||||
- `dms brightness [list|set]` - Control display/monitor brightness
|
||||
- `dms color pick` - Native color picker (see below)
|
||||
- `dms update` - Update DMS and dependencies (disabled in distro packages)
|
||||
|
||||
@@ -51,8 +51,6 @@ func init() {
|
||||
})
|
||||
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
|
||||
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
|
||||
pluginsLockCmd.Flags().StringP("output", "o", "", "Also write the lockfile to this path")
|
||||
pluginsRestoreCmd.Flags().Bool("prune", false, "Remove managed plugins that are not in the lockfile")
|
||||
}
|
||||
|
||||
var debugSrvCmd = &cobra.Command{
|
||||
@@ -180,36 +178,6 @@ var pluginsUpdateCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var pluginsLockCmd = &cobra.Command{
|
||||
Use: "lock",
|
||||
Short: "Record installed plugins and their exact revisions",
|
||||
Long: "Write a portable plugins.lock.json containing every managed user plugin and its current Git commit.",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if err := lockPluginsCLI(output); err != nil {
|
||||
log.Fatalf("Error writing plugin lockfile: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var pluginsRestoreCmd = &cobra.Command{
|
||||
Use: "restore [lockfile]",
|
||||
Short: "Restore plugins from exact revisions in a lockfile",
|
||||
Long: "Install or reset all plugins in a portable plugins.lock.json. Existing managed plugins are retained unless --prune is specified.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
path := ""
|
||||
if len(args) == 1 {
|
||||
path = args[0]
|
||||
}
|
||||
prune, _ := cmd.Flags().GetBool("prune")
|
||||
if err := restorePluginsCLI(path, prune); err != nil {
|
||||
log.Fatalf("Error restoring plugins: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func runVersion(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("%s\n", formatVersion(Version))
|
||||
}
|
||||
@@ -465,40 +433,6 @@ func installPluginCLI(idOrName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockPluginsCLI(outputPath string) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
warnings, err := manager.WriteCurrentLockfile(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
|
||||
}
|
||||
fmt.Printf("Plugin lockfile written: %s\n", manager.GetLockfilePath())
|
||||
if outputPath != "" && outputPath != manager.GetLockfilePath() {
|
||||
fmt.Printf("Plugin lockfile exported: %s\n", outputPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restorePluginsCLI(path string, prune bool) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
if err := manager.RestoreFromLockfile(path, prune); err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
path = manager.GetLockfilePath()
|
||||
}
|
||||
fmt.Printf("Plugins restored from lockfile: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAvailablePluginIDs() []string {
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
@@ -770,7 +704,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
ipcCmd,
|
||||
debugSrvCmd,
|
||||
pluginsCmd,
|
||||
registryCmd,
|
||||
dank16Cmd,
|
||||
brightnessCmd,
|
||||
dpmsCmd,
|
||||
|
||||
@@ -40,7 +40,6 @@ var resolveIncludeCmd = &cobra.Command{
|
||||
"layout.kdl",
|
||||
"outputs.kdl",
|
||||
"binds.kdl",
|
||||
"input.kdl",
|
||||
"cursor.conf",
|
||||
"layout.conf",
|
||||
"outputs.conf",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var registryCmd = &cobra.Command{
|
||||
Use: "registry",
|
||||
Short: "Manage plugin and theme registries",
|
||||
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
|
||||
}
|
||||
|
||||
var registryListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured registries",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
for _, s := range registries.Load(afero.NewOsFs()) {
|
||||
suffix := ""
|
||||
if s.Official() {
|
||||
suffix = " (official)"
|
||||
}
|
||||
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var registryAddCmd = &cobra.Command{
|
||||
Use: "add <name> <url>",
|
||||
Short: "Add a registry",
|
||||
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
|
||||
log.Fatalf("Error adding registry: %v", err)
|
||||
}
|
||||
fmt.Printf("Registry added: %s\n", args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var registryRemoveCmd = &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a registry",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) != 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
var names []string
|
||||
for _, s := range registries.Load(afero.NewOsFs()) {
|
||||
if !s.Official() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
|
||||
log.Fatalf("Error removing registry: %v", err)
|
||||
}
|
||||
fmt.Printf("Registry removed: %s\n", args[0])
|
||||
},
|
||||
}
|
||||
@@ -143,10 +143,6 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||
niriFile: "alttab.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
||||
},
|
||||
"input": {
|
||||
niriFile: "input.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriInputConfig },
|
||||
},
|
||||
"outputs": {
|
||||
niriFile: "outputs.kdl",
|
||||
hyprFile: "outputs.lua",
|
||||
|
||||
@@ -15,8 +15,7 @@ func init() {
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
||||
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
@@ -14,7 +14,7 @@ var Version = "dev"
|
||||
func init() {
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b h1:UwX1H4BkzazL7ips9ljnHzBXGttYARcIi5Njj9aqIt4=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b/go.mod h1:xt8RldAfti0QCWidwYIzsSSoJWsE61WgEhTu2H9UpD4=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599 h1:hxy5SRGREsQ6v4xUOxWnWVPWD6ZU+H0jA+LHcTwXbCg=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599/go.mod h1:7p7cfydr4WM1G6eOPFlANXF3IV5du3FoA4CbDPprHAo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
|
||||
@@ -264,7 +264,6 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
|
||||
{"layout.kdl", NiriLayoutConfig},
|
||||
{"alttab.kdl", NiriAlttabConfig},
|
||||
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
||||
{"input.kdl", NiriInputConfig},
|
||||
{"outputs.kdl", ""},
|
||||
{"cursor.kdl", ""},
|
||||
{"windowrules.kdl", ""},
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// ! DO NOT EDIT !
|
||||
// ! AUTO-GENERATED BY DMS !
|
||||
// ! CHANGES WILL BE OVERWRITTEN !
|
||||
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
|
||||
|
||||
input {
|
||||
mouse {
|
||||
accel-speed 0.0
|
||||
}
|
||||
touchpad {
|
||||
tap
|
||||
accel-speed 0.0
|
||||
natural-scroll
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,31 @@ input {
|
||||
numlock
|
||||
}
|
||||
|
||||
// Next sections include libinput settings.
|
||||
// Omitting settings disables them, or leaves them at their default values.
|
||||
// All commented-out settings here are examples, not defaults.
|
||||
touchpad {
|
||||
// off
|
||||
tap
|
||||
// dwt
|
||||
// dwtp
|
||||
// drag false
|
||||
// drag-lock
|
||||
natural-scroll
|
||||
// accel-speed 0.2
|
||||
// accel-profile "flat"
|
||||
// scroll-method "two-finger"
|
||||
// disabled-on-external-mouse
|
||||
}
|
||||
|
||||
mouse {
|
||||
// off
|
||||
// natural-scroll
|
||||
// accel-speed 0.2
|
||||
// accel-profile "flat"
|
||||
// scroll-method "no-scroll"
|
||||
}
|
||||
|
||||
trackpoint {
|
||||
// off
|
||||
// natural-scroll
|
||||
@@ -52,7 +77,6 @@ input {
|
||||
// Setting max-scroll-amount="0%" makes it work only on windows already fully on screen.
|
||||
// focus-follows-mouse max-scroll-amount="0%"
|
||||
}
|
||||
|
||||
// You can configure outputs by their name, which you can find
|
||||
// by running `niri msg outputs` while inside a niri instance.
|
||||
// The built-in laptop monitor is usually called "eDP-1".
|
||||
@@ -247,4 +271,3 @@ include optional=true "dms/alttab.kdl"
|
||||
include optional=true "dms/binds.kdl"
|
||||
include optional=true "dms/outputs.kdl"
|
||||
include optional=true "dms/cursor.kdl"
|
||||
include optional=true "dms/input.kdl"
|
||||
|
||||
@@ -16,6 +16,3 @@ var NiriAlttabConfig string
|
||||
|
||||
//go:embed embedded/niri-binds.kdl
|
||||
var NiriBindsConfig string
|
||||
|
||||
//go:embed embedded/niri-input.kdl
|
||||
var NiriInputConfig string
|
||||
|
||||
@@ -313,7 +313,6 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
|
||||
fg := HexToRGB(hexColor)
|
||||
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
||||
Lf, af, bf := cf.Lab()
|
||||
Lf *= 100.0
|
||||
|
||||
dir := 1.0
|
||||
if isLightMode {
|
||||
@@ -342,7 +341,6 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
|
||||
fg := HexToRGB(hexColor)
|
||||
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
||||
origL, af, bf := cf.Lab()
|
||||
origL *= 100.0
|
||||
|
||||
var darkerResult, lighterResult string
|
||||
darkerL, lighterL := origL, origL
|
||||
@@ -422,24 +420,6 @@ func blendHue(base, target, factor float64) float64 {
|
||||
return result
|
||||
}
|
||||
|
||||
// color8 sits a fixed L* offset from the background so it keeps its dim role
|
||||
// regardless of primary brightness (conventional palettes put ANSI bright
|
||||
// black ~2-2.5:1 from the background, e.g. catppuccin-mocha #585b70 on #1e1e2e)
|
||||
func DeriveDim(bgHex string, hue, sat float64, isLight bool) string {
|
||||
offset := 22.0
|
||||
if isLight {
|
||||
offset = -offset
|
||||
}
|
||||
|
||||
bgL := getLstar(bgHex)
|
||||
targetL := math.Max(0, math.Min(100, bgL+offset))
|
||||
|
||||
tint := HSVToRGB(HSV{H: hue, S: sat, V: 0.5})
|
||||
c := colorful.Color{R: tint.R, G: tint.G, B: tint.B}
|
||||
_, af, bf := c.Lab()
|
||||
return labToHex(targetL, af, bf)
|
||||
}
|
||||
|
||||
func DeriveContainer(primary string, isLight bool) string {
|
||||
rgb := HexToRGB(primary)
|
||||
hsv := RGBToHSV(rgb)
|
||||
@@ -520,7 +500,10 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
gray7V := baseVal * 0.28
|
||||
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
|
||||
|
||||
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
|
||||
gray8S := baseSat * 0.05
|
||||
gray8V := baseVal * 0.85
|
||||
dimTarget := secondaryTarget * 0.5
|
||||
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
|
||||
|
||||
brightRedS := math.Min(baseSat*1.0, 1.0)
|
||||
brightRedV := math.Min(baseVal*1.2, 1.0)
|
||||
@@ -576,7 +559,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
gray7V := math.Min(baseVal*1.05, 1.0)
|
||||
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
|
||||
|
||||
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
|
||||
gray8S := baseSat * 0.15
|
||||
gray8V := baseVal * 0.65
|
||||
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
|
||||
|
||||
brightRedS := math.Min(baseSat*0.75, 1.0)
|
||||
brightRedV := math.Min(baseVal*1.35, 1.0)
|
||||
|
||||
@@ -679,73 +679,3 @@ func TestContrastAlgorithmComparison(t *testing.T) {
|
||||
|
||||
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
|
||||
}
|
||||
|
||||
func TestEnsureContrastDPSLightModeStaysLight(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result string
|
||||
bg string
|
||||
minLstar float64
|
||||
}{
|
||||
{
|
||||
name: "bidirectional adjustment",
|
||||
result: EnsureContrastDPSBidirectional("#d0ccc6", "#f8f8f8", 17.5, true),
|
||||
bg: "#f8f8f8",
|
||||
minLstar: 20.0,
|
||||
},
|
||||
{
|
||||
name: "lstar adjustment",
|
||||
result: EnsureContrastDPSLstar("#c0c0c0", "#f8f8f8", 30.0, true),
|
||||
bg: "#f8f8f8",
|
||||
minLstar: 20.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lstar := getLstar(tt.result)
|
||||
if lstar < tt.minLstar {
|
||||
t.Errorf("result %s has L* %.2f on light bg %s, expected >= %.2f (collapsed to near-black)",
|
||||
tt.result, lstar, tt.bg, tt.minLstar)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteColor8Dim(t *testing.T) {
|
||||
hues := []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#ef4444"}
|
||||
|
||||
for _, base := range hues {
|
||||
t.Run(base, func(t *testing.T) {
|
||||
palette := GeneratePalette(base, PaletteOptions{IsLight: false, UseDPS: true})
|
||||
|
||||
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
|
||||
if bgRatio < 1.5 || bgRatio > 3.0 {
|
||||
t.Errorf("Color8 %s vs bg %s ratio %.2f, expected 1.5-3.0 (bright black stays near bg)",
|
||||
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
|
||||
}
|
||||
|
||||
sepRatio := ContrastRatio(palette.Color4.Hex, palette.Color8.Hex)
|
||||
if sepRatio < 2.0 {
|
||||
t.Errorf("Color4 %s vs Color8 %s ratio %.2f, expected >= 2.0 (blue must not collide with bright black)",
|
||||
palette.Color4.Hex, palette.Color8.Hex, sepRatio)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
|
||||
palette := GeneratePalette("#f59e0b", PaletteOptions{IsLight: true, UseDPS: true})
|
||||
|
||||
lstar := getLstar(palette.Color8.Hex)
|
||||
if lstar < 60.0 {
|
||||
t.Errorf("light mode Color8 %s has L* %.2f, expected >= 60 (dim grey, not near-black)",
|
||||
palette.Color8.Hex, lstar)
|
||||
}
|
||||
|
||||
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
|
||||
if bgRatio > 3.0 {
|
||||
t.Errorf("light mode Color8 %s vs bg %s ratio %.2f, expected <= 3.0",
|
||||
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +53,6 @@ func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDi
|
||||
}
|
||||
}
|
||||
|
||||
func emergeInstallArgs(packages []string) []string {
|
||||
args := []string{"emerge", "--ask=n", "--quiet", "--autounmask-continue=y", "--autounmask-keep-keywords=y", "--autounmask-keep-masks=y"}
|
||||
return append(args, packages...)
|
||||
}
|
||||
|
||||
func (g *GentooDistribution) getArchKeyword() string {
|
||||
arch := runtime.GOARCH
|
||||
switch arch {
|
||||
@@ -324,7 +319,6 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
}
|
||||
g.log("Portage tree synced successfully")
|
||||
|
||||
args := emergeInstallArgs(missingPkgs)
|
||||
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhasePrerequisites,
|
||||
@@ -332,10 +326,12 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
|
||||
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
|
||||
}
|
||||
|
||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||
args = append(args, missingPkgs...)
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -525,7 +521,8 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
|
||||
}
|
||||
}
|
||||
|
||||
args := emergeInstallArgs(packageNames)
|
||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||
args = append(args, packageNames...)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
@@ -716,7 +713,8 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
|
||||
guruPackages[i] = pkg + "::guru"
|
||||
}
|
||||
|
||||
args := emergeInstallArgs(guruPackages)
|
||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||
args = append(args, guruPackages...)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseAURPackages,
|
||||
|
||||
@@ -6,7 +6,37 @@ func NewClient() Client {
|
||||
geoclueClient, err := newGeoClueClient()
|
||||
if err != nil {
|
||||
log.Warnf("GeoClue2 unavailable: %v", err)
|
||||
return newIpClient()
|
||||
return newSeededIpClient()
|
||||
}
|
||||
|
||||
loc, _ := geoclueClient.GetLocation()
|
||||
if loc.Latitude != 0 || loc.Longitude != 0 {
|
||||
log.Info("Using GeoClue2 location")
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
log.Info("GeoClue2 has no fix yet, seeding with IP location")
|
||||
ipLoc, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
log.Warnf("IP location seed failed: %v", err)
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
log.Info("Seeded GeoClue2 with IP location")
|
||||
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
func newSeededIpClient() *IpClient {
|
||||
client := newIpClient()
|
||||
ipLoc, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
log.Warnf("IP location also failed: %v", err)
|
||||
return client
|
||||
}
|
||||
|
||||
log.Info("Using IP location")
|
||||
client.currLocation.Latitude = ipLoc.Latitude
|
||||
client.currLocation.Longitude = ipLoc.Longitude
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ const (
|
||||
type GeoClueClient struct {
|
||||
currLocation *Location
|
||||
locationMutex sync.RWMutex
|
||||
seedOnce sync.Once
|
||||
|
||||
dbusConn *dbus.Conn
|
||||
clientPath dbus.ObjectPath
|
||||
@@ -231,29 +230,14 @@ func (c *GeoClueClient) SeedLocation(loc Location) {
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) GetLocation() (Location, error) {
|
||||
loc := c.currentLocation()
|
||||
if loc.Latitude != 0 || loc.Longitude != 0 {
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
c.seedOnce.Do(func() {
|
||||
ipLoc, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
log.Warnf("GeoClue2 has no fix, IP location seed failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("Seeded GeoClue2 with IP location")
|
||||
c.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
|
||||
})
|
||||
|
||||
return c.currentLocation(), nil
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) currentLocation() Location {
|
||||
c.locationMutex.RLock()
|
||||
defer c.locationMutex.RUnlock()
|
||||
if c.currLocation == nil {
|
||||
return Location{}
|
||||
return Location{
|
||||
Latitude: 0.0,
|
||||
Longitude: 0.0,
|
||||
}, nil
|
||||
}
|
||||
return *c.currLocation
|
||||
stateCopy := *c.currLocation
|
||||
return stateCopy, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -225,44 +224,6 @@ func (h *HyprlandProvider) validateAction(action string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var luaExprActionPattern = regexp.MustCompile(`^(function\s*\(|hl\.)`)
|
||||
|
||||
// isRawLuaActionText reports that action is a Lua expression to re-emit
|
||||
// verbatim rather than freeform dispatcher text to wrap for hyprctl. The
|
||||
// balance check keeps malformed input from corrupting the generated file.
|
||||
func isRawLuaActionText(action string) bool {
|
||||
if !luaExprActionPattern.MatchString(action) {
|
||||
return false
|
||||
}
|
||||
depth := 0
|
||||
var quote byte
|
||||
escaped := false
|
||||
for i := 0; i < len(action); i++ {
|
||||
c := action[i]
|
||||
switch {
|
||||
case escaped:
|
||||
escaped = false
|
||||
case quote != 0:
|
||||
switch c {
|
||||
case '\\':
|
||||
escaped = true
|
||||
case quote:
|
||||
quote = 0
|
||||
}
|
||||
case c == '"' || c == '\'':
|
||||
quote = c
|
||||
case c == '(':
|
||||
depth++
|
||||
case c == ')':
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return depth == 0 && quote == 0 && !escaped
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
|
||||
if err := h.ensureWritableConfig(); err != nil {
|
||||
return err
|
||||
@@ -293,12 +254,11 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
|
||||
canonicalKey := canonicalHyprlandOverrideKey(key)
|
||||
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
|
||||
existingBinds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: canonicalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
Options: options,
|
||||
RawLuaAction: isRawLuaActionText(action),
|
||||
Key: canonicalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
Options: options,
|
||||
}
|
||||
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
|
||||
@@ -463,56 +463,6 @@ func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRawLuaActionText(t *testing.T) {
|
||||
cases := []struct {
|
||||
action string
|
||||
want bool
|
||||
}{
|
||||
{`function() hl.plugin.scrolloverview.overview("toggle") end`, true},
|
||||
{`hl.dsp.exec_cmd("foo")`, true},
|
||||
{`hl.dsp.no_op()`, true},
|
||||
{"workspace 3", false},
|
||||
{"exec zeditor", false},
|
||||
{`function() hl.foo( end`, false},
|
||||
{`function() hl.foo("a) end`, false},
|
||||
{`hl.foo()) hl.bar((`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isRawLuaActionText(tc.action); got != tc.want {
|
||||
t.Errorf("isRawLuaActionText(%q) = %v, want %v", tc.action, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte("-- DMS user keybind overrides\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := NewHyprlandProvider(tmpDir)
|
||||
rawAction := `function() hl.plugin.scrolloverview.overview("toggle") end`
|
||||
if err := provider.SetBind("SUPER + G", rawAction, "Toggle overview", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if !strings.Contains(got, `hl.bind("SUPER + G", `+rawAction) {
|
||||
t.Fatalf("expected raw Lua action to be written verbatim, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "hyprctl dispatch function") {
|
||||
t.Fatalf("expected raw Lua action to not be wrapped in hyprctl dispatch, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
|
||||
@@ -386,8 +386,8 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
default:
|
||||
syncAccentColor(primaryDark)
|
||||
}
|
||||
refreshGTKTheme(opts.Mode)
|
||||
refreshGTKColorScheme()
|
||||
refreshGTK(opts.Mode)
|
||||
refreshGTK4()
|
||||
}
|
||||
|
||||
if isDMSKDEColorSchemeActive(opts.ConfigDir) {
|
||||
@@ -471,9 +471,12 @@ output_path = '%s'
|
||||
case TemplateKindTerminal:
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
case TemplateKindVSCode:
|
||||
for _, editor := range vscodeEditors {
|
||||
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
|
||||
}
|
||||
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
||||
case TemplateKindEmacs:
|
||||
if utils.EmacsConfigDir() != "" {
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||
@@ -630,23 +633,6 @@ func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []stri
|
||||
return false
|
||||
}
|
||||
|
||||
type vscodeEditor struct {
|
||||
name string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var vscodeEditors = []vscodeEditor{
|
||||
{"vscode", ".vscode"},
|
||||
{"codium", ".vscode-oss"},
|
||||
{"cursor", ".cursor"},
|
||||
{"windsurf", ".windsurf"},
|
||||
{"vscode-insiders", ".vscode-insiders"},
|
||||
}
|
||||
|
||||
func (e vscodeEditor) extensionsDir(homeDir string) string {
|
||||
return filepath.Join(homeDir, e.dataDir, "extensions")
|
||||
}
|
||||
|
||||
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
|
||||
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
@@ -925,7 +911,7 @@ func generateDank16Variants(primaryDark, primaryLight, surface string, mode Colo
|
||||
}
|
||||
|
||||
func isDMSGTKActive(configDir string) bool {
|
||||
gtkCSS := filepath.Join(configDir, "gtk-4.0", "gtk.css")
|
||||
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
|
||||
|
||||
info, err := os.Lstat(gtkCSS)
|
||||
if err != nil {
|
||||
@@ -982,32 +968,11 @@ func applyKDEColorScheme(mode ColorMode) {
|
||||
}
|
||||
}
|
||||
|
||||
func gtkThemeInstalled(theme string) bool {
|
||||
home, _ := os.UserHomeDir()
|
||||
candidates := []string{
|
||||
filepath.Join(home, ".local/share/themes", theme),
|
||||
filepath.Join(home, ".themes", theme),
|
||||
filepath.Join("/usr/share/themes", theme),
|
||||
filepath.Join("/usr/local/share/themes", theme),
|
||||
}
|
||||
for _, dir := range candidates {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func refreshGTKTheme(mode ColorMode) {
|
||||
theme := mode.GTKTheme()
|
||||
if !gtkThemeInstalled(theme) {
|
||||
log.Infof("Skipping gtk-theme refresh: %s is not installed", theme)
|
||||
return
|
||||
}
|
||||
func refreshGTK(mode ColorMode) {
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil {
|
||||
log.Warnf("Failed to reset gtk-theme: %v", err)
|
||||
}
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", theme); err != nil {
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil {
|
||||
log.Warnf("Failed to set gtk-theme: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1028,8 +993,8 @@ func expectColorSchemeEcho(scheme string) {
|
||||
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
|
||||
// following the portal color-scheme (Chromium) can drop the restore signal
|
||||
// mid-repaint and latch the wrong mode, so this is opt-in.
|
||||
func refreshGTKColorScheme() {
|
||||
if os.Getenv("DMS_ENABLE_GTK_REFRESH") != "1" {
|
||||
func refreshGTK4() {
|
||||
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
|
||||
return
|
||||
}
|
||||
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||
@@ -1047,13 +1012,13 @@ func refreshGTKColorScheme() {
|
||||
|
||||
expectColorSchemeEcho(toggle)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
|
||||
log.Warnf("Failed to toggle color-scheme for GTK refresh: %v", err)
|
||||
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
expectColorSchemeEcho(current)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
|
||||
log.Warnf("Failed to restore color-scheme for GTK refresh: %v", err)
|
||||
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,8 +1147,16 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
||||
}
|
||||
|
||||
func checkVSCodeExtension(homeDir string) bool {
|
||||
for _, editor := range vscodeEditors {
|
||||
pattern := filepath.Join(editor.extensionsDir(homeDir), "danklinux.dms-theme-*")
|
||||
extDirs := []string{
|
||||
filepath.Join(homeDir, ".vscode/extensions"),
|
||||
filepath.Join(homeDir, ".vscode-oss/extensions"),
|
||||
filepath.Join(homeDir, ".config/Code - OSS/extensions"),
|
||||
filepath.Join(homeDir, ".cursor/extensions"),
|
||||
filepath.Join(homeDir, ".windsurf/extensions"),
|
||||
}
|
||||
|
||||
for _, extDir := range extDirs {
|
||||
pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
|
||||
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const pluginLockfileVersion = 1
|
||||
|
||||
var gitCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
|
||||
|
||||
var scpLikeRepoPattern = regexp.MustCompile(`^[^@:/\s]+@[^@:/\s]+:\S+$`)
|
||||
|
||||
type PluginLockfile struct {
|
||||
LockfileVersion int `json:"lockfileVersion"`
|
||||
Plugins map[string]LockedPlugin `json:"plugins"`
|
||||
}
|
||||
|
||||
type LockedPlugin struct {
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
type LockStore struct {
|
||||
fs afero.Fs
|
||||
path string
|
||||
}
|
||||
|
||||
func NewLockStore(fs afero.Fs, path string) *LockStore {
|
||||
return &LockStore{fs: fs, path: path}
|
||||
}
|
||||
|
||||
func NewPluginLockfile() PluginLockfile {
|
||||
return PluginLockfile{
|
||||
LockfileVersion: pluginLockfileVersion,
|
||||
Plugins: make(map[string]LockedPlugin),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LockStore) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *LockStore) Exists() (bool, error) {
|
||||
return afero.Exists(s.fs, s.path)
|
||||
}
|
||||
|
||||
func (s *LockStore) Load() (PluginLockfile, error) {
|
||||
data, err := afero.ReadFile(s.fs, s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewPluginLockfile(), nil
|
||||
}
|
||||
return PluginLockfile{}, fmt.Errorf("failed to read plugin lockfile: %w", err)
|
||||
}
|
||||
|
||||
return ParsePluginLockfile(data)
|
||||
}
|
||||
|
||||
func ParsePluginLockfile(data []byte) (PluginLockfile, error) {
|
||||
var lock PluginLockfile
|
||||
if err := json.Unmarshal(data, &lock); err != nil {
|
||||
return PluginLockfile{}, fmt.Errorf("failed to parse plugin lockfile: %w", err)
|
||||
}
|
||||
if lock.Plugins == nil {
|
||||
lock.Plugins = make(map[string]LockedPlugin)
|
||||
}
|
||||
if err := lock.Validate(); err != nil {
|
||||
return PluginLockfile{}, err
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func (s *LockStore) Write(lock PluginLockfile) error {
|
||||
if err := lock.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(lock, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode plugin lockfile: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
if err := s.fs.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create plugin lockfile directory: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := s.path + ".tmp"
|
||||
if err := afero.WriteFile(s.fs, tmpPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write temporary plugin lockfile: %w", err)
|
||||
}
|
||||
defer s.fs.Remove(tmpPath) //nolint:errcheck
|
||||
|
||||
if err := s.fs.Rename(tmpPath, s.path); err != nil {
|
||||
return fmt.Errorf("failed to replace plugin lockfile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lock PluginLockfile) Validate() error {
|
||||
if lock.LockfileVersion != pluginLockfileVersion {
|
||||
return fmt.Errorf("unsupported plugin lockfile version %d (expected %d)", lock.LockfileVersion, pluginLockfileVersion)
|
||||
}
|
||||
|
||||
repoCommits := make(map[string]string)
|
||||
for id, plugin := range lock.Plugins {
|
||||
if !isSafePluginPathComponent(id) {
|
||||
return fmt.Errorf("invalid locked plugin id: %q", id)
|
||||
}
|
||||
if strings.TrimSpace(plugin.Repo) == "" {
|
||||
return fmt.Errorf("locked plugin %q has no repository", id)
|
||||
}
|
||||
if err := validatePluginRepo(plugin.Repo); err != nil {
|
||||
return fmt.Errorf("locked plugin %q: %w", id, err)
|
||||
}
|
||||
if !gitCommitPattern.MatchString(plugin.Commit) {
|
||||
return fmt.Errorf("locked plugin %q has invalid commit %q", id, plugin.Commit)
|
||||
}
|
||||
if err := validatePluginRepoPath(plugin.Path); err != nil {
|
||||
return fmt.Errorf("locked plugin %q: %w", id, err)
|
||||
}
|
||||
if commit, ok := repoCommits[plugin.Repo]; ok && !strings.EqualFold(commit, plugin.Commit) {
|
||||
return fmt.Errorf("plugins from repository %q must use the same commit", plugin.Repo)
|
||||
}
|
||||
repoCommits[plugin.Repo] = plugin.Commit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginRepo(repo string) error {
|
||||
parsed, err := url.Parse(repo)
|
||||
if err != nil {
|
||||
if scpLikeRepoPattern.MatchString(repo) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid repository URL %q", repo)
|
||||
}
|
||||
if parsed.User == nil {
|
||||
return nil
|
||||
}
|
||||
if _, hasPassword := parsed.User.Password(); hasPassword {
|
||||
return fmt.Errorf("repository URL must not contain credentials")
|
||||
}
|
||||
switch parsed.Scheme {
|
||||
case "http", "https":
|
||||
return fmt.Errorf("repository URL must not contain credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginRepoPath(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("invalid repository path %q", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lock PluginLockfile) IDs() []string {
|
||||
ids := make([]string, 0, len(lock.Plugins))
|
||||
for id := range lock.Plugins {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func (lock *PluginLockfile) SetRepositoryCommit(repo, commit string) {
|
||||
for id, plugin := range lock.Plugins {
|
||||
if plugin.Repo != repo {
|
||||
continue
|
||||
}
|
||||
plugin.Commit = commit
|
||||
lock.Plugins[id] = plugin
|
||||
}
|
||||
}
|
||||
|
||||
func (lock PluginLockfile) Clone() PluginLockfile {
|
||||
cloned := NewPluginLockfile()
|
||||
for id, plugin := range lock.Plugins {
|
||||
cloned.Plugins[id] = plugin
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testCommit = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
func TestPluginLockfileRoundTrip(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
store := NewLockStore(fs, "/config/plugins.lock.json")
|
||||
lock := NewPluginLockfile()
|
||||
lock.Plugins["weather"] = LockedPlugin{
|
||||
Repo: "https://github.com/example/plugins.git",
|
||||
Path: "plugins/weather",
|
||||
Commit: testCommit,
|
||||
}
|
||||
|
||||
require.NoError(t, store.Write(lock))
|
||||
loaded, err := store.Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, lock, loaded)
|
||||
|
||||
data, err := afero.ReadFile(fs, store.Path())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "{\n \"lockfileVersion\": 1,\n \"plugins\": {\n \"weather\": {\n \"repo\": \"https://github.com/example/plugins.git\",\n \"path\": \"plugins/weather\",\n \"commit\": \"0123456789abcdef0123456789abcdef01234567\"\n }\n }\n}\n", string(data))
|
||||
}
|
||||
|
||||
func TestPluginLockfileValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lock PluginLockfile
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "rejects unsupported version",
|
||||
lock: PluginLockfile{LockfileVersion: 2, Plugins: map[string]LockedPlugin{}},
|
||||
want: "unsupported plugin lockfile version",
|
||||
},
|
||||
{
|
||||
name: "rejects unsafe id",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"../bad": {Repo: "repo", Commit: testCommit}}},
|
||||
want: "invalid locked plugin id",
|
||||
},
|
||||
{
|
||||
name: "rejects unsafe repository path",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "repo", Path: "../bad", Commit: testCommit}}},
|
||||
want: "invalid repository path",
|
||||
},
|
||||
{
|
||||
name: "rejects abbreviated commit",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "repo", Commit: "abc123"}}},
|
||||
want: "invalid commit",
|
||||
},
|
||||
{
|
||||
name: "rejects repository credentials",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "https://token@example.com/plugin.git", Commit: testCommit}}},
|
||||
want: "must not contain credentials",
|
||||
},
|
||||
{
|
||||
name: "rejects ssh password",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "ssh://git:secret@example.com/plugin.git", Commit: testCommit}}},
|
||||
want: "must not contain credentials",
|
||||
},
|
||||
{
|
||||
name: "rejects conflicting monorepo commits",
|
||||
lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{
|
||||
"one": {Repo: "repo", Commit: testCommit},
|
||||
"two": {Repo: "repo", Commit: "1123456789abcdef0123456789abcdef01234567"},
|
||||
}},
|
||||
want: "must use the same commit",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.lock.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginLockfileAcceptsSSHRemotes(t *testing.T) {
|
||||
repos := []string{
|
||||
"https://github.com/user/plugin.git",
|
||||
"ssh://git@github.com/user/plugin.git",
|
||||
"git@github.com:user/plugin.git",
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
t.Run(repo, func(t *testing.T) {
|
||||
lock := PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{
|
||||
"plugin": {Repo: repo, Commit: testCommit},
|
||||
}}
|
||||
assert.NoError(t, lock.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingPluginLockfileIsEmpty(t *testing.T) {
|
||||
store := NewLockStore(afero.NewMemMapFs(), "/config/plugins.lock.json")
|
||||
lock, err := store.Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pluginLockfileVersion, lock.LockfileVersion)
|
||||
assert.Empty(t, lock.Plugins)
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
type Manager struct {
|
||||
fs afero.Fs
|
||||
pluginsDir string
|
||||
lockPath string
|
||||
gitClient GitClient
|
||||
}
|
||||
|
||||
@@ -29,7 +28,6 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
|
||||
return &Manager{
|
||||
fs: fs,
|
||||
pluginsDir: pluginsDir,
|
||||
lockPath: getPluginLockPath(),
|
||||
gitClient: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
@@ -43,15 +41,6 @@ func getPluginsDir() string {
|
||||
return filepath.Join(configDir, "DankMaterialShell", "plugins")
|
||||
}
|
||||
|
||||
func getPluginLockPath() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Error("failed to get user config dir", "err", err)
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "DankMaterialShell", "plugins.lock.json")
|
||||
}
|
||||
|
||||
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
|
||||
path, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
@@ -129,12 +118,7 @@ func (m *Manager) findInDir(dir, pluginID string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) Install(plugin Plugin) error {
|
||||
if !isSafePluginPathComponent(plugin.ID) {
|
||||
return fmt.Errorf("invalid plugin id: %q", plugin.ID)
|
||||
}
|
||||
|
||||
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
|
||||
repoPath := pluginPath
|
||||
|
||||
exists, err := afero.DirExists(m.fs, pluginPath)
|
||||
if err != nil {
|
||||
@@ -144,18 +128,6 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
if exists {
|
||||
return fmt.Errorf("plugin already installed: %s", plugin.Name)
|
||||
}
|
||||
if strings.TrimSpace(plugin.Repo) == "" {
|
||||
return fmt.Errorf("plugin repository is required")
|
||||
}
|
||||
if err := validatePluginRepo(plugin.Repo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePluginRepoPath(plugin.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.ensureLockfile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.fs.MkdirAll(m.pluginsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create plugins directory: %w", err)
|
||||
@@ -168,7 +140,7 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
|
||||
if plugin.Path != "" {
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath = filepath.Join(reposDir, repoName)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
repoExists, err := afero.DirExists(m.fs, repoPath)
|
||||
if err != nil {
|
||||
@@ -206,13 +178,12 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
if err := m.createSymlink(sourcePath, pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to create symlink: %w", err)
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
|
||||
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil {
|
||||
m.fs.Remove(pluginPath) //nolint:errcheck
|
||||
return fmt.Errorf("failed to write plugin repository metadata: %w", err)
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||
m.fs.RemoveAll(pluginPath) //nolint:errcheck
|
||||
@@ -220,16 +191,6 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
if plugin.Path != "" {
|
||||
m.fs.Remove(pluginPath) //nolint:errcheck
|
||||
m.fs.Remove(pluginPath + ".meta") //nolint:errcheck
|
||||
} else {
|
||||
m.fs.RemoveAll(pluginPath) //nolint:errcheck
|
||||
}
|
||||
return fmt.Errorf("failed to update plugin lockfile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -246,15 +207,6 @@ func (m *Manager) createSymlink(source, dest string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) Update(plugin Plugin) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -267,42 +219,46 @@ func (m *Manager) Update(plugin Plugin) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
|
||||
}
|
||||
if plugin.Repo == "" {
|
||||
plugin.Repo, err = m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugin origin: %w", err)
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
if metaExists {
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
|
||||
if err := m.gitClient.Pull(repoPath); err != nil {
|
||||
// Repository is likely corrupted or has issues, delete and re-clone
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted repository: %w", err)
|
||||
}
|
||||
|
||||
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone repository: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try to pull, if it fails, delete and re-clone
|
||||
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted plugin: %w", err)
|
||||
}
|
||||
|
||||
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone plugin: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := m.repositoryPath(plugin.ID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
||||
if err := m.gitClient.Pull(repoPath); err != nil {
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted plugin repository: %w", err)
|
||||
}
|
||||
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone plugin repository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
m.gitClient.CheckoutRevision(repoPath, locked.Commit) //nolint:errcheck
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Uninstall(plugin Plugin) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -315,64 +271,70 @@ func (m *Manager) Uninstall(plugin Plugin) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
|
||||
}
|
||||
updatedLock := lock.Clone()
|
||||
delete(updatedLock.Plugins, plugin.ID)
|
||||
if err := m.lockStore().Write(updatedLock); err != nil {
|
||||
return err
|
||||
}
|
||||
rollbackLock := func(err error) error {
|
||||
if rollbackErr := m.lockStore().Write(lock); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to restore plugin lockfile: %v)", err, rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
if plugin.Path != "" {
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
if metaExists {
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
shouldCleanup, err := m.shouldCleanupRepo(plugin.Repo, plugin.ID)
|
||||
shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID)
|
||||
if err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to check repo cleanup: %w", err))
|
||||
return fmt.Errorf("failed to check repo cleanup: %w", err)
|
||||
}
|
||||
|
||||
if err := m.fs.Remove(pluginPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to remove symlink: %w", err))
|
||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
||||
}
|
||||
|
||||
if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists {
|
||||
if err := m.fs.Remove(metaPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to remove metadata: %w", err))
|
||||
}
|
||||
if err := m.fs.Remove(metaPath); err != nil {
|
||||
return fmt.Errorf("failed to remove metadata: %w", err)
|
||||
}
|
||||
|
||||
if shouldCleanup {
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to cleanup repository: %w", err))
|
||||
return fmt.Errorf("failed to cleanup repository: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to remove plugin: %w", err))
|
||||
return fmt.Errorf("failed to remove plugin: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) shouldCleanupRepo(repoURL, excludePlugin string) (bool, error) {
|
||||
lock, err := m.ensureLockfile()
|
||||
func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
|
||||
installed, err := m.ListInstalled()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for id, plugin := range lock.Plugins {
|
||||
|
||||
registry, err := NewRegistry()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
allPlugins, err := registry.List()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, id := range installed {
|
||||
if id == excludePlugin {
|
||||
continue
|
||||
}
|
||||
if plugin.Repo == repoURL && plugin.Path != "" {
|
||||
return false, nil
|
||||
|
||||
for _, p := range allPlugins {
|
||||
if p.ID == id && p.Repo == repoURL && p.Path != "" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,16 +456,6 @@ func (m *Manager) UninstallByIDOrName(idOrName string) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot uninstall system plugin: %s", idOrName)
|
||||
}
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest != nil {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[manifest.ID]; ok {
|
||||
return m.Uninstall(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path})
|
||||
}
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, _ := afero.Exists(m.fs, metaPath)
|
||||
@@ -536,16 +488,6 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot update system plugin: %s", idOrName)
|
||||
}
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest != nil {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[manifest.ID]; ok {
|
||||
return m.Update(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path})
|
||||
}
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, _ := afero.Exists(m.fs, metaPath)
|
||||
@@ -560,14 +502,8 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
|
||||
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to update plugin: %w", err)
|
||||
}
|
||||
if manifest == nil {
|
||||
return nil
|
||||
}
|
||||
repo, err := m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugin origin: %w", err)
|
||||
}
|
||||
return m.recordInstalledPlugin(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: repo}, pluginPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
|
||||
@@ -636,15 +572,6 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if locked, ok := lock.Plugins[pluginID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(pluginID)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -658,11 +585,25 @@ func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, d
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
repoPath := pluginPath
|
||||
if plugin.Path != "" {
|
||||
repoPath = m.repositoryPath(pluginID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
var hasUp bool
|
||||
var localHash, remoteHash string
|
||||
if metaExists {
|
||||
// Plugin is from a monorepo, check the repo directory
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
|
||||
} else {
|
||||
// Plugin is a standalone repo
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
|
||||
}
|
||||
hasUp, localHash, remoteHash, err := m.gitClient.HasUpdates(repoPath)
|
||||
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func (m *Manager) lockStore() *LockStore {
|
||||
lockPath := m.lockPath
|
||||
if lockPath == "" {
|
||||
lockPath = filepath.Join(filepath.Dir(m.pluginsDir), "plugins.lock.json")
|
||||
}
|
||||
return NewLockStore(m.fs, lockPath)
|
||||
}
|
||||
|
||||
func (m *Manager) GetLockfilePath() string {
|
||||
return m.lockStore().Path()
|
||||
}
|
||||
|
||||
func (m *Manager) ensureLockfile() (PluginLockfile, error) {
|
||||
store := m.lockStore()
|
||||
exists, err := store.Exists()
|
||||
if err != nil {
|
||||
return PluginLockfile{}, fmt.Errorf("failed to check plugin lockfile: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return store.Load()
|
||||
}
|
||||
|
||||
lock, _, err := m.SnapshotLockfile()
|
||||
if err != nil {
|
||||
return PluginLockfile{}, err
|
||||
}
|
||||
if err := store.Write(lock); err != nil {
|
||||
return PluginLockfile{}, err
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SnapshotLockfile() (PluginLockfile, []string, error) {
|
||||
lock := NewPluginLockfile()
|
||||
warnings := []string{}
|
||||
|
||||
exists, err := afero.DirExists(m.fs, m.pluginsDir)
|
||||
if err != nil {
|
||||
return PluginLockfile{}, nil, fmt.Errorf("failed to check plugins directory: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return lock, warnings, nil
|
||||
}
|
||||
|
||||
entries, err := afero.ReadDir(m.fs, m.pluginsDir)
|
||||
if err != nil {
|
||||
return PluginLockfile{}, nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == ".repos" || strings.HasSuffix(name, ".meta") {
|
||||
continue
|
||||
}
|
||||
|
||||
pluginPath := filepath.Join(m.pluginsDir, name)
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest == nil || manifest.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
locked, repoPath, err := m.lockedPluginFromInstall(pluginPath)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: %v", manifest.ID, err))
|
||||
continue
|
||||
}
|
||||
commit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: cannot read git revision: %v", manifest.ID, err))
|
||||
continue
|
||||
}
|
||||
locked.Commit = commit
|
||||
lock.SetRepositoryCommit(locked.Repo, commit)
|
||||
lock.Plugins[manifest.ID] = locked
|
||||
}
|
||||
|
||||
if err := lock.Validate(); err != nil {
|
||||
return PluginLockfile{}, warnings, err
|
||||
}
|
||||
return lock, warnings, nil
|
||||
}
|
||||
|
||||
func (m *Manager) lockedPluginFromInstall(pluginPath string) (LockedPlugin, string, error) {
|
||||
metaPath := pluginPath + ".meta"
|
||||
if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists {
|
||||
data, err := afero.ReadFile(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return LockedPlugin{}, "", fmt.Errorf("failed to read legacy metadata: %w", err)
|
||||
}
|
||||
metadata := parseLegacyPluginMetadata(string(data))
|
||||
repo := metadata["repo"]
|
||||
path := metadata["path"]
|
||||
repoDir := metadata["repodir"]
|
||||
if repo == "" || repoDir == "" {
|
||||
return LockedPlugin{}, "", fmt.Errorf("legacy metadata is incomplete")
|
||||
}
|
||||
if err := validatePluginRepo(repo); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
if !isSafePluginPathComponent(repoDir) {
|
||||
return LockedPlugin{}, "", fmt.Errorf("legacy repository directory is invalid")
|
||||
}
|
||||
if err := validatePluginRepoPath(path); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
return LockedPlugin{Repo: repo, Path: path}, filepath.Join(m.pluginsDir, ".repos", repoDir), nil
|
||||
}
|
||||
|
||||
repo, err := m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return LockedPlugin{}, "", fmt.Errorf("plugin is not a managed git checkout")
|
||||
}
|
||||
if err := validatePluginRepo(repo); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
return LockedPlugin{Repo: repo}, pluginPath, nil
|
||||
}
|
||||
|
||||
func parseLegacyPluginMetadata(data string) map[string]string {
|
||||
metadata := make(map[string]string)
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if ok {
|
||||
metadata[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (m *Manager) recordInstalledPlugin(plugin Plugin, repoPath string) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read installed plugin revision: %w", err)
|
||||
}
|
||||
|
||||
lock.SetRepositoryCommit(plugin.Repo, commit)
|
||||
lock.Plugins[plugin.ID] = LockedPlugin{
|
||||
Repo: plugin.Repo,
|
||||
Path: plugin.Path,
|
||||
Commit: commit,
|
||||
}
|
||||
return m.lockStore().Write(lock)
|
||||
}
|
||||
|
||||
func (m *Manager) WriteCurrentLockfile(outputPath string) ([]string, error) {
|
||||
lock, warnings, err := m.SnapshotLockfile()
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
if err := m.lockStore().Write(lock); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
if outputPath != "" && outputPath != m.GetLockfilePath() {
|
||||
if err := NewLockStore(m.fs, outputPath).Write(lock); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (m *Manager) RestoreFromLockfile(sourcePath string, prune bool) error {
|
||||
if sourcePath == "" {
|
||||
sourcePath = m.GetLockfilePath()
|
||||
}
|
||||
sourceStore := NewLockStore(m.fs, sourcePath)
|
||||
exists, err := sourceStore.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check plugin lockfile: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("plugin lockfile not found: %s", sourcePath)
|
||||
}
|
||||
target, err := sourceStore.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range target.IDs() {
|
||||
wanted := target.Plugins[id]
|
||||
installedPath, err := m.findInstalledPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if installedPath != "" {
|
||||
existing, managed := current.Plugins[id]
|
||||
if !managed {
|
||||
return fmt.Errorf("plugin %q is already installed but is not managed by the lockfile", id)
|
||||
}
|
||||
if existing.Repo != wanted.Repo || existing.Path != wanted.Path {
|
||||
if err := m.UninstallByIDOrName(id); err != nil {
|
||||
return err
|
||||
}
|
||||
installedPath = ""
|
||||
}
|
||||
}
|
||||
|
||||
plugin := Plugin{ID: id, Name: id, Repo: wanted.Repo, Path: wanted.Path}
|
||||
newlyInstalled := installedPath == ""
|
||||
if installedPath == "" {
|
||||
if err := m.Install(plugin); err != nil {
|
||||
return fmt.Errorf("failed to restore plugin %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := m.repositoryPath(id, wanted)
|
||||
previousCommit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
if newlyInstalled {
|
||||
m.UninstallByIDOrName(id) //nolint:errcheck
|
||||
}
|
||||
return fmt.Errorf("failed to read current revision for plugin %q: %w", id, err)
|
||||
}
|
||||
if err := m.gitClient.CheckoutRevision(repoPath, wanted.Commit); err != nil {
|
||||
if newlyInstalled {
|
||||
if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil {
|
||||
return fmt.Errorf("failed to restore plugin %q at %s: %w (also failed to remove the partial install: %v)", id, wanted.Commit, err, rollbackErr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to restore plugin %q at %s: %w", id, wanted.Commit, err)
|
||||
}
|
||||
rollback := func(restoreErr error) error {
|
||||
if newlyInstalled {
|
||||
if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to remove the partial install: %v)", restoreErr, rollbackErr)
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
if rollbackErr := m.gitClient.CheckoutRevision(repoPath, previousCommit); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to restore revision %s: %v)", restoreErr, previousCommit, rollbackErr)
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
pluginPath := installedPath
|
||||
if pluginPath == "" {
|
||||
pluginPath = filepath.Join(m.pluginsDir, id)
|
||||
}
|
||||
if actualID := m.getPluginID(pluginPath); actualID != id {
|
||||
return rollback(fmt.Errorf("restored plugin %q has manifest id %q", id, actualID))
|
||||
}
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
current, err = m.lockStore().Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if prune {
|
||||
for _, id := range current.IDs() {
|
||||
if _, wanted := target.Plugins[id]; wanted {
|
||||
continue
|
||||
}
|
||||
if err := m.UninstallByIDOrName(id); err != nil {
|
||||
return fmt.Errorf("failed to prune plugin %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
return m.lockStore().Write(target)
|
||||
}
|
||||
|
||||
merged, err := m.lockStore().Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for id, plugin := range target.Plugins {
|
||||
merged.SetRepositoryCommit(plugin.Repo, plugin.Commit)
|
||||
merged.Plugins[id] = plugin
|
||||
}
|
||||
return m.lockStore().Write(merged)
|
||||
}
|
||||
|
||||
func (m *Manager) repositoryPath(pluginID string, plugin LockedPlugin) string {
|
||||
if plugin.Path != "" {
|
||||
return filepath.Join(m.pluginsDir, ".repos", m.getRepoName(plugin.Repo))
|
||||
}
|
||||
if installedPath, err := m.findInDir(m.pluginsDir, pluginID); err == nil && installedPath != "" {
|
||||
return installedPath
|
||||
}
|
||||
return filepath.Join(m.pluginsDir, pluginID)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -17,7 +16,6 @@ func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) {
|
||||
manager := &Manager{
|
||||
fs: fs,
|
||||
pluginsDir: pluginsDir,
|
||||
lockPath: "/config/plugins.lock.json",
|
||||
gitClient: &mockGitClient{},
|
||||
}
|
||||
return manager, fs, pluginsDir
|
||||
@@ -247,244 +245,3 @@ func TestManagerGetPluginsDir(t *testing.T) {
|
||||
manager, _, pluginsDir := setupTestManager(t)
|
||||
assert.Equal(t, pluginsDir, manager.GetPluginsDir())
|
||||
}
|
||||
|
||||
func TestInstallUpdatesLockfile(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Install(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, LockedPlugin{Repo: plugin.Repo, Commit: testCommit}, lock.Plugins[plugin.ID])
|
||||
exists, err := afero.Exists(fs, filepath.Join(pluginsDir, plugin.ID))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestUpdateRefreshesLockedCommit(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, plugin.ID)
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
newCommit := "1123456789abcdef0123456789abcdef01234567"
|
||||
manager.gitClient = &mockGitClient{
|
||||
pullFunc: func(string) error { return nil },
|
||||
revisionFunc: func(string) (string, error) { return newCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Update(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCommit, lock.Plugins[plugin.ID].Commit)
|
||||
}
|
||||
|
||||
func TestUpdateResolvesRenamedPluginDirectory(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, "renamed-dir")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
var pulledPath string
|
||||
manager.gitClient = &mockGitClient{
|
||||
pullFunc: func(path string) error {
|
||||
pulledPath = path
|
||||
return nil
|
||||
},
|
||||
cloneFunc: func(path, _ string) error {
|
||||
t.Fatalf("unexpected clone to %s", path)
|
||||
return nil
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Update(plugin))
|
||||
assert.Equal(t, pluginPath, pulledPath)
|
||||
}
|
||||
|
||||
func TestUninstallRemovesLockedPlugin(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, plugin.ID)
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
|
||||
require.NoError(t, manager.Uninstall(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, lock.Plugins, plugin.ID)
|
||||
}
|
||||
|
||||
func TestRestoreFromLockfileInstallsExactCommit(t *testing.T) {
|
||||
manager, fs, _ := setupTestManager(t)
|
||||
wantedCommit := "2123456789abcdef0123456789abcdef01234567"
|
||||
incoming := PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"restored": {Repo: "https://github.com/test/restored.git", Commit: wantedCommit},
|
||||
},
|
||||
}
|
||||
require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming))
|
||||
currentCommit := testCommit
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"restored"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return currentCommit, nil },
|
||||
checkoutFunc: func(_ string, revision string) error {
|
||||
currentCommit = revision
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, manager.RestoreFromLockfile("/incoming.lock.json", false))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantedCommit, lock.Plugins["restored"].Commit)
|
||||
}
|
||||
|
||||
func TestSnapshotLockfileMigratesLegacyMonorepoMetadata(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "legacy")
|
||||
repoPath := filepath.Join(pluginsDir, ".repos", "legacy-repo")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, fs.MkdirAll(repoPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"legacy"}`), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, pluginPath+".meta", []byte("repo=https://github.com/test/suite.git\npath=plugins/legacy\nrepodir=legacy-repo"), 0o644))
|
||||
manager.gitClient = &mockGitClient{
|
||||
revisionFunc: func(path string) (string, error) {
|
||||
assert.Equal(t, repoPath, path)
|
||||
return testCommit, nil
|
||||
},
|
||||
}
|
||||
|
||||
lock, warnings, err := manager.SnapshotLockfile()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, LockedPlugin{
|
||||
Repo: "https://github.com/test/suite.git",
|
||||
Path: "plugins/legacy",
|
||||
Commit: testCommit,
|
||||
}, lock.Plugins["legacy"])
|
||||
}
|
||||
|
||||
func TestRecordInstalledPluginUpdatesSharedRepositoryCommit(t *testing.T) {
|
||||
manager, _, _ := setupTestManager(t)
|
||||
repo := "https://github.com/test/suite.git"
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"one": {Repo: repo, Path: "plugins/one", Commit: testCommit},
|
||||
"two": {Repo: repo, Path: "plugins/two", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
newCommit := "3123456789abcdef0123456789abcdef01234567"
|
||||
manager.gitClient = &mockGitClient{
|
||||
revisionFunc: func(string) (string, error) { return newCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.recordInstalledPlugin(Plugin{ID: "one", Repo: repo, Path: "plugins/one"}, "/repo"))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCommit, lock.Plugins["one"].Commit)
|
||||
assert.Equal(t, newCommit, lock.Plugins["two"].Commit)
|
||||
}
|
||||
|
||||
func TestRestoreFromLockfilePrunesManagedPlugins(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "old")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"old","name":"Old"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"old": {Repo: "https://github.com/test/old.git", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, NewLockStore(fs, "/empty.lock.json").Write(NewPluginLockfile()))
|
||||
|
||||
require.NoError(t, manager.RestoreFromLockfile("/empty.lock.json", true))
|
||||
exists, err := afero.Exists(fs, pluginPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, lock.Plugins)
|
||||
}
|
||||
|
||||
func TestRestoreMissingLockfileDoesNotPrune(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "kept")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"kept"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"kept": {Repo: "https://github.com/test/kept.git", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
|
||||
err := manager.RestoreFromLockfile("/missing.lock.json", true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
exists, statErr := afero.Exists(fs, pluginPath)
|
||||
require.NoError(t, statErr)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestRestoreFailedRevisionRemovesPartialInstall(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
incoming := PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"broken": {Repo: "https://github.com/test/broken.git", Commit: testCommit},
|
||||
},
|
||||
}
|
||||
require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming))
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"broken"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
checkoutFunc: func(string, string) error {
|
||||
return errors.New("revision unavailable")
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.RestoreFromLockfile("/incoming.lock.json", false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "revision unavailable")
|
||||
exists, statErr := afero.Exists(fs, filepath.Join(pluginsDir, "broken"))
|
||||
require.NoError(t, statErr)
|
||||
assert.False(t, exists)
|
||||
lock, loadErr := manager.lockStore().Load()
|
||||
require.NoError(t, loadErr)
|
||||
assert.NotContains(t, lock.Plugins, "broken")
|
||||
}
|
||||
|
||||
@@ -2,18 +2,17 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/plumbing"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||
|
||||
type Plugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -34,10 +33,7 @@ type Plugin struct {
|
||||
type GitClient interface {
|
||||
PlainClone(path string, url string) error
|
||||
Pull(path string) error
|
||||
OriginURL(path string) (string, error)
|
||||
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
|
||||
CurrentRevision(path string) (string, error)
|
||||
CheckoutRevision(path string, revision string) error
|
||||
}
|
||||
|
||||
type realGitClient struct{}
|
||||
@@ -69,22 +65,6 @@ func (g *realGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) OriginURL(path string) (string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
urls := remote.Config().URLs
|
||||
if len(urls) == 0 {
|
||||
return "", errors.New("origin remote has no URL")
|
||||
}
|
||||
return urls[0], nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
@@ -138,53 +118,11 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
return localHash != remoteHead, localHash, remoteHead, nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) CurrentRevision(path string) (string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return head.Hash().String(), nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) CheckoutRevision(path string, revision string) error {
|
||||
if !gitCommitPattern.MatchString(revision) {
|
||||
return fmt.Errorf("invalid git revision %q", revision)
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := plumbing.NewHash(revision)
|
||||
if _, objectErr := repo.CommitObject(hash); objectErr != nil {
|
||||
if _, remoteErr := repo.Remote("origin"); remoteErr != nil {
|
||||
return fmt.Errorf("revision %s is not available in repository: %w", revision, objectErr)
|
||||
}
|
||||
fetchErr := repo.Fetch(&git.FetchOptions{})
|
||||
if fetchErr != nil && fetchErr != git.NoErrAlreadyUpToDate {
|
||||
return fmt.Errorf("failed to fetch locked revision: %w", fetchErr)
|
||||
}
|
||||
}
|
||||
if _, err := repo.CommitObject(hash); err != nil {
|
||||
return fmt.Errorf("revision %s is not available in repository: %w", revision, err)
|
||||
}
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return worktree.Reset(&git.ResetOptions{Commit: hash, Mode: git.HardReset})
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
registries []registries.Source
|
||||
plugins []Plugin
|
||||
git GitClient
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
plugins []Plugin
|
||||
git GitClient
|
||||
}
|
||||
|
||||
func NewRegistry() (*Registry, error) {
|
||||
@@ -192,63 +130,63 @@ func NewRegistry() (*Registry, error) {
|
||||
}
|
||||
|
||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||
cacheDir := getCacheDir()
|
||||
return &Registry{
|
||||
fs: fs,
|
||||
cacheDir: getCacheDir(),
|
||||
registries: registries.Load(fs),
|
||||
git: &realGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: cacheDir,
|
||||
git: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) cacheDirFor(src registries.Source) string {
|
||||
return filepath.Join(r.cacheDir, src.Name)
|
||||
}
|
||||
|
||||
func getCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
||||
}
|
||||
|
||||
// A cached clone is reused only when its origin still matches the configured
|
||||
// URL; renamed or re-pointed registries re-clone instead of pulling from the
|
||||
// stale remote.
|
||||
func (r *Registry) updateOne(src registries.Source) error {
|
||||
dir := r.cacheDirFor(src)
|
||||
exists, err := afero.DirExists(r.fs, dir)
|
||||
func (r *Registry) Update() error {
|
||||
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check cache directory: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
origin, originErr := r.git.OriginURL(dir)
|
||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
||||
return nil
|
||||
if !exists {
|
||||
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
if err := r.fs.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
||||
|
||||
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||
return fmt.Errorf("failed to clone registry: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
|
||||
if err := r.git.Pull(r.cacheDir); err != nil {
|
||||
// Repository is likely corrupted or has issues, delete and re-clone
|
||||
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted registry: %w", err)
|
||||
}
|
||||
|
||||
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone registry: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
if err := r.git.PlainClone(dir, src.URL); err != nil {
|
||||
return fmt.Errorf("failed to clone: %w", err)
|
||||
}
|
||||
return nil
|
||||
return r.loadPlugins()
|
||||
}
|
||||
|
||||
// A registry without a plugins/ directory is a valid themes-only registry.
|
||||
func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
|
||||
pluginsDir := filepath.Join(dir, "plugins")
|
||||
func (r *Registry) loadPlugins() error {
|
||||
pluginsDir := filepath.Join(r.cacheDir, "plugins")
|
||||
|
||||
entries, err := afero.ReadDir(r.fs, pluginsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
return fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
}
|
||||
|
||||
var plugins []Plugin
|
||||
r.plugins = []Plugin{}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
@@ -268,51 +206,15 @@ func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
|
||||
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
|
||||
}
|
||||
|
||||
plugins = append(plugins, plugin)
|
||||
r.plugins = append(r.plugins, plugin)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// Pre-multi-registry caches were a single clone at the base dir; the per-name
|
||||
// layout nests under it, so a leftover clone is deleted wholesale first.
|
||||
func (r *Registry) resetLegacyCache() {
|
||||
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
|
||||
_ = r.fs.RemoveAll(r.cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Update refreshes every configured registry, aggregating plugins in
|
||||
// declaration order (first occurrence of an ID wins). A failing registry is
|
||||
// reported in the joined error but does not block the others.
|
||||
func (r *Registry) Update() error {
|
||||
r.resetLegacyCache()
|
||||
r.plugins = []Plugin{}
|
||||
seen := make(map[string]struct{})
|
||||
var errs []error
|
||||
for _, src := range r.registries {
|
||||
if err := r.updateOne(src); err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
plugins, err := r.loadPluginsFrom(r.cacheDirFor(src))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
for _, p := range plugins {
|
||||
if _, dup := seen[p.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = struct{}{}
|
||||
r.plugins = append(r.plugins, p)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() ([]Plugin, error) {
|
||||
if len(r.plugins) == 0 {
|
||||
if err := r.Update(); err != nil && len(r.plugins) == 0 {
|
||||
if err := r.Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,29 +2,18 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/plumbing/object"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testRegistryURL = "https://example.com/test-registry.git"
|
||||
|
||||
type mockGitClient struct {
|
||||
cloneFunc func(path string, url string) error
|
||||
pullFunc func(path string) error
|
||||
originFunc func(path string) (string, error)
|
||||
hasUpdatesFunc func(path string) (bool, string, string, error)
|
||||
revisionFunc func(path string) (string, error)
|
||||
checkoutFunc func(path string, revision string) error
|
||||
}
|
||||
|
||||
func (m *mockGitClient) PlainClone(path string, url string) error {
|
||||
@@ -41,13 +30,6 @@ func (m *mockGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) OriginURL(path string) (string, error) {
|
||||
if m.originFunc != nil {
|
||||
return m.originFunc(path)
|
||||
}
|
||||
return "https://github.com/test/plugin.git", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
if m.hasUpdatesFunc != nil {
|
||||
return m.hasUpdatesFunc(path)
|
||||
@@ -55,27 +37,11 @@ func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
return false, "", "", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) CurrentRevision(path string) (string, error) {
|
||||
if m.revisionFunc != nil {
|
||||
return m.revisionFunc(path)
|
||||
}
|
||||
return "0123456789abcdef0123456789abcdef01234567", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) CheckoutRevision(path string, revision string) error {
|
||||
if m.checkoutFunc != nil {
|
||||
return m.checkoutFunc(path, revision)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewRegistry(t *testing.T) {
|
||||
registry, err := NewRegistry()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, registry)
|
||||
assert.NotEmpty(t, registry.cacheDir)
|
||||
require.NotEmpty(t, registry.registries)
|
||||
assert.Equal(t, registries.OfficialName, registry.registries[0].Name)
|
||||
}
|
||||
|
||||
func TestGetCacheDir(t *testing.T) {
|
||||
@@ -83,50 +49,14 @@ func TestGetCacheDir(t *testing.T) {
|
||||
assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry")
|
||||
}
|
||||
|
||||
func TestRealGitClientRevisionCheckout(t *testing.T) {
|
||||
repoPath := t.TempDir()
|
||||
repo, err := git.PlainInit(repoPath, false)
|
||||
require.NoError(t, err)
|
||||
worktree, err := repo.Worktree()
|
||||
require.NoError(t, err)
|
||||
filePath := filepath.Join(repoPath, "value.txt")
|
||||
signature := &object.Signature{Name: "DMS Test", Email: "test@example.com", When: time.Unix(1, 0)}
|
||||
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("one"), 0o644))
|
||||
_, err = worktree.Add("value.txt")
|
||||
require.NoError(t, err)
|
||||
first, err := worktree.Commit("first", &git.CommitOptions{Author: signature})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("two"), 0o644))
|
||||
_, err = worktree.Add("value.txt")
|
||||
require.NoError(t, err)
|
||||
second, err := worktree.Commit("second", &git.CommitOptions{Author: signature})
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &realGitClient{}
|
||||
revision, err := client.CurrentRevision(repoPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second.String(), revision)
|
||||
require.NoError(t, client.CheckoutRevision(repoPath, first.String()))
|
||||
|
||||
revision, err = client.CurrentRevision(repoPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.String(), revision)
|
||||
data, err := os.ReadFile(filePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "one", string(data))
|
||||
}
|
||||
|
||||
func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
|
||||
fs := afero.NewMemMapFs()
|
||||
tmpDir := "/test-cache"
|
||||
registry := &Registry{
|
||||
fs: fs,
|
||||
cacheDir: tmpDir,
|
||||
registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
|
||||
plugins: []Plugin{},
|
||||
git: &mockGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: tmpDir,
|
||||
plugins: []Plugin{},
|
||||
git: &mockGitClient{},
|
||||
}
|
||||
return registry, fs, tmpDir
|
||||
}
|
||||
@@ -174,14 +104,14 @@ func TestLoadPlugins(t *testing.T) {
|
||||
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
|
||||
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
|
||||
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
err := registry.loadPlugins()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 2)
|
||||
assert.Len(t, registry.plugins, 2)
|
||||
|
||||
assert.Equal(t, "TestPlugin1", plugins[0].Name)
|
||||
assert.Equal(t, "TestPlugin2", plugins[1].Name)
|
||||
assert.Equal(t, []string{"dankbar-widget"}, plugins[0].Capabilities)
|
||||
assert.Equal(t, []string{"dep1", "dep2"}, plugins[1].Dependencies)
|
||||
assert.Equal(t, "TestPlugin1", registry.plugins[0].Name)
|
||||
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name)
|
||||
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities)
|
||||
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies)
|
||||
})
|
||||
|
||||
t.Run("skips non-json files", func(t *testing.T) {
|
||||
@@ -206,10 +136,34 @@ func TestLoadPlugins(t *testing.T) {
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
err = registry.loadPlugins()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("skips directories", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
pluginsDir := filepath.Join(tmpDir, "plugins")
|
||||
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
plugin := Plugin{
|
||||
Name: "ValidPlugin",
|
||||
Capabilities: []string{"test"},
|
||||
Category: "test",
|
||||
Repo: "https://github.com/test/test",
|
||||
Author: "Test",
|
||||
Description: "Test",
|
||||
Compositors: []string{"niri"},
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
err = registry.loadPlugins()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
})
|
||||
|
||||
t.Run("skips invalid json files", func(t *testing.T) {
|
||||
@@ -234,18 +188,18 @@ func TestLoadPlugins(t *testing.T) {
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
err = registry.loadPlugins()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("missing plugins directory is a themes-only registry", func(t *testing.T) {
|
||||
t.Run("returns error when plugins directory missing", func(t *testing.T) {
|
||||
registry, _, _ := setupTestRegistry(t)
|
||||
|
||||
plugins, err := registry.loadPluginsFrom(registry.cacheDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, plugins)
|
||||
err := registry.loadPlugins()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to read plugins directory")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -286,40 +240,19 @@ func TestList(t *testing.T) {
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
mockGit := &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
plugins, err := registry.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "NewPlugin", plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("partial registry failure still returns loaded plugins", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "test", URL: testRegistryURL},
|
||||
{Name: "broken", URL: "https://example.com/broken.git"},
|
||||
}
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
if url != testRegistryURL {
|
||||
return errors.New("clone failed")
|
||||
}
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
plugins, err := registry.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
@@ -338,15 +271,16 @@ func TestUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
cloneCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
mockGit := &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
assert.Equal(t, testRegistryURL, url)
|
||||
assert.Equal(t, filepath.Join(tmpDir, "test"), path)
|
||||
assert.Equal(t, registryRepo, url)
|
||||
assert.Equal(t, tmpDir, path)
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
@@ -355,7 +289,7 @@ func TestUpdate(t *testing.T) {
|
||||
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("pulls when cache exists with matching origin", func(t *testing.T) {
|
||||
t.Run("pulls updates when cache exists", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
plugin := Plugin{
|
||||
@@ -369,188 +303,24 @@ func TestUpdate(t *testing.T) {
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
err := fs.MkdirAll(tmpDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
pullCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return testRegistryURL, nil
|
||||
},
|
||||
mockGit := &mockGitClient{
|
||||
pullFunc: func(path string) error {
|
||||
pullCalled = true
|
||||
assert.Equal(t, subdir, path)
|
||||
assert.Equal(t, tmpDir, path)
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
err := registry.Update()
|
||||
err = registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, pullCalled)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("re-clones when cached origin does not match configured URL", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(subdir, "stale"), []byte("x"), 0o644))
|
||||
|
||||
pullCalled := false
|
||||
cloneCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return "https://example.com/old-origin.git", nil
|
||||
},
|
||||
pullFunc: func(path string) error {
|
||||
pullCalled = true
|
||||
return nil
|
||||
},
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
assert.Equal(t, testRegistryURL, url)
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, pullCalled, "stale origin must not be pulled")
|
||||
assert.True(t, cloneCalled)
|
||||
exists, _ := afero.Exists(fs, filepath.Join(subdir, "stale"))
|
||||
assert.False(t, exists, "stale cache contents removed before re-clone")
|
||||
})
|
||||
|
||||
t.Run("re-clones when pull fails", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
|
||||
cloneCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return testRegistryURL, nil
|
||||
},
|
||||
pullFunc: func(path string) error {
|
||||
return errors.New("shallow clone corruption")
|
||||
},
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, cloneCalled)
|
||||
})
|
||||
|
||||
t.Run("aggregates from multiple registries", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
pluginA := Plugin{ID: "a", Name: "PluginA", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
pluginB := Plugin{ID: "b", Name: "PluginB", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "official", URL: testRegistryURL},
|
||||
{Name: "louzt", URL: "https://example.com/louzt.git"},
|
||||
}
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
switch filepath.Base(path) {
|
||||
case "official":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginA)
|
||||
case "louzt":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginB)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 2)
|
||||
assert.Equal(t, "a", registry.plugins[0].ID)
|
||||
assert.Equal(t, "b", registry.plugins[1].ID)
|
||||
})
|
||||
|
||||
t.Run("dedupes by ID with declaration order priority", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
pluginOfficial := Plugin{ID: "weather", Name: "OfficialWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
pluginLouzt := Plugin{ID: "weather", Name: "LouztWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "official", URL: testRegistryURL},
|
||||
{Name: "louzt", URL: "https://example.com/louzt.git"},
|
||||
}
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
switch filepath.Base(path) {
|
||||
case "official":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginOfficial)
|
||||
case "louzt":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginLouzt)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "OfficialWeather", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("continues past failing registry and reports it", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "broken", URL: "https://example.com/broken.git"},
|
||||
{Name: "test", URL: testRegistryURL},
|
||||
}
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
if url != testRegistryURL {
|
||||
return errors.New("network unreachable")
|
||||
}
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "registry broken")
|
||||
assert.Len(t, registry.plugins, 1, "healthy registry still loads")
|
||||
})
|
||||
|
||||
t.Run("removes legacy single-clone cache at base", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755))
|
||||
createTestPlugin(t, fs, tmpDir, "legacy.json", Plugin{ID: "legacy", Name: "Legacy"})
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
exists, _ := afero.DirExists(fs, filepath.Join(tmpDir, ".git"))
|
||||
assert.False(t, exists, "legacy clone removed")
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "x", registry.plugins[0].ID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,16 +22,6 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
|
||||
if !strings.Contains(content, "Keys.onPressed") || !strings.Contains(content, "event.text") {
|
||||
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
||||
}
|
||||
|
||||
// Wayland IMEs commit unconsumed printable keys as text-input text rather
|
||||
// than forwarding raw keys, so the lock screen needs an IME commit sink
|
||||
// alongside raw key handling.
|
||||
if !strings.Contains(content, "id: imeCommitSink") {
|
||||
t.Fatalf("passwordField must keep the imeCommitSink TextInput so IME-routed keyboards can type (#2950)")
|
||||
}
|
||||
if !strings.Contains(content, "Qt.ImhSensitiveData") {
|
||||
t.Fatalf("imeCommitSink must advertise hidden-text hints so IMEs treat it as a password field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
OfficialName = "official"
|
||||
officialURL = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||
)
|
||||
|
||||
// Source identifies a registry repository. Name doubles as the per-registry
|
||||
// cache subdirectory, so it is restricted to a filesystem-safe slug.
|
||||
type Source struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (s Source) Official() bool {
|
||||
return s.Name == OfficialName
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
|
||||
func configPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "DankMaterialShell", "registries.json"), nil
|
||||
}
|
||||
|
||||
// Load returns the official registry followed by any user-configured extras.
|
||||
// A missing or unreadable config yields just the official registry.
|
||||
func Load(fs afero.Fs) []Source {
|
||||
sources := []Source{{Name: OfficialName, URL: officialURL}}
|
||||
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
return sources
|
||||
}
|
||||
data, err := afero.ReadFile(fs, path)
|
||||
if err != nil {
|
||||
return sources
|
||||
}
|
||||
var extras []Source
|
||||
if err := json.Unmarshal(data, &extras); err != nil {
|
||||
return sources
|
||||
}
|
||||
for _, s := range extras {
|
||||
if !nameRe.MatchString(s.Name) || s.Name == OfficialName || s.URL == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, s)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func saveExtras(fs afero.Fs, extras []Source) error {
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(extras, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return afero.WriteFile(fs, path, append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func loadExtras(fs afero.Fs) []Source {
|
||||
sources := Load(fs)
|
||||
return sources[1:]
|
||||
}
|
||||
|
||||
func Add(fs afero.Fs, name, url string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
url = strings.TrimSpace(url)
|
||||
|
||||
if !nameRe.MatchString(name) {
|
||||
return fmt.Errorf("invalid registry name %q: use 1-32 lowercase letters, digits or hyphens", name)
|
||||
}
|
||||
if name == OfficialName {
|
||||
return fmt.Errorf("registry name %q is reserved", OfficialName)
|
||||
}
|
||||
if url == "" {
|
||||
return fmt.Errorf("registry URL is required")
|
||||
}
|
||||
|
||||
extras := loadExtras(fs)
|
||||
for _, s := range extras {
|
||||
if s.Name == name {
|
||||
return fmt.Errorf("registry %q already exists", name)
|
||||
}
|
||||
if s.URL == url {
|
||||
return fmt.Errorf("registry %q already uses this URL", s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return saveExtras(fs, append(extras, Source{Name: name, URL: url}))
|
||||
}
|
||||
|
||||
func Remove(fs afero.Fs, name string) error {
|
||||
if name == OfficialName {
|
||||
return fmt.Errorf("the official registry cannot be removed")
|
||||
}
|
||||
|
||||
extras := loadExtras(fs)
|
||||
kept := make([]Source, 0, len(extras))
|
||||
for _, s := range extras {
|
||||
if s.Name != name {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(extras) {
|
||||
return fmt.Errorf("registry %q not found", name)
|
||||
}
|
||||
|
||||
return saveExtras(fs, kept)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupFs(t *testing.T) afero.Fs {
|
||||
t.Setenv("XDG_CONFIG_HOME", "/xdg")
|
||||
return afero.NewMemMapFs()
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 1)
|
||||
assert.Equal(t, OfficialName, sources[0].Name)
|
||||
assert.Equal(t, officialURL, sources[0].URL)
|
||||
assert.True(t, sources[0].Official())
|
||||
}
|
||||
|
||||
func TestAddAndLoad(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/extra.git"))
|
||||
require.NoError(t, Add(fs, "another", "https://example.com/another.git"))
|
||||
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 3)
|
||||
assert.Equal(t, OfficialName, sources[0].Name)
|
||||
assert.Equal(t, "extra", sources[1].Name)
|
||||
assert.Equal(t, "another", sources[2].Name)
|
||||
assert.False(t, sources[1].Official())
|
||||
}
|
||||
|
||||
func TestAddValidation(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
assert.Error(t, Add(fs, "", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "Has Spaces", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "UPPER", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "../escape", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, OfficialName, "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "noname", ""))
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "extra", "https://example.com/other.git"), "duplicate name rejected")
|
||||
assert.Error(t, Add(fs, "extra2", "https://example.com/x.git"), "duplicate URL rejected")
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
|
||||
require.NoError(t, Remove(fs, "extra"))
|
||||
assert.Len(t, Load(fs), 1)
|
||||
|
||||
assert.Error(t, Remove(fs, "extra"), "already removed")
|
||||
assert.Error(t, Remove(fs, OfficialName), "official is not removable")
|
||||
}
|
||||
|
||||
func TestLoadIgnoresInvalidConfig(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, fs.MkdirAll("/xdg/DankMaterialShell", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte("{not json"), 0o644))
|
||||
assert.Len(t, Load(fs), 1)
|
||||
|
||||
entries := `[{"name":"ok","url":"https://example.com/ok.git"},{"name":"Bad Name","url":"https://example.com/bad.git"},{"name":"official","url":"https://example.com/spoof.git"},{"name":"nourl","url":""}]`
|
||||
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte(entries), 0o644))
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 2, "invalid entries dropped")
|
||||
assert.Equal(t, "ok", sources[1].Name)
|
||||
assert.Equal(t, officialURL, sources[0].URL, "official cannot be spoofed from config")
|
||||
}
|
||||
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
|
||||
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
|
||||
m.promptBroker = broker
|
||||
|
||||
adapter, err := findAdapter(conn)
|
||||
adapter, err := m.findAdapter()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
|
||||
}
|
||||
m.adapterPath = adapter
|
||||
|
||||
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
|
||||
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
|
||||
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
|
||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||
|
||||
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
for path, interfaces := range objects {
|
||||
@@ -89,7 +89,7 @@ func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNoAdapter
|
||||
return "", fmt.Errorf("no adapter found")
|
||||
}
|
||||
|
||||
func (m *Manager) initialize() error {
|
||||
@@ -487,11 +487,6 @@ func (m *Manager) StopDiscovery() error {
|
||||
}
|
||||
|
||||
func (m *Manager) SetPowered(powered bool) error {
|
||||
if powered {
|
||||
if err := rfkillUnblockBluetooth(); err != nil {
|
||||
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
|
||||
}
|
||||
}
|
||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package bluez
|
||||
|
||||
func rfkillUnblockBluetooth() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package bluez
|
||||
|
||||
import "os"
|
||||
|
||||
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
|
||||
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
|
||||
func rfkillUnblockBluetooth() error {
|
||||
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var event [8]byte
|
||||
event[4] = 2
|
||||
event[5] = 3
|
||||
_, err = f.Write(event[:])
|
||||
return err
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
var ErrNoAdapter = errors.New("no bluetooth adapter found")
|
||||
|
||||
func WaitForAdapter() error {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(objectMgrIface),
|
||||
dbus.WithMatchMember("InterfacesAdded"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signals := make(chan *dbus.Signal, 64)
|
||||
conn.Signal(signals)
|
||||
|
||||
if _, err := findAdapter(conn); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for sig := range signals {
|
||||
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
|
||||
continue
|
||||
}
|
||||
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := ifaces[adapter1Iface]; ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("dbus signal stream closed")
|
||||
}
|
||||
@@ -229,9 +229,6 @@ func (m *Manager) snapshotState() CUPSState {
|
||||
func (m *Manager) Subscribe(id string) chan CUPSState {
|
||||
ch := make(chan CUPSState, 64)
|
||||
|
||||
m.subLifecycleMu.Lock()
|
||||
defer m.subLifecycleMu.Unlock()
|
||||
|
||||
wasEmpty := true
|
||||
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
|
||||
wasEmpty = false
|
||||
@@ -240,25 +237,19 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
|
||||
|
||||
m.subscribers.Store(id, ch)
|
||||
|
||||
if !wasEmpty || m.subscription == nil {
|
||||
return ch
|
||||
if wasEmpty && m.subscription != nil {
|
||||
if err := m.subscription.Start(); err != nil {
|
||||
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
|
||||
} else {
|
||||
m.eventWG.Add(1)
|
||||
go m.eventHandler()
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.subscription.Start(); err != nil {
|
||||
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
|
||||
return ch
|
||||
}
|
||||
|
||||
m.eventWG.Add(1)
|
||||
go m.eventHandler()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(id string) {
|
||||
m.subLifecycleMu.Lock()
|
||||
defer m.subLifecycleMu.Unlock()
|
||||
|
||||
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||
close(val)
|
||||
}
|
||||
@@ -269,22 +260,18 @@ func (m *Manager) Unsubscribe(id string) {
|
||||
return false
|
||||
})
|
||||
|
||||
if !isEmpty || m.subscription == nil {
|
||||
return
|
||||
if isEmpty && m.subscription != nil {
|
||||
m.subscription.Stop()
|
||||
m.eventWG.Wait()
|
||||
}
|
||||
|
||||
m.subscription.Stop()
|
||||
m.eventWG.Wait()
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
close(m.stopChan)
|
||||
|
||||
m.subLifecycleMu.Lock()
|
||||
if m.subscription != nil {
|
||||
m.subscription.Stop()
|
||||
}
|
||||
m.subLifecycleMu.Unlock()
|
||||
|
||||
m.eventWG.Wait()
|
||||
m.notifierWg.Wait()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package cups
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
|
||||
@@ -78,67 +75,6 @@ func TestManager_Subscribe(t *testing.T) {
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
|
||||
// unsynchronized so overlapping Start/Stop trips the race detector
|
||||
type stubSubscription struct {
|
||||
mu sync.Mutex
|
||||
events chan SubscriptionEvent
|
||||
conn *int
|
||||
running bool
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Start() error {
|
||||
if s.running {
|
||||
return errors.New("already running")
|
||||
}
|
||||
s.running = true
|
||||
|
||||
s.mu.Lock()
|
||||
s.events = make(chan SubscriptionEvent)
|
||||
s.mu.Unlock()
|
||||
|
||||
v := 0
|
||||
s.conn = &v
|
||||
*s.conn++
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Stop() {
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
s.conn = nil
|
||||
|
||||
s.mu.Lock()
|
||||
close(s.events)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.events
|
||||
}
|
||||
|
||||
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
|
||||
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
|
||||
m.subscription = &stubSubscription{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range 8 {
|
||||
wg.Go(func() {
|
||||
id := fmt.Sprintf("client-%d", i)
|
||||
for range 50 {
|
||||
m.Subscribe(id)
|
||||
m.Unsubscribe(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_Close(t *testing.T) {
|
||||
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ type Manager struct {
|
||||
client CUPSClientInterface
|
||||
pkHelper PkHelper
|
||||
subscription SubscriptionManagerInterface
|
||||
subLifecycleMu sync.Mutex
|
||||
stateMutex sync.RWMutex
|
||||
subscribers syncmap.Map[string, chan CUPSState]
|
||||
stopChan chan struct{}
|
||||
|
||||
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
|
||||
return nil, fmt.Errorf("failed to find keyboards: %w", err)
|
||||
}
|
||||
|
||||
initialCapsLock, _ := capsLockFromDevices(devices)
|
||||
initialCapsLock := readInitialCapsLockState(devices[0])
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
@@ -85,21 +85,14 @@ func NewManager() (*Manager, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func capsLockFromDevices(devices []EvdevDevice) (bool, bool) {
|
||||
for _, device := range devices {
|
||||
if device == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ledStates, err := device.State(evLedType)
|
||||
if err != nil || len(ledStates) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
return ledStates[ledCapslockKey], true
|
||||
func readInitialCapsLockState(device EvdevDevice) bool {
|
||||
ledStates, err := device.State(evLedType)
|
||||
if err != nil {
|
||||
log.Debugf("Could not read LED state: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return false, false
|
||||
return ledStates[ledCapslockKey]
|
||||
}
|
||||
|
||||
func findKeyboards() ([]EvdevDevice, error) {
|
||||
@@ -304,22 +297,25 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
|
||||
m.devicesMutex.RUnlock()
|
||||
return
|
||||
}
|
||||
ordered := make([]EvdevDevice, 0, len(m.devices))
|
||||
ordered = append(ordered, m.devices[deviceIndex])
|
||||
for i, device := range m.devices {
|
||||
if i == deviceIndex {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, device)
|
||||
}
|
||||
device := m.devices[deviceIndex]
|
||||
m.devicesMutex.RUnlock()
|
||||
|
||||
capsLockState, ok := capsLockFromDevices(ordered)
|
||||
if !ok {
|
||||
log.Debug("No LED-capable device available for caps lock state")
|
||||
ledStates, err := device.State(evLedType)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to read LED state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(ledStates) == 0 {
|
||||
log.Debug("No LED state available (empty map)")
|
||||
|
||||
// This means the device either:
|
||||
// - doesn't support LED reporting at all, or
|
||||
// - the kernel returned an empty state
|
||||
return
|
||||
}
|
||||
|
||||
capsLockState := ledStates[ledCapslockKey]
|
||||
m.updateCapsLockStateDirect(capsLockState)
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func TestCapsLockFromDevices(t *testing.T) {
|
||||
func TestReadInitialCapsLockState(t *testing.T) {
|
||||
t.Run("caps lock is on", func(t *testing.T) {
|
||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||
ledState := evdev.StateMap{
|
||||
@@ -314,8 +314,7 @@ func TestCapsLockFromDevices(t *testing.T) {
|
||||
}
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.True(t, ok)
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
@@ -326,8 +325,7 @@ func TestCapsLockFromDevices(t *testing.T) {
|
||||
}
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.True(t, ok)
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
@@ -335,25 +333,9 @@ func TestCapsLockFromDevices(t *testing.T) {
|
||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
|
||||
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.False(t, ok)
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("falls back past device without LED state", func(t *testing.T) {
|
||||
noLedDevice := mocks.NewMockEvdevDevice(t)
|
||||
noLedDevice.EXPECT().State(evdev.EvType(evLedType)).Return(evdev.StateMap{}, nil).Once()
|
||||
|
||||
ledDevice := mocks.NewMockEvdevDevice(t)
|
||||
ledState := evdev.StateMap{
|
||||
ledCapslockKey: true,
|
||||
}
|
||||
ledDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{noLedDevice, nil, ledDevice})
|
||||
assert.True(t, ok)
|
||||
assert.True(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasInputGroupAccess(t *testing.T) {
|
||||
|
||||
@@ -8,11 +8,24 @@ import (
|
||||
)
|
||||
|
||||
func NewManager(client geolocation.Client) (*Manager, error) {
|
||||
currLocation, err := client.GetLocation()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to get initial location: %v", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
client: client,
|
||||
dirty: make(chan struct{}),
|
||||
stopChan: make(chan struct{}),
|
||||
state: &State{},
|
||||
|
||||
state: &State{
|
||||
Latitude: currLocation.Latitude,
|
||||
Longitude: currLocation.Longitude,
|
||||
},
|
||||
}
|
||||
|
||||
if err := m.startSignalPump(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.notifierWg.Add(1)
|
||||
@@ -21,22 +34,6 @@ func NewManager(client geolocation.Client) (*Manager, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// The geolocation client may fetch IP location on first use, so nothing
|
||||
// touches it until a consumer actually asks for location data.
|
||||
func (m *Manager) ensureStarted() {
|
||||
m.startOnce.Do(func() {
|
||||
go func() {
|
||||
currLocation, err := m.client.GetLocation()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to get initial location: %v", err)
|
||||
} else {
|
||||
m.handleLocationChange(currLocation)
|
||||
}
|
||||
m.startSignalPump()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
close(m.stopChan)
|
||||
m.notifierWg.Wait()
|
||||
@@ -51,7 +48,6 @@ func (m *Manager) Close() {
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(id string) chan State {
|
||||
m.ensureStarted()
|
||||
ch := make(chan State, 64)
|
||||
m.subscribers.Store(id, ch)
|
||||
return ch
|
||||
@@ -63,7 +59,7 @@ func (m *Manager) Unsubscribe(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) startSignalPump() {
|
||||
func (m *Manager) startSignalPump() error {
|
||||
m.sigWG.Add(1)
|
||||
go func() {
|
||||
defer m.sigWG.Done()
|
||||
@@ -84,6 +80,8 @@ func (m *Manager) startSignalPump() {
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleLocationChange(location geolocation.Location) {
|
||||
@@ -104,7 +102,6 @@ func (m *Manager) notifySubscribers() {
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
m.ensureStarted()
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
if m.state == nil {
|
||||
|
||||
@@ -16,8 +16,7 @@ type Manager struct {
|
||||
state *State
|
||||
stateMutex sync.RWMutex
|
||||
|
||||
client geolocation.Client
|
||||
startOnce sync.Once
|
||||
client geolocation.Client
|
||||
|
||||
stopChan chan struct{}
|
||||
sigWG sync.WaitGroup
|
||||
|
||||
@@ -302,17 +302,17 @@ func (a *SecretAgent) GetSecrets(
|
||||
}
|
||||
a.backend.cachedVPNCredsMu.Unlock()
|
||||
|
||||
a.backend.cachedOpenConnectMu.Lock()
|
||||
cachedOpenConnect := a.backend.cachedOpenConnectAuth
|
||||
if cachedOpenConnect != nil && cachedOpenConnect.ConnectionUUID == connUuid {
|
||||
a.backend.cachedOpenConnectAuth = nil
|
||||
a.backend.cachedOpenConnectMu.Unlock()
|
||||
a.backend.cachedGPSamlMu.Lock()
|
||||
cachedGPSaml := a.backend.cachedGPSamlCookie
|
||||
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
|
||||
a.backend.cachedGPSamlCookie = nil
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
log.Infof("[SecretAgent] Using cached OpenConnect authentication for %s", connUuid)
|
||||
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
|
||||
|
||||
return buildOpenConnectSecretsResponse(settingName, cachedOpenConnect.Cookie, cachedOpenConnect.Host, cachedOpenConnect.Fingerprint), nil
|
||||
return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
|
||||
}
|
||||
a.backend.cachedOpenConnectMu.Unlock()
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
if len(fields) == 1 && fields[0] == "gp-saml" {
|
||||
gateway := ""
|
||||
@@ -347,17 +347,17 @@ func (a *SecretAgent) GetSecrets(
|
||||
|
||||
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
|
||||
|
||||
a.backend.cachedOpenConnectMu.Lock()
|
||||
a.backend.cachedOpenConnectAuth = &cachedOpenConnectAuth{
|
||||
a.backend.cachedGPSamlMu.Lock()
|
||||
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
|
||||
ConnectionUUID: connUuid,
|
||||
Cookie: authResult.Cookie,
|
||||
Host: authResult.Host,
|
||||
User: authResult.User,
|
||||
Fingerprint: authResult.Fingerprint,
|
||||
}
|
||||
a.backend.cachedOpenConnectMu.Unlock()
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
return buildOpenConnectSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
||||
return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,7 +987,7 @@ func buildWiFiSecretsResponse(settingName string, secrets map[string]string) nmS
|
||||
return out
|
||||
}
|
||||
|
||||
func buildOpenConnectSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
||||
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
||||
out := nmSettingMap{}
|
||||
vpnSec := nmVariantMap{}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestNeedsExternalBrowserAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenConnectSecretsResponse(t *testing.T) {
|
||||
func TestBuildGPSamlSecretsResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settingName string
|
||||
@@ -155,7 +155,7 @@ func TestBuildOpenConnectSecretsResponse(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildOpenConnectSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
|
||||
result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
|
||||
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, tt.settingName)
|
||||
|
||||
@@ -80,16 +80,16 @@ type NetworkManagerBackend struct {
|
||||
|
||||
hotspotPendingDevice string
|
||||
|
||||
pendingVPNSave *pendingVPNCredentials
|
||||
pendingVPNSaveMu sync.Mutex
|
||||
cachedVPNCreds *cachedVPNCredentials
|
||||
cachedVPNCredsMu sync.Mutex
|
||||
cachedPKCS11PIN *cachedPKCS11PIN
|
||||
cachedPKCS11Mu sync.Mutex
|
||||
cachedOpenConnectAuth *cachedOpenConnectAuth
|
||||
cachedOpenConnectMu sync.Mutex
|
||||
cachedWiFiSecret *cachedWiFiSecret
|
||||
cachedWiFiSecretMu sync.Mutex
|
||||
pendingVPNSave *pendingVPNCredentials
|
||||
pendingVPNSaveMu sync.Mutex
|
||||
cachedVPNCreds *cachedVPNCredentials
|
||||
cachedVPNCredsMu sync.Mutex
|
||||
cachedPKCS11PIN *cachedPKCS11PIN
|
||||
cachedPKCS11Mu sync.Mutex
|
||||
cachedGPSamlCookie *cachedGPSamlCookie
|
||||
cachedGPSamlMu sync.Mutex
|
||||
cachedWiFiSecret *cachedWiFiSecret
|
||||
cachedWiFiSecretMu sync.Mutex
|
||||
|
||||
onStateChange func()
|
||||
}
|
||||
@@ -100,9 +100,8 @@ type pendingVPNCredentials struct {
|
||||
Password string
|
||||
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
|
||||
// falls back to Password under the "password" key when empty.
|
||||
Secrets map[string]string
|
||||
PersistentSecrets map[string]string
|
||||
SavePassword bool
|
||||
Secrets map[string]string
|
||||
SavePassword bool
|
||||
}
|
||||
|
||||
type cachedVPNCredentials struct {
|
||||
@@ -125,7 +124,7 @@ type cachedWiFiSecret struct {
|
||||
Secrets map[string]string
|
||||
}
|
||||
|
||||
type cachedOpenConnectAuth struct {
|
||||
type cachedGPSamlCookie struct {
|
||||
ConnectionUUID string
|
||||
Cookie string
|
||||
Host string
|
||||
|
||||
@@ -3,7 +3,6 @@ package network
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -11,29 +10,16 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
type openConnectAuthResult struct {
|
||||
type gpSamlAuthResult struct {
|
||||
Cookie string
|
||||
Host string
|
||||
User string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type openConnectAuthError struct {
|
||||
cause error
|
||||
serverCert string
|
||||
}
|
||||
|
||||
func (e *openConnectAuthError) Error() string {
|
||||
return fmt.Sprintf("openconnect --authenticate failed: %v", e.cause)
|
||||
}
|
||||
|
||||
func (e *openConnectAuthError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
|
||||
// Only supports protocol=gp. Other protocols need their own implementations.
|
||||
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*openConnectAuthResult, error) {
|
||||
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
|
||||
if gateway == "" {
|
||||
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
|
||||
}
|
||||
@@ -77,7 +63,7 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
|
||||
}
|
||||
}()
|
||||
|
||||
result := &openConnectAuthResult{Host: gateway}
|
||||
result := &gpSamlAuthResult{Host: gateway}
|
||||
var allOutput []string
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
@@ -131,8 +117,13 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*openConnectAuthResult, error) {
|
||||
return runOpenConnectAuthenticate(ctx, []string{
|
||||
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
|
||||
ocPath, err := exec.LookPath("openconnect")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openconnect not found: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--protocol=gp",
|
||||
"--usergroup=gateway:prelogin-cookie",
|
||||
"--user=" + user,
|
||||
@@ -140,83 +131,18 @@ func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user
|
||||
"--allow-insecure-crypto",
|
||||
"--authenticate",
|
||||
gateway,
|
||||
}, preloginCookie)
|
||||
}
|
||||
|
||||
func runOpenConnectPasswordAuth(
|
||||
ctx context.Context,
|
||||
data map[string]string,
|
||||
username, password, serverCert string,
|
||||
) (*openConnectAuthResult, error) {
|
||||
if data["protocol"] != "fortinet" {
|
||||
return nil, fmt.Errorf("only Fortinet password authentication is supported")
|
||||
}
|
||||
gateway := data["gateway"]
|
||||
if gateway == "" {
|
||||
return nil, fmt.Errorf("OpenConnect gateway is empty")
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return nil, fmt.Errorf("OpenConnect username and password are required")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--protocol=fortinet",
|
||||
"--user=" + username,
|
||||
"--passwd-on-stdin",
|
||||
"--non-inter",
|
||||
}
|
||||
if usergroup := data["usergroup"]; usergroup != "" {
|
||||
args = append(args, "--usergroup="+usergroup)
|
||||
}
|
||||
if serverCert != "" {
|
||||
args = append(args, "--servercert="+serverCert)
|
||||
}
|
||||
args = append(args, "--authenticate", gateway)
|
||||
|
||||
result, err := runOpenConnectAuthenticate(ctx, args, password)
|
||||
if err == nil {
|
||||
result.Host = gateway
|
||||
if result.Fingerprint == "" {
|
||||
result.Fingerprint = serverCert
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func runOpenConnectAuthenticate(ctx context.Context, args []string, secret string) (*openConnectAuthResult, error) {
|
||||
ocPath, err := exec.LookPath("openconnect")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openconnect not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ocPath, args...)
|
||||
cmd.Stdin = strings.NewReader(secret + "\n")
|
||||
cmd.Stdin = strings.NewReader(preloginCookie)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
result := parseOpenConnectAuthenticateOutput(string(output))
|
||||
serverCert := suggestedOpenConnectServerCert(string(output))
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("openconnect authentication timed out or was cancelled: %w", ctx.Err())
|
||||
}
|
||||
return nil, &openConnectAuthError{cause: err, serverCert: serverCert}
|
||||
}
|
||||
if result.Cookie == "" {
|
||||
return nil, &openConnectAuthError{
|
||||
cause: errors.New("no COOKIE in command output"),
|
||||
serverCert: serverCert,
|
||||
}
|
||||
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
log.Infof("[OpenConnect] Authentication successful: cookie_len=%d, host=%s, has_fingerprint=%v",
|
||||
len(result.Cookie), result.Host, result.Fingerprint != "")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
|
||||
result := &openConnectAuthResult{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
result := &gpSamlAuthResult{}
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "COOKIE="):
|
||||
@@ -232,7 +158,15 @@ func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
if result.Cookie == "" {
|
||||
return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
|
||||
}
|
||||
|
||||
log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
|
||||
len(result.Cookie), result.Host, result.Fingerprint)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func unshellQuote(s string) string {
|
||||
@@ -245,7 +179,7 @@ func unshellQuote(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func parseGPSamlFromCommandLine(line string, result *openConnectAuthResult) {
|
||||
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
|
||||
if !strings.Contains(line, "openconnect") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -75,7 +71,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
initialResult *openConnectAuthResult
|
||||
initialResult *gpSamlAuthResult
|
||||
expectedCookie string
|
||||
expectedUser string
|
||||
expectedFP string
|
||||
@@ -83,7 +79,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
{
|
||||
name: "full openconnect command",
|
||||
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
|
||||
initialResult: &openConnectAuthResult{},
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "AUTH123",
|
||||
expectedUser: "john",
|
||||
expectedFP: "pin-sha256:ABC",
|
||||
@@ -91,7 +87,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
{
|
||||
name: "with equals signs in cookie",
|
||||
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
|
||||
initialResult: &openConnectAuthResult{},
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "authcookie=xyz123&portal=GATE",
|
||||
expectedUser: "jane",
|
||||
expectedFP: "",
|
||||
@@ -99,7 +95,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
{
|
||||
name: "non-openconnect line",
|
||||
line: "some other output",
|
||||
initialResult: &openConnectAuthResult{},
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "",
|
||||
expectedFP: "",
|
||||
@@ -107,7 +103,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
{
|
||||
name: "preserves existing values",
|
||||
line: "openconnect --user=newuser",
|
||||
initialResult: &openConnectAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
|
||||
initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
|
||||
expectedCookie: "existing",
|
||||
expectedUser: "newuser",
|
||||
expectedFP: "existing-fp",
|
||||
@@ -115,7 +111,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
{
|
||||
name: "only updates empty fields",
|
||||
line: "openconnect --cookie=NEW --user=NEW",
|
||||
initialResult: &openConnectAuthResult{Cookie: "OLD"},
|
||||
initialResult: &gpSamlAuthResult{Cookie: "OLD"},
|
||||
expectedCookie: "OLD",
|
||||
expectedUser: "NEW",
|
||||
expectedFP: "",
|
||||
@@ -123,7 +119,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
{
|
||||
name: "real gp-saml-gui output",
|
||||
line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
|
||||
initialResult: &openConnectAuthResult{},
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "john.doe@example.com",
|
||||
expectedFP: "",
|
||||
@@ -131,7 +127,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
||||
{
|
||||
name: "with server cert flag",
|
||||
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
|
||||
initialResult: &openConnectAuthResult{},
|
||||
initialResult: &gpSamlAuthResult{},
|
||||
expectedCookie: "",
|
||||
expectedUser: "",
|
||||
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
||||
@@ -162,7 +158,7 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
|
||||
"",
|
||||
}
|
||||
|
||||
result := &openConnectAuthResult{}
|
||||
result := &gpSamlAuthResult{}
|
||||
for _, line := range lines {
|
||||
parseGPSamlFromCommandLine(line, result)
|
||||
}
|
||||
@@ -171,19 +167,3 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
|
||||
assert.Empty(t, result.Cookie, "cookie should not be parsed from command line")
|
||||
assert.Empty(t, result.Fingerprint)
|
||||
}
|
||||
|
||||
func TestRunOpenConnectAuthenticateSanitizesFailure(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
openConnectPath := filepath.Join(binDir, "openconnect")
|
||||
script := "#!/bin/sh\nprintf '%s\\n' 'Cookie: should-not-leak' 'Add --servercert pin-sha256:TEST-FINGERPRINT' >&2\nexit 1\n"
|
||||
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
_, err := runOpenConnectAuthenticate(context.Background(), []string{"--authenticate", "vpn.example.test"}, "password")
|
||||
assert.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "should-not-leak")
|
||||
|
||||
var authErr *openConnectAuthError
|
||||
assert.True(t, errors.As(err, &authErr))
|
||||
assert.Equal(t, "pin-sha256:TEST-FINGERPRINT", authErr.serverCert)
|
||||
}
|
||||
|
||||
@@ -326,7 +326,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
}
|
||||
|
||||
authAction := detectVPNAuthAction(vpnServiceType, vpnData)
|
||||
var openConnectAuth *openConnectAuthResult
|
||||
|
||||
switch authAction {
|
||||
case "openvpn_username":
|
||||
@@ -336,19 +335,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
|
||||
return err
|
||||
}
|
||||
case "openconnect_password":
|
||||
if err := b.ensureOpenConnectAgentFlags(targetConn, vpnData); err != nil {
|
||||
return fmt.Errorf("failed to prepare OpenConnect connection: %w", err)
|
||||
}
|
||||
|
||||
authCtx, authCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
openConnectAuth, err = b.handleOpenConnectPasswordAuth(
|
||||
authCtx, targetConn, connName, targetUUID, vpnServiceType, vpnData,
|
||||
)
|
||||
authCancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenConnect authentication failed: %w", err)
|
||||
}
|
||||
case "gp_saml":
|
||||
gateway := vpnData["gateway"]
|
||||
protocol := vpnData["protocol"]
|
||||
@@ -359,7 +345,7 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
|
||||
|
||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
openConnectAuth, err = b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
||||
authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
||||
samlCancel()
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
@@ -377,6 +363,16 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
}
|
||||
}
|
||||
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = &cachedGPSamlCookie{
|
||||
ConnectionUUID: targetUUID,
|
||||
Cookie: authResult.Cookie,
|
||||
Host: authResult.Host,
|
||||
User: authResult.User,
|
||||
Fingerprint: authResult.Fingerprint,
|
||||
}
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
|
||||
if err := targetConn.ClearSecrets(); err != nil {
|
||||
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
|
||||
} else {
|
||||
@@ -386,19 +382,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName)
|
||||
}
|
||||
|
||||
if openConnectAuth != nil {
|
||||
b.cachedOpenConnectMu.Lock()
|
||||
b.cachedOpenConnectAuth = &cachedOpenConnectAuth{
|
||||
ConnectionUUID: targetUUID,
|
||||
Cookie: openConnectAuth.Cookie,
|
||||
Host: openConnectAuth.Host,
|
||||
User: openConnectAuth.User,
|
||||
Fingerprint: openConnectAuth.Fingerprint,
|
||||
}
|
||||
b.cachedOpenConnectMu.Unlock()
|
||||
log.Infof("[ConnectVPN] OpenConnect authentication cached for %s, proceeding with activation", connName)
|
||||
}
|
||||
|
||||
b.stateMutex.Lock()
|
||||
b.state.IsConnectingVPN = true
|
||||
b.state.ConnectingVPNUUID = targetUUID
|
||||
@@ -411,13 +394,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
||||
nm := b.nmConn.(gonetworkmanager.NetworkManager)
|
||||
_, err = nm.ActivateConnection(targetConn, nil, nil)
|
||||
if err != nil {
|
||||
b.cachedOpenConnectMu.Lock()
|
||||
b.cachedOpenConnectAuth = nil
|
||||
b.cachedOpenConnectMu.Unlock()
|
||||
b.pendingVPNSaveMu.Lock()
|
||||
b.pendingVPNSave = nil
|
||||
b.pendingVPNSaveMu.Unlock()
|
||||
|
||||
b.stateMutex.Lock()
|
||||
b.state.IsConnectingVPN = false
|
||||
b.state.ConnectingVPNUUID = ""
|
||||
@@ -449,9 +425,6 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
||||
log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol)
|
||||
}
|
||||
}
|
||||
if protocol == "fortinet" && data["authtype"] == "password" {
|
||||
return "openconnect_password"
|
||||
}
|
||||
case strings.Contains(serviceType, "openvpn"):
|
||||
connType := data["connection-type"]
|
||||
username := data["username"]
|
||||
@@ -462,200 +435,6 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func setOpenConnectAgentFlags(data map[string]string) bool {
|
||||
changed := false
|
||||
for _, field := range []string{"cookie", "gateway", "gwcert"} {
|
||||
key := field + "-flags"
|
||||
if data[key] != "2" {
|
||||
data[key] = "2"
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) ensureOpenConnectAgentFlags(conn gonetworkmanager.Connection, data map[string]string) error {
|
||||
if !setOpenConnectAgentFlags(data) {
|
||||
return nil
|
||||
}
|
||||
if b.dbusConn == nil {
|
||||
return fmt.Errorf("NetworkManager D-Bus connection is unavailable")
|
||||
}
|
||||
|
||||
connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", conn.GetPath())
|
||||
var existingSettings map[string]map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil {
|
||||
return fmt.Errorf("failed to get connection settings: %w", err)
|
||||
}
|
||||
|
||||
vpn, ok := existingSettings["vpn"]
|
||||
if !ok {
|
||||
return fmt.Errorf("VPN settings are missing")
|
||||
}
|
||||
vpn["data"] = dbus.MakeVariant(data)
|
||||
|
||||
var stored map[string]map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSecrets", 0, "vpn").Store(&stored); err != nil {
|
||||
return fmt.Errorf("failed to preserve VPN secrets: %w", err)
|
||||
}
|
||||
if storedVPN, ok := stored["vpn"]; ok {
|
||||
if secrets, ok := storedVPN["secrets"]; ok {
|
||||
vpn["secrets"] = secrets
|
||||
}
|
||||
}
|
||||
|
||||
settings := map[string]map[string]dbus.Variant{"vpn": vpn}
|
||||
if connection, ok := existingSettings["connection"]; ok {
|
||||
settings["connection"] = connection
|
||||
}
|
||||
|
||||
var result map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0,
|
||||
settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil {
|
||||
return fmt.Errorf("failed to set NetworkManager secret-agent flags: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) handleOpenConnectPasswordAuth(
|
||||
ctx context.Context,
|
||||
targetConn gonetworkmanager.Connection,
|
||||
connName, targetUUID, vpnServiceType string,
|
||||
data map[string]string,
|
||||
) (*openConnectAuthResult, error) {
|
||||
username := data["username"]
|
||||
secrets := map[string]string{}
|
||||
if stored, err := targetConn.GetSecrets("vpn"); err == nil {
|
||||
if vpn, ok := stored["vpn"]; ok {
|
||||
if saved, ok := vpn["secrets"].(map[string]string); ok {
|
||||
secrets = saved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
password := secrets["password"]
|
||||
serverCert := secrets["certificate:"+data["gateway"]]
|
||||
if serverCert == "" {
|
||||
serverCert = secrets["gwcert"]
|
||||
}
|
||||
|
||||
var reply PromptReply
|
||||
if username == "" || password == "" {
|
||||
if b.promptBroker == nil {
|
||||
return nil, fmt.Errorf("password authentication requires an interactive prompt")
|
||||
}
|
||||
|
||||
fields := []string{}
|
||||
fieldsInfo := []FieldInfo{}
|
||||
if username == "" {
|
||||
fields = append(fields, "username")
|
||||
fieldsInfo = append(fieldsInfo, FieldInfo{Name: "username", Label: "Username", IsSecret: false})
|
||||
}
|
||||
if password == "" {
|
||||
fields = append(fields, "password")
|
||||
fieldsInfo = append(fieldsInfo, FieldInfo{Name: "password", Label: "Password", IsSecret: true})
|
||||
}
|
||||
|
||||
token, err := b.promptBroker.Ask(ctx, PromptRequest{
|
||||
Name: connName,
|
||||
ConnType: "vpn",
|
||||
VpnService: vpnServiceType,
|
||||
SettingName: "vpn",
|
||||
Fields: fields,
|
||||
FieldsInfo: fieldsInfo,
|
||||
Reason: "required",
|
||||
ConnectionId: connName,
|
||||
ConnectionUuid: targetUUID,
|
||||
ConnectionPath: string(targetConn.GetPath()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request credentials: %w", err)
|
||||
}
|
||||
|
||||
reply, err = b.promptBroker.Wait(ctx, token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credentials prompt failed: %w", err)
|
||||
}
|
||||
if username == "" {
|
||||
username = reply.Secrets["username"]
|
||||
}
|
||||
if password == "" {
|
||||
password = reply.Secrets["password"]
|
||||
}
|
||||
}
|
||||
|
||||
auth, err := runOpenConnectPasswordAuth(ctx, data, username, password, serverCert)
|
||||
persistentSecrets := map[string]string{}
|
||||
var authErr *openConnectAuthError
|
||||
if err != nil && errors.As(err, &authErr) && authErr.serverCert != "" && authErr.serverCert != serverCert {
|
||||
if b.promptBroker == nil {
|
||||
return nil, fmt.Errorf("VPN server certificate is untrusted: %s", authErr.serverCert)
|
||||
}
|
||||
|
||||
reason := "server-certificate"
|
||||
if serverCert != "" {
|
||||
reason = "server-certificate-changed"
|
||||
}
|
||||
|
||||
token, promptErr := b.promptBroker.Ask(ctx, PromptRequest{
|
||||
Name: connName,
|
||||
ConnType: "vpn",
|
||||
VpnService: vpnServiceType,
|
||||
SettingName: "vpn",
|
||||
Hints: []string{authErr.serverCert},
|
||||
Reason: reason,
|
||||
ConnectionId: connName,
|
||||
ConnectionUuid: targetUUID,
|
||||
ConnectionPath: string(targetConn.GetPath()),
|
||||
})
|
||||
if promptErr != nil {
|
||||
return nil, fmt.Errorf("failed to request certificate confirmation: %w", promptErr)
|
||||
}
|
||||
if _, promptErr = b.promptBroker.Wait(ctx, token); promptErr != nil {
|
||||
return nil, fmt.Errorf("certificate confirmation failed: %w", promptErr)
|
||||
}
|
||||
|
||||
auth, err = runOpenConnectPasswordAuth(ctx, data, username, password, authErr.serverCert)
|
||||
if err == nil {
|
||||
persistentSecrets["certificate:"+data["gateway"]] = authErr.serverCert
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(reply.Secrets) > 0 || len(persistentSecrets) > 0 {
|
||||
creds := &pendingVPNCredentials{
|
||||
ConnectionPath: string(targetConn.GetPath()),
|
||||
PersistentSecrets: persistentSecrets,
|
||||
}
|
||||
if _, ok := reply.Secrets["username"]; ok {
|
||||
creds.Username = username
|
||||
}
|
||||
if reply.Save {
|
||||
creds.Username = username
|
||||
creds.Password = password
|
||||
creds.Secrets = map[string]string{"password": password}
|
||||
creds.SavePassword = true
|
||||
}
|
||||
b.pendingVPNSaveMu.Lock()
|
||||
b.pendingVPNSave = creds
|
||||
b.pendingVPNSaveMu.Unlock()
|
||||
}
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func suggestedOpenConnectServerCert(output string) string {
|
||||
for _, field := range strings.Fields(output) {
|
||||
field = strings.Trim(field, "'\".,")
|
||||
if strings.HasPrefix(field, "pin-sha256:") {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error {
|
||||
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
|
||||
|
||||
@@ -979,13 +758,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.VPNErrorUuid = ""
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached one-shot authentication values on success.
|
||||
// Clear cached PKCS11 PIN and SAML cookie on success
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedOpenConnectMu.Lock()
|
||||
b.cachedOpenConnectAuth = nil
|
||||
b.cachedOpenConnectMu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
|
||||
b.pendingVPNSaveMu.Lock()
|
||||
pending := b.pendingVPNSave
|
||||
@@ -1008,16 +787,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.VPNErrorUuid = connectingVPNUUID
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached one-shot authentication values on failure.
|
||||
// Clear cached PKCS11 PIN and SAML cookie on failure
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedOpenConnectMu.Lock()
|
||||
b.cachedOpenConnectAuth = nil
|
||||
b.cachedOpenConnectMu.Unlock()
|
||||
b.pendingVPNSaveMu.Lock()
|
||||
b.pendingVPNSave = nil
|
||||
b.pendingVPNSaveMu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1035,16 +811,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
||||
b.state.VPNErrorUuid = connectingVPNUUID
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
// Clear cached one-shot authentication values.
|
||||
// Clear cached PKCS11 PIN and SAML cookie
|
||||
b.cachedPKCS11Mu.Lock()
|
||||
b.cachedPKCS11PIN = nil
|
||||
b.cachedPKCS11Mu.Unlock()
|
||||
b.cachedOpenConnectMu.Lock()
|
||||
b.cachedOpenConnectAuth = nil
|
||||
b.cachedOpenConnectMu.Unlock()
|
||||
b.pendingVPNSaveMu.Lock()
|
||||
b.pendingVPNSave = nil
|
||||
b.pendingVPNSaveMu.Unlock()
|
||||
b.cachedGPSamlMu.Lock()
|
||||
b.cachedGPSamlCookie = nil
|
||||
b.cachedGPSamlMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,40 +863,17 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
|
||||
log.Infof("[saveVPNCredentials] Saving username")
|
||||
}
|
||||
|
||||
secs := map[string]string{}
|
||||
if len(creds.PersistentSecrets) > 0 {
|
||||
var stored map[string]map[string]dbus.Variant
|
||||
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSecrets", 0, "vpn").Store(&stored); err != nil {
|
||||
log.Warnf("[saveVPNCredentials] GetSecrets failed: %v", err)
|
||||
return
|
||||
}
|
||||
if storedVPN, ok := stored["vpn"]; ok {
|
||||
if storedSecrets, ok := storedVPN["secrets"]; ok {
|
||||
saved, _ := storedSecrets.Value().(map[string]string)
|
||||
for field, value := range saved {
|
||||
secs[field] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
for field, value := range creds.PersistentSecrets {
|
||||
secs[field] = value
|
||||
data[field+"-flags"] = "0"
|
||||
}
|
||||
}
|
||||
|
||||
// Save secrets if requested
|
||||
if creds.SavePassword {
|
||||
toSave := creds.Secrets
|
||||
if len(toSave) == 0 {
|
||||
toSave = map[string]string{"password": creds.Password}
|
||||
secs := creds.Secrets
|
||||
if len(secs) == 0 {
|
||||
secs = map[string]string{"password": creds.Password}
|
||||
}
|
||||
for field, value := range toSave {
|
||||
secs[field] = value
|
||||
for field := range secs {
|
||||
data[field+"-flags"] = "0"
|
||||
}
|
||||
}
|
||||
if len(secs) > 0 {
|
||||
vpn["secrets"] = dbus.MakeVariant(secs)
|
||||
log.Infof("[saveVPNCredentials] Saving %d secret field(s)", len(secs))
|
||||
log.Infof("[saveVPNCredentials] Saving %d secret field(s) with flags=0", len(secs))
|
||||
}
|
||||
|
||||
vpn["data"] = dbus.MakeVariant(data)
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
|
||||
"github.com/Wifx/gonetworkmanager/v2"
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -140,137 +136,3 @@ func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T)
|
||||
backend.updateVPNConnectionState()
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectVPNAuthAction_FortinetPasswordOnly(t *testing.T) {
|
||||
service := "org.freedesktop.NetworkManager.openconnect"
|
||||
|
||||
assert.Equal(t, "openconnect_password", detectVPNAuthAction(service, map[string]string{
|
||||
"protocol": "fortinet",
|
||||
"authtype": "password",
|
||||
}))
|
||||
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
|
||||
"protocol": "anyconnect",
|
||||
"authtype": "password",
|
||||
}))
|
||||
assert.Empty(t, detectVPNAuthAction(service, map[string]string{
|
||||
"protocol": "fortinet",
|
||||
"authtype": "saml",
|
||||
}))
|
||||
}
|
||||
|
||||
func TestEnsureOpenConnectAgentFlags(t *testing.T) {
|
||||
data := map[string]string{"protocol": "fortinet"}
|
||||
assert.True(t, setOpenConnectAgentFlags(data))
|
||||
assert.Equal(t, "2", data["cookie-flags"])
|
||||
assert.Equal(t, "2", data["gateway-flags"])
|
||||
assert.Equal(t, "2", data["gwcert-flags"])
|
||||
assert.False(t, setOpenConnectAgentFlags(data))
|
||||
}
|
||||
|
||||
func TestOpenConnectCertificateConfirmation(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
openConnectPath := filepath.Join(binDir, "openconnect")
|
||||
script := `#!/bin/sh
|
||||
case "$*" in
|
||||
*--servercert=pin-sha256:TEST-FINGERPRINT*)
|
||||
printf '%s\n' "COOKIE='SVPNCOOKIE=test'" "HOST='vpn.example.test'" "FINGERPRINT='pin-sha256:TEST-FINGERPRINT'"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' 'Add --servercert pin-sha256:TEST-FINGERPRINT' >&2
|
||||
exit 1
|
||||
`
|
||||
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
conn := mock_gonetworkmanager.NewMockConnection(t)
|
||||
connPath := dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/999")
|
||||
conn.EXPECT().GetSecrets("vpn").Return(gonetworkmanager.ConnectionSettings{
|
||||
"vpn": {"secrets": map[string]string{"password": "test-password"}},
|
||||
}, nil)
|
||||
conn.EXPECT().GetPath().Return(connPath).Twice()
|
||||
|
||||
broker := &fakePromptBroker{
|
||||
asked: make(chan PromptRequest, 1),
|
||||
reply: PromptReply{},
|
||||
}
|
||||
backend := &NetworkManagerBackend{promptBroker: broker}
|
||||
data := map[string]string{
|
||||
"gateway": "vpn.example.test:443",
|
||||
"protocol": "fortinet",
|
||||
"authtype": "password",
|
||||
"username": "test-user",
|
||||
}
|
||||
|
||||
result, err := backend.handleOpenConnectPasswordAuth(
|
||||
context.Background(), conn, "Test VPN", "test-uuid",
|
||||
"org.freedesktop.NetworkManager.openconnect", data,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "SVPNCOOKIE=test", result.Cookie)
|
||||
assert.Equal(t, "vpn.example.test:443", result.Host)
|
||||
|
||||
prompt := <-broker.asked
|
||||
assert.Equal(t, "server-certificate", prompt.Reason)
|
||||
assert.Equal(t, []string{"pin-sha256:TEST-FINGERPRINT"}, prompt.Hints)
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"certificate:vpn.example.test:443": "pin-sha256:TEST-FINGERPRINT",
|
||||
}, backend.pendingVPNSave.PersistentSecrets)
|
||||
}
|
||||
|
||||
func TestOpenConnectCertificateRotationReprompts(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
openConnectPath := filepath.Join(binDir, "openconnect")
|
||||
script := `#!/bin/sh
|
||||
case "$*" in
|
||||
*--servercert=pin-sha256:NEW-FINGERPRINT*)
|
||||
printf '%s\n' "COOKIE='SVPNCOOKIE=test'" "HOST='vpn.example.test'" "FINGERPRINT='pin-sha256:NEW-FINGERPRINT'"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' 'Add --servercert pin-sha256:NEW-FINGERPRINT' >&2
|
||||
exit 1
|
||||
`
|
||||
assert.NoError(t, os.WriteFile(openConnectPath, []byte(script), 0o755))
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
conn := mock_gonetworkmanager.NewMockConnection(t)
|
||||
connPath := dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/999")
|
||||
conn.EXPECT().GetSecrets("vpn").Return(gonetworkmanager.ConnectionSettings{
|
||||
"vpn": {"secrets": map[string]string{
|
||||
"password": "test-password",
|
||||
"certificate:vpn.example.test:443": "pin-sha256:OLD-FINGERPRINT",
|
||||
}},
|
||||
}, nil)
|
||||
conn.EXPECT().GetPath().Return(connPath).Twice()
|
||||
|
||||
broker := &fakePromptBroker{
|
||||
asked: make(chan PromptRequest, 1),
|
||||
reply: PromptReply{},
|
||||
}
|
||||
backend := &NetworkManagerBackend{promptBroker: broker}
|
||||
data := map[string]string{
|
||||
"gateway": "vpn.example.test:443",
|
||||
"protocol": "fortinet",
|
||||
"authtype": "password",
|
||||
"username": "test-user",
|
||||
}
|
||||
|
||||
result, err := backend.handleOpenConnectPasswordAuth(
|
||||
context.Background(), conn, "Test VPN", "test-uuid",
|
||||
"org.freedesktop.NetworkManager.openconnect", data,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "SVPNCOOKIE=test", result.Cookie)
|
||||
|
||||
prompt := <-broker.asked
|
||||
assert.Equal(t, "server-certificate-changed", prompt.Reason)
|
||||
assert.Equal(t, []string{"pin-sha256:NEW-FINGERPRINT"}, prompt.Hints)
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"certificate:vpn.example.test:443": "pin-sha256:NEW-FINGERPRINT",
|
||||
}, backend.pendingVPNSave.PersistentSecrets)
|
||||
assert.False(t, backend.pendingVPNSave.SavePassword)
|
||||
assert.Empty(t, backend.pendingVPNSave.Secrets)
|
||||
}
|
||||
|
||||
@@ -43,8 +43,6 @@ func HandleRequest(conn *models.Conn, req models.Request, manager *Manager) {
|
||||
handleGetNetworkQRCode(conn, req, manager)
|
||||
case "network.qrcode-content":
|
||||
handleGetNetworkQRCodeContent(conn, req, manager)
|
||||
case "network.generate-qrcode":
|
||||
handleGenerateQRCode(conn, req)
|
||||
case "network.delete-qrcode":
|
||||
handleDeleteQRCode(conn, req, manager)
|
||||
case "network.ethernet.info":
|
||||
@@ -367,22 +365,6 @@ func handleGetNetworkQRCodeContent(conn *models.Conn, req models.Request, manage
|
||||
models.Respond(conn, req.ID, content)
|
||||
}
|
||||
|
||||
func handleGenerateQRCode(conn *models.Conn, req models.Request) {
|
||||
text, err := params.String(req.Params, "text")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
paths, err := generateTextQRCode(text)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, paths)
|
||||
}
|
||||
|
||||
func handleDeleteQRCode(conn *models.Conn, req models.Request, _ *Manager) {
|
||||
path, err := params.String(req.Params, "path")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/yeqown/go-qrcode/v2"
|
||||
"github.com/yeqown/go-qrcode/writer/standard"
|
||||
)
|
||||
|
||||
const textQRCodeTmpPrefix = "/tmp/dank-text-qrcode-"
|
||||
|
||||
func generateTextQRCode(text string) ([2]string, error) {
|
||||
qrc, err := qrcode.New(text)
|
||||
if err != nil {
|
||||
return [2]string{}, fmt.Errorf("failed to create QR code for text: %w", err)
|
||||
}
|
||||
|
||||
pathThemed, pathNormal := textQRCodePaths(text)
|
||||
|
||||
if err := saveQRCodePNG(qrc, pathThemed, standard.WithBgTransparent(), standard.WithFgColorRGBHex("#ffffff")); err != nil {
|
||||
return [2]string{}, err
|
||||
}
|
||||
if err := saveQRCodePNG(qrc, pathNormal); err != nil {
|
||||
return [2]string{}, err
|
||||
}
|
||||
|
||||
return [2]string{pathThemed, pathNormal}, nil
|
||||
}
|
||||
|
||||
// Write to a temp file and rename into place so the shell's Image never
|
||||
// observes a partially written PNG.
|
||||
func saveQRCodePNG(qrc *qrcode.QRCode, path string, opts ...standard.ImageOption) error {
|
||||
tmpPath := path + ".tmp"
|
||||
opts = append(opts, standard.WithBuiltinImageEncoder(standard.PNG_FORMAT))
|
||||
|
||||
w, err := standard.New(tmpPath, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create QR code writer: %w", err)
|
||||
}
|
||||
if err := qrc.Save(w); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to save QR code: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to move QR code into place: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Paths are unique per generation, not per text: the library's mask selection
|
||||
// is non-deterministic, so regenerating the same text produces different
|
||||
// bytes, and reusing a path lets the shell's URL-keyed pixmap cache serve a
|
||||
// stale pattern over the new file.
|
||||
func textQRCodePaths(text string) (themed, normal string) {
|
||||
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(text)))[:8]
|
||||
nonce := time.Now().UnixNano()
|
||||
themed = fmt.Sprintf("%s%s-%d-themed.png", textQRCodeTmpPrefix, hash, nonce)
|
||||
normal = fmt.Sprintf("%s%s-%d-normal.png", textQRCodeTmpPrefix, hash, nonce)
|
||||
return
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func qrCodePaths(ssid string) (themed, normal string) {
|
||||
|
||||
func isValidQRCodePath(path string) bool {
|
||||
clean := filepath.Clean(path)
|
||||
return (strings.HasPrefix(clean, qrCodeTmpPrefix) || strings.HasPrefix(clean, textQRCodeTmpPrefix)) && strings.HasSuffix(clean, ".png")
|
||||
return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png")
|
||||
}
|
||||
|
||||
var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
type RegistryInfo struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Official bool `json:"official"`
|
||||
}
|
||||
|
||||
type SuccessResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func HandleRequest(conn *models.Conn, req models.Request) {
|
||||
switch req.Method {
|
||||
case "registries.list":
|
||||
HandleList(conn, req)
|
||||
case "registries.add":
|
||||
HandleAdd(conn, req)
|
||||
case "registries.remove":
|
||||
HandleRemove(conn, req)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func HandleList(conn *models.Conn, req models.Request) {
|
||||
sources := registries.Load(afero.NewOsFs())
|
||||
result := make([]RegistryInfo, len(sources))
|
||||
for i, s := range sources {
|
||||
result[i] = RegistryInfo{Name: s.Name, URL: s.URL, Official: s.Official()}
|
||||
}
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
func HandleAdd(conn *models.Conn, req models.Request) {
|
||||
name, ok := models.Get[string](req, "name")
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
|
||||
return
|
||||
}
|
||||
url, ok := models.Get[string](req, "url")
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'url' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := registries.Add(afero.NewOsFs(), name, url); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("registry added: %s", name),
|
||||
})
|
||||
}
|
||||
|
||||
func HandleRemove(conn *models.Conn, req models.Request) {
|
||||
name, ok := models.Get[string](req, "name")
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := registries.Remove(afero.NewOsFs(), name); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("registry removed: %s", name),
|
||||
})
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||
serverRegistries "github.com/AvengeMedia/DankMaterialShell/core/internal/server/registries"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||
@@ -48,11 +47,6 @@ func RouteRequest(conn *models.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "registries.") {
|
||||
serverRegistries.HandleRequest(conn, req)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "theme.auto.") {
|
||||
if themeModeManager == nil {
|
||||
models.RespondError(conn, req.ID, "theme mode manager not initialized")
|
||||
@@ -122,12 +116,11 @@ func RouteRequest(conn *models.Conn, req models.Request) {
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "cups.") {
|
||||
mgr, err := ensureCupsManager()
|
||||
if err != nil {
|
||||
if cupsManager == nil {
|
||||
models.RespondError(conn, req.ID, "CUPS manager not initialized")
|
||||
return
|
||||
}
|
||||
cups.HandleRequest(conn, req, mgr)
|
||||
cups.HandleRequest(conn, req, cupsManager)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+37
-113
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
|
||||
@@ -36,7 +37,7 @@ import (
|
||||
"github.com/AvengeMedia/dankgo/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 29
|
||||
const APIVersion = 28
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
@@ -79,8 +80,8 @@ var geoClientInstance geolocation.Client
|
||||
const dbusClientID = "dms-dbus-client"
|
||||
|
||||
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
||||
var cupsMu sync.Mutex
|
||||
var cupsSubscriberCount int
|
||||
var cupsSubscribers syncmap.Map[string, bool]
|
||||
var cupsSubscriberCount atomic.Int32
|
||||
|
||||
var appPaths = paths.New("danklinux")
|
||||
|
||||
@@ -173,55 +174,17 @@ func InitializeAppPickerManager() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeCupsManagerLocked() (bool, error) {
|
||||
if cupsManager != nil {
|
||||
return false, nil
|
||||
}
|
||||
func InitializeCupsManager() error {
|
||||
manager, err := cups.NewManager()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to initialize cups manager: %v", err)
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
|
||||
cupsManager = manager
|
||||
|
||||
log.Info("CUPS manager initialized")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureCupsManager() (*cups.Manager, error) {
|
||||
cupsMu.Lock()
|
||||
created, err := initializeCupsManagerLocked()
|
||||
mgr := cupsManager
|
||||
cupsMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if created {
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
func cupsAvailable() bool {
|
||||
cupsMu.Lock()
|
||||
defer cupsMu.Unlock()
|
||||
return cupsManager != nil
|
||||
}
|
||||
|
||||
func releaseCupsSubscriber() {
|
||||
cupsMu.Lock()
|
||||
cupsSubscriberCount--
|
||||
var mgr *cups.Manager
|
||||
if cupsSubscriberCount == 0 && cupsManager != nil {
|
||||
mgr = cupsManager
|
||||
cupsManager = nil
|
||||
}
|
||||
cupsMu.Unlock()
|
||||
if mgr == nil {
|
||||
return
|
||||
}
|
||||
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
|
||||
mgr.Close()
|
||||
notifyCapabilityChange()
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeBrightnessManager() error {
|
||||
@@ -416,7 +379,7 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "browser")
|
||||
}
|
||||
|
||||
if cupsAvailable() {
|
||||
if cupsManager != nil {
|
||||
caps = append(caps, "cups")
|
||||
}
|
||||
|
||||
@@ -486,7 +449,7 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "browser")
|
||||
}
|
||||
|
||||
if cupsAvailable() {
|
||||
if cupsManager != nil {
|
||||
caps = append(caps, "cups")
|
||||
}
|
||||
|
||||
@@ -950,31 +913,38 @@ func handleSubscribe(conn *models.Conn, req models.Request) {
|
||||
}
|
||||
|
||||
if shouldSubscribe("cups") {
|
||||
cupsMu.Lock()
|
||||
cupsSubscriberCount++
|
||||
created, err := initializeCupsManagerLocked()
|
||||
mgr := cupsManager
|
||||
cupsMu.Unlock()
|
||||
cupsSubscribers.Store(clientID+"-cups", true)
|
||||
count := cupsSubscriberCount.Add(1)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
|
||||
} else if created {
|
||||
notifyCapabilityChange()
|
||||
if count == 1 {
|
||||
if err := InitializeCupsManager(); err != nil {
|
||||
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
|
||||
} else {
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
}
|
||||
|
||||
if mgr == nil {
|
||||
releaseCupsSubscriber()
|
||||
} else {
|
||||
if cupsManager != nil {
|
||||
wg.Add(1)
|
||||
cupsChan := mgr.Subscribe(clientID + "-cups")
|
||||
cupsChan := cupsManager.Subscribe(clientID + "-cups")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
mgr.Unsubscribe(clientID + "-cups")
|
||||
releaseCupsSubscriber()
|
||||
cupsManager.Unsubscribe(clientID + "-cups")
|
||||
cupsSubscribers.Delete(clientID + "-cups")
|
||||
count := cupsSubscriberCount.Add(-1)
|
||||
|
||||
if count == 0 {
|
||||
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
|
||||
if cupsManager != nil {
|
||||
cupsManager.Close()
|
||||
cupsManager = nil
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
initialState := mgr.GetState()
|
||||
initialState := cupsManager.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
|
||||
case <-stopChan:
|
||||
@@ -1183,38 +1153,6 @@ func handleSubscribe(conn *models.Conn, req models.Request) {
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("location") && locationManager != nil {
|
||||
wg.Add(1)
|
||||
locationChan := locationManager.Subscribe(clientID + "-location")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer locationManager.Unsubscribe(clientID + "-location")
|
||||
|
||||
initialState := locationManager.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "location", Data: initialState}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case state, ok := <-locationChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "location", Data: state}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
|
||||
wg.Add(1)
|
||||
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
|
||||
@@ -1316,12 +1254,9 @@ func cleanupManagers() {
|
||||
if appPickerManager != nil {
|
||||
appPickerManager.Close()
|
||||
}
|
||||
cupsMu.Lock()
|
||||
if cupsManager != nil {
|
||||
cupsManager.Close()
|
||||
cupsManager = nil
|
||||
}
|
||||
cupsMu.Unlock()
|
||||
if brightnessManager != nil {
|
||||
brightnessManager.Close()
|
||||
}
|
||||
@@ -1700,21 +1635,10 @@ func (s *Server) Serve(printDocs bool) error {
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
err := InitializeBluezManager()
|
||||
if err == nil {
|
||||
notifyCapabilityChange()
|
||||
return
|
||||
}
|
||||
if err := InitializeBluezManager(); err != nil {
|
||||
log.Warnf("Bluez manager unavailable: %v", err)
|
||||
if !errors.Is(err, bluez.ErrNoAdapter) {
|
||||
return
|
||||
}
|
||||
if err := bluez.WaitForAdapter(); err != nil {
|
||||
log.Warnf("Bluetooth adapter watch failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("Bluetooth adapter appeared, initializing bluez manager")
|
||||
} else {
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -143,8 +143,7 @@ func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||
case "konsole":
|
||||
argv = []string{term, "-p", "tabtitle=" + title}
|
||||
case "gnome-terminal":
|
||||
// --wait: the factory process otherwise returns immediately
|
||||
argv = []string{term, "--wait", "--title=" + title}
|
||||
argv = []string{term, "--title=" + title}
|
||||
execFlag = "--"
|
||||
default:
|
||||
argv = []string{term}
|
||||
|
||||
@@ -412,30 +412,25 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
onLine := func(line string) { m.appendLog(line) }
|
||||
argv := wrapInTerminal(term, "DMS — System Update (custom)", opts.CustomCommand, opts.TerminalArgs)
|
||||
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
||||
code := ErrCodeBackendFailed
|
||||
switch {
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
m.failCustomUpgrade(ErrCodeTimeout, err)
|
||||
return
|
||||
code = ErrCodeTimeout
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
m.failCustomUpgrade(ErrCodeCancelled, err)
|
||||
return
|
||||
code = ErrCodeCancelled
|
||||
}
|
||||
// exit status reflects the trailing `read`, not the update command
|
||||
m.appendLog(fmt.Sprintf("Terminal exited early: %v", err))
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseError
|
||||
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
return
|
||||
}
|
||||
|
||||
m.finishSuccessfulUpgrade(false)
|
||||
m.runRefresh(context.Background(), false)
|
||||
}
|
||||
|
||||
func (m *Manager) failCustomUpgrade(code ErrorCode, err error) {
|
||||
m.mu.Lock()
|
||||
m.state.Phase = PhaseError
|
||||
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
|
||||
m.mu.Unlock()
|
||||
m.markDirty()
|
||||
}
|
||||
|
||||
func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
||||
m.appendLog("Upgrade complete.")
|
||||
|
||||
|
||||
@@ -233,27 +233,3 @@ func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) {
|
||||
t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapInTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
term string
|
||||
wantPrefix []string
|
||||
}{
|
||||
{"kitty", []string{"kitty", "--class", "com.danklinux.dms", "-T", "Title"}},
|
||||
{"gnome-terminal", []string{"gnome-terminal", "--wait", "--title=Title"}},
|
||||
{"foot", []string{"foot", "--app-id=com.danklinux.dms", "--title=Title"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := wrapInTerminal(tt.term, "Title", "echo hi", nil)
|
||||
if len(got) < len(tt.wantPrefix) || !reflect.DeepEqual(got[:len(tt.wantPrefix)], tt.wantPrefix) {
|
||||
t.Errorf("wrapInTerminal(%q) = %#v, want prefix %#v", tt.term, got, tt.wantPrefix)
|
||||
}
|
||||
tail := got[len(got)-3:]
|
||||
if tail[0] != "sh" || tail[1] != "-c" {
|
||||
t.Errorf("wrapInTerminal(%q) tail = %#v, want [sh -c <cmd>]", tt.term, tail)
|
||||
}
|
||||
if !strings.Contains(tail[2], "echo hi") {
|
||||
t.Errorf("wrapInTerminal(%q) command %q does not contain shell command", tt.term, tail[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"github.com/AvengeMedia/dankgo/boottimer"
|
||||
"github.com/godbus/dbus/v5"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||
@@ -24,6 +23,9 @@ import (
|
||||
|
||||
const animKelvinStep = 25
|
||||
|
||||
// Go timers freeze during suspend; cap sleeps so wall-clock deadlines can't be missed.
|
||||
const maxScheduleWait = 5 * time.Minute
|
||||
|
||||
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
@@ -750,8 +752,7 @@ func (m *Manager) schedulerLoop() {
|
||||
m.post(func() { m.applyCurrentTemp("startup") })
|
||||
}
|
||||
|
||||
timer := boottimer.New(24 * time.Hour)
|
||||
defer timer.Stop()
|
||||
var timer *time.Timer
|
||||
for {
|
||||
m.configMutex.RLock()
|
||||
enabled := m.config.Enabled
|
||||
@@ -759,23 +760,32 @@ func (m *Manager) schedulerLoop() {
|
||||
|
||||
now := time.Now()
|
||||
m.recalcSchedule(now)
|
||||
// publish independent of output readiness so night status never
|
||||
// presents a stale schedule while applies are blocked (#2967)
|
||||
m.updateStateFromSchedule()
|
||||
|
||||
waitDur := 24 * time.Hour
|
||||
var waitDur time.Duration
|
||||
if enabled {
|
||||
deadline := m.getNextDeadline(now)
|
||||
if waitDur = time.Until(deadline); waitDur < time.Second {
|
||||
waitDur = time.Until(deadline)
|
||||
switch {
|
||||
case waitDur < time.Second:
|
||||
waitDur = time.Second
|
||||
case waitDur > maxScheduleWait:
|
||||
waitDur = maxScheduleWait
|
||||
}
|
||||
} else {
|
||||
waitDur = 24 * time.Hour
|
||||
}
|
||||
timer.Reset(waitDur)
|
||||
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(waitDur)
|
||||
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
timer.Stop()
|
||||
return
|
||||
case <-m.updateTrigger:
|
||||
timer.Stop()
|
||||
m.scheduleMutex.Lock()
|
||||
m.schedule.calcDay = time.Time{}
|
||||
m.scheduleMutex.Unlock()
|
||||
@@ -1076,11 +1086,14 @@ func (m *Manager) handleResume() {
|
||||
}
|
||||
|
||||
// Compositor gamma state is unknown after resume; force a resend (#1235)
|
||||
// and re-arm the scheduler timer, which froze during suspend.
|
||||
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
||||
out.lastTemp = 0
|
||||
return true
|
||||
})
|
||||
m.recalcSchedule(time.Now())
|
||||
m.applyCurrentTemp("resume")
|
||||
m.triggerUpdate()
|
||||
}
|
||||
|
||||
func (m *Manager) triggerUpdate() {
|
||||
@@ -1107,15 +1120,13 @@ func (m *Manager) SetTemperature(low, high int) error {
|
||||
m.configMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
updated := m.config
|
||||
updated.LowTemp = low
|
||||
updated.HighTemp = high
|
||||
if err := updated.Validate(); err != nil {
|
||||
m.configMutex.Unlock()
|
||||
m.config.LowTemp = low
|
||||
m.config.HighTemp = high
|
||||
err := m.config.Validate()
|
||||
m.configMutex.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.config = updated
|
||||
m.configMutex.Unlock()
|
||||
m.triggerUpdate()
|
||||
return nil
|
||||
}
|
||||
@@ -1127,16 +1138,14 @@ func (m *Manager) SetLocation(lat, lon float64) error {
|
||||
m.configMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
updated := m.config
|
||||
updated.Latitude = &lat
|
||||
updated.Longitude = &lon
|
||||
updated.UseIPLocation = false
|
||||
if err := updated.Validate(); err != nil {
|
||||
m.configMutex.Unlock()
|
||||
m.config.Latitude = &lat
|
||||
m.config.Longitude = &lon
|
||||
m.config.UseIPLocation = false
|
||||
err := m.config.Validate()
|
||||
m.configMutex.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.config = updated
|
||||
m.configMutex.Unlock()
|
||||
m.triggerUpdate()
|
||||
return nil
|
||||
}
|
||||
@@ -1171,15 +1180,13 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
|
||||
m.configMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
updated := m.config
|
||||
updated.ManualSunrise = &sunrise
|
||||
updated.ManualSunset = &sunset
|
||||
if err := updated.Validate(); err != nil {
|
||||
m.configMutex.Unlock()
|
||||
m.config.ManualSunrise = &sunrise
|
||||
m.config.ManualSunset = &sunset
|
||||
err := m.config.Validate()
|
||||
m.configMutex.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.config = updated
|
||||
m.configMutex.Unlock()
|
||||
m.triggerUpdate()
|
||||
return nil
|
||||
}
|
||||
@@ -1202,14 +1209,12 @@ func (m *Manager) SetGamma(gamma float64) error {
|
||||
m.configMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
updated := m.config
|
||||
updated.Gamma = gamma
|
||||
if err := updated.Validate(); err != nil {
|
||||
m.configMutex.Unlock()
|
||||
m.config.Gamma = gamma
|
||||
err := m.config.Validate()
|
||||
m.configMutex.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.config = updated
|
||||
m.configMutex.Unlock()
|
||||
m.triggerUpdate()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
|
||||
)
|
||||
|
||||
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
|
||||
@@ -413,75 +412,3 @@ func TestNewManager_InvalidConfig(t *testing.T) {
|
||||
_, err := NewManager(mockDisplay, config)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSetters_RejectedValuesLeaveConfigUntouched(t *testing.T) {
|
||||
newManager := func() *Manager {
|
||||
return &Manager{
|
||||
config: DefaultConfig(),
|
||||
updateTrigger: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("SetTemperature", func(t *testing.T) {
|
||||
m := newManager()
|
||||
before := m.config
|
||||
|
||||
err := m.SetTemperature(3200, 2500)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, before, m.config)
|
||||
assert.Empty(t, m.updateTrigger)
|
||||
})
|
||||
|
||||
t.Run("SetLocation", func(t *testing.T) {
|
||||
m := newManager()
|
||||
before := m.config
|
||||
|
||||
err := m.SetLocation(120.0, 10.0)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, before, m.config)
|
||||
assert.Empty(t, m.updateTrigger)
|
||||
})
|
||||
|
||||
t.Run("SetGamma", func(t *testing.T) {
|
||||
m := newManager()
|
||||
before := m.config
|
||||
|
||||
err := m.SetGamma(-1.0)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, before, m.config)
|
||||
assert.Empty(t, m.updateTrigger)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetters_ValidValuesCommitAndTrigger(t *testing.T) {
|
||||
m := &Manager{
|
||||
config: DefaultConfig(),
|
||||
updateTrigger: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
err := m.SetTemperature(3000, 6000)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3000, m.config.LowTemp)
|
||||
assert.Equal(t, 6000, m.config.HighTemp)
|
||||
assert.Len(t, m.updateTrigger, 1)
|
||||
}
|
||||
|
||||
func TestApplyGamma_SkipsUnchangedTempAndGamma(t *testing.T) {
|
||||
m := &Manager{config: DefaultConfig()}
|
||||
m.controlsInitialized = true
|
||||
|
||||
out := &outputState{
|
||||
id: 1,
|
||||
rampSize: 256,
|
||||
gammaControl: &wlr_gamma_control.ZwlrGammaControlV1{},
|
||||
lastTemp: 5000,
|
||||
lastGamma: m.config.Gamma,
|
||||
}
|
||||
m.outputs.Store(out.id, out)
|
||||
|
||||
m.applyGamma(5000)
|
||||
|
||||
assert.False(t, out.failed, "unchanged temp must not reach the compositor write path")
|
||||
assert.Equal(t, 5000, out.lastTemp)
|
||||
assert.Equal(t, uint32(256), out.rampSize)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||
|
||||
type ColorScheme struct {
|
||||
Primary string `json:"primary,omitempty"`
|
||||
PrimaryText string `json:"primaryText,omitempty"`
|
||||
@@ -150,7 +151,6 @@ type Theme struct {
|
||||
type GitClient interface {
|
||||
PlainClone(path string, url string) error
|
||||
Pull(path string) error
|
||||
OriginURL(path string) (string, error)
|
||||
}
|
||||
|
||||
type realGitClient struct{}
|
||||
@@ -182,28 +182,11 @@ func (g *realGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) OriginURL(path string) (string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
urls := remote.Config().URLs
|
||||
if len(urls) == 0 {
|
||||
return "", errors.New("origin remote has no URL")
|
||||
}
|
||||
return urls[0], nil
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
registries []registries.Source
|
||||
themes []Theme
|
||||
git GitClient
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
themes []Theme
|
||||
git GitClient
|
||||
}
|
||||
|
||||
func NewRegistry() (*Registry, error) {
|
||||
@@ -211,63 +194,61 @@ func NewRegistry() (*Registry, error) {
|
||||
}
|
||||
|
||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||
cacheDir := getCacheDir()
|
||||
return &Registry{
|
||||
fs: fs,
|
||||
cacheDir: getCacheDir(),
|
||||
registries: registries.Load(fs),
|
||||
git: &realGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: cacheDir,
|
||||
git: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) cacheDirFor(src registries.Source) string {
|
||||
return filepath.Join(r.cacheDir, src.Name)
|
||||
}
|
||||
|
||||
func getCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
||||
}
|
||||
|
||||
// A cached clone is reused only when its origin still matches the configured
|
||||
// URL; renamed or re-pointed registries re-clone instead of pulling from the
|
||||
// stale remote.
|
||||
func (r *Registry) updateOne(src registries.Source) error {
|
||||
dir := r.cacheDirFor(src)
|
||||
exists, err := afero.DirExists(r.fs, dir)
|
||||
func (r *Registry) Update() error {
|
||||
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check cache directory: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
origin, originErr := r.git.OriginURL(dir)
|
||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
||||
return nil
|
||||
if !exists {
|
||||
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
if err := r.fs.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
||||
|
||||
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||
return fmt.Errorf("failed to clone registry: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := r.git.Pull(r.cacheDir); err != nil {
|
||||
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted registry: %w", err)
|
||||
}
|
||||
|
||||
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone registry: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
if err := r.git.PlainClone(dir, src.URL); err != nil {
|
||||
return fmt.Errorf("failed to clone: %w", err)
|
||||
}
|
||||
return nil
|
||||
return r.loadThemes()
|
||||
}
|
||||
|
||||
// A registry without a themes/ directory is a valid plugins-only registry.
|
||||
func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
|
||||
themesDir := filepath.Join(dir, "themes")
|
||||
func (r *Registry) loadThemes() error {
|
||||
themesDir := filepath.Join(r.cacheDir, "themes")
|
||||
|
||||
entries, err := afero.ReadDir(r.fs, themesDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read themes directory: %w", err)
|
||||
return fmt.Errorf("failed to read themes directory: %w", err)
|
||||
}
|
||||
|
||||
var themes []Theme
|
||||
r.themes = []Theme{}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
@@ -297,46 +278,10 @@ func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
|
||||
theme.PreviewPath = previewPath
|
||||
}
|
||||
|
||||
themes = append(themes, theme)
|
||||
r.themes = append(r.themes, theme)
|
||||
}
|
||||
return themes, nil
|
||||
}
|
||||
|
||||
// Pre-multi-registry caches were a single clone at the base dir; the per-name
|
||||
// layout nests under it, so a leftover clone is deleted wholesale first.
|
||||
func (r *Registry) resetLegacyCache() {
|
||||
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
|
||||
_ = r.fs.RemoveAll(r.cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Update refreshes every configured registry, aggregating themes in
|
||||
// declaration order (first occurrence of an ID wins). A failing registry is
|
||||
// reported in the joined error but does not block the others.
|
||||
func (r *Registry) Update() error {
|
||||
r.resetLegacyCache()
|
||||
r.themes = []Theme{}
|
||||
seen := make(map[string]struct{})
|
||||
var errs []error
|
||||
for _, src := range r.registries {
|
||||
if err := r.updateOne(src); err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
themes, err := r.loadThemesFrom(r.cacheDirFor(src))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
for _, t := range themes {
|
||||
if _, dup := seen[t.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[t.ID] = struct{}{}
|
||||
r.themes = append(r.themes, t)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
|
||||
@@ -355,7 +300,7 @@ func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
|
||||
|
||||
func (r *Registry) List() ([]Theme, error) {
|
||||
if len(r.themes) == 0 {
|
||||
if err := r.Update(); err != nil && len(r.themes) == 0 {
|
||||
if err := r.Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -398,25 +343,11 @@ func (r *Registry) Get(idOrName string) (*Theme, error) {
|
||||
}
|
||||
|
||||
func (r *Registry) GetThemeSourcePath(themeID string) string {
|
||||
// Themes may live under any registry's subdir. Search them all; first hit wins.
|
||||
for _, cfg := range r.registries {
|
||||
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID, "theme.json")
|
||||
if exists, _ := afero.Exists(r.fs, candidate); exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// Fallback to first registry (legacy path semantics).
|
||||
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID, "theme.json")
|
||||
return filepath.Join(r.cacheDir, "themes", themeID, "theme.json")
|
||||
}
|
||||
|
||||
func (r *Registry) GetThemeDir(themeID string) string {
|
||||
for _, cfg := range r.registries {
|
||||
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID)
|
||||
if exists, _ := afero.DirExists(r.fs, candidate); exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID)
|
||||
return filepath.Join(r.cacheDir, "themes", themeID)
|
||||
}
|
||||
|
||||
func SortByFirstParty(themes []Theme) []Theme {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package themes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -66,73 +64,3 @@ func TestLoadThemeWCAGInvalidJSON(t *testing.T) {
|
||||
t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag)
|
||||
}
|
||||
}
|
||||
|
||||
type stubGitClient struct {
|
||||
cloneFunc func(path string, url string) error
|
||||
}
|
||||
|
||||
func (s *stubGitClient) PlainClone(path string, url string) error {
|
||||
if s.cloneFunc != nil {
|
||||
return s.cloneFunc(path, url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *stubGitClient) Pull(path string) error { return nil }
|
||||
func (s *stubGitClient) OriginURL(path string) (string, error) { return "", os.ErrNotExist }
|
||||
|
||||
func writeTestTheme(t *testing.T, fs afero.Fs, registryDir, themeID, name string) {
|
||||
dir := registryDir + "/themes/" + themeID
|
||||
if err := fs.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
themeJSON := `{"id":"` + themeID + `","name":"` + name + `","version":"1.0","author":"a","description":"d"}`
|
||||
if err := afero.WriteFile(fs, dir+"/theme.json", []byte(themeJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMultiRegistry(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
base := "/test-cache"
|
||||
r := &Registry{
|
||||
fs: fs,
|
||||
cacheDir: base,
|
||||
registries: []registries.Source{
|
||||
{Name: "official", URL: "https://example.com/official.git"},
|
||||
{Name: "extra", URL: "https://example.com/extra.git"},
|
||||
},
|
||||
themes: []Theme{},
|
||||
}
|
||||
r.git = &stubGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
switch path {
|
||||
case base + "/official":
|
||||
writeTestTheme(t, fs, path, "shared", "OfficialShared")
|
||||
writeTestTheme(t, fs, path, "one", "One")
|
||||
case base + "/extra":
|
||||
writeTestTheme(t, fs, path, "shared", "ExtraShared")
|
||||
writeTestTheme(t, fs, path, "two", "Two")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
if err := r.Update(); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if len(r.themes) != 3 {
|
||||
t.Fatalf("expected 3 themes after dedupe, got %d", len(r.themes))
|
||||
}
|
||||
for _, theme := range r.themes {
|
||||
if theme.ID == "shared" && theme.Name != "OfficialShared" {
|
||||
t.Fatalf("first registry should win for duplicate ID, got %q", theme.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if dir := r.GetThemeDir("two"); dir != base+"/extra/themes/two" {
|
||||
t.Fatalf("expected theme dir under extra registry, got %q", dir)
|
||||
}
|
||||
if path := r.GetThemeSourcePath("one"); path != base+"/official/themes/one/theme.json" {
|
||||
t.Fatalf("expected theme source under official registry, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule dank-qml-common updated: 28fde73112...1919595f0d
@@ -16,7 +16,7 @@ revision=1
|
||||
create_wrksrc=yes
|
||||
build_style=go
|
||||
build_wrksrc="core"
|
||||
hostmakedepends="make tar"
|
||||
hostmakedepends="make"
|
||||
go_import_path="github.com/AvengeMedia/DankMaterialShell/core"
|
||||
go_package="${go_import_path}/cmd/dms"
|
||||
go_build_tags="distro_binary withshell"
|
||||
|
||||
Generated
+3
-3
@@ -3,11 +3,11 @@
|
||||
"dank-qml-common": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1786121412,
|
||||
"narHash": "sha256-XHNpDNfQfjP6nIscd26GK6ydgBdVN5gknYGOVxXBWLw=",
|
||||
"lastModified": 1784647395,
|
||||
"narHash": "sha256-kcLSJC8XBzmWoSBc90Zn81qrNsqWMkwQ1m/zZixOw30=",
|
||||
"owner": "AvengeMedia",
|
||||
"repo": "dank-qml-common",
|
||||
"rev": "28fde7311296cbd041e5b704e54a481082b92b18",
|
||||
"rev": "1919595f0dde4f5bf5ac0baa1902ff6d070971d5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
inherit version;
|
||||
pname = "dms-shell";
|
||||
src = ./core;
|
||||
vendorHash = "sha256-pjaRyB6E2TZvVd5a4xcdGSRVr9Dg9wEG/5e+HdtZJCg=";
|
||||
vendorHash = "sha256-//ya3Yb25/kEBeT6M4bO8Egi+Tb7iiUwevXlzTyIgqA=";
|
||||
|
||||
subPackages = [ "cmd/dms" ];
|
||||
|
||||
|
||||
@@ -11,27 +11,16 @@ Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("CacheData")
|
||||
|
||||
readonly property int cacheConfigVersion: 2
|
||||
readonly property int cacheConfigVersion: 1
|
||||
|
||||
readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation)
|
||||
readonly property string _stateDir: Paths.strip(_stateUrl)
|
||||
|
||||
property bool _loading: false
|
||||
property bool _hasLoaded: false
|
||||
property int _loadedCacheVersion: 0
|
||||
|
||||
readonly property var _pinKeys: ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"]
|
||||
readonly property var _dataKeys: ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings"].concat(_pinKeys)
|
||||
|
||||
property string wallpaperLastPath: ""
|
||||
property string profileLastPath: ""
|
||||
|
||||
property var brightnessDevicePins: ({})
|
||||
property var wifiNetworkPins: ({})
|
||||
property var bluetoothDevicePins: ({})
|
||||
property var audioInputDevicePins: ({})
|
||||
property var audioOutputDevicePins: ({})
|
||||
|
||||
property var fileBrowserSettings: ({
|
||||
"wallpaper": {
|
||||
"lastPath": "",
|
||||
@@ -89,46 +78,8 @@ Singleton {
|
||||
|
||||
function loadCache() {
|
||||
_loading = true;
|
||||
try {
|
||||
parseCache(cacheFile.text());
|
||||
} finally {
|
||||
_loading = false;
|
||||
_hasLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function set(key, value) {
|
||||
if (_dataKeys.indexOf(key) < 0) {
|
||||
log.warn("Unknown cache key:", key);
|
||||
return;
|
||||
}
|
||||
root[key] = value;
|
||||
saveCache();
|
||||
}
|
||||
|
||||
function migratePins(pins) {
|
||||
if (!pins)
|
||||
return;
|
||||
if (!_hasLoaded)
|
||||
loadCache();
|
||||
if (_loadedCacheVersion >= cacheConfigVersion)
|
||||
return;
|
||||
|
||||
let migrated = false;
|
||||
for (const key of _pinKeys) {
|
||||
const legacy = pins[key];
|
||||
if (!legacy || Object.keys(legacy).length === 0)
|
||||
continue;
|
||||
if (Object.keys(root[key] || {}).length > 0)
|
||||
continue;
|
||||
root[key] = legacy;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (!migrated)
|
||||
return;
|
||||
log.info("Migrated device pins from settings.json");
|
||||
saveCache();
|
||||
parseCache(cacheFile.text());
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
function parseCache(content) {
|
||||
@@ -136,7 +87,6 @@ Singleton {
|
||||
try {
|
||||
if (content && content.trim()) {
|
||||
const cache = JSON.parse(content);
|
||||
_loadedCacheVersion = cache.configVersion || 0;
|
||||
|
||||
wallpaperLastPath = cache.wallpaperLastPath !== undefined ? cache.wallpaperLastPath : "";
|
||||
profileLastPath = cache.profileLastPath !== undefined ? cache.profileLastPath : "";
|
||||
@@ -172,10 +122,6 @@ Singleton {
|
||||
};
|
||||
}
|
||||
|
||||
for (const key of _pinKeys) {
|
||||
root[key] = cache[key] !== undefined ? cache[key] : {};
|
||||
}
|
||||
|
||||
if (cache.configVersion === undefined) {
|
||||
migrateFromUndefinedToV1(cache);
|
||||
cleanupUnusedKeys();
|
||||
@@ -192,16 +138,12 @@ Singleton {
|
||||
function saveCache() {
|
||||
if (_loading)
|
||||
return;
|
||||
const data = {
|
||||
cacheFile.setText(JSON.stringify({
|
||||
"wallpaperLastPath": wallpaperLastPath,
|
||||
"profileLastPath": profileLastPath,
|
||||
"fileBrowserSettings": fileBrowserSettings,
|
||||
"configVersion": cacheConfigVersion
|
||||
};
|
||||
for (const key of _pinKeys) {
|
||||
data[key] = root[key];
|
||||
}
|
||||
cacheFile.setText(JSON.stringify(data, null, 2));
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function migrateFromUndefinedToV1(cache) {
|
||||
@@ -209,7 +151,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function cleanupUnusedKeys() {
|
||||
const validKeys = _dataKeys.concat(["configVersion"]);
|
||||
const validKeys = ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings", "configVersion"];
|
||||
|
||||
try {
|
||||
const content = cacheFile.text();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function clearImageCache() {
|
||||
Quickshell.execDetached(["rm", "-rf", Paths.stringify(Paths.imagecache)]);
|
||||
Paths.mkdir(Paths.imagecache);
|
||||
}
|
||||
|
||||
function clearOldCache(ageInMinutes) {
|
||||
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", "*.png", "-mmin", `+${ageInMinutes}`, "-delete"]);
|
||||
}
|
||||
|
||||
function clearCacheForSize(size) {
|
||||
Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", `*@${size}x${size}.png`, "-delete"]);
|
||||
}
|
||||
|
||||
function getCacheSize(callback) {
|
||||
Proc.runCommand("cache_size", ["du", "-sm", Paths.stringify(Paths.imagecache)], function (output, exitCode) {
|
||||
const sizeMB = parseInt(output.split("\t")[0]) || 0;
|
||||
callback(sizeMB);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,26 @@ function withRevision(descriptor, revision) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function withAnimationOffset(descriptor, x, y) {
|
||||
var next = normalize(descriptor);
|
||||
next.animationOffset = {
|
||||
"x": x === undefined ? next.animationOffset.x : _number(x, next.animationOffset.x),
|
||||
"y": y === undefined ? next.animationOffset.y : _number(y, next.animationOffset.y)
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function withBodyRect(descriptor, x, y, width, height) {
|
||||
var next = normalize(descriptor);
|
||||
next.bodyRect = {
|
||||
"x": x === undefined ? next.bodyRect.x : _number(x, next.bodyRect.x),
|
||||
"y": y === undefined ? next.bodyRect.y : _number(y, next.bodyRect.y),
|
||||
"width": width === undefined ? next.bodyRect.width : Math.max(0, _number(width, next.bodyRect.width)),
|
||||
"height": height === undefined ? next.bodyRect.height : Math.max(0, _number(height, next.bodyRect.height))
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function same(a, b, threshold) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
|
||||
@@ -162,6 +162,21 @@ function fillBounds(rect, side, seamOverlap, dpr) {
|
||||
};
|
||||
}
|
||||
|
||||
function clipEnvelope(rect, side, radii, seamOverlap, dpr) {
|
||||
var fill = fillBounds(rect, side, seamOverlap, dpr);
|
||||
var chrome = chromeBounds(fill, side, radii.start, radii.end, radii.farExtent, dpr);
|
||||
return {
|
||||
"x": chrome.x,
|
||||
"y": chrome.y,
|
||||
"width": chrome.width,
|
||||
"height": chrome.height,
|
||||
"bodyX": snap(fill.x - chrome.x, dpr),
|
||||
"bodyY": snap(fill.y - chrome.y, dpr),
|
||||
"bodyWidth": fill.width,
|
||||
"bodyHeight": fill.height
|
||||
};
|
||||
}
|
||||
|
||||
function blurRegions(descriptor, rect, radii, dpr) {
|
||||
var side = descriptor.barSide;
|
||||
var regions = [bodyRect(rect, dpr)];
|
||||
@@ -204,3 +219,14 @@ function unionBounds(rects, padding, dpr) {
|
||||
"height": Math.max(0, snap(maxY - minY + pad * 2, dpr))
|
||||
};
|
||||
}
|
||||
|
||||
function shadowSourceBounds(descriptor, rect, radii, padding, dpr) {
|
||||
return unionBounds(blurRegions(descriptor, rect, radii, dpr), padding, dpr);
|
||||
}
|
||||
|
||||
function stableEqual(a, b, dpr) {
|
||||
if (!a || !b)
|
||||
return false;
|
||||
var threshold = 0.5 / (dpr || 1);
|
||||
return Math.abs(a.x - b.x) < threshold && Math.abs(a.y - b.y) < threshold && Math.abs(a.width - b.width) < threshold && Math.abs(a.height - b.height) < threshold;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ function connectorX(barSide, baseX, bodyWidth, placement, spacing, radius) {
|
||||
return barSide === "left" ? s : s - w;
|
||||
}
|
||||
|
||||
function connectorY(barSide, baseY, bodyHeight, placement, spacing, radius) {
|
||||
var s = seamY(barSide, baseY, bodyHeight, placement);
|
||||
var h = connectorHeight(barSide, spacing, radius);
|
||||
if (barSide === "top")
|
||||
return s;
|
||||
if (barSide === "bottom")
|
||||
return s - h;
|
||||
return placement === "left" ? s - h : s;
|
||||
}
|
||||
|
||||
// Which corner of the connector's bounding rect hosts the concave arc that
|
||||
// carves into the body. Used for arc-sweep orientation.
|
||||
function arcCorner(barSide, placement) {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
.pragma library
|
||||
|
||||
function formatRate(bytesPerSec, gbDecimals) {
|
||||
if (bytesPerSec < 1024)
|
||||
return bytesPerSec.toFixed(0) + " B/s";
|
||||
if (bytesPerSec < 1024 * 1024)
|
||||
return (bytesPerSec / 1024).toFixed(1) + " KB/s";
|
||||
if (bytesPerSec < 1024 * 1024 * 1024)
|
||||
return (bytesPerSec / (1024 * 1024)).toFixed(1) + " MB/s";
|
||||
return (bytesPerSec / (1024 * 1024 * 1024)).toFixed(gbDecimals ?? 2) + " GB/s";
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024)
|
||||
return bytes.toFixed(0) + "B";
|
||||
if (bytes < 1024 * 1024)
|
||||
return (bytes / 1024).toFixed(0) + "K";
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + "M";
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + "G";
|
||||
}
|
||||
|
||||
function formatIsoTime(isoString) {
|
||||
if (!isoString)
|
||||
return "";
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime()))
|
||||
return "";
|
||||
return date.toLocaleTimeString(Qt.locale(), "HH:mm");
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemaining(ms, zeroText, minText, hText, hmText) {
|
||||
if (ms <= 0)
|
||||
return zeroText;
|
||||
const totalMinutes = Math.ceil(ms / 60000);
|
||||
if (totalMinutes < 60)
|
||||
return minText.arg(totalMinutes);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const mins = totalMinutes - hours * 60;
|
||||
if (mins === 0)
|
||||
return hText.arg(hours);
|
||||
return hmText.arg(hours).arg(mins);
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? "0" + n : "" + n;
|
||||
}
|
||||
|
||||
function formatUntil(ts, use24h) {
|
||||
if (!ts)
|
||||
return "";
|
||||
const d = new Date(ts);
|
||||
const hours = d.getHours();
|
||||
const minutes = d.getMinutes();
|
||||
if (use24h)
|
||||
return pad2(hours) + ":" + pad2(minutes);
|
||||
const suffix = hours >= 12 ? "PM" : "AM";
|
||||
const h12 = ((hours + 11) % 12) + 1;
|
||||
return h12 + ":" + pad2(minutes) + " " + suffix;
|
||||
}
|
||||
|
||||
function addToHistory(arr, val, max) {
|
||||
const newArr = arr.slice();
|
||||
newArr.push(val);
|
||||
if (newArr.length > max)
|
||||
newArr.shift();
|
||||
return newArr;
|
||||
}
|
||||
@@ -271,61 +271,3 @@ function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) {
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function qtKeyFromName(name) {
|
||||
var n = (name || "").toUpperCase();
|
||||
if (n.length === 1 && n >= "A" && n <= "Z")
|
||||
return Qt.Key_A + (n.charCodeAt(0) - 65);
|
||||
if (n.length === 1 && n >= "0" && n <= "9")
|
||||
return Qt.Key_0 + (n.charCodeAt(0) - 48);
|
||||
if (n.length >= 2 && n[0] === "F") {
|
||||
var f = parseInt(n.slice(1), 10);
|
||||
if (f >= 1 && f <= 12)
|
||||
return Qt.Key_F1 + (f - 1);
|
||||
}
|
||||
var named = {
|
||||
"SPACE": Qt.Key_Space,
|
||||
"TAB": Qt.Key_Tab,
|
||||
"RETURN": Qt.Key_Return,
|
||||
"ENTER": Qt.Key_Enter,
|
||||
"BACKSPACE": Qt.Key_Backspace,
|
||||
"DELETE": Qt.Key_Delete,
|
||||
"HOME": Qt.Key_Home,
|
||||
"END": Qt.Key_End,
|
||||
"UP": Qt.Key_Up,
|
||||
"DOWN": Qt.Key_Down,
|
||||
"LEFT": Qt.Key_Left,
|
||||
"RIGHT": Qt.Key_Right
|
||||
};
|
||||
return named[n] || 0;
|
||||
}
|
||||
|
||||
function isModifierKey(qk) {
|
||||
return qk === Qt.Key_Control || qk === Qt.Key_Shift || qk === Qt.Key_Alt || qk === Qt.Key_Meta
|
||||
|| qk === Qt.Key_NumLock || qk === Qt.Key_CapsLock || qk === Qt.Key_ScrollLock;
|
||||
}
|
||||
|
||||
function eventMatchesCombo(event, combo) {
|
||||
if (!combo)
|
||||
return false;
|
||||
var parts = combo.split("+");
|
||||
var keyName = parts[parts.length - 1].trim().toUpperCase();
|
||||
var wantsShift = false;
|
||||
var hasCtrl = false;
|
||||
for (var i = 0; i < parts.length - 1; i++) {
|
||||
var mod = parts[i].trim().toLowerCase();
|
||||
if (mod === "shift")
|
||||
wantsShift = true;
|
||||
else if (mod === "ctrl" || mod === "control")
|
||||
hasCtrl = true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
if (hasCtrl && !(event.modifiers & Qt.ControlModifier))
|
||||
return false;
|
||||
if (event.modifiers & (Qt.AltModifier | Qt.MetaModifier))
|
||||
return false;
|
||||
if (((event.modifiers & Qt.ShiftModifier) !== 0) !== wantsShift)
|
||||
return false;
|
||||
return event.key === qtKeyFromName(keyName);
|
||||
}
|
||||
|
||||
@@ -1370,3 +1370,10 @@ function buildDmsAction(baseKey, args) {
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function getScreenshotOptions() {
|
||||
return [
|
||||
{ id: "write-to-disk", label: "Save to disk", type: "bool" },
|
||||
{ id: "show-pointer", label: "Show pointer", type: "bool" }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,11 +15,7 @@ Singleton {
|
||||
function openModal(modal) {
|
||||
PopoutManager.screenshotActive = false;
|
||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||
var next = {};
|
||||
for (var k in currentModalsByScreen)
|
||||
next[k] = currentModalsByScreen[k];
|
||||
next[screenName] = modal;
|
||||
currentModalsByScreen = next;
|
||||
currentModalsByScreen[screenName] = modal;
|
||||
modalChanged();
|
||||
Qt.callLater(() => {
|
||||
if (!modal.allowStacking)
|
||||
@@ -38,12 +34,7 @@ Singleton {
|
||||
function closeModal(modal) {
|
||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||
if (currentModalsByScreen[screenName] === modal) {
|
||||
var next = {};
|
||||
for (var k in currentModalsByScreen) {
|
||||
if (k !== screenName)
|
||||
next[k] = currentModalsByScreen[k];
|
||||
}
|
||||
currentModalsByScreen = next;
|
||||
delete currentModalsByScreen[screenName];
|
||||
modalChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ Singleton {
|
||||
|
||||
readonly property url imagecache: `${cache}/imagecache`
|
||||
|
||||
property var iconResolver: null
|
||||
property var desktopIconResolver: null
|
||||
property var trashHandler: null
|
||||
|
||||
Component.onCompleted: mkdir(imagecache)
|
||||
|
||||
function stringify(path: url): string {
|
||||
@@ -92,7 +88,7 @@ Singleton {
|
||||
function themedIconPath(name: string): string {
|
||||
if (!name)
|
||||
return "";
|
||||
const themed = iconResolver ? iconResolver(name) : "";
|
||||
const themed = (typeof IconThemeService !== "undefined") ? IconThemeService.resolve(name) : "";
|
||||
if (themed)
|
||||
return themed;
|
||||
return Quickshell.iconPath(name, true);
|
||||
@@ -109,15 +105,11 @@ Singleton {
|
||||
return moddedId;
|
||||
return themedIconPath(moddedId);
|
||||
}
|
||||
if (!desktopIconResolver)
|
||||
return themedIconPath(iconName);
|
||||
return themedIconPath(iconName) || desktopIconResolver(iconName);
|
||||
return themedIconPath(iconName) || DesktopService.resolveIconPath(iconName);
|
||||
}
|
||||
|
||||
function trashPath(path: string, callback): void {
|
||||
if (!trashHandler)
|
||||
return;
|
||||
trashHandler(path, callback);
|
||||
TrashService.trashPath(path, callback);
|
||||
}
|
||||
|
||||
function copyPathToClipboard(path: string): void {
|
||||
@@ -133,7 +125,7 @@ Singleton {
|
||||
return toFileUrl(expandTilde(target));
|
||||
if (target.startsWith("file://"))
|
||||
return target;
|
||||
const themed = iconResolver ? iconResolver(target) : "";
|
||||
const themed = (typeof IconThemeService !== "undefined") ? IconThemeService.resolve(target) : "";
|
||||
if (themed)
|
||||
return themed;
|
||||
return "image://icon/" + target;
|
||||
@@ -156,9 +148,7 @@ Singleton {
|
||||
if (icon && icon !== "")
|
||||
return icon;
|
||||
|
||||
if (!desktopIconResolver)
|
||||
return "";
|
||||
return desktopIconResolver(appId);
|
||||
return DesktopService.resolveIconPath(appId);
|
||||
}
|
||||
|
||||
function getAppName(appId: string, desktopEntry: var): string {
|
||||
|
||||
@@ -65,35 +65,16 @@ Singleton {
|
||||
|
||||
function _openPopout(popout) {
|
||||
if (popout.dashVisible !== undefined) {
|
||||
let flagStayedTrue = popout.dashVisible === true;
|
||||
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing) {
|
||||
if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing)
|
||||
popout.dashVisible = false;
|
||||
flagStayedTrue = false;
|
||||
}
|
||||
popout.dashVisible = true;
|
||||
// Flag already true (e.g. retargeting to another monitor) won't re-fire
|
||||
// the change handler, so drive the surface open explicitly.
|
||||
if (flagStayedTrue)
|
||||
_ensureSurfaceOpen(popout);
|
||||
return;
|
||||
}
|
||||
if (popout.notificationHistoryVisible !== undefined) {
|
||||
const flagStayedTrue = popout.notificationHistoryVisible === true;
|
||||
popout.notificationHistoryVisible = true;
|
||||
if (flagStayedTrue)
|
||||
_ensureSurfaceOpen(popout);
|
||||
return;
|
||||
}
|
||||
_ensureSurfaceOpen(popout);
|
||||
}
|
||||
|
||||
function _ensureSurfaceOpen(popout) {
|
||||
if (typeof popout.present === "function") {
|
||||
popout.present();
|
||||
return;
|
||||
}
|
||||
if (typeof popout.open === "function")
|
||||
popout.open();
|
||||
popout.open();
|
||||
}
|
||||
|
||||
function _closePopout(popout) {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
.pragma library
|
||||
|
||||
function findParentFlickable(item) {
|
||||
while (item) {
|
||||
if (item.hasOwnProperty("contentY") && item.hasOwnProperty("contentItem"))
|
||||
return item;
|
||||
item = item.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSettings(item) {
|
||||
while (item) {
|
||||
if (item.saveValue !== undefined && item.loadValue !== undefined)
|
||||
return item;
|
||||
item = item.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePinList(value) {
|
||||
if (Array.isArray(value))
|
||||
return value.filter(v => v);
|
||||
if (typeof value === "string" && value.length > 0)
|
||||
return [value];
|
||||
return [];
|
||||
}
|
||||
@@ -16,10 +16,6 @@ Singleton {
|
||||
|
||||
readonly property int sessionConfigVersion: 3
|
||||
|
||||
signal loaded
|
||||
signal brightnessDisplayHintChanged(string deviceName)
|
||||
signal loadErrorOccurred(string file, string message)
|
||||
|
||||
property bool _parseError: false
|
||||
property bool _hasLoaded: false
|
||||
property bool _isReadOnly: false
|
||||
@@ -148,7 +144,6 @@ Singleton {
|
||||
property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none")
|
||||
|
||||
property bool wallpaperCyclingEnabled: false
|
||||
property bool wallpaperCyclingRandom: false
|
||||
property string wallpaperCyclingMode: "interval"
|
||||
property int wallpaperCyclingInterval: 300
|
||||
property string wallpaperCyclingTime: "06:00"
|
||||
@@ -229,7 +224,6 @@ Singleton {
|
||||
property string niriOverviewLastMode: "apps"
|
||||
property string settingsSidebarExpandedIds: ","
|
||||
property string settingsSidebarCollapsedIds: ","
|
||||
property bool showConfigReloadToast: true
|
||||
|
||||
Component.onCompleted: {
|
||||
loadSettings();
|
||||
@@ -281,14 +275,15 @@ Singleton {
|
||||
if (typeof Theme !== "undefined")
|
||||
Theme.generateSystemThemesFromCurrentTheme();
|
||||
|
||||
loaded();
|
||||
if (typeof WallpaperCyclingService !== "undefined")
|
||||
WallpaperCyclingService.updateCyclingState();
|
||||
|
||||
_checkSessionWritable();
|
||||
} catch (e) {
|
||||
_parseError = true;
|
||||
const msg = e.message;
|
||||
log.error("Failed to parse session.json - file will not be overwritten.");
|
||||
Qt.callLater(() => loadErrorOccurred("session.json", msg));
|
||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,12 +357,13 @@ Singleton {
|
||||
if (typeof Theme !== "undefined")
|
||||
Theme.generateSystemThemesFromCurrentTheme();
|
||||
|
||||
loaded();
|
||||
if (typeof WallpaperCyclingService !== "undefined")
|
||||
WallpaperCyclingService.updateCyclingState();
|
||||
} catch (e) {
|
||||
_parseError = true;
|
||||
const msg = e.message;
|
||||
log.error("Failed to parse session.json - file will not be overwritten.");
|
||||
Qt.callLater(() => loadErrorOccurred("session.json", msg));
|
||||
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse %1").arg("session.json"), msg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +464,11 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setWallpaperPath(path) {
|
||||
wallpaperPath = path;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setWallpaper(imagePath) {
|
||||
wallpaperPath = imagePath;
|
||||
if (perModeWallpaper) {
|
||||
@@ -647,11 +648,6 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setWallpaperCyclingRandom(random) {
|
||||
wallpaperCyclingRandom = random;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setWallpaperCyclingMode(mode) {
|
||||
wallpaperCyclingMode = mode;
|
||||
saveSettings();
|
||||
@@ -698,37 +694,6 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setMonitorCyclingRandom(screenName, random) {
|
||||
var screen = null;
|
||||
var screens = Quickshell.screens;
|
||||
for (var i = 0; i < screens.length; i++) {
|
||||
if (screens[i].name === screenName) {
|
||||
screen = screens[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!screen) {
|
||||
log.warn("Screen not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var identifier = typeof SettingsData !== "undefined" ? SettingsData.getScreenDisplayName(screen) : screen.name;
|
||||
|
||||
var newSettings = {};
|
||||
for (var key in monitorCyclingSettings) {
|
||||
var isThisScreen = key === screen.name || (screen.model && key === screen.model);
|
||||
if (!isThisScreen) {
|
||||
newSettings[key] = monitorCyclingSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
newSettings[identifier] = getMonitorCyclingSettings(screenName);
|
||||
newSettings[identifier].random = random;
|
||||
monitorCyclingSettings = newSettings;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setMonitorCyclingMode(screenName, mode) {
|
||||
var screen = null;
|
||||
var screens = Quickshell.screens;
|
||||
@@ -882,6 +847,11 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setNightModeLocationProvider(provider) {
|
||||
nightModeLocationProvider = provider;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setThemeModeAutoEnabled(enabled) {
|
||||
themeModeAutoEnabled = enabled;
|
||||
saveSettings();
|
||||
@@ -970,6 +940,10 @@ Singleton {
|
||||
setBarPinnedApps(currentPinned);
|
||||
}
|
||||
|
||||
function isBarPinnedApp(appId) {
|
||||
return appId && barPinnedApps.indexOf(appId) !== -1;
|
||||
}
|
||||
|
||||
function hideTrayId(trayId) {
|
||||
if (!trayId)
|
||||
return;
|
||||
@@ -1032,6 +1006,11 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setLaunchPrefix(prefix) {
|
||||
launchPrefix = prefix;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setLastBrightnessDevice(device) {
|
||||
lastBrightnessDevice = device;
|
||||
saveSettings();
|
||||
@@ -1046,7 +1025,10 @@ Singleton {
|
||||
}
|
||||
brightnessExponentialDevices = newSettings;
|
||||
saveSettings();
|
||||
brightnessDisplayHintChanged(deviceName);
|
||||
|
||||
if (typeof DisplayService !== "undefined") {
|
||||
DisplayService.updateDeviceBrightnessDisplay(deviceName);
|
||||
}
|
||||
}
|
||||
|
||||
function getBrightnessExponential(deviceName) {
|
||||
@@ -1060,6 +1042,10 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getBrightnessUserSetValue(deviceName) {
|
||||
return brightnessUserSetValues[deviceName];
|
||||
}
|
||||
|
||||
function clearBrightnessUserSetValue(deviceName) {
|
||||
var newValues = Object.assign({}, brightnessUserSetValues);
|
||||
delete newValues[deviceName];
|
||||
@@ -1083,6 +1069,21 @@ Singleton {
|
||||
return value !== undefined ? value : 1.2;
|
||||
}
|
||||
|
||||
function setSelectedGpuIndex(index) {
|
||||
selectedGpuIndex = index;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setNvidiaGpuTempEnabled(enabled) {
|
||||
nvidiaGpuTempEnabled = enabled;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setNonNvidiaGpuTempEnabled(enabled) {
|
||||
nonNvidiaGpuTempEnabled = enabled;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setEnabledGpuPciIds(pciIds) {
|
||||
enabledGpuPciIds = pciIds;
|
||||
saveSettings();
|
||||
@@ -1198,6 +1199,15 @@ Singleton {
|
||||
return deviceMaxVolumes[nodeName] ?? 100;
|
||||
}
|
||||
|
||||
function removeDeviceMaxVolume(nodeName) {
|
||||
if (!nodeName)
|
||||
return;
|
||||
const updated = Object.assign({}, deviceMaxVolumes);
|
||||
delete updated[nodeName];
|
||||
deviceMaxVolumes = updated;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function updateLocale() {
|
||||
if (!locale) {
|
||||
I18n._pickTranslation();
|
||||
@@ -1261,6 +1271,12 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function clearLauncherHistory() {
|
||||
launcherLastQuery = "";
|
||||
launcherSearchHistory = [];
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setAppDrawerLastMode(mode) {
|
||||
appDrawerLastMode = mode;
|
||||
saveSettings();
|
||||
@@ -1359,7 +1375,6 @@ Singleton {
|
||||
function getMonitorCyclingSettings(screenName) {
|
||||
var defaults = {
|
||||
"enabled": false,
|
||||
"random": false,
|
||||
"mode": "interval",
|
||||
"interval": 300,
|
||||
"time": "06:00"
|
||||
@@ -1401,4 +1416,141 @@ Singleton {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "wallpaper"
|
||||
|
||||
function get(): string {
|
||||
if (root.perMonitorWallpaper) {
|
||||
return "ERROR: Per-monitor mode enabled. Use getFor(screenName) instead.";
|
||||
}
|
||||
return root.wallpaperPath || "";
|
||||
}
|
||||
|
||||
function set(path: string): string {
|
||||
if (root.perMonitorWallpaper) {
|
||||
return "ERROR: Per-monitor mode enabled. Use setFor(screenName, path) instead.";
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return "ERROR: No path provided";
|
||||
}
|
||||
|
||||
var absolutePath = path.startsWith("/") ? path : StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/" + path;
|
||||
|
||||
try {
|
||||
root.setWallpaper(absolutePath);
|
||||
return "SUCCESS: Wallpaper set to " + absolutePath;
|
||||
} catch (e) {
|
||||
return "ERROR: Failed to set wallpaper: " + e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): string {
|
||||
root.setWallpaper("");
|
||||
root.setPerMonitorWallpaper(false);
|
||||
root.monitorWallpapers = {};
|
||||
root.saveSettings();
|
||||
return "SUCCESS: All wallpapers cleared";
|
||||
}
|
||||
|
||||
function next(): string {
|
||||
if (root.perMonitorWallpaper) {
|
||||
return "ERROR: Per-monitor mode enabled. Use nextFor(screenName) instead.";
|
||||
}
|
||||
|
||||
if (!root.wallpaperPath) {
|
||||
return "ERROR: No wallpaper set";
|
||||
}
|
||||
|
||||
try {
|
||||
WallpaperCyclingService.cycleNextManually();
|
||||
return "SUCCESS: Cycling to next wallpaper";
|
||||
} catch (e) {
|
||||
return "ERROR: Failed to cycle wallpaper: " + e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function prev(): string {
|
||||
if (root.perMonitorWallpaper) {
|
||||
return "ERROR: Per-monitor mode enabled. Use prevFor(screenName) instead.";
|
||||
}
|
||||
|
||||
if (!root.wallpaperPath) {
|
||||
return "ERROR: No wallpaper set";
|
||||
}
|
||||
|
||||
try {
|
||||
WallpaperCyclingService.cyclePrevManually();
|
||||
return "SUCCESS: Cycling to previous wallpaper";
|
||||
} catch (e) {
|
||||
return "ERROR: Failed to cycle wallpaper: " + e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function getFor(screenName: string): string {
|
||||
if (!screenName) {
|
||||
return "ERROR: No screen name provided";
|
||||
}
|
||||
return root.getMonitorWallpaper(screenName) || "";
|
||||
}
|
||||
|
||||
function setFor(screenName: string, path: string): string {
|
||||
if (!screenName) {
|
||||
return "ERROR: No screen name provided";
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return "ERROR: No path provided";
|
||||
}
|
||||
|
||||
var absolutePath = path.startsWith("/") ? path : StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/" + path;
|
||||
|
||||
try {
|
||||
if (!root.perMonitorWallpaper) {
|
||||
root.setPerMonitorWallpaper(true);
|
||||
}
|
||||
root.setMonitorWallpaper(screenName, absolutePath);
|
||||
return "SUCCESS: Wallpaper set for " + screenName + " to " + absolutePath;
|
||||
} catch (e) {
|
||||
return "ERROR: Failed to set wallpaper for " + screenName + ": " + e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function nextFor(screenName: string): string {
|
||||
if (!screenName) {
|
||||
return "ERROR: No screen name provided";
|
||||
}
|
||||
|
||||
var currentWallpaper = root.getMonitorWallpaper(screenName);
|
||||
if (!currentWallpaper) {
|
||||
return "ERROR: No wallpaper set for " + screenName;
|
||||
}
|
||||
|
||||
try {
|
||||
WallpaperCyclingService.cycleNextForMonitor(screenName);
|
||||
return "SUCCESS: Cycling to next wallpaper for " + screenName;
|
||||
} catch (e) {
|
||||
return "ERROR: Failed to cycle wallpaper for " + screenName + ": " + e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function prevFor(screenName: string): string {
|
||||
if (!screenName) {
|
||||
return "ERROR: No screen name provided";
|
||||
}
|
||||
|
||||
var currentWallpaper = root.getMonitorWallpaper(screenName);
|
||||
if (!currentWallpaper) {
|
||||
return "ERROR: No wallpaper set for " + screenName;
|
||||
}
|
||||
|
||||
try {
|
||||
WallpaperCyclingService.cyclePrevForMonitor(screenName);
|
||||
return "SUCCESS: Cycling to previous wallpaper for " + screenName;
|
||||
} catch (e) {
|
||||
return "ERROR: Failed to cycle wallpaper for " + screenName + ": " + e.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+478
-112
@@ -15,7 +15,7 @@ Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("SettingsData")
|
||||
|
||||
readonly property int settingsConfigVersion: 13
|
||||
readonly property int settingsConfigVersion: 12
|
||||
|
||||
enum Position {
|
||||
Top,
|
||||
@@ -164,11 +164,6 @@ Singleton {
|
||||
property string matugenTargetMonitor: ""
|
||||
property real popupTransparency: 1.0
|
||||
property real dockTransparency: 1
|
||||
property bool floatingWindowSyncGlobal: true
|
||||
property real floatingWindowTransparency: 1.0
|
||||
property bool floatingWindowForegroundLayers: true
|
||||
property real floatingWindowForegroundTransparency: 1.0
|
||||
property bool dmsWindowsFloating: true
|
||||
property string widgetBackgroundColor: "sch"
|
||||
property string widgetBackgroundCustomColor: "#6750A4"
|
||||
property real widgetBackgroundCustomStrength: 0.50
|
||||
@@ -189,34 +184,6 @@ Singleton {
|
||||
property int mangoLayoutRadiusOverride: -1
|
||||
property int mangoLayoutBorderSize: -1
|
||||
property bool mangoTrackpadNaturalScrolling: true
|
||||
property string mouseAccelProfile: "default"
|
||||
property real mouseAccelSpeed: 0.0
|
||||
property bool mouseLeftHanded: false
|
||||
property bool mouseMiddleEmulation: false
|
||||
property bool mouseNaturalScroll: false
|
||||
property real mouseScrollFactor: 1.0
|
||||
property string mouseScrollMethod: "default"
|
||||
property string touchpadAccelProfile: "default"
|
||||
property real touchpadAccelSpeed: 0.0
|
||||
property bool touchpadDisableOnExternalMouse: false
|
||||
property bool touchpadDisableWhileTyping: true
|
||||
property bool touchpadDragLock: false
|
||||
property bool touchpadMiddleEmulation: false
|
||||
property bool touchpadNaturalScroll: true
|
||||
property real touchpadScrollFactor: 1.0
|
||||
property string touchpadScrollMethod: "default"
|
||||
property bool touchpadTapAndDrag: true
|
||||
property bool touchpadTapToClick: true
|
||||
|
||||
property string keyboardLayouts: ""
|
||||
property string keyboardVariants: ""
|
||||
property string keyboardModel: ""
|
||||
property string keyboardOptions: ""
|
||||
property string keyboardKeymapFile: ""
|
||||
property string keyboardTrackLayout: ""
|
||||
property int keyboardRepeatDelay: 0
|
||||
property int keyboardRepeatRate: 0
|
||||
property bool keyboardNumlock: false
|
||||
|
||||
property int firstDayOfWeek: -1
|
||||
property bool showWeekNumber: false
|
||||
@@ -269,7 +236,6 @@ Singleton {
|
||||
onBlurEnabledChanged: saveSettings()
|
||||
property bool blurForegroundLayers: true
|
||||
onBlurForegroundLayersChanged: saveSettings()
|
||||
property real foregroundLayerTransparency: 1.0
|
||||
property real blurLayerOutlineOpacity: 0.12
|
||||
onBlurLayerOutlineOpacityChanged: saveSettings()
|
||||
property bool blurBorderEnabled: true
|
||||
@@ -495,7 +461,6 @@ Singleton {
|
||||
property bool mediaAdaptiveWidthEnabled: true
|
||||
property bool audioVisualizerEnabled: true
|
||||
property bool mediaUseAlbumArtAccent: false
|
||||
property bool appleMusicAnimatedArtEnabled: false
|
||||
property string audioScrollMode: "volume"
|
||||
property int audioWheelScrollAmount: 5
|
||||
property bool audioDeviceScrollVolumeEnabled: false
|
||||
@@ -676,6 +641,9 @@ Singleton {
|
||||
readonly property string iconTheme: resolveIconTheme()
|
||||
property var availableIconThemes: ["System Default"]
|
||||
property string systemDefaultIconTheme: ""
|
||||
property bool qt5ctAvailable: false
|
||||
property bool qt6ctAvailable: false
|
||||
property bool gtkAvailable: false
|
||||
|
||||
property var cursorSettings: ({
|
||||
"theme": "System Default",
|
||||
@@ -726,23 +694,16 @@ Singleton {
|
||||
property bool notepadUseCompositorGap: false
|
||||
property int notepadEdgeGap: 0
|
||||
|
||||
property string activeCompositor: ""
|
||||
|
||||
// Compositor layout gap when enabled and available, else the manual value.
|
||||
readonly property int notepadEffectiveEdgeGap: {
|
||||
if (notepadUseCompositorGap) {
|
||||
var g = -1;
|
||||
switch (activeCompositor) {
|
||||
case "niri":
|
||||
if (CompositorService.isNiri)
|
||||
g = niriLayoutGapsOverride;
|
||||
break;
|
||||
case "hyprland":
|
||||
else if (CompositorService.isHyprland)
|
||||
g = hyprlandLayoutGapsOverride;
|
||||
break;
|
||||
case "mango":
|
||||
else if (CompositorService.isMango)
|
||||
g = mangoLayoutGapsOverride;
|
||||
break;
|
||||
}
|
||||
if (g >= 0)
|
||||
return g;
|
||||
}
|
||||
@@ -811,7 +772,14 @@ Singleton {
|
||||
property bool fadeToDpmsEnabled: true
|
||||
property int fadeToDpmsGracePeriod: 5
|
||||
property string launchPrefix: ""
|
||||
property var brightnessDevicePins: ({})
|
||||
property var wifiNetworkPins: ({})
|
||||
property var bluetoothDevicePins: ({})
|
||||
property var audioInputDevicePins: ({})
|
||||
property var audioOutputDevicePins: ({})
|
||||
|
||||
property bool gtkThemingEnabled: false
|
||||
property bool qtThemingEnabled: false
|
||||
property bool syncModeWithPortal: true
|
||||
property bool terminalsAlwaysDark: false
|
||||
|
||||
@@ -934,8 +902,6 @@ Singleton {
|
||||
property bool lockPamInlineU2f: false
|
||||
property bool lockPamExternallyManaged: false
|
||||
property string lockU2fPamPath: ""
|
||||
property string lockScreenSecurityKeyShortcut: "Ctrl+Q"
|
||||
property bool lockScreenSecurityKeyShortcutEnabled: false
|
||||
property bool greeterPamExternallyManaged: false
|
||||
property string lockScreenInactiveColor: "#000000"
|
||||
property int lockScreenNotificationMode: 0
|
||||
@@ -950,7 +916,6 @@ Singleton {
|
||||
property int notificationTimeoutLow: 5000
|
||||
property int notificationTimeoutNormal: 5000
|
||||
property int notificationTimeoutCritical: 0
|
||||
property bool notificationIgnoreAppTimeout: false
|
||||
property bool notificationCompactMode: false
|
||||
property bool notificationShowTimeoutBar: false
|
||||
property bool notificationDedupeEnabled: true
|
||||
@@ -990,7 +955,6 @@ Singleton {
|
||||
property string customPowerActionHibernate: ""
|
||||
property string customPowerActionReboot: ""
|
||||
property string customPowerActionPowerOff: ""
|
||||
property var customPowerButtons: []
|
||||
|
||||
property bool updaterHideWidget: false
|
||||
property bool updaterCheckOnStart: false
|
||||
@@ -1148,6 +1112,44 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getSystemMonitorVariants() {
|
||||
return systemMonitorVariants || [];
|
||||
}
|
||||
|
||||
function createSystemMonitorVariant(name, config) {
|
||||
const id = "sysmon_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9);
|
||||
const variant = {
|
||||
id: id,
|
||||
name: name,
|
||||
config: config || getDefaultSystemMonitorConfig()
|
||||
};
|
||||
const variants = JSON.parse(JSON.stringify(systemMonitorVariants || []));
|
||||
variants.push(variant);
|
||||
systemMonitorVariants = variants;
|
||||
saveSettings();
|
||||
return variant;
|
||||
}
|
||||
|
||||
function updateSystemMonitorVariant(variantId, updates) {
|
||||
const variants = JSON.parse(JSON.stringify(systemMonitorVariants || []));
|
||||
const idx = variants.findIndex(v => v.id === variantId);
|
||||
if (idx === -1)
|
||||
return;
|
||||
Object.assign(variants[idx], updates);
|
||||
systemMonitorVariants = variants;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function removeSystemMonitorVariant(variantId) {
|
||||
const variants = (systemMonitorVariants || []).filter(v => v.id !== variantId);
|
||||
systemMonitorVariants = variants;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getSystemMonitorVariant(variantId) {
|
||||
return (systemMonitorVariants || []).find(v => v.id === variantId) || null;
|
||||
}
|
||||
|
||||
function getDefaultSystemMonitorConfig() {
|
||||
return {
|
||||
showHeader: true,
|
||||
@@ -1288,6 +1290,70 @@ Singleton {
|
||||
return (desktopWidgetInstances || []).find(inst => inst.id === instanceId) || null;
|
||||
}
|
||||
|
||||
function getDesktopWidgetInstancesOfType(widgetType) {
|
||||
return (desktopWidgetInstances || []).filter(inst => inst.widgetType === widgetType);
|
||||
}
|
||||
|
||||
function getEnabledDesktopWidgetInstances() {
|
||||
return (desktopWidgetInstances || []).filter(inst => inst.enabled);
|
||||
}
|
||||
|
||||
function moveDesktopWidgetInstance(instanceId, direction) {
|
||||
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
|
||||
const idx = instances.findIndex(inst => inst.id === instanceId);
|
||||
if (idx === -1)
|
||||
return false;
|
||||
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (targetIdx < 0 || targetIdx >= instances.length)
|
||||
return false;
|
||||
const temp = instances[idx];
|
||||
instances[idx] = instances[targetIdx];
|
||||
instances[targetIdx] = temp;
|
||||
desktopWidgetInstances = instances;
|
||||
saveSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
function reorderDesktopWidgetInstance(instanceId, newIndex) {
|
||||
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
|
||||
const idx = instances.findIndex(inst => inst.id === instanceId);
|
||||
if (idx === -1 || newIndex < 0 || newIndex >= instances.length)
|
||||
return false;
|
||||
const [item] = instances.splice(idx, 1);
|
||||
instances.splice(newIndex, 0, item);
|
||||
desktopWidgetInstances = instances;
|
||||
saveSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
function reorderDesktopWidgetInstanceInGroup(instanceId, groupId, newIndexInGroup) {
|
||||
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
|
||||
const groups = desktopWidgetGroups || [];
|
||||
const groupMatches = inst => {
|
||||
if (groupId === null)
|
||||
return !inst.group || !groups.some(g => g.id === inst.group);
|
||||
return inst.group === groupId;
|
||||
};
|
||||
const groupInstances = instances.filter(groupMatches);
|
||||
const currentGroupIdx = groupInstances.findIndex(inst => inst.id === instanceId);
|
||||
if (currentGroupIdx === -1 || currentGroupIdx === newIndexInGroup)
|
||||
return false;
|
||||
if (newIndexInGroup < 0 || newIndexInGroup >= groupInstances.length)
|
||||
return false;
|
||||
const globalIdx = instances.findIndex(inst => inst.id === instanceId);
|
||||
if (globalIdx === -1)
|
||||
return false;
|
||||
const [item] = instances.splice(globalIdx, 1);
|
||||
const targetInstance = groupInstances[newIndexInGroup];
|
||||
let targetGlobalIdx = instances.findIndex(inst => inst.id === targetInstance.id);
|
||||
if (newIndexInGroup > currentGroupIdx)
|
||||
targetGlobalIdx++;
|
||||
instances.splice(targetGlobalIdx, 0, item);
|
||||
desktopWidgetInstances = instances;
|
||||
saveSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveDesktopWidgetInstanceToGroup(instanceId, groupId, newIndexInGroup) {
|
||||
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
|
||||
const groups = desktopWidgetGroups || [];
|
||||
@@ -1353,14 +1419,22 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getDesktopWidgetGroup(groupId) {
|
||||
return (desktopWidgetGroups || []).find(g => g.id === groupId) || null;
|
||||
}
|
||||
|
||||
function getDesktopWidgetInstancesByGroup(groupId) {
|
||||
return (desktopWidgetInstances || []).filter(inst => inst.group === groupId);
|
||||
}
|
||||
|
||||
function getUngroupedDesktopWidgetInstances() {
|
||||
return (desktopWidgetInstances || []).filter(inst => !inst.group);
|
||||
}
|
||||
|
||||
signal forceDankBarLayoutRefresh
|
||||
signal forceDockLayoutRefresh
|
||||
signal widgetDataChanged
|
||||
signal workspaceIconsUpdated
|
||||
signal compositorLayoutRefreshNeeded(bool frame)
|
||||
signal compositorInputRefreshNeeded
|
||||
signal compositorCursorRefreshNeeded
|
||||
signal notificationPopupsInvalidated
|
||||
|
||||
function refreshAuthAvailability() {
|
||||
Processes.detectAuthCapabilities();
|
||||
@@ -1395,15 +1469,24 @@ Singleton {
|
||||
}
|
||||
|
||||
function updateCompositorLayout() {
|
||||
compositorLayoutRefreshNeeded(false);
|
||||
}
|
||||
|
||||
function updateCompositorInput() {
|
||||
compositorInputRefreshNeeded();
|
||||
if (typeof CompositorService === "undefined")
|
||||
return;
|
||||
if (CompositorService.isNiri && typeof NiriService !== "undefined")
|
||||
NiriService.generateNiriLayoutConfig();
|
||||
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined")
|
||||
HyprlandService.generateLayoutConfig();
|
||||
if (CompositorService.isMango && typeof MangoService !== "undefined")
|
||||
MangoService.generateLayoutConfig();
|
||||
}
|
||||
|
||||
function updateFrameCompositorLayout() {
|
||||
compositorLayoutRefreshNeeded(true);
|
||||
// Generate before begin() so compositor readiness is already pending at transitionRequested
|
||||
if (typeof CompositorService !== "undefined") {
|
||||
if (CompositorService.isNiri && typeof NiriService !== "undefined")
|
||||
NiriService.generateNiriLayoutConfig(true);
|
||||
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined")
|
||||
HyprlandService.generateLayoutConfig(true);
|
||||
}
|
||||
FrameTransitionState.begin();
|
||||
}
|
||||
|
||||
@@ -1624,7 +1707,6 @@ Singleton {
|
||||
"applyStoredTheme": applyStoredTheme,
|
||||
"regenSystemThemes": regenSystemThemes,
|
||||
"updateCompositorLayout": updateCompositorLayout,
|
||||
"updateCompositorInput": updateCompositorInput,
|
||||
"applyStoredIconTheme": applyStoredIconTheme,
|
||||
"updateBarConfigs": updateBarConfigs,
|
||||
"updateCompositorCursor": updateCompositorCursor,
|
||||
@@ -1648,7 +1730,6 @@ Singleton {
|
||||
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
|
||||
|
||||
const oldVersion = obj?.configVersion ?? 0;
|
||||
const legacyPins = oldVersion < 13 ? Store.extractPins(obj) : null;
|
||||
if (oldVersion < settingsConfigVersion) {
|
||||
const migrated = Store.migrateToVersion(obj, settingsConfigVersion);
|
||||
if (migrated) {
|
||||
@@ -1656,8 +1737,6 @@ Singleton {
|
||||
obj = migrated;
|
||||
}
|
||||
}
|
||||
if (legacyPins)
|
||||
Qt.callLater(() => CacheData.migratePins(legacyPins));
|
||||
|
||||
if (obj?.lockScreenActiveMonitor !== undefined) {
|
||||
var oldVal = obj.lockScreenActiveMonitor;
|
||||
@@ -1698,6 +1777,7 @@ Singleton {
|
||||
_hasLoaded = true;
|
||||
applyStoredTheme();
|
||||
updateCompositorCursor();
|
||||
Processes.detectQtTools();
|
||||
Qt.callLater(checkIconThemeDrift);
|
||||
|
||||
_checkSettingsWritable();
|
||||
@@ -2055,6 +2135,14 @@ Singleton {
|
||||
return showSeconds ? "h:mm:ss AP" : "h:mm AP";
|
||||
}
|
||||
|
||||
function getEffectiveClockDateFormat() {
|
||||
return clockDateFormat && clockDateFormat.length > 0 ? clockDateFormat : "ddd d";
|
||||
}
|
||||
|
||||
function getEffectiveLockDateFormat() {
|
||||
return lockDateFormat && lockDateFormat.length > 0 ? lockDateFormat : Locale.LongFormat;
|
||||
}
|
||||
|
||||
function initializeListModels() {
|
||||
const defaultBar = barConfigs[0] || getBarConfig("default");
|
||||
if (defaultBar) {
|
||||
@@ -2067,6 +2155,39 @@ Singleton {
|
||||
widgetDataChanged();
|
||||
}
|
||||
|
||||
function hasNamedWorkspaces() {
|
||||
if (typeof NiriService === "undefined" || !CompositorService.isNiri)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < NiriService.allWorkspaces.length; i++) {
|
||||
var ws = NiriService.allWorkspaces[i];
|
||||
if (ws.name && ws.name.trim() !== "")
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNamedWorkspaces() {
|
||||
var namedWorkspaces = [];
|
||||
if (typeof NiriService === "undefined" || !CompositorService.isNiri)
|
||||
return namedWorkspaces;
|
||||
|
||||
for (const ws of NiriService.allWorkspaces) {
|
||||
if (ws.name && ws.name.trim() !== "") {
|
||||
namedWorkspaces.push(ws.name);
|
||||
}
|
||||
}
|
||||
return namedWorkspaces;
|
||||
}
|
||||
|
||||
function getPopupYPosition(barHeight) {
|
||||
const defaultBar = barConfigs[0] || getBarConfig("default");
|
||||
const gothOffset = defaultBar?.gothCornersEnabled ? Theme.cornerRadius : 0;
|
||||
const spacing = defaultBar?.spacing ?? 4;
|
||||
const bottomGap = defaultBar?.bottomGap ?? 0;
|
||||
return barHeight + spacing + bottomGap - gothOffset + Theme.popupDistance;
|
||||
}
|
||||
|
||||
function getPopupTriggerPosition(pos, screen, barThickness, widgetWidth, barSpacing, barPosition, barConfig) {
|
||||
const relativeX = pos.x;
|
||||
const relativeY = pos.y;
|
||||
@@ -2360,10 +2481,51 @@ Singleton {
|
||||
updateBarConfigs();
|
||||
|
||||
if (positionChanged) {
|
||||
notificationPopupsInvalidated();
|
||||
NotificationService.dismissAllPopups();
|
||||
}
|
||||
}
|
||||
|
||||
function checkBarCollisions(barId) {
|
||||
const bar = getBarConfig(barId);
|
||||
if (!bar || !bar.enabled)
|
||||
return [];
|
||||
|
||||
const conflicts = [];
|
||||
const enabledBars = getEnabledBarConfigs();
|
||||
|
||||
for (var i = 0; i < enabledBars.length; i++) {
|
||||
const other = enabledBars[i];
|
||||
if (other.id === barId)
|
||||
continue;
|
||||
const samePosition = bar.position === other.position;
|
||||
if (!samePosition)
|
||||
continue;
|
||||
const barScreens = bar.screenPreferences || ["all"];
|
||||
const otherScreens = other.screenPreferences || ["all"];
|
||||
|
||||
const hasAll = barScreens.includes("all") || otherScreens.includes("all");
|
||||
if (hasAll) {
|
||||
conflicts.push({
|
||||
"barId": other.id,
|
||||
"barName": other.name,
|
||||
"reason": "Same position on all screens"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapping = barScreens.some(screen => otherScreens.includes(screen));
|
||||
if (overlapping) {
|
||||
conflicts.push({
|
||||
"barId": other.id,
|
||||
"barName": other.name,
|
||||
"reason": "Same position on overlapping screens"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function deleteBarConfig(barId) {
|
||||
if (barId === "default")
|
||||
return;
|
||||
@@ -2408,7 +2570,8 @@ Singleton {
|
||||
const bc = bars[i];
|
||||
if (bc.position !== sidePos)
|
||||
continue;
|
||||
if (barConfigCoversScreen(bc, screen))
|
||||
const prefs = bc.screenPreferences || ["all"];
|
||||
if (prefs.includes("all") || isScreenInPreferences(screen, prefs))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -2511,11 +2674,36 @@ Singleton {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function barConfigCoversScreen(bc, screen) {
|
||||
var prefs = bc?.screenPreferences || ["all"];
|
||||
if (prefs.includes("all") || isScreenInPreferences(screen, prefs))
|
||||
return true;
|
||||
return (bc?.showOnLastDisplay ?? false) && Quickshell.screens.length === 1;
|
||||
function getFrameFilteredScreens() {
|
||||
var prefs = frameScreenPreferences || ["all"];
|
||||
if (!prefs || prefs.length === 0 || prefs.includes("all")) {
|
||||
return Quickshell.screens;
|
||||
}
|
||||
return Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs));
|
||||
}
|
||||
|
||||
function getActiveBarEdgeForScreen(screen) {
|
||||
if (!screen)
|
||||
return "";
|
||||
for (var i = 0; i < barConfigs.length; i++) {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled)
|
||||
continue;
|
||||
var prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||
continue;
|
||||
switch (bc.position ?? 0) {
|
||||
case SettingsData.Position.Top:
|
||||
return "top";
|
||||
case SettingsData.Position.Bottom:
|
||||
return "bottom";
|
||||
case SettingsData.Position.Left:
|
||||
return "left";
|
||||
case SettingsData.Position.Right:
|
||||
return "right";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getActiveBarEdgesForScreen(screen) {
|
||||
@@ -2526,7 +2714,8 @@ Singleton {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled)
|
||||
continue;
|
||||
if (!barConfigCoversScreen(bc, screen))
|
||||
var prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||
continue;
|
||||
switch (bc.position ?? 0) {
|
||||
case SettingsData.Position.Top:
|
||||
@@ -2546,47 +2735,51 @@ Singleton {
|
||||
return edges;
|
||||
}
|
||||
|
||||
function getOverlayBarEdgesForScreen(screen) {
|
||||
if (!screen)
|
||||
return [];
|
||||
var edges = [];
|
||||
for (var i = 0; i < barConfigs.length; i++) {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled || !(bc.useOverlayLayer ?? false))
|
||||
continue;
|
||||
if (!barConfigCoversScreen(bc, screen))
|
||||
continue;
|
||||
switch (bc.position ?? 0) {
|
||||
case SettingsData.Position.Top:
|
||||
edges.push("top");
|
||||
break;
|
||||
case SettingsData.Position.Bottom:
|
||||
edges.push("bottom");
|
||||
break;
|
||||
case SettingsData.Position.Left:
|
||||
edges.push("left");
|
||||
break;
|
||||
case SettingsData.Position.Right:
|
||||
edges.push("right");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
readonly property real frameBarContentGap: frameBarInsetPadding < 0 ? frameThickness : frameBarInsetPadding
|
||||
readonly property real frameBarContentGapExtra: Math.max(0, frameBarContentGap - frameThickness)
|
||||
|
||||
function frameEdgeReservation(screen, edge) {
|
||||
if (!screen)
|
||||
return 0;
|
||||
return getActiveBarEdgesForScreen(screen).includes(edge) ? frameBarSize : frameThickness;
|
||||
}
|
||||
|
||||
function frameEdgeInsetForSide(screen, side) {
|
||||
if (!frameEnabled)
|
||||
if (!frameEnabled || !screen)
|
||||
return 0;
|
||||
return frameEdgeReservation(screen, side);
|
||||
const edges = getActiveBarEdgesForScreen(screen);
|
||||
return edges.includes(side) ? frameBarSize : frameThickness;
|
||||
}
|
||||
|
||||
function getActiveBarThicknessForScreen(screen) {
|
||||
if (frameEnabled)
|
||||
return frameBarSize;
|
||||
if (!screen)
|
||||
return frameThickness;
|
||||
for (var i = 0; i < barConfigs.length; i++) {
|
||||
var bc = barConfigs[i];
|
||||
if (!bc.enabled)
|
||||
continue;
|
||||
var prefs = bc.screenPreferences || ["all"];
|
||||
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||
continue;
|
||||
const innerPadding = bc.innerPadding ?? 4;
|
||||
const barT = Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding));
|
||||
const spacing = bc.spacing ?? 4;
|
||||
const bottomGap = bc.bottomGap ?? 0;
|
||||
return barT + spacing + bottomGap;
|
||||
}
|
||||
return frameThickness;
|
||||
}
|
||||
|
||||
function sendTestNotifications() {
|
||||
NotificationService.dismissAllPopups();
|
||||
sendTestNotification(0);
|
||||
testNotifTimer1.start();
|
||||
testNotifTimer2.start();
|
||||
}
|
||||
|
||||
function sendTestNotification(index) {
|
||||
const notifications = [["Notification Position Test", "DMS test notification 1 of 3 ~ Hi there!", "preferences-system"], ["Second Test", "DMS Notification 2 of 3 ~ Check it out!", "applications-graphics"], ["Third Test", "DMS notification 3 of 3 ~ Enjoy!", "face-smile"]];
|
||||
|
||||
if (index < 0 || index >= notifications.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notif = notifications[index];
|
||||
testNotificationProcess.command = ["notify-send", "-h", "int:transient:1", "-a", "DMS", "-i", notif[2], notif[0], notif[1]];
|
||||
testNotificationProcess.running = true;
|
||||
}
|
||||
|
||||
function setMatugenScheme(scheme) {
|
||||
@@ -2605,6 +2798,15 @@ Singleton {
|
||||
set("matugenContrast", value);
|
||||
}
|
||||
|
||||
function setRunUserMatugenTemplates(enabled) {
|
||||
if (runUserMatugenTemplates === enabled)
|
||||
return;
|
||||
set("runUserMatugenTemplates", enabled);
|
||||
if (typeof Theme !== "undefined") {
|
||||
Theme.generateSystemThemesFromCurrentTheme();
|
||||
}
|
||||
}
|
||||
|
||||
function setMatugenTargetMonitor(monitorName) {
|
||||
if (matugenTargetMonitor === monitorName)
|
||||
return;
|
||||
@@ -2623,6 +2825,11 @@ Singleton {
|
||||
SessionData.setWeatherLocation(displayName, coordinates);
|
||||
}
|
||||
|
||||
function setIconTheme(themeName) {
|
||||
const light = iconThemePerMode && typeof SessionData !== "undefined" && SessionData.isLightMode;
|
||||
setIconThemeForMode(themeName, light);
|
||||
}
|
||||
|
||||
function setIconThemeForMode(themeName, light) {
|
||||
if (light)
|
||||
iconThemeLight = themeName;
|
||||
@@ -2668,7 +2875,20 @@ Singleton {
|
||||
// https://github.com/Supreeeme/xwayland-satellite/issues/104
|
||||
// no idea if this matters on other compositors but we also set XCURSOR stuff in the launcher
|
||||
function updateCompositorCursor() {
|
||||
compositorCursorRefreshNeeded();
|
||||
if (typeof CompositorService === "undefined")
|
||||
return;
|
||||
if (CompositorService.isNiri && typeof NiriService !== "undefined") {
|
||||
NiriService.generateNiriCursorConfig();
|
||||
return;
|
||||
}
|
||||
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined") {
|
||||
HyprlandService.generateCursorConfig();
|
||||
return;
|
||||
}
|
||||
if (CompositorService.isMango && typeof MangoService !== "undefined") {
|
||||
MangoService.generateCursorConfig();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function updateXResources() {
|
||||
@@ -2736,6 +2956,20 @@ Singleton {
|
||||
return env;
|
||||
}
|
||||
|
||||
function setGtkThemingEnabled(enabled) {
|
||||
set("gtkThemingEnabled", enabled);
|
||||
if (enabled && typeof Theme !== "undefined") {
|
||||
Theme.generateSystemThemesFromCurrentTheme();
|
||||
}
|
||||
}
|
||||
|
||||
function setQtThemingEnabled(enabled) {
|
||||
set("qtThemingEnabled", enabled);
|
||||
if (enabled && typeof Theme !== "undefined") {
|
||||
Theme.generateSystemThemesFromCurrentTheme();
|
||||
}
|
||||
}
|
||||
|
||||
function setShowDock(enabled) {
|
||||
showDock = enabled;
|
||||
const defaultBar = barConfigs[0] || getBarConfig("default");
|
||||
@@ -2781,6 +3015,16 @@ Singleton {
|
||||
Qt.callLater(() => forceDockLayoutRefresh());
|
||||
}
|
||||
|
||||
function setDankBarSpacing(spacing) {
|
||||
const defaultBar = barConfigs[0] || getBarConfig("default");
|
||||
if (defaultBar) {
|
||||
updateBarConfig(defaultBar.id, {
|
||||
"spacing": spacing
|
||||
});
|
||||
}
|
||||
updateCompositorLayout();
|
||||
}
|
||||
|
||||
function setDankBarPosition(position) {
|
||||
const defaultBar = barConfigs[0] || getBarConfig("default");
|
||||
if (!defaultBar)
|
||||
@@ -2836,6 +3080,39 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function resetDankBarWidgetsToDefault() {
|
||||
var defaultLeft = ["launcherButton", "workspaceSwitcher", "focusedWindow"];
|
||||
var defaultCenter = ["music", "clock", "weather"];
|
||||
var defaultRight = ["systemTray", "clipboard", "notificationButton", "battery", "controlCenterButton"];
|
||||
const defaultBar = barConfigs[0] || getBarConfig("default");
|
||||
if (defaultBar) {
|
||||
updateBarConfig(defaultBar.id, {
|
||||
"leftWidgets": defaultLeft,
|
||||
"centerWidgets": defaultCenter,
|
||||
"rightWidgets": defaultRight
|
||||
});
|
||||
}
|
||||
updateListModel(leftWidgetsModel, defaultLeft);
|
||||
updateListModel(centerWidgetsModel, defaultCenter);
|
||||
updateListModel(rightWidgetsModel, defaultRight);
|
||||
showLauncherButton = true;
|
||||
showWorkspaceSwitcher = true;
|
||||
showFocusedWindow = true;
|
||||
showWeather = true;
|
||||
showMusic = true;
|
||||
showClipboard = true;
|
||||
showCpuUsage = true;
|
||||
showMemUsage = true;
|
||||
showCpuTemp = true;
|
||||
showGpuTemp = true;
|
||||
showSystemTray = true;
|
||||
showClock = true;
|
||||
showNotificationButton = true;
|
||||
showBattery = true;
|
||||
showControlCenterButton = true;
|
||||
showCapsLockIndicator = true;
|
||||
}
|
||||
|
||||
function setWorkspaceNameIcon(workspaceName, iconData) {
|
||||
var iconMap = JSON.parse(JSON.stringify(workspaceNameIcons));
|
||||
iconMap[workspaceName] = iconData;
|
||||
@@ -3117,6 +3394,15 @@ Singleton {
|
||||
Theme.reloadCustomThemeVariant();
|
||||
}
|
||||
|
||||
function toggleDankBarVisible() {
|
||||
const defaultBar = barConfigs[0] || getBarConfig("default");
|
||||
if (defaultBar) {
|
||||
updateBarConfig(defaultBar.id, {
|
||||
"visible": !defaultBar.visible
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowDock() {
|
||||
setShowDock(!showDock);
|
||||
}
|
||||
@@ -3138,6 +3424,13 @@ Singleton {
|
||||
savePluginSettings();
|
||||
}
|
||||
|
||||
function removePluginSettings(pluginId) {
|
||||
if (pluginSettings[pluginId]) {
|
||||
delete pluginSettings[pluginId];
|
||||
savePluginSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginSettingsForPlugin(pluginId) {
|
||||
const settings = pluginSettings[pluginId];
|
||||
return settings ? JSON.parse(JSON.stringify(settings)) : {};
|
||||
@@ -3163,6 +3456,22 @@ Singleton {
|
||||
return settings ? JSON.parse(JSON.stringify(settings)) : {};
|
||||
}
|
||||
|
||||
function setNiriOutputSettings(outputId, settings) {
|
||||
const updated = JSON.parse(JSON.stringify(niriOutputSettings));
|
||||
updated[outputId] = settings;
|
||||
niriOutputSettings = updated;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function removeNiriOutputSettings(outputId) {
|
||||
if (!niriOutputSettings[outputId])
|
||||
return;
|
||||
const updated = JSON.parse(JSON.stringify(niriOutputSettings));
|
||||
delete updated[outputId];
|
||||
niriOutputSettings = updated;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getHyprlandOutputSetting(outputId, key, defaultValue) {
|
||||
if (!hyprlandOutputSettings[outputId])
|
||||
return defaultValue;
|
||||
@@ -3187,6 +3496,40 @@ Singleton {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getHyprlandOutputSettings(outputId) {
|
||||
const settings = hyprlandOutputSettings[outputId];
|
||||
return settings ? JSON.parse(JSON.stringify(settings)) : {};
|
||||
}
|
||||
|
||||
function setHyprlandOutputSettings(outputId, settings) {
|
||||
const updated = JSON.parse(JSON.stringify(hyprlandOutputSettings));
|
||||
updated[outputId] = settings;
|
||||
hyprlandOutputSettings = updated;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function removeHyprlandOutputSettings(outputId) {
|
||||
if (!hyprlandOutputSettings[outputId])
|
||||
return;
|
||||
const updated = JSON.parse(JSON.stringify(hyprlandOutputSettings));
|
||||
delete updated[outputId];
|
||||
hyprlandOutputSettings = updated;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function getDisplayProfiles(compositor) {
|
||||
return displayProfiles[compositor] || {};
|
||||
}
|
||||
|
||||
function setDisplayProfile(compositor, profileId, data) {
|
||||
const updated = JSON.parse(JSON.stringify(displayProfiles));
|
||||
if (!updated[compositor])
|
||||
updated[compositor] = {};
|
||||
updated[compositor][profileId] = data;
|
||||
displayProfiles = updated;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function removeDisplayProfile(compositor, profileId) {
|
||||
if (!displayProfiles[compositor] || !displayProfiles[compositor][profileId])
|
||||
return;
|
||||
@@ -3240,6 +3583,29 @@ Singleton {
|
||||
id: rightWidgetsModel
|
||||
}
|
||||
|
||||
property Process testNotificationProcess
|
||||
|
||||
testNotificationProcess: Process {
|
||||
command: []
|
||||
running: false
|
||||
}
|
||||
|
||||
property Timer testNotifTimer1
|
||||
|
||||
testNotifTimer1: Timer {
|
||||
interval: 400
|
||||
repeat: false
|
||||
onTriggered: sendTestNotification(1)
|
||||
}
|
||||
|
||||
property Timer testNotifTimer2
|
||||
|
||||
testNotifTimer2: Timer {
|
||||
interval: 800
|
||||
repeat: false
|
||||
onTriggered: sendTestNotification(2)
|
||||
}
|
||||
|
||||
property alias settingsFile: settingsFile
|
||||
|
||||
Timer {
|
||||
|
||||
@@ -416,6 +416,24 @@ const StockThemes = {
|
||||
},
|
||||
};
|
||||
|
||||
const ThemeCategories = {
|
||||
GENERIC: {
|
||||
name: "Generic",
|
||||
variants: [
|
||||
"blue",
|
||||
"purple",
|
||||
"green",
|
||||
"orange",
|
||||
"red",
|
||||
"cyan",
|
||||
"pink",
|
||||
"amber",
|
||||
"coral",
|
||||
"monochrome",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const ThemeNames = {
|
||||
BLUE: "blue",
|
||||
PURPLE: "purple",
|
||||
@@ -430,6 +448,10 @@ const ThemeNames = {
|
||||
DYNAMIC: "dynamic",
|
||||
};
|
||||
|
||||
function isStockTheme(themeName) {
|
||||
return Object.keys(StockThemes.DARK).includes(themeName);
|
||||
}
|
||||
|
||||
function getAvailableThemes(isLight = false) {
|
||||
return isLight ? StockThemes.LIGHT : StockThemes.DARK;
|
||||
}
|
||||
@@ -442,3 +464,7 @@ function getThemeByName(themeName, isLight = false) {
|
||||
function getAllThemeNames() {
|
||||
return Object.keys(StockThemes.DARK);
|
||||
}
|
||||
|
||||
function getThemeCategories() {
|
||||
return ThemeCategories;
|
||||
}
|
||||
|
||||
+609
-97
@@ -96,6 +96,8 @@ Singleton {
|
||||
}
|
||||
|
||||
property bool matugenAvailable: false
|
||||
property bool gtkThemingEnabled: typeof SettingsData !== "undefined" ? SettingsData.gtkAvailable : false
|
||||
property bool qtThemingEnabled: typeof SettingsData !== "undefined" ? (SettingsData.qt5ctAvailable || SettingsData.qt6ctAvailable) : false
|
||||
property var workerRunning: false
|
||||
property var pendingThemeRequest: null
|
||||
|
||||
@@ -105,11 +107,8 @@ Singleton {
|
||||
property int _colorsRetryCount: 0
|
||||
property double _lastGenerateMs: 0
|
||||
|
||||
property bool blurLayersActive: false
|
||||
property bool matugenToastSuppressed: false
|
||||
|
||||
signal screenTransitionNeeded
|
||||
signal themeGenerationStarting
|
||||
property bool themeModeAutomationActive: false
|
||||
property bool dmsServiceWasDisconnected: true
|
||||
|
||||
readonly property var dank16: {
|
||||
const raw = matugenColors?.dank16;
|
||||
@@ -145,7 +144,7 @@ Singleton {
|
||||
Quickshell.execDetached(["mkdir", "-p", stateDir]);
|
||||
// shellDir may be an embedded-UI extraction, which is read-only and
|
||||
// unexecutable (dankgo shellapp/shellfs makeReadOnly chmods 0444)
|
||||
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "assets", "", shellDir]);
|
||||
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "", shellDir, "assets-only"]);
|
||||
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
|
||||
matugenAvailable = (code === 0) && !envDisableMatugen;
|
||||
|
||||
@@ -197,6 +196,235 @@ Singleton {
|
||||
const currentIsLight = (typeof SessionData !== "undefined") ? SessionData.isLightMode : false;
|
||||
SettingsData.updateCosmicThemeMode(currentIsLight);
|
||||
}
|
||||
|
||||
if (typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled) {
|
||||
startThemeModeAutomation();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
enabled: typeof SessionData !== "undefined"
|
||||
|
||||
function onThemeModeAutoEnabledChanged() {
|
||||
if (SessionData.themeModeAutoEnabled) {
|
||||
root.startThemeModeAutomation();
|
||||
} else {
|
||||
root.stopThemeModeAutomation();
|
||||
}
|
||||
}
|
||||
|
||||
function onThemeModeAutoModeChanged() {
|
||||
if (root.themeModeAutomationActive) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
root.syncLocationThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onThemeModeStartHourChanged() {
|
||||
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onThemeModeStartMinuteChanged() {
|
||||
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onThemeModeEndHourChanged() {
|
||||
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onThemeModeEndMinuteChanged() {
|
||||
if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onThemeModeShareGammaSettingsChanged() {
|
||||
if (root.themeModeAutomationActive) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
root.syncLocationThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onNightModeStartHourChanged() {
|
||||
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onNightModeStartMinuteChanged() {
|
||||
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onNightModeEndHourChanged() {
|
||||
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onNightModeEndMinuteChanged() {
|
||||
if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) {
|
||||
root.evaluateThemeMode();
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onLatitudeChanged() {
|
||||
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
|
||||
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0 && typeof DMSService !== "undefined") {
|
||||
DMSService.sendRequest("wayland.gamma.setLocation", {
|
||||
"latitude": SessionData.latitude,
|
||||
"longitude": SessionData.longitude
|
||||
});
|
||||
}
|
||||
root.evaluateThemeMode();
|
||||
root.syncLocationThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onLongitudeChanged() {
|
||||
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
|
||||
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0 && typeof DMSService !== "undefined") {
|
||||
DMSService.sendRequest("wayland.gamma.setLocation", {
|
||||
"latitude": SessionData.latitude,
|
||||
"longitude": SessionData.longitude
|
||||
});
|
||||
}
|
||||
root.evaluateThemeMode();
|
||||
root.syncLocationThemeSchedule();
|
||||
}
|
||||
}
|
||||
|
||||
function onNightModeUseIPLocationChanged() {
|
||||
if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") {
|
||||
if (typeof DMSService !== "undefined") {
|
||||
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
|
||||
"use": SessionData.nightModeUseIPLocation
|
||||
}, response => {
|
||||
if (!response.error && !SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
|
||||
DMSService.sendRequest("wayland.gamma.setLocation", {
|
||||
"latitude": SessionData.latitude,
|
||||
"longitude": SessionData.longitude
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
root.evaluateThemeMode();
|
||||
root.syncLocationThemeSchedule();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// React to gamma backend's isDay state changes for location-based mode
|
||||
Connections {
|
||||
target: DisplayService
|
||||
enabled: typeof DisplayService !== "undefined" && typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location" && !themeAutoBackendAvailable()
|
||||
|
||||
function onGammaIsDayChanged() {
|
||||
if (root.isLightMode !== DisplayService.gammaIsDay) {
|
||||
root.setLightMode(DisplayService.gammaIsDay, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DMSService
|
||||
|
||||
function onThemeAutoStateUpdate(data) {
|
||||
if (!SessionData.themeModeAutoEnabled) {
|
||||
return;
|
||||
}
|
||||
applyThemeAutoState(data);
|
||||
}
|
||||
|
||||
function onConnectionStateChanged() {
|
||||
if (DMSService.isConnected && SessionData.themeModeAutoMode === "time") {
|
||||
root.syncTimeThemeSchedule();
|
||||
}
|
||||
|
||||
if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") {
|
||||
root.syncLocationThemeSchedule();
|
||||
}
|
||||
|
||||
if (themeAutoBackendAvailable() && SessionData.themeModeAutoEnabled) {
|
||||
DMSService.sendRequest("theme.auto.getState", null, response => {
|
||||
if (response && response.result) {
|
||||
applyThemeAutoState(response.result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!SessionData.themeModeAutoEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") {
|
||||
if (SessionData.nightModeUseIPLocation) {
|
||||
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
|
||||
"use": true
|
||||
}, response => {
|
||||
if (!response.error) {
|
||||
log.info("Theme automation: IP location enabled after connection");
|
||||
}
|
||||
});
|
||||
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
|
||||
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
|
||||
"use": false
|
||||
}, response => {
|
||||
if (!response.error) {
|
||||
DMSService.sendRequest("wayland.gamma.setLocation", {
|
||||
"latitude": SessionData.latitude,
|
||||
"longitude": SessionData.longitude
|
||||
}, locationResponse => {
|
||||
if (locationResponse?.error) {
|
||||
log.warn("Theme automation: Failed to set location", locationResponse.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log.warn("Theme automation: No location configured");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SessionService
|
||||
enabled: SessionData.themeModeAutoEnabled
|
||||
|
||||
function onSessionUnlocked() {
|
||||
root.triggerThemeAutomationRefresh();
|
||||
}
|
||||
|
||||
function onSessionResumed() {
|
||||
root.triggerThemeAutomationRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function triggerThemeAutomationRefresh() {
|
||||
if (!themeAutoBackendAvailable()) {
|
||||
root.evaluateThemeMode();
|
||||
return;
|
||||
}
|
||||
DMSService.sendRequest("theme.auto.trigger", {});
|
||||
}
|
||||
|
||||
function getMatugenColor(path, fallback) {
|
||||
@@ -343,30 +571,13 @@ Singleton {
|
||||
property color surfaceVariantAlpha: withAlpha(surfaceVariant, 0.2)
|
||||
|
||||
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
|
||||
readonly property bool blurForegroundLayers: blurLayersActive && foregroundLayers
|
||||
readonly property bool transparentBlurLayers: blurLayersActive && !foregroundLayers
|
||||
readonly property real foregroundLayerTransparency: typeof SettingsData === "undefined" ? 1.0 : (SettingsData.foregroundLayerTransparency ?? 1.0)
|
||||
readonly property bool blurForegroundLayers: BlurService.enabled && foregroundLayers
|
||||
readonly property bool transparentBlurLayers: BlurService.enabled && !foregroundLayers
|
||||
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
|
||||
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
|
||||
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
||||
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||
readonly property color floatingSurfaceHigh: foregroundLayers ? withAlpha(surfaceContainerHigh, foregroundLayerTransparency) : withAlpha(surfaceContainerHigh, 0)
|
||||
readonly property bool floatingWindowSynced: typeof SettingsData === "undefined" || (SettingsData.floatingWindowSyncGlobal ?? true)
|
||||
readonly property real floatingWindowTransparency: {
|
||||
if (typeof SettingsData === "undefined" || floatingWindowSynced)
|
||||
return popupTransparency;
|
||||
return SettingsData.floatingWindowTransparency ?? 1.0;
|
||||
}
|
||||
readonly property bool floatingWindowForegroundLayers: floatingWindowSynced ? foregroundLayers : (SettingsData.floatingWindowForegroundLayers ?? true)
|
||||
readonly property real floatingWindowForegroundTransparency: {
|
||||
if (typeof SettingsData === "undefined" || floatingWindowSynced)
|
||||
return foregroundLayerTransparency;
|
||||
return SettingsData.floatingWindowForegroundTransparency ?? 1.0;
|
||||
}
|
||||
readonly property color floatingWindowSurface: withAlpha(surfaceContainer, floatingWindowTransparency)
|
||||
readonly property color floatingWindowSurfaceHigh: floatingWindowForegroundLayers ? withAlpha(surfaceContainerHigh, floatingWindowForegroundTransparency) : withAlpha(surfaceContainerHigh, 0)
|
||||
readonly property color floatingWindowNestedSurface: floatingWindowSurfaceHigh
|
||||
readonly property color notepadWindowSurface: withAlpha(surfaceContainer, notepadTransparency)
|
||||
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||
readonly property color nestedSurface: floatingSurfaceHigh
|
||||
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||
@@ -374,20 +585,6 @@ Singleton {
|
||||
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
|
||||
readonly property real layerOutlineOpacity: blurLayerOutlineOpacity
|
||||
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
|
||||
readonly property real floatingWindowFieldAlpha: floatingWindowTransparency
|
||||
readonly property color floatingWindowFieldColor: withAlpha(surfaceContainerHigh, floatingWindowFieldAlpha)
|
||||
readonly property real popupFieldAlpha: {
|
||||
if (transparentBlurLayers)
|
||||
return 0.28;
|
||||
if (blurForegroundLayers)
|
||||
return Math.max(foregroundLayerTransparency, 0.62);
|
||||
return popupTransparency;
|
||||
}
|
||||
readonly property color popupFieldColor: withAlpha(surfaceContainerHigh, popupFieldAlpha)
|
||||
readonly property color popupFieldBorderColor: withAlpha(outline, blurLayersActive ? 0.16 : layerOutlineOpacity)
|
||||
readonly property color popupFieldFocusedBorderColor: withAlpha(primary, blurLayersActive ? 0.72 : 1.0)
|
||||
readonly property color floatingWindowFieldBorderColor: popupFieldBorderColor
|
||||
readonly property color floatingWindowFieldFocusedBorderColor: popupFieldFocusedBorderColor
|
||||
property color surfaceTextHover: withAlpha(surfaceText, 0.08)
|
||||
property color surfaceTextAlpha: withAlpha(surfaceText, 0.3)
|
||||
|
||||
@@ -461,7 +658,7 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (foregroundLayers ? withAlpha(surfaceContainerHigh, blurLayersActive ? Math.min(popupTransparency, 0.24) : popupTransparency) : withAlpha(surfaceContainer, 0))
|
||||
readonly property color ccTileInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.16) : (foregroundLayers ? withAlpha(surfaceContainerHigh, BlurService.enabled ? Math.min(popupTransparency, 0.24) : popupTransparency) : withAlpha(surfaceContainer, 0))
|
||||
readonly property color ccPillInactiveBg: transparentBlurLayers ? withAlpha(surfaceContainerHigh, 0.08) : nestedSurface
|
||||
readonly property color ccPillInactiveHoverBg: transparentBlurLayers ? withAlpha(primary, 0.10) : primaryPressed
|
||||
readonly property color ccSliderTrackColor: transparentBlurLayers ? surfaceText : surfaceContainerHigh
|
||||
@@ -781,6 +978,22 @@ Singleton {
|
||||
};
|
||||
}
|
||||
|
||||
function elevationTintOpacity(level) {
|
||||
if (!level)
|
||||
return 0;
|
||||
if (level === elevationLevel1)
|
||||
return 0.05;
|
||||
if (level === elevationLevel2)
|
||||
return 0.08;
|
||||
if (level === elevationLevel3)
|
||||
return 0.11;
|
||||
if (level === elevationLevel4)
|
||||
return 0.12;
|
||||
if (level === elevationLevel5)
|
||||
return 0.14;
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
readonly property var animationDurations: [
|
||||
{
|
||||
"shorter": 0,
|
||||
@@ -1048,7 +1261,9 @@ Singleton {
|
||||
}
|
||||
|
||||
function screenTransition() {
|
||||
screenTransitionNeeded();
|
||||
if (CompositorService.isNiri) {
|
||||
NiriService.doScreenTransition();
|
||||
}
|
||||
}
|
||||
|
||||
function switchTheme(themeName, savePrefs = true, enableTransition = true) {
|
||||
@@ -1100,6 +1315,7 @@ Singleton {
|
||||
SessionData.setLightMode(light);
|
||||
}
|
||||
|
||||
PortalService.setLightMode(light);
|
||||
if (typeof SettingsData !== "undefined") {
|
||||
SettingsData.updateCosmicThemeMode(light);
|
||||
}
|
||||
@@ -1110,6 +1326,22 @@ Singleton {
|
||||
setLightMode(!isLightMode, savePrefs, true);
|
||||
}
|
||||
|
||||
function forceGenerateSystemThemes() {
|
||||
if (!matugenAvailable) {
|
||||
return;
|
||||
}
|
||||
generateSystemThemesFromCurrentTheme();
|
||||
}
|
||||
|
||||
function getAvailableThemes() {
|
||||
return StockThemes.getAllThemeNames();
|
||||
}
|
||||
|
||||
function getThemeDisplayName(themeName) {
|
||||
const themeData = StockThemes.getThemeByName(themeName, isLightMode);
|
||||
return themeData.name;
|
||||
}
|
||||
|
||||
function getThemeColors(themeName) {
|
||||
if (themeName === "custom" && customThemeData) {
|
||||
return customThemeData;
|
||||
@@ -1225,7 +1457,11 @@ Singleton {
|
||||
readonly property var _availableThemeNames: StockThemes.getAllThemeNames()
|
||||
property string currentThemeName: currentTheme
|
||||
|
||||
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : floatingWindowTransparency
|
||||
function panelBackground() {
|
||||
return withAlpha(surfaceContainer, panelTransparency);
|
||||
}
|
||||
|
||||
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency
|
||||
|
||||
property bool widgetBackgroundHasAlpha: {
|
||||
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
|
||||
@@ -1301,6 +1537,10 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function isColorDark(c) {
|
||||
return (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) < 0.5;
|
||||
}
|
||||
|
||||
function barIconSize(barThickness, offset, maximizeIcon, iconScale) {
|
||||
const defaultOffset = offset !== undefined ? offset : -6;
|
||||
const size = (maximizeIcon ?? false) ? iconSizeLarge : iconSize;
|
||||
@@ -1383,6 +1623,19 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function getPowerProfileDescription(profile) {
|
||||
switch (profile) {
|
||||
case 0:
|
||||
return I18n.tr("Extend battery life", "power profile description");
|
||||
case 1:
|
||||
return I18n.tr("Balance power and performance", "power profile description");
|
||||
case 2:
|
||||
return I18n.tr("Prioritize performance", "power profile description");
|
||||
default:
|
||||
return I18n.tr("Custom power profile", "power profile description");
|
||||
}
|
||||
}
|
||||
|
||||
function onLightModeChanged() {
|
||||
if (currentTheme === "custom" && customThemeFileView.path) {
|
||||
customThemeFileView.reload();
|
||||
@@ -1410,7 +1663,9 @@ Singleton {
|
||||
|
||||
log.info("Setting desired theme -", kind, "mode:", isLight ? "light" : "dark", stockColors ? "(stock colors)" : "(dynamic)");
|
||||
|
||||
themeGenerationStarting();
|
||||
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
|
||||
NiriService.suppressNextToast();
|
||||
}
|
||||
|
||||
const desired = {
|
||||
"kind": kind,
|
||||
@@ -1522,7 +1777,8 @@ Singleton {
|
||||
|
||||
if (currentTheme === dynamic) {
|
||||
if (!rawWallpaperPath) {
|
||||
log.warn("Auto theme has no wallpaper - skipping matugen");
|
||||
log.warn("Auto theme has no wallpaper - skipping matugen, syncing portal mode only");
|
||||
PortalService.setLightMode(isLight);
|
||||
return;
|
||||
}
|
||||
const selectedMatugenType = (typeof SettingsData !== "undefined" && SettingsData.matugenScheme) ? SettingsData.matugenScheme : "scheme-tonal-spot";
|
||||
@@ -1670,47 +1926,6 @@ Singleton {
|
||||
return colors;
|
||||
}
|
||||
|
||||
function refreshGtkTheme() {
|
||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
|
||||
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
|
||||
const schema = "org.gnome.desktop.interface";
|
||||
const key = "gtk-theme";
|
||||
|
||||
const makeCmd = (tool, schema, val) => {
|
||||
if (tool === "gsettings") {
|
||||
return `gsettings set ${schema} ${key} '' && gsettings set ${schema} ${key} ${val}`;
|
||||
} else {
|
||||
const dconfPath = `/${schema.replace(/\./g, "/")}`;
|
||||
return `dconf write ${dconfPath}/${key} "''" && dconf write ${dconfPath}/${key} "'${val}'"`;
|
||||
}
|
||||
};
|
||||
|
||||
Proc.runCommand("gtkRefresher", ["sh", "-c", makeCmd("gsettings", schema, theme)], (output, exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
log.warn("Failed to refresh gtk-theme");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function patchGtk3colors() {
|
||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
|
||||
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight, shellDir], (output, exitCode) => {
|
||||
switch (exitCode) {
|
||||
case 0:
|
||||
refreshGtkTheme();
|
||||
break;
|
||||
case 2:
|
||||
break;
|
||||
default:
|
||||
log.warn(`Failed to patch GTK3 colors: ${output}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyGtkColors() {
|
||||
if (!matugenAvailable) {
|
||||
if (typeof ToastService !== "undefined") {
|
||||
@@ -1720,9 +1935,9 @@ Singleton {
|
||||
}
|
||||
|
||||
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "true" : "false";
|
||||
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight, shellDir], (output, exitCode) => {
|
||||
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, isLight, shellDir], (output, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
|
||||
if (typeof ToastService !== "undefined" && typeof NiriService !== "undefined" && !NiriService.matugenSuppression) {
|
||||
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
|
||||
}
|
||||
} else {
|
||||
@@ -1760,17 +1975,18 @@ Singleton {
|
||||
return Qt.rgba(c.r, c.g, c.b, a);
|
||||
}
|
||||
|
||||
function popupLayerColor(baseColor) {
|
||||
if (isConnectedEffect)
|
||||
return connectedSurfaceColor;
|
||||
return withAlpha(baseColor, popupTransparency);
|
||||
}
|
||||
|
||||
function blendAlpha(c, a) {
|
||||
if (!c || c.r === undefined)
|
||||
return Qt.rgba(0, 0, 0, 0);
|
||||
return Qt.rgba(c.r, c.g, c.b, c.a * a);
|
||||
}
|
||||
|
||||
function hoverTint(base) {
|
||||
const factor = 1.2;
|
||||
return isLightMode ? Qt.darker(base, factor) : Qt.lighter(base, factor);
|
||||
}
|
||||
|
||||
function blend(c1, c2, r) {
|
||||
return Qt.rgba(c1.r * (1 - r) + c2.r * r, c1.g * (1 - r) + c2.g * r, c1.b * (1 - r) + c2.b * r, c1.a * (1 - r) + c2.a * r);
|
||||
}
|
||||
@@ -1915,11 +2131,8 @@ Singleton {
|
||||
root.matugenCompleted(currentMode, "error");
|
||||
}
|
||||
|
||||
if (!pendingThemeRequest) {
|
||||
if (SettingsData.matugenTemplateGtk)
|
||||
patchGtk3colors();
|
||||
if (!pendingThemeRequest)
|
||||
return;
|
||||
}
|
||||
|
||||
const req = pendingThemeRequest;
|
||||
pendingThemeRequest = null;
|
||||
@@ -2087,4 +2300,303 @@ Singleton {
|
||||
root.switchTheme(defaultTheme, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme mode automation functions
|
||||
function themeAutoBackendAvailable() {
|
||||
return typeof DMSService !== "undefined" && DMSService.isConnected && Array.isArray(DMSService.capabilities) && DMSService.capabilities.includes("theme.auto");
|
||||
}
|
||||
|
||||
function applyThemeAutoState(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (state.config && state.config.mode && state.config.mode !== SessionData.themeModeAutoMode) {
|
||||
return;
|
||||
}
|
||||
if (typeof SessionData !== "undefined" && state.nextTransition !== undefined) {
|
||||
SessionData.themeModeNextTransition = state.nextTransition || "";
|
||||
}
|
||||
if (state.isLight !== undefined && root.isLightMode !== state.isLight) {
|
||||
root.setLightMode(state.isLight, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
function syncTimeThemeSchedule() {
|
||||
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DMSService.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "time";
|
||||
|
||||
if (!timeModeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
DMSService.sendRequest("theme.auto.setMode", {
|
||||
"mode": "time"
|
||||
});
|
||||
|
||||
const shareSettings = SessionData.themeModeShareGammaSettings;
|
||||
const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour;
|
||||
const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute;
|
||||
const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour;
|
||||
const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute;
|
||||
|
||||
DMSService.sendRequest("theme.auto.setSchedule", {
|
||||
"startHour": startHour,
|
||||
"startMinute": startMinute,
|
||||
"endHour": endHour,
|
||||
"endMinute": endMinute
|
||||
}, response => {
|
||||
if (response && response.error) {
|
||||
log.error("Theme automation: Failed to sync time schedule:", response.error);
|
||||
}
|
||||
});
|
||||
|
||||
DMSService.sendRequest("theme.auto.setEnabled", {
|
||||
"enabled": true
|
||||
});
|
||||
DMSService.sendRequest("theme.auto.trigger", {});
|
||||
}
|
||||
|
||||
function syncLocationThemeSchedule() {
|
||||
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DMSService.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const locationModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location";
|
||||
|
||||
if (!locationModeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
DMSService.sendRequest("theme.auto.setMode", {
|
||||
"mode": "location"
|
||||
});
|
||||
|
||||
if (SessionData.nightModeUseIPLocation) {
|
||||
DMSService.sendRequest("theme.auto.setUseIPLocation", {
|
||||
"use": true
|
||||
});
|
||||
} else {
|
||||
DMSService.sendRequest("theme.auto.setUseIPLocation", {
|
||||
"use": false
|
||||
});
|
||||
if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
|
||||
DMSService.sendRequest("theme.auto.setLocation", {
|
||||
"latitude": SessionData.latitude,
|
||||
"longitude": SessionData.longitude
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DMSService.sendRequest("theme.auto.setEnabled", {
|
||||
"enabled": true
|
||||
});
|
||||
DMSService.sendRequest("theme.auto.trigger", {});
|
||||
}
|
||||
|
||||
function evaluateThemeMode() {
|
||||
if (typeof SessionData === "undefined" || !SessionData.themeModeAutoEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (themeAutoBackendAvailable()) {
|
||||
DMSService.sendRequest("theme.auto.getState", null, response => {
|
||||
if (response && response.result) {
|
||||
applyThemeAutoState(response.result);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = SessionData.themeModeAutoMode;
|
||||
|
||||
if (mode === "location") {
|
||||
evaluateLocationBasedThemeMode();
|
||||
} else {
|
||||
evaluateTimeBasedThemeMode();
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateLocationBasedThemeMode() {
|
||||
if (typeof DisplayService !== "undefined") {
|
||||
const shouldBeLight = DisplayService.gammaIsDay;
|
||||
if (root.isLightMode !== shouldBeLight) {
|
||||
root.setLightMode(shouldBeLight, true, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SessionData.nightModeUseIPLocation && SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
|
||||
const shouldBeLight = calculateIsDaytime(SessionData.latitude, SessionData.longitude);
|
||||
if (root.isLightMode !== shouldBeLight) {
|
||||
root.setLightMode(shouldBeLight, true, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.themeModeAutomationActive) {
|
||||
if (SessionData.nightModeUseIPLocation) {
|
||||
log.warn("Theme automation: Waiting for IP location from backend");
|
||||
} else {
|
||||
log.warn("Theme automation: Location mode requires coordinates");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateTimeBasedThemeMode() {
|
||||
const shareSettings = SessionData.themeModeShareGammaSettings;
|
||||
|
||||
const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour;
|
||||
const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute;
|
||||
const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour;
|
||||
const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute;
|
||||
|
||||
const now = new Date();
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const startMinutes = startHour * 60 + startMinute;
|
||||
const endMinutes = endHour * 60 + endMinute;
|
||||
|
||||
let shouldBeLight;
|
||||
if (startMinutes < endMinutes) {
|
||||
shouldBeLight = currentMinutes < startMinutes || currentMinutes >= endMinutes;
|
||||
} else {
|
||||
shouldBeLight = currentMinutes >= endMinutes && currentMinutes < startMinutes;
|
||||
}
|
||||
|
||||
if (root.isLightMode !== shouldBeLight) {
|
||||
root.setLightMode(shouldBeLight, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateIsDaytime(lat, lng) {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), 0, 0);
|
||||
const diff = now - start;
|
||||
const dayOfYear = Math.floor(diff / 86400000);
|
||||
const latRad = lat * Math.PI / 180;
|
||||
|
||||
const declination = 23.45 * Math.sin((360 / 365) * (dayOfYear - 81) * Math.PI / 180);
|
||||
const declinationRad = declination * Math.PI / 180;
|
||||
|
||||
const cosHourAngle = -Math.tan(latRad) * Math.tan(declinationRad);
|
||||
|
||||
if (cosHourAngle > 1) {
|
||||
return false; // Polar night
|
||||
}
|
||||
if (cosHourAngle < -1) {
|
||||
return true; // Midnight sun
|
||||
}
|
||||
|
||||
const hourAngle = Math.acos(cosHourAngle);
|
||||
const hourAngleDeg = hourAngle * 180 / Math.PI;
|
||||
|
||||
const sunriseHour = 12 - hourAngleDeg / 15;
|
||||
const sunsetHour = 12 + hourAngleDeg / 15;
|
||||
|
||||
const timeZoneOffset = now.getTimezoneOffset() / 60;
|
||||
const localSunrise = sunriseHour - lng / 15 - timeZoneOffset;
|
||||
const localSunset = sunsetHour - lng / 15 - timeZoneOffset;
|
||||
|
||||
const currentHour = now.getHours() + now.getMinutes() / 60;
|
||||
|
||||
const normalizeSunrise = ((localSunrise % 24) + 24) % 24;
|
||||
const normalizeSunset = ((localSunset % 24) + 24) % 24;
|
||||
|
||||
return currentHour >= normalizeSunrise && currentHour < normalizeSunset;
|
||||
}
|
||||
|
||||
// Helper function to send location to backend
|
||||
function sendLocationToBackend() {
|
||||
if (typeof SessionData === "undefined" || typeof DMSService === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DMSService.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SessionData.nightModeUseIPLocation) {
|
||||
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
|
||||
"use": true
|
||||
}, response => {
|
||||
if (response?.error) {
|
||||
log.warn("Theme automation: Failed to enable IP location", response.error);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
|
||||
DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
|
||||
"use": false
|
||||
}, response => {
|
||||
if (!response.error) {
|
||||
DMSService.sendRequest("wayland.gamma.setLocation", {
|
||||
"latitude": SessionData.latitude,
|
||||
"longitude": SessionData.longitude
|
||||
}, locResp => {
|
||||
if (locResp?.error) {
|
||||
log.warn("Theme automation: Failed to set location", locResp.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: locationRetryTimer
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: false
|
||||
property int retryCount: 0
|
||||
|
||||
onTriggered: {
|
||||
if (root.sendLocationToBackend()) {
|
||||
stop();
|
||||
retryCount = 0;
|
||||
root.evaluateThemeMode();
|
||||
} else {
|
||||
retryCount++;
|
||||
if (retryCount >= 10) {
|
||||
stop();
|
||||
retryCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startThemeModeAutomation() {
|
||||
root.themeModeAutomationActive = true;
|
||||
|
||||
root.syncTimeThemeSchedule();
|
||||
root.syncLocationThemeSchedule();
|
||||
|
||||
const sent = root.sendLocationToBackend();
|
||||
|
||||
if (!sent && typeof SessionData !== "undefined" && SessionData.themeModeAutoMode === "location") {
|
||||
locationRetryTimer.start();
|
||||
} else {
|
||||
root.evaluateThemeMode();
|
||||
}
|
||||
}
|
||||
|
||||
function stopThemeModeAutomation() {
|
||||
root.themeModeAutomationActive = false;
|
||||
if (typeof DMSService !== "undefined" && DMSService.isConnected) {
|
||||
DMSService.sendRequest("theme.auto.setEnabled", {
|
||||
"enabled": false
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,31 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Services.SystemTray
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var activeTrayMenus: ({})
|
||||
property var _pendingMenuRequest: null
|
||||
|
||||
signal openTrayMenuRequested
|
||||
|
||||
function requestOpenMenu(itemId, screenName) {
|
||||
_pendingMenuRequest = {
|
||||
"itemId": itemId,
|
||||
"screenName": screenName
|
||||
};
|
||||
openTrayMenuRequested();
|
||||
}
|
||||
|
||||
// Every SystemTrayBar instance receives the signal; the claim ensures
|
||||
// exactly one opens the menu, preferring the requested screen
|
||||
function claimMenuRequest(instanceScreenName) {
|
||||
if (!_pendingMenuRequest)
|
||||
return null;
|
||||
if (_pendingMenuRequest.screenName && _pendingMenuRequest.screenName !== instanceScreenName)
|
||||
return null;
|
||||
const request = _pendingMenuRequest;
|
||||
_pendingMenuRequest = null;
|
||||
return request;
|
||||
}
|
||||
|
||||
function findTrayItem(itemId: string): var {
|
||||
if (!itemId)
|
||||
return null;
|
||||
|
||||
return SystemTray.items.values.find(item => {
|
||||
const id = item?.id || "";
|
||||
const title = item?.tooltipTitle || "";
|
||||
const fullKey = title ? `${id}::${title}` : id;
|
||||
return fullKey === itemId || id === itemId;
|
||||
});
|
||||
}
|
||||
|
||||
function registerMenu(screenName, menu) {
|
||||
if (!screenName || !menu)
|
||||
return;
|
||||
const newMenus = Object.assign({}, activeTrayMenus);
|
||||
newMenus[screenName] = menu;
|
||||
activeTrayMenus = newMenus;
|
||||
if (!screenName || !menu) return
|
||||
const newMenus = Object.assign({}, activeTrayMenus)
|
||||
newMenus[screenName] = menu
|
||||
activeTrayMenus = newMenus
|
||||
}
|
||||
|
||||
function unregisterMenu(screenName) {
|
||||
if (!screenName)
|
||||
return;
|
||||
const newMenus = Object.assign({}, activeTrayMenus);
|
||||
delete newMenus[screenName];
|
||||
activeTrayMenus = newMenus;
|
||||
if (!screenName) return
|
||||
const newMenus = Object.assign({}, activeTrayMenus)
|
||||
delete newMenus[screenName]
|
||||
activeTrayMenus = newMenus
|
||||
}
|
||||
|
||||
function closeHoverMenus() {
|
||||
function closeAllMenus() {
|
||||
for (const screenName in activeTrayMenus) {
|
||||
const menu = activeTrayMenus[screenName]
|
||||
if (!menu || menu.openedByHover !== true) continue
|
||||
if (!menu) continue
|
||||
if (typeof menu.close === "function") {
|
||||
menu.close()
|
||||
} else if (menu.showMenu !== undefined) {
|
||||
@@ -71,17 +33,4 @@ Singleton {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
for (const screenName in activeTrayMenus) {
|
||||
const menu = activeTrayMenus[screenName];
|
||||
if (!menu)
|
||||
continue;
|
||||
if (typeof menu.close === "function") {
|
||||
menu.close();
|
||||
} else if (menu.showMenu !== undefined) {
|
||||
menu.showMenu = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
function markdownToHtml(text) {
|
||||
if (!text) return "";
|
||||
|
||||
// Store code blocks and inline code to protect them from further processing
|
||||
const codeBlocks = [];
|
||||
const inlineCode = [];
|
||||
let blockIndex = 0;
|
||||
let inlineIndex = 0;
|
||||
|
||||
// First, extract and replace code blocks with placeholders
|
||||
let html = text.replace(/```([\s\S]*?)```/g, (match, code) => {
|
||||
// Trim leading and trailing blank lines only
|
||||
const trimmedCode = code.replace(/^\n+|\n+$/g, '');
|
||||
// Escape HTML entities in code
|
||||
const escapedCode = trimmedCode.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
@@ -18,7 +22,9 @@ function markdownToHtml(text) {
|
||||
return `\x00CODEBLOCK${blockIndex++}\x00`;
|
||||
});
|
||||
|
||||
// Extract and replace inline code
|
||||
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
||||
// Escape HTML entities in code
|
||||
const escapedCode = code.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
@@ -34,10 +40,12 @@ function markdownToHtml(text) {
|
||||
return prefix + `\x00URL${urlIndex++}\x00`;
|
||||
});
|
||||
|
||||
// Escape HTML entities (but not in code blocks or URLs)
|
||||
html = html.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.*?)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*?)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*?)$/gm, '<h1>$1</h1>');
|
||||
@@ -50,16 +58,20 @@ function markdownToHtml(text) {
|
||||
html = html.replace(/__(.*?)__/g, '<b>$1</b>');
|
||||
html = html.replace(/_(.*?)_/g, '<i>$1</i>');
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Lists
|
||||
html = html.replace(/^\* (.*?)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/^- (.*?)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/^\d+\. (.*?)$/gm, '<li>$1</li>');
|
||||
|
||||
// Wrap consecutive list items in ul/ol tags
|
||||
html = html.replace(/(<li>[\s\S]*?<\/li>\s*)+/g, function(match) {
|
||||
return '<ul>' + match + '</ul>';
|
||||
});
|
||||
|
||||
// Restore extracted URLs as anchor tags (preserves raw & in href)
|
||||
html = html.replace(/\x00URL(\d+)\x00/g, (_, index) => {
|
||||
const url = urls[parseInt(index)];
|
||||
const display = url.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
@@ -75,23 +87,30 @@ function markdownToHtml(text) {
|
||||
return inlineCode[parseInt(index)];
|
||||
});
|
||||
|
||||
// Line breaks (after code blocks are restored)
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = html.replace(/\n/g, '<br/>');
|
||||
|
||||
// Wrap in paragraph tags if not already wrapped
|
||||
if (!html.startsWith('<')) {
|
||||
html = '<p>' + html + '</p>';
|
||||
}
|
||||
|
||||
// Clean up the final HTML
|
||||
// Remove <br/> tags immediately before block elements
|
||||
html = html.replace(/<br\/>\s*<pre>/g, '<pre>');
|
||||
html = html.replace(/<br\/>\s*<ul>/g, '<ul>');
|
||||
html = html.replace(/<br\/>\s*<h[1-6]>/g, '<h$1>');
|
||||
|
||||
// Remove empty paragraphs
|
||||
html = html.replace(/<p>\s*<\/p>/g, '');
|
||||
html = html.replace(/<p>\s*<br\/>\s*<\/p>/g, '');
|
||||
|
||||
html = html.replace(/(<br\/>){3,}/g, '<br/><br/>');
|
||||
html = html.replace(/(<\/p>)\s*(<p>)/g, '$1$2');
|
||||
// Remove excessive line breaks
|
||||
html = html.replace(/(<br\/>){3,}/g, '<br/><br/>'); // Max 2 consecutive line breaks
|
||||
html = html.replace(/(<\/p>)\s*(<p>)/g, '$1$2'); // Remove whitespace between paragraphs
|
||||
|
||||
// Remove leading/trailing whitespace
|
||||
html = html.trim();
|
||||
|
||||
return html;
|
||||
|
||||
@@ -5,13 +5,11 @@ import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
signal toastRequested(int severity, string title, string body, string command, string category)
|
||||
signal toastCategoryDismissed(string category)
|
||||
|
||||
property var settingsRoot: null
|
||||
|
||||
onSettingsRootChanged: {
|
||||
@@ -319,7 +317,7 @@ Singleton {
|
||||
function launchAuthApplyTerminalFallback(fromPrecheck, details) {
|
||||
authApplyTerminalFallbackFromPrecheck = fromPrecheck;
|
||||
if (details && details !== "")
|
||||
toastRequested(0, I18n.tr("Authentication changes need sudo. Opening terminal so you can use password or fingerprint."), details, "", "auth-sync");
|
||||
ToastService.showInfo(I18n.tr("Authentication changes need sudo. Opening terminal so you can use password or fingerprint."), details, "", "auth-sync");
|
||||
authApplyTerminalFallbackStderr = "";
|
||||
authApplyTerminalFallbackProcess.running = true;
|
||||
}
|
||||
@@ -366,21 +364,21 @@ Singleton {
|
||||
}
|
||||
|
||||
function deferGreeterAutoLoginSyncToPill(details) {
|
||||
toastCategoryDismissed("greeter-autologin-sync");
|
||||
ToastService.dismissCategory("greeter-autologin-sync");
|
||||
if (settingsRoot)
|
||||
settingsRoot.set("greeterSyncPending", true);
|
||||
toastRequested(1, I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms-greeter sync --autologin", "greeter-autologin-sync");
|
||||
ToastService.showWarning(I18n.tr("Auto-login change needs a sync"), I18n.tr("Administrator access is required. Use the Sync button in Settings → Greeter to apply.") + (details ? "\n\n" + details : ""), "dms-greeter sync --autologin", "greeter-autologin-sync");
|
||||
finishGreeterAutoLoginSync();
|
||||
}
|
||||
|
||||
function greeterAutoLoginSyncSuccessToast(details) {
|
||||
const enabling = settingsRoot && settingsRoot.greeterAutoLogin;
|
||||
// Clear the sticky in-progress toast, then confirm with an auto-dismissing toast.
|
||||
toastCategoryDismissed("greeter-autologin-sync");
|
||||
ToastService.dismissCategory("greeter-autologin-sync");
|
||||
if (enabling) {
|
||||
toastRequested(1, I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""), "", "");
|
||||
ToastService.showWarning(I18n.tr("Auto-login enabled"), I18n.tr("You'll skip the greeter password after the next reboot. The lock screen and signing out still require your password.") + (details ? "\n\n" + details : ""));
|
||||
} else {
|
||||
toastRequested(0, I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""), "", "");
|
||||
ToastService.showInfo(I18n.tr("Auto-login disabled"), I18n.tr("You'll enter your password at the greeter after the next reboot.") + (details ? "\n\n" + details : ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,10 +485,41 @@ Singleton {
|
||||
return pamFprintDetected ? "probe_failed" : "missing_pam_support";
|
||||
}
|
||||
|
||||
// --- Qt tools detection ---
|
||||
|
||||
function detectQtTools() {
|
||||
qtToolsDetectionProcess.running = true;
|
||||
}
|
||||
|
||||
function checkPluginSettings() {
|
||||
pluginSettingsCheckProcess.running = true;
|
||||
}
|
||||
|
||||
property var qtToolsDetectionProcess: Process {
|
||||
command: ["sh", "-c", "echo -n 'qt5ct:'; command -v qt5ct >/dev/null && echo 'true' || echo 'false'; echo -n 'qt6ct:'; command -v qt6ct >/dev/null && echo 'true' || echo 'false'; echo -n 'gtk:'; (command -v gsettings >/dev/null || command -v dconf >/dev/null) && echo 'true' || echo 'false'"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (!settingsRoot)
|
||||
return;
|
||||
if (text && text.trim()) {
|
||||
const lines = text.trim().split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith("qt5ct:")) {
|
||||
settingsRoot.qt5ctAvailable = line.split(":")[1] === "true";
|
||||
} else if (line.startsWith("qt6ct:")) {
|
||||
settingsRoot.qt6ctAvailable = line.split(":")[1] === "true";
|
||||
} else if (line.startsWith("gtk:")) {
|
||||
settingsRoot.gtkAvailable = line.split(":")[1] === "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: authApplyDebounce
|
||||
interval: 300
|
||||
@@ -546,7 +575,7 @@ Singleton {
|
||||
onExited: exitCode => {
|
||||
const enabling = root.settingsRoot && root.settingsRoot.greeterAutoLogin;
|
||||
if (exitCode === 0) {
|
||||
root.toastRequested(1, enabling ? I18n.tr("Applying auto-login on startup...") : I18n.tr("Disabling auto-login on startup..."), "", "dms-greeter sync --autologin", "greeter-autologin-sync");
|
||||
ToastService.showWarning(enabling ? I18n.tr("Applying auto-login on startup...") : I18n.tr("Disabling auto-login on startup..."), "", "dms-greeter sync --autologin", "greeter-autologin-sync");
|
||||
root.greeterAutoLoginSyncProcess.running = true;
|
||||
return;
|
||||
}
|
||||
@@ -575,7 +604,7 @@ Singleton {
|
||||
let details = out;
|
||||
if (err !== "")
|
||||
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
|
||||
root.toastRequested(0, I18n.tr("Authentication changes applied"), details, "", "auth-sync");
|
||||
ToastService.showInfo(I18n.tr("Authentication changes applied"), details, "", "auth-sync");
|
||||
root.detectAuthCapabilities();
|
||||
root.finishAuthApply();
|
||||
return;
|
||||
@@ -586,7 +615,7 @@ Singleton {
|
||||
details = out;
|
||||
if (err !== "")
|
||||
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
|
||||
root.toastRequested(1, I18n.tr("Background authentication sync failed. Trying terminal mode."), details, "", "auth-sync");
|
||||
ToastService.showWarning(I18n.tr("Background authentication sync failed. Trying terminal mode."), details, "", "auth-sync");
|
||||
root.launchAuthApplyTerminalFallback(false, "");
|
||||
}
|
||||
}
|
||||
@@ -602,7 +631,7 @@ Singleton {
|
||||
onExited: exitCode => {
|
||||
const err = (root.authApplySudoProbeStderr || "").trim();
|
||||
if (exitCode === 0) {
|
||||
root.toastRequested(0, I18n.tr("Applying authentication changes..."), "", "", "auth-sync");
|
||||
ToastService.showInfo(I18n.tr("Applying authentication changes..."), "", "", "auth-sync");
|
||||
root.authApplyProcess.running = true;
|
||||
return;
|
||||
}
|
||||
@@ -622,10 +651,10 @@ Singleton {
|
||||
onExited: exitCode => {
|
||||
if (exitCode === 0) {
|
||||
const message = root.authApplyTerminalFallbackFromPrecheck ? I18n.tr("Terminal opened. Complete authentication there; it will close automatically when done.") : I18n.tr("Terminal fallback opened. Complete authentication there; it will close automatically when done.");
|
||||
root.toastRequested(0, message, "", "", "auth-sync");
|
||||
ToastService.showInfo(message, "", "", "auth-sync");
|
||||
} else {
|
||||
let details = (root.authApplyTerminalFallbackStderr || "").trim();
|
||||
root.toastRequested(2, I18n.tr("Terminal fallback failed. Install a supported terminal emulator or run 'dms auth sync' manually.") + " (exit " + exitCode + ")", details, "", "auth-sync");
|
||||
ToastService.showError(I18n.tr("Terminal fallback failed. Install a supported terminal emulator or run 'dms auth sync' manually.") + " (exit " + exitCode + ")", details, "", "auth-sync");
|
||||
}
|
||||
root.finishAuthApply();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ var SPEC = {
|
||||
includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] },
|
||||
|
||||
wallpaperCyclingEnabled: { def: false },
|
||||
wallpaperCyclingRandom: { def: false },
|
||||
wallpaperCyclingMode: { def: "interval" },
|
||||
wallpaperCyclingInterval: { def: 300 },
|
||||
wallpaperCyclingTime: { def: "06:00" },
|
||||
@@ -99,8 +98,7 @@ var SPEC = {
|
||||
niriOverviewLastMode: { def: "apps" },
|
||||
|
||||
settingsSidebarExpandedIds: { def: "," },
|
||||
settingsSidebarCollapsedIds: { def: "," },
|
||||
showConfigReloadToast: { def: true }
|
||||
settingsSidebarCollapsedIds: { def: "," }
|
||||
};
|
||||
|
||||
function getValidKeys() {
|
||||
|
||||
@@ -75,3 +75,26 @@ function migrateToVersion(obj, targetVersion, settingsData) {
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function cleanup(fileText) {
|
||||
var getValidKeys = SpecModule.getValidKeys;
|
||||
if (!fileText || !fileText.trim()) return null;
|
||||
|
||||
try {
|
||||
var session = JSON.parse(fileText);
|
||||
var validKeys = getValidKeys();
|
||||
var needsSave = false;
|
||||
|
||||
for (var key in session) {
|
||||
if (validKeys.indexOf(key) < 0) {
|
||||
delete session[key];
|
||||
needsSave = true;
|
||||
}
|
||||
}
|
||||
|
||||
return needsSave ? JSON.stringify(session, null, 2) : null;
|
||||
} catch (e) {
|
||||
console.warn("SessionData: Failed to cleanup unused keys:", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,6 @@ var SPEC = {
|
||||
|
||||
popupTransparency: { def: 1.0, coerce: percentToUnit },
|
||||
dockTransparency: { def: 1.0, coerce: percentToUnit },
|
||||
floatingWindowSyncGlobal: { def: true },
|
||||
floatingWindowTransparency: { def: 1.0, coerce: percentToUnit },
|
||||
floatingWindowForegroundLayers: { def: true },
|
||||
floatingWindowForegroundTransparency: { def: 1.0, coerce: percentToUnit },
|
||||
dmsWindowsFloating: { def: true },
|
||||
|
||||
widgetBackgroundColor: { def: "sch" },
|
||||
widgetBackgroundCustomColor: { def: "#6750A4" },
|
||||
@@ -43,34 +38,6 @@ var SPEC = {
|
||||
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
|
||||
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
|
||||
mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
|
||||
mouseAccelProfile: { def: "default", onChange: "updateCompositorInput" },
|
||||
mouseAccelSpeed: { def: 0.0, onChange: "updateCompositorInput" },
|
||||
mouseLeftHanded: { def: false, onChange: "updateCompositorInput" },
|
||||
mouseMiddleEmulation: { def: false, onChange: "updateCompositorInput" },
|
||||
mouseNaturalScroll: { def: false, onChange: "updateCompositorInput" },
|
||||
mouseScrollFactor: { def: 1.0, onChange: "updateCompositorInput" },
|
||||
mouseScrollMethod: { def: "default", onChange: "updateCompositorInput" },
|
||||
touchpadAccelProfile: { def: "default", onChange: "updateCompositorInput" },
|
||||
touchpadAccelSpeed: { def: 0.0, onChange: "updateCompositorInput" },
|
||||
touchpadDisableOnExternalMouse: { def: false, onChange: "updateCompositorInput" },
|
||||
touchpadDisableWhileTyping: { def: true, onChange: "updateCompositorInput" },
|
||||
touchpadDragLock: { def: false, onChange: "updateCompositorInput" },
|
||||
touchpadMiddleEmulation: { def: false, onChange: "updateCompositorInput" },
|
||||
touchpadNaturalScroll: { def: true, onChange: "updateCompositorInput" },
|
||||
touchpadScrollFactor: { def: 1.0, onChange: "updateCompositorInput" },
|
||||
touchpadScrollMethod: { def: "default", onChange: "updateCompositorInput" },
|
||||
touchpadTapAndDrag: { def: true, onChange: "updateCompositorInput" },
|
||||
touchpadTapToClick: { def: true, onChange: "updateCompositorInput" },
|
||||
|
||||
keyboardLayouts: { def: "", onChange: "updateCompositorInput" },
|
||||
keyboardVariants: { def: "", onChange: "updateCompositorInput" },
|
||||
keyboardModel: { def: "", onChange: "updateCompositorInput" },
|
||||
keyboardOptions: { def: "", onChange: "updateCompositorInput" },
|
||||
keyboardKeymapFile: { def: "", onChange: "updateCompositorInput" },
|
||||
keyboardTrackLayout: { def: "", onChange: "updateCompositorInput" },
|
||||
keyboardRepeatDelay: { def: 0, onChange: "updateCompositorInput" },
|
||||
keyboardRepeatRate: { def: 0, onChange: "updateCompositorInput" },
|
||||
keyboardNumlock: { def: false, onChange: "updateCompositorInput" },
|
||||
|
||||
firstDayOfWeek: { def: -1 },
|
||||
showWeekNumber: { def: false },
|
||||
@@ -102,7 +69,6 @@ var SPEC = {
|
||||
barElevationEnabled: { def: true },
|
||||
blurEnabled: { def: false },
|
||||
blurForegroundLayers: { def: true },
|
||||
foregroundLayerTransparency: { def: 1.0, coerce: percentToUnit },
|
||||
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
||||
blurBorderEnabled: { def: true },
|
||||
blurBorderColor: { def: "outline" },
|
||||
@@ -221,7 +187,6 @@ var SPEC = {
|
||||
mediaAdaptiveWidthEnabled: { def: true },
|
||||
audioVisualizerEnabled: { def: true },
|
||||
mediaUseAlbumArtAccent: { def: false },
|
||||
appleMusicAnimatedArtEnabled: { def: false },
|
||||
audioScrollMode: { def: "volume" },
|
||||
audioWheelScrollAmount: { def: 5 },
|
||||
audioDeviceScrollVolumeEnabled: { def: false },
|
||||
@@ -315,6 +280,9 @@ var SPEC = {
|
||||
lastAppliedIconTheme: { def: "" },
|
||||
availableIconThemes: { def: ["System Default"], persist: false },
|
||||
systemDefaultIconTheme: { def: "", persist: false },
|
||||
qt5ctAvailable: { def: false, persist: false },
|
||||
qt6ctAvailable: { def: false, persist: false },
|
||||
gtkAvailable: { def: false, persist: false },
|
||||
|
||||
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 }, mango: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
|
||||
availableCursorThemes: { def: ["System Default"], persist: false },
|
||||
@@ -387,7 +355,14 @@ var SPEC = {
|
||||
fadeToDpmsEnabled: { def: true },
|
||||
fadeToDpmsGracePeriod: { def: 5 },
|
||||
launchPrefix: { def: "" },
|
||||
brightnessDevicePins: { def: {} },
|
||||
wifiNetworkPins: { def: {} },
|
||||
bluetoothDevicePins: { def: {} },
|
||||
audioInputDevicePins: { def: {} },
|
||||
audioOutputDevicePins: { def: {} },
|
||||
|
||||
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
||||
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
||||
syncModeWithPortal: { def: true },
|
||||
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
|
||||
|
||||
@@ -485,8 +460,6 @@ var SPEC = {
|
||||
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||
u2fMode: { def: "or" },
|
||||
lockPamPath: { def: "" },
|
||||
lockScreenSecurityKeyShortcut: { def: "Ctrl+Q" },
|
||||
lockScreenSecurityKeyShortcutEnabled: { def: false },
|
||||
lockPamInlineFprint: { def: false },
|
||||
lockPamInlineU2f: { def: false },
|
||||
lockPamExternallyManaged: { def: false },
|
||||
@@ -504,7 +477,6 @@ var SPEC = {
|
||||
notificationTimeoutLow: { def: 5000 },
|
||||
notificationTimeoutNormal: { def: 5000 },
|
||||
notificationTimeoutCritical: { def: 0 },
|
||||
notificationIgnoreAppTimeout: { def: false },
|
||||
notificationCompactMode: { def: false },
|
||||
notificationShowTimeoutBar: { def: false },
|
||||
notificationDedupeEnabled: { def: true },
|
||||
@@ -543,7 +515,6 @@ var SPEC = {
|
||||
customPowerActionHibernate: { def: "" },
|
||||
customPowerActionReboot: { def: "" },
|
||||
customPowerActionPowerOff: { def: "" },
|
||||
customPowerButtons: { def: [] },
|
||||
|
||||
updaterHideWidget: { def: false },
|
||||
updaterCheckOnStart: { def: false },
|
||||
|
||||
@@ -2,21 +2,6 @@
|
||||
|
||||
.import "./SettingsSpec.js" as SpecModule
|
||||
|
||||
var PIN_KEYS = ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"];
|
||||
|
||||
function extractPins(obj) {
|
||||
if (!obj) return null;
|
||||
|
||||
var pins = null;
|
||||
for (var i = 0; i < PIN_KEYS.length; i++) {
|
||||
var value = obj[PIN_KEYS[i]];
|
||||
if (!value || Object.keys(value).length === 0) continue;
|
||||
if (!pins) pins = {};
|
||||
pins[PIN_KEYS[i]] = value;
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
|
||||
function parse(root, jsonObj) {
|
||||
var SPEC = SpecModule.SPEC;
|
||||
|
||||
@@ -278,16 +263,28 @@ function migrateToVersion(obj, targetVersion) {
|
||||
settings.configVersion = 12;
|
||||
}
|
||||
|
||||
if (currentVersion < 13) {
|
||||
console.info("Migrating settings from version", currentVersion, "to version 13");
|
||||
console.info("Moving device and network pins to cache.json");
|
||||
|
||||
for (var p = 0; p < PIN_KEYS.length; p++) {
|
||||
delete settings[PIN_KEYS[p]];
|
||||
}
|
||||
|
||||
settings.configVersion = 13;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
function cleanup(fileText) {
|
||||
var getValidKeys = SpecModule.getValidKeys;
|
||||
if (!fileText || !fileText.trim()) return;
|
||||
|
||||
try {
|
||||
var settings = JSON.parse(fileText);
|
||||
var validKeys = getValidKeys();
|
||||
var needsSave = false;
|
||||
|
||||
for (var key in settings) {
|
||||
if (validKeys.indexOf(key) < 0) {
|
||||
delete settings[key];
|
||||
needsSave = true;
|
||||
}
|
||||
}
|
||||
|
||||
return needsSave ? JSON.stringify(settings, null, 2) : null;
|
||||
} catch (e) {
|
||||
console.warn("SettingsData: Failed to cleanup unused keys:", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,11 @@ const times = [
|
||||
|
||||
// adds a custom time to the times config
|
||||
|
||||
function addTime(angle, riseName, setName) {
|
||||
times.push([angle, riseName, setName]);
|
||||
};
|
||||
|
||||
|
||||
// calculations for sun times
|
||||
|
||||
const J0 = 0.0009;
|
||||
@@ -241,3 +246,63 @@ function getMoonIllumination(date) {
|
||||
angle
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
function hoursLater(date, h) {
|
||||
return new Date(date.valueOf() + h * dayMs / 24);
|
||||
}
|
||||
|
||||
// calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article
|
||||
|
||||
function getMoonTimes(date, lat, lng, inUTC) {
|
||||
const t = new Date(date);
|
||||
if (inUTC) t.setUTCHours(0, 0, 0, 0);
|
||||
else t.setHours(0, 0, 0, 0);
|
||||
|
||||
const hc = 0.133 * rad;
|
||||
let h0 = getMoonPosition(t, lat, lng).altitude - hc,
|
||||
rise, set, ye;
|
||||
|
||||
// go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set)
|
||||
for (let i = 1; i <= 24; i += 2) {
|
||||
const h1 = getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc;
|
||||
const h2 = getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc;
|
||||
const a = (h0 + h2) / 2 - h1;
|
||||
const b = (h2 - h0) / 2;
|
||||
const xe = -b / (2 * a);
|
||||
const d = b * b - 4 * a * h1;
|
||||
let roots = 0, x1 = 0, x2 = 0;
|
||||
ye = (a * xe + b) * xe + h1;
|
||||
|
||||
if (d >= 0) {
|
||||
const dx = Math.sqrt(d) / (Math.abs(a) * 2);
|
||||
x1 = xe - dx;
|
||||
x2 = xe + dx;
|
||||
if (Math.abs(x1) <= 1) roots++;
|
||||
if (Math.abs(x2) <= 1) roots++;
|
||||
if (x1 < -1) x1 = x2;
|
||||
}
|
||||
|
||||
if (roots === 1) {
|
||||
if (h0 < 0) rise = i + x1;
|
||||
else set = i + x1;
|
||||
|
||||
} else if (roots === 2) {
|
||||
rise = i + (ye < 0 ? x2 : x1);
|
||||
set = i + (ye < 0 ? x1 : x2);
|
||||
}
|
||||
|
||||
if (rise && set) break;
|
||||
|
||||
h0 = h2;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
|
||||
if (rise) result.rise = hoursLater(t, rise);
|
||||
if (set) result.set = hoursLater(t, set);
|
||||
|
||||
if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
+1
-40
@@ -31,8 +31,6 @@ Item {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("DMSShell")
|
||||
readonly property var _sessionsServiceRef: SessionsService
|
||||
// Keep the optional integration service alive
|
||||
readonly property var _dankConnectServiceRef: DankConnectService
|
||||
|
||||
property var core: null
|
||||
|
||||
@@ -235,11 +233,6 @@ Item {
|
||||
PolkitService.polkitAvailable;
|
||||
DisplayConfigState.hasOutputBackend;
|
||||
PortalService.systemColorScheme;
|
||||
IconThemeService.revision;
|
||||
DesktopService.isSystemd;
|
||||
TrashService.count;
|
||||
WallpaperCyclingService.cyclingActive;
|
||||
ThemeAutoService.active;
|
||||
}
|
||||
|
||||
Loader {
|
||||
@@ -321,17 +314,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: BarWidgetService
|
||||
property: "dockContextMenu"
|
||||
value: dockContextMenuLoader.item
|
||||
}
|
||||
Binding {
|
||||
target: BarWidgetService
|
||||
property: "dockTrashContextMenu"
|
||||
value: dockTrashContextMenuLoader.item
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onDockShowTrashChanged() {
|
||||
@@ -447,23 +429,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
id: qrGeneratorModalLoader
|
||||
active: false
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.qrGeneratorModalLoader = qrGeneratorModalLoader;
|
||||
}
|
||||
|
||||
QRGeneratorModal {
|
||||
id: qrGeneratorModalItem
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.qrGeneratorModal = qrGeneratorModalItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
id: polkitAuthModalLoader
|
||||
active: false
|
||||
@@ -637,7 +602,7 @@ Item {
|
||||
if (visible) {
|
||||
wasShown = true;
|
||||
} else if (wasShown) {
|
||||
Qt.callLater(() => PopoutService.unloadSettingsNow());
|
||||
PopoutService.unloadSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -942,7 +907,6 @@ Item {
|
||||
expandedWidthValue: 960
|
||||
edgeGap: SettingsData.notepadEffectiveEdgeGap
|
||||
slideEdge: SettingsData.notepadSlideoutSide
|
||||
customTransparency: Theme.notepadTransparency
|
||||
|
||||
onIsVisibleChanged: {
|
||||
if (isVisible)
|
||||
@@ -1018,9 +982,6 @@ Item {
|
||||
case "reboot":
|
||||
SessionService.reboot();
|
||||
break;
|
||||
case "softreboot":
|
||||
SessionService.softReboot();
|
||||
break;
|
||||
case "poweroff":
|
||||
SessionService.poweroff();
|
||||
break;
|
||||
|
||||
+60
-89
@@ -25,30 +25,32 @@ Item {
|
||||
required property var windowRuleModalLoader
|
||||
|
||||
function getPreferredBar(refPropertyName) {
|
||||
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
|
||||
return null;
|
||||
|
||||
const focusedScreenName = BarWidgetService.getFocusedScreenName();
|
||||
|
||||
const bars = [];
|
||||
if (root.dankBarRepeater) {
|
||||
for (let i = 0; i < root.dankBarRepeater.count; i++)
|
||||
bars.push(...(root.dankBarRepeater.itemAt(i)?.item?.barVariants?.instances || []));
|
||||
}
|
||||
const frameBars = BarWidgetService.frameHostedBars;
|
||||
for (const screenName in frameBars)
|
||||
bars.push(frameBars[screenName]);
|
||||
const loaders = Array.from({
|
||||
length: root.dankBarRepeater.count
|
||||
}, (_, i) => root.dankBarRepeater.itemAt(i));
|
||||
|
||||
let currentBar = null;
|
||||
for (const bar of bars) {
|
||||
if (!bar)
|
||||
continue;
|
||||
|
||||
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
|
||||
const hasRef = !refPropertyName || !!bar[refPropertyName];
|
||||
for (const loader of loaders) {
|
||||
const instances = loader?.item?.barVariants?.instances || [];
|
||||
for (const bar of instances) {
|
||||
if (!bar)
|
||||
continue;
|
||||
|
||||
if (hasRef) {
|
||||
currentBar = bar;
|
||||
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
|
||||
const hasRef = !refPropertyName || !!bar[refPropertyName];
|
||||
|
||||
if (onFocusedScreen)
|
||||
break;
|
||||
if (hasRef) {
|
||||
currentBar = bar;
|
||||
|
||||
if (onFocusedScreen)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,66 +285,28 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
function _resolvePosition(position) {
|
||||
switch ((position || "").toLowerCase()) {
|
||||
case "left":
|
||||
return "left";
|
||||
case "center":
|
||||
return "center";
|
||||
case "right":
|
||||
return "right";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function _dashBar(position) {
|
||||
if (position)
|
||||
return root.getPreferredBar();
|
||||
return root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
|
||||
}
|
||||
|
||||
function _openDash(tab, position) {
|
||||
const bar = _dashBar(position);
|
||||
if (!bar)
|
||||
return false;
|
||||
|
||||
const tabId = _resolveTabId(tab);
|
||||
const dash = root.dankDashPopoutLoader.item;
|
||||
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
|
||||
if (position && bar.positionDash)
|
||||
bar.positionDash(dash, position);
|
||||
dash.requestTab(tabId);
|
||||
if (dash.updateSurfacePosition)
|
||||
dash.updateSurfacePosition();
|
||||
return true;
|
||||
}
|
||||
|
||||
return bar.triggerDashTab(tabId, position);
|
||||
}
|
||||
|
||||
function _toggleDash(tab, position) {
|
||||
if (root.dankDashPopoutLoader.item?.dashVisible) {
|
||||
root.dankDashPopoutLoader.item.dashVisible = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const bar = _dashBar(position);
|
||||
if (!bar)
|
||||
return false;
|
||||
return bar.triggerDashTab(_resolveTabId(tab), position);
|
||||
}
|
||||
|
||||
function resolveTabIndex(tab: string): int {
|
||||
return SettingsData.dashTabIndexForId(_resolveTabId(tab));
|
||||
}
|
||||
|
||||
function open(tab: string): string {
|
||||
return _openDash(tab, "") ? "DASH_OPEN_SUCCESS" : "DASH_OPEN_FAILED";
|
||||
}
|
||||
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
|
||||
if (!bar)
|
||||
return "DASH_OPEN_FAILED";
|
||||
|
||||
function openAt(tab: string, position: string): string {
|
||||
return _openDash(tab, _resolvePosition(position)) ? "DASH_OPEN_SUCCESS" : "DASH_OPEN_FAILED";
|
||||
const tabId = _resolveTabId(tab);
|
||||
const dash = root.dankDashPopoutLoader.item;
|
||||
if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) {
|
||||
dash.requestTab(tabId);
|
||||
if (dash.updateSurfacePosition)
|
||||
dash.updateSurfacePosition();
|
||||
return "DASH_OPEN_SUCCESS";
|
||||
}
|
||||
|
||||
if (!bar.triggerDashTab(tabId))
|
||||
return "DASH_OPEN_FAILED";
|
||||
|
||||
return "DASH_OPEN_SUCCESS";
|
||||
}
|
||||
|
||||
function close(): string {
|
||||
@@ -354,11 +318,18 @@ Item {
|
||||
}
|
||||
|
||||
function toggle(tab: string): string {
|
||||
return _toggleDash(tab, "") ? "DASH_TOGGLE_SUCCESS" : "DASH_TOGGLE_FAILED";
|
||||
}
|
||||
if (root.dankDashPopoutLoader.item?.dashVisible) {
|
||||
root.dankDashPopoutLoader.item.dashVisible = false;
|
||||
return "DASH_TOGGLE_SUCCESS";
|
||||
}
|
||||
|
||||
function toggleAt(tab: string, position: string): string {
|
||||
return _toggleDash(tab, _resolvePosition(position)) ? "DASH_TOGGLE_SUCCESS" : "DASH_TOGGLE_FAILED";
|
||||
const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar();
|
||||
if (bar) {
|
||||
if (!bar.triggerDashTab(_resolveTabId(tab)))
|
||||
return "DASH_TOGGLE_FAILED";
|
||||
return "DASH_TOGGLE_SUCCESS";
|
||||
}
|
||||
return "DASH_TOGGLE_FAILED";
|
||||
}
|
||||
|
||||
target: "dash"
|
||||
@@ -2023,6 +1994,18 @@ Item {
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function findTrayItem(itemId: string): var {
|
||||
if (!itemId)
|
||||
return null;
|
||||
|
||||
return SystemTray.items.values.find(item => {
|
||||
const id = item?.id || "";
|
||||
const title = item?.tooltipTitle || "";
|
||||
const fullKey = title ? `${id}::${title}` : id;
|
||||
return fullKey === itemId || id === itemId;
|
||||
});
|
||||
}
|
||||
|
||||
function list(): string {
|
||||
const items = SystemTray.items.values;
|
||||
if (items.length === 0)
|
||||
@@ -2038,7 +2021,7 @@ Item {
|
||||
}
|
||||
|
||||
function activate(itemId: string): string {
|
||||
const item = TrayMenuManager.findTrayItem(itemId);
|
||||
const item = findTrayItem(itemId);
|
||||
if (!item)
|
||||
return `ERROR: Tray item not found: ${itemId}`;
|
||||
|
||||
@@ -2046,20 +2029,8 @@ Item {
|
||||
return `SUCCESS: Activated ${itemId}`;
|
||||
}
|
||||
|
||||
function menu(itemId: string): string {
|
||||
const item = TrayMenuManager.findTrayItem(itemId);
|
||||
if (!item)
|
||||
return `ERROR: Tray item not found: ${itemId}`;
|
||||
|
||||
if (!item.hasMenu)
|
||||
return `ERROR: Tray item has no menu: ${itemId}`;
|
||||
|
||||
TrayMenuManager.requestOpenMenu(itemId, BarWidgetService.getFocusedScreenName());
|
||||
return `SUCCESS: Requested menu ${itemId}`;
|
||||
}
|
||||
|
||||
function status(itemId: string): string {
|
||||
const item = TrayMenuManager.findTrayItem(itemId);
|
||||
const item = findTrayItem(itemId);
|
||||
if (!item)
|
||||
return `ERROR: Tray item not found: ${itemId}`;
|
||||
|
||||
|
||||
@@ -318,6 +318,10 @@ DankModal {
|
||||
width: parent.width - Theme.spacingS * 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
height: 52
|
||||
cornerRadius: Theme.cornerRadius
|
||||
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
normalBorderColor: Theme.outlineMedium
|
||||
focusedBorderColor: Theme.primary
|
||||
leftIconName: "search"
|
||||
leftIconSize: Theme.iconSize
|
||||
leftIconColor: Theme.surfaceVariantText
|
||||
|
||||
@@ -15,7 +15,7 @@ Rectangle {
|
||||
|
||||
height: Math.round(Theme.fontSizeMedium * 4.2)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -4,9 +4,10 @@ import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
DankFloatingWindow {
|
||||
FloatingWindow {
|
||||
id: root
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
readonly property int modalWidth: 680
|
||||
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
|
||||
|
||||
@@ -20,6 +21,7 @@ DankFloatingWindow {
|
||||
title: i18n("What's New")
|
||||
minimumSize: Qt.size(modalWidth, modalHeight)
|
||||
maximumSize: Qt.size(modalWidth, modalHeight)
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onClosed: visible = false
|
||||
@@ -110,7 +112,7 @@ DankFloatingWindow {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: Math.round(Theme.fontSizeMedium * 4.5)
|
||||
color: Theme.floatingWindowNestedSurface
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
|
||||
@@ -314,20 +314,26 @@ Item {
|
||||
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
|
||||
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
|
||||
|
||||
function _frameEdgeInset(side) {
|
||||
if (!effectiveScreen)
|
||||
return 0;
|
||||
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
|
||||
}
|
||||
|
||||
readonly property real _connectedAlignedX: {
|
||||
switch (resolvedConnectedBarSide) {
|
||||
case "top":
|
||||
case "bottom":
|
||||
{
|
||||
const insetL = SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
|
||||
const insetR = SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
|
||||
const insetL = _frameEdgeInset("left");
|
||||
const insetR = _frameEdgeInset("right");
|
||||
const usable = Math.max(0, screenWidth - insetL - insetR);
|
||||
return insetL + Math.max(0, (usable - alignedWidth) / 2);
|
||||
}
|
||||
case "left":
|
||||
return SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
|
||||
return _frameEdgeInset("left");
|
||||
case "right":
|
||||
return screenWidth - alignedWidth - SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
|
||||
return screenWidth - alignedWidth - _frameEdgeInset("right");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -335,14 +341,14 @@ Item {
|
||||
readonly property real _connectedAlignedY: {
|
||||
switch (resolvedConnectedBarSide) {
|
||||
case "top":
|
||||
return SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
|
||||
return _frameEdgeInset("top");
|
||||
case "bottom":
|
||||
return screenHeight - alignedHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
|
||||
return screenHeight - alignedHeight - _frameEdgeInset("bottom");
|
||||
case "left":
|
||||
case "right":
|
||||
{
|
||||
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
|
||||
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
|
||||
const insetT = _frameEdgeInset("top");
|
||||
const insetB = _frameEdgeInset("bottom");
|
||||
const usable = Math.max(0, screenHeight - insetT - insetB);
|
||||
return insetT + Math.max(0, (usable - alignedHeight) / 2);
|
||||
}
|
||||
|
||||
@@ -819,24 +819,16 @@ Item {
|
||||
}
|
||||
|
||||
if (isCategoryFiltered) {
|
||||
var categoryPluginId = AppSearchService.getPluginIdForCategory(appCategory);
|
||||
if (categoryPluginId) {
|
||||
var pluginCategoryItems = getPluginItems(categoryPluginId, "");
|
||||
for (var i = 0; i < pluginCategoryItems.length; i++) {
|
||||
allItems.push(pluginCategoryItems[i]);
|
||||
}
|
||||
} else {
|
||||
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||
for (var i = 0; i < rawApps.length; i++) {
|
||||
allItems.push(getOrTransformApp(rawApps[i]));
|
||||
}
|
||||
// Also include core apps (DMS Settings etc.) that match this category
|
||||
var allCoreApps = AppSearchService.getCoreApps("");
|
||||
for (var i = 0; i < allCoreApps.length; i++) {
|
||||
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
|
||||
if (coreAppCats.indexOf(appCategory) !== -1)
|
||||
allItems.push(transformCoreApp(allCoreApps[i]));
|
||||
}
|
||||
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||
for (var i = 0; i < rawApps.length; i++) {
|
||||
allItems.push(getOrTransformApp(rawApps[i]));
|
||||
}
|
||||
// Also include core apps (DMS Settings etc.) that match this category
|
||||
var allCoreApps = AppSearchService.getCoreApps("");
|
||||
for (var i = 0; i < allCoreApps.length; i++) {
|
||||
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
|
||||
if (coreAppCats.indexOf(appCategory) !== -1)
|
||||
allItems.push(transformCoreApp(allCoreApps[i]));
|
||||
}
|
||||
} else {
|
||||
var apps = searchApps(searchQuery);
|
||||
@@ -847,7 +839,7 @@ Item {
|
||||
|
||||
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
|
||||
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
|
||||
var newSections = Scorer.groupBySection(scoredItems, buildDynamicSectionDefs(allItems), sortAlpha, searchQuery ? 50 : 500);
|
||||
var newSections = Scorer.groupBySection(scoredItems, sectionDefinitions, sortAlpha, searchQuery ? 50 : 500);
|
||||
|
||||
for (var sid in collapsedSections) {
|
||||
for (var i = 0; i < newSections.length; i++) {
|
||||
|
||||
@@ -105,6 +105,12 @@ Item {
|
||||
}
|
||||
readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide)
|
||||
|
||||
function _frameEdgeInset(side) {
|
||||
if (!effectiveScreen)
|
||||
return 0;
|
||||
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
|
||||
}
|
||||
|
||||
readonly property var _connectedModalPos: {
|
||||
const fallback = {
|
||||
"x": (screenWidth - modalWidth) / 2,
|
||||
@@ -114,10 +120,10 @@ Item {
|
||||
case "top":
|
||||
case "bottom":
|
||||
{
|
||||
const insetL = SettingsData.frameEdgeInsetForSide(effectiveScreen, "left");
|
||||
const insetR = SettingsData.frameEdgeInsetForSide(effectiveScreen, "right");
|
||||
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
|
||||
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
|
||||
const insetL = _frameEdgeInset("left");
|
||||
const insetR = _frameEdgeInset("right");
|
||||
const insetT = _frameEdgeInset("top");
|
||||
const insetB = _frameEdgeInset("bottom");
|
||||
const usable = Math.max(0, screenWidth - insetL - insetR);
|
||||
const usableH = Math.max(0, screenHeight - insetT - insetB);
|
||||
return {
|
||||
@@ -128,11 +134,11 @@ Item {
|
||||
case "left":
|
||||
case "right":
|
||||
{
|
||||
const insetT = SettingsData.frameEdgeInsetForSide(effectiveScreen, "top");
|
||||
const insetB = SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom");
|
||||
const insetT = _frameEdgeInset("top");
|
||||
const insetB = _frameEdgeInset("bottom");
|
||||
const usable = Math.max(0, screenHeight - insetT - insetB);
|
||||
return {
|
||||
"x": resolvedConnectedBarSide === "left" ? SettingsData.frameEdgeInsetForSide(effectiveScreen, "left") : screenWidth - modalWidth - SettingsData.frameEdgeInsetForSide(effectiveScreen, "right"),
|
||||
"x": resolvedConnectedBarSide === "left" ? _frameEdgeInset("left") : screenWidth - modalWidth - _frameEdgeInset("right"),
|
||||
"y": insetT + Math.max(0, (usable - modalHeight) / 2)
|
||||
};
|
||||
}
|
||||
@@ -182,16 +188,16 @@ Item {
|
||||
readonly property real _connectedChromeY: {
|
||||
if (!launcherArcExtenderActive)
|
||||
return alignedY;
|
||||
return resolvedConnectedBarSide === "top" ? Theme.snap(SettingsData.frameEdgeInsetForSide(effectiveScreen, "top"), dpr) : alignedY;
|
||||
return resolvedConnectedBarSide === "top" ? Theme.snap(_frameEdgeInset("top"), dpr) : alignedY;
|
||||
}
|
||||
readonly property real _connectedChromeWidth: alignedWidth
|
||||
readonly property real _connectedChromeHeight: {
|
||||
if (!launcherArcExtenderActive)
|
||||
return alignedHeight;
|
||||
if (resolvedConnectedBarSide === "top")
|
||||
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "top")), dpr);
|
||||
return Theme.snap(Math.max(alignedHeight, alignedY + alignedHeight - _frameEdgeInset("top")), dpr);
|
||||
if (resolvedConnectedBarSide === "bottom")
|
||||
return Theme.snap(Math.max(alignedHeight, screenHeight - SettingsData.frameEdgeInsetForSide(effectiveScreen, "bottom") - alignedY), dpr);
|
||||
return Theme.snap(Math.max(alignedHeight, screenHeight - _frameEdgeInset("bottom") - alignedY), dpr);
|
||||
return alignedHeight;
|
||||
}
|
||||
readonly property real contentSurfaceHeight: launcherArcExtenderActive ? _connectedChromeHeight : alignedHeight
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user