mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-09 15:08:31 -04:00
Compare commits
1 Commits
master
..
52068fb61b
| Author | SHA1 | Date | |
|---|---|---|---|
| 52068fb61b |
@@ -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).
|
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**
|
**Plugin System**
|
||||||
Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
|
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
||||||
`~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and
|
|
||||||
their exact Git commits, so the same plugins can be reproduced on another machine.
|
|
||||||
|
|
||||||
## Supported Compositors
|
## Supported Compositors
|
||||||
|
|
||||||
@@ -122,8 +120,6 @@ dms ipc call audio setvolume 50
|
|||||||
dms ipc call wallpaper set /path/to/image.jpg
|
dms ipc call wallpaper set /path/to/image.jpg
|
||||||
dms brightness list # List available displays
|
dms brightness list # List available displays
|
||||||
dms plugins search # Browse plugin registry
|
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)
|
[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 run [-d]` - Start shell (optionally as daemon)
|
||||||
- `dms restart` / `dms kill` - Manage running processes
|
- `dms restart` / `dms kill` - Manage running processes
|
||||||
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
|
- `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 brightness [list|set]` - Control display/monitor brightness
|
||||||
- `dms color pick` - Native color picker (see below)
|
- `dms color pick` - Native color picker (see below)
|
||||||
- `dms update` - Update DMS and dependencies (disabled in distro packages)
|
- `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().BoolP("all", "a", false, "Update all installed plugins")
|
||||||
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
|
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{
|
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) {
|
func runVersion(cmd *cobra.Command, args []string) {
|
||||||
fmt.Printf("%s\n", formatVersion(Version))
|
fmt.Printf("%s\n", formatVersion(Version))
|
||||||
}
|
}
|
||||||
@@ -465,40 +433,6 @@ func installPluginCLI(idOrName string) error {
|
|||||||
return nil
|
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 {
|
func getAvailablePluginIDs() []string {
|
||||||
registry, err := plugins.NewRegistry()
|
registry, err := plugins.NewRegistry()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -770,7 +704,6 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
ipcCmd,
|
ipcCmd,
|
||||||
debugSrvCmd,
|
debugSrvCmd,
|
||||||
pluginsCmd,
|
pluginsCmd,
|
||||||
registryCmd,
|
|
||||||
dank16Cmd,
|
dank16Cmd,
|
||||||
brightnessCmd,
|
brightnessCmd,
|
||||||
dpmsCmd,
|
dpmsCmd,
|
||||||
|
|||||||
+73
-151
@@ -449,7 +449,12 @@ func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) {
|
|||||||
func checkDMSInstallation() []checkResult {
|
func checkDMSInstallation() []checkResult {
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
|
|
||||||
dmsPath := resolveDoctorShellPath()
|
dmsPath := ""
|
||||||
|
if err := shellApp.ResolveConfig(nil, nil); err == nil && shellApp.ConfigPath() != "" {
|
||||||
|
dmsPath = shellApp.ConfigPath()
|
||||||
|
} else if path, err := config.LocateDMSConfig(); err == nil {
|
||||||
|
dmsPath = path
|
||||||
|
}
|
||||||
|
|
||||||
if dmsPath == "" {
|
if dmsPath == "" {
|
||||||
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
|
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
|
||||||
@@ -1164,140 +1169,19 @@ func formatResultsPlain(results []checkResult) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
defaultDoctorFontFamily = "Inter Variable"
|
|
||||||
defaultDoctorMonoFontFamily = "Fira Code"
|
|
||||||
)
|
|
||||||
|
|
||||||
// bundledFontRelPaths maps settings/default family names to font files shipped with
|
|
||||||
// the shell and loaded via Qt FontLoader (not registered with fontconfig).
|
|
||||||
var bundledFontRelPaths = map[string][]string{
|
|
||||||
"inter variable": {
|
|
||||||
"DankCommon/assets/fonts/inter/InterVariable.ttf",
|
|
||||||
"assets/fonts/inter/InterVariable.ttf",
|
|
||||||
},
|
|
||||||
"fira code": {
|
|
||||||
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
|
||||||
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
|
||||||
},
|
|
||||||
"firacode nerd font": {
|
|
||||||
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
|
||||||
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDoctorShellPath() string {
|
|
||||||
if err := shellApp.ResolveConfig(nil, nil); err == nil && shellApp.ConfigPath() != "" {
|
|
||||||
return shellApp.ConfigPath()
|
|
||||||
}
|
|
||||||
if path, err := config.LocateDMSConfig(); err == nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func findBundledFontFile(shellPath, family string) string {
|
|
||||||
if shellPath == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
relPaths, ok := bundledFontRelPaths[strings.ToLower(strings.TrimSpace(family))]
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, rel := range relPaths {
|
|
||||||
path := filepath.Join(shellPath, rel)
|
|
||||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBundledDefaultFont(family string) bool {
|
|
||||||
_, ok := bundledFontRelPaths[strings.ToLower(strings.TrimSpace(family))]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func fontInFcList(name, cacheLower string) bool {
|
|
||||||
target := strings.ToLower(strings.TrimSpace(name))
|
|
||||||
if target == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(cacheLower, "\n") {
|
|
||||||
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
|
|
||||||
if strings.TrimSpace(fam) == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailable bool, url string) checkResult {
|
|
||||||
if bundled := findBundledFontFile(shellPath, family); bundled != "" {
|
|
||||||
details := "Bundled (Qt FontLoader)"
|
|
||||||
if doctorVerbose {
|
|
||||||
details = bundled
|
|
||||||
}
|
|
||||||
return checkResult{catFonts, label, statusOK, family, details, url}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isBundledDefaultFont(family) {
|
|
||||||
if shellPath == "" {
|
|
||||||
return checkResult{
|
|
||||||
catFonts, label, statusWarn,
|
|
||||||
fmt.Sprintf("'%s' not verified", family),
|
|
||||||
"Could not locate shell config to verify bundled font files.",
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return checkResult{
|
|
||||||
catFonts, label, statusWarn,
|
|
||||||
fmt.Sprintf("'%s' bundled file missing", family),
|
|
||||||
"Expected font file missing from shell install. Reinstall DMS or check DankCommon assets.",
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !fcListAvailable {
|
|
||||||
return checkResult{
|
|
||||||
catFonts, label, statusWarn,
|
|
||||||
fmt.Sprintf("'%s' not verified", family),
|
|
||||||
"fc-list not installed; cannot verify custom fonts in fontconfig.",
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fcCache == "" {
|
|
||||||
return checkResult{
|
|
||||||
catFonts, label, statusWarn,
|
|
||||||
fmt.Sprintf("'%s' not found", family),
|
|
||||||
"Fontconfig cache is empty or unreadable. Try running 'fc-cache -fv'.",
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fontInFcList(family, fcCache) {
|
|
||||||
return checkResult{catFonts, label, statusOK, family, "Available via fontconfig", url}
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkResult{
|
|
||||||
catFonts, label, statusWarn,
|
|
||||||
fmt.Sprintf("'%s' not found", family),
|
|
||||||
"Font is not registered with fontconfig. Try running 'fc-cache -fv' or install the font.",
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkFonts() []checkResult {
|
func checkFonts() []checkResult {
|
||||||
var results []checkResult
|
var results []checkResult
|
||||||
url := doctorDocsURL + "#fonts"
|
url := doctorDocsURL + "#fonts"
|
||||||
|
|
||||||
fontFamily := defaultDoctorFontFamily
|
configDir, err := os.UserConfigDir()
|
||||||
monoFontFamily := defaultDoctorMonoFontFamily
|
if err != nil {
|
||||||
|
return nil
|
||||||
if configDir, err := os.UserConfigDir(); err == nil {
|
}
|
||||||
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
||||||
|
|
||||||
|
fontFamily := "Inter Variable"
|
||||||
|
monoFontFamily := "Fira Code"
|
||||||
|
|
||||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||||
var settings struct {
|
var settings struct {
|
||||||
FontFamily string `json:"fontFamily"`
|
FontFamily string `json:"fontFamily"`
|
||||||
@@ -1312,34 +1196,72 @@ func checkFonts() []checkResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !utils.CommandExists("fc-list") {
|
||||||
|
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify if fonts are cached.", url})
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
shellPath := resolveDoctorShellPath()
|
// Retrieve font list
|
||||||
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily)
|
|
||||||
|
|
||||||
fcListAvailable := utils.CommandExists("fc-list")
|
|
||||||
fcCache := ""
|
|
||||||
|
|
||||||
if needFontconfig {
|
|
||||||
if !fcListAvailable {
|
|
||||||
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify custom fonts in fontconfig cache.", url})
|
|
||||||
} else {
|
|
||||||
output, err := exec.Command("fc-list", ":", "family").Output()
|
output, err := exec.Command("fc-list", ":", "family").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
|
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
|
||||||
} else {
|
return results
|
||||||
fcCache = strings.ToLower(string(output))
|
|
||||||
if len(strings.TrimSpace(fcCache)) == 0 {
|
|
||||||
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results,
|
outStr := string(output)
|
||||||
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
|
if len(strings.TrimSpace(outStr)) == 0 {
|
||||||
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
|
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
|
||||||
)
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerFonts := strings.ToLower(outStr)
|
||||||
|
|
||||||
|
// Helper to check if a font exists
|
||||||
|
hasFont := func(name string) bool {
|
||||||
|
target := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(lowerFonts, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Each line can have comma-separated families
|
||||||
|
families := strings.Split(line, ",")
|
||||||
|
for _, fam := range families {
|
||||||
|
if strings.TrimSpace(fam) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal Font Check
|
||||||
|
if hasFont(fontFamily) {
|
||||||
|
results = append(results, checkResult{catFonts, "Normal Font", statusOK, fontFamily, "Available", url})
|
||||||
|
} else {
|
||||||
|
results = append(results, checkResult{
|
||||||
|
catFonts, "Normal Font", statusWarn,
|
||||||
|
fmt.Sprintf("'%s' not found", fontFamily),
|
||||||
|
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monospace Font Check
|
||||||
|
if hasFont(monoFontFamily) {
|
||||||
|
results = append(results, checkResult{catFonts, "Monospace Font", statusOK, monoFontFamily, "Available", url})
|
||||||
|
} else {
|
||||||
|
results = append(results, checkResult{
|
||||||
|
catFonts, "Monospace Font", statusWarn,
|
||||||
|
fmt.Sprintf("'%s' not found", monoFontFamily),
|
||||||
|
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -15,8 +15,7 @@ func init() {
|
|||||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
updateCmd.AddCommand(updateCheckCmd)
|
updateCmd.AddCommand(updateCheckCmd)
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
|
|
||||||
rootCmd.AddCommand(getCommonCommands()...)
|
rootCmd.AddCommand(getCommonCommands()...)
|
||||||
|
|
||||||
rootCmd.AddCommand(authCmd)
|
rootCmd.AddCommand(authCmd)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ var Version = "dev"
|
|||||||
func init() {
|
func init() {
|
||||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
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(getCommonCommands()...)
|
||||||
rootCmd.AddCommand(authCmd)
|
rootCmd.AddCommand(authCmd)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b
|
github.com/AvengeMedia/dankgo v0.0.0-20260724133713-a4ef23371e05
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // 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 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
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-20260724133713-a4ef23371e05 h1:Ij/yzOT8y2HL7V5Rkec1GxJV0rUvhKqfAzyGxVRTk1o=
|
||||||
github.com/AvengeMedia/dankgo v0.0.0-20260730184236-239485829b0b/go.mod h1:xt8RldAfti0QCWidwYIzsSSoJWsE61WgEhTu2H9UpD4=
|
github.com/AvengeMedia/dankgo v0.0.0-20260724133713-a4ef23371e05/go.mod h1:xt8RldAfti0QCWidwYIzsSSoJWsE61WgEhTu2H9UpD4=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||||
|
|||||||
@@ -313,7 +313,6 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
|
|||||||
fg := HexToRGB(hexColor)
|
fg := HexToRGB(hexColor)
|
||||||
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
||||||
Lf, af, bf := cf.Lab()
|
Lf, af, bf := cf.Lab()
|
||||||
Lf *= 100.0
|
|
||||||
|
|
||||||
dir := 1.0
|
dir := 1.0
|
||||||
if isLightMode {
|
if isLightMode {
|
||||||
@@ -342,7 +341,6 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
|
|||||||
fg := HexToRGB(hexColor)
|
fg := HexToRGB(hexColor)
|
||||||
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
||||||
origL, af, bf := cf.Lab()
|
origL, af, bf := cf.Lab()
|
||||||
origL *= 100.0
|
|
||||||
|
|
||||||
var darkerResult, lighterResult string
|
var darkerResult, lighterResult string
|
||||||
darkerL, lighterL := origL, origL
|
darkerL, lighterL := origL, origL
|
||||||
@@ -422,24 +420,6 @@ func blendHue(base, target, factor float64) float64 {
|
|||||||
return result
|
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 {
|
func DeriveContainer(primary string, isLight bool) string {
|
||||||
rgb := HexToRGB(primary)
|
rgb := HexToRGB(primary)
|
||||||
hsv := RGBToHSV(rgb)
|
hsv := RGBToHSV(rgb)
|
||||||
@@ -520,7 +500,10 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
|||||||
gray7V := baseVal * 0.28
|
gray7V := baseVal * 0.28
|
||||||
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
|
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)
|
brightRedS := math.Min(baseSat*1.0, 1.0)
|
||||||
brightRedV := math.Min(baseVal*1.2, 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)
|
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.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)
|
brightRedS := math.Min(baseSat*0.75, 1.0)
|
||||||
brightRedV := math.Min(baseVal*1.35, 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)
|
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 {
|
func (g *GentooDistribution) getArchKeyword() string {
|
||||||
arch := runtime.GOARCH
|
arch := runtime.GOARCH
|
||||||
switch arch {
|
switch arch {
|
||||||
@@ -324,7 +319,6 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
}
|
}
|
||||||
g.log("Portage tree synced successfully")
|
g.log("Portage tree synced successfully")
|
||||||
|
|
||||||
args := emergeInstallArgs(missingPkgs)
|
|
||||||
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
|
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhasePrerequisites,
|
Phase: PhasePrerequisites,
|
||||||
@@ -332,10 +326,12 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
|||||||
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
|
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
|
||||||
IsComplete: false,
|
IsComplete: false,
|
||||||
NeedsSudo: true,
|
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, ", ")),
|
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, " "))
|
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
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{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseSystemPackages,
|
Phase: PhaseSystemPackages,
|
||||||
@@ -716,7 +713,8 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
|
|||||||
guruPackages[i] = pkg + "::guru"
|
guruPackages[i] = pkg + "::guru"
|
||||||
}
|
}
|
||||||
|
|
||||||
args := emergeInstallArgs(guruPackages)
|
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||||
|
args = append(args, guruPackages...)
|
||||||
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseAURPackages,
|
Phase: PhaseAURPackages,
|
||||||
|
|||||||
@@ -6,7 +6,37 @@ func NewClient() Client {
|
|||||||
geoclueClient, err := newGeoClueClient()
|
geoclueClient, err := newGeoClueClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("GeoClue2 unavailable: %v", err)
|
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
|
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 {
|
type GeoClueClient struct {
|
||||||
currLocation *Location
|
currLocation *Location
|
||||||
locationMutex sync.RWMutex
|
locationMutex sync.RWMutex
|
||||||
seedOnce sync.Once
|
|
||||||
|
|
||||||
dbusConn *dbus.Conn
|
dbusConn *dbus.Conn
|
||||||
clientPath dbus.ObjectPath
|
clientPath dbus.ObjectPath
|
||||||
@@ -231,29 +230,14 @@ func (c *GeoClueClient) SeedLocation(loc Location) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *GeoClueClient) GetLocation() (Location, error) {
|
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()
|
c.locationMutex.RLock()
|
||||||
defer c.locationMutex.RUnlock()
|
defer c.locationMutex.RUnlock()
|
||||||
if c.currLocation == nil {
|
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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -225,44 +224,6 @@ func (h *HyprlandProvider) validateAction(action string) error {
|
|||||||
return nil
|
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 {
|
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
|
||||||
if err := h.ensureWritableConfig(); err != nil {
|
if err := h.ensureWritableConfig(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -298,7 +259,6 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
|
|||||||
Description: description,
|
Description: description,
|
||||||
Flags: flags,
|
Flags: flags,
|
||||||
Options: options,
|
Options: options,
|
||||||
RawLuaAction: isRawLuaActionText(action),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.writeOverrideBinds(existingBinds)
|
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) {
|
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
|
|||||||
@@ -471,9 +471,12 @@ output_path = '%s'
|
|||||||
case TemplateKindTerminal:
|
case TemplateKindTerminal:
|
||||||
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||||
case TemplateKindVSCode:
|
case TemplateKindVSCode:
|
||||||
for _, editor := range vscodeEditors {
|
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
|
||||||
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), 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:
|
case TemplateKindEmacs:
|
||||||
if utils.EmacsConfigDir() != "" {
|
if utils.EmacsConfigDir() != "" {
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||||
@@ -630,23 +633,6 @@ func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []stri
|
|||||||
return false
|
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) {
|
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
|
||||||
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
|
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
|
||||||
matches, err := filepath.Glob(pattern)
|
matches, err := filepath.Glob(pattern)
|
||||||
@@ -1182,8 +1168,16 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkVSCodeExtension(homeDir string) bool {
|
func checkVSCodeExtension(homeDir string) bool {
|
||||||
for _, editor := range vscodeEditors {
|
extDirs := []string{
|
||||||
pattern := filepath.Join(editor.extensionsDir(homeDir), "danklinux.dms-theme-*")
|
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 {
|
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
|
||||||
return true
|
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 {
|
type Manager struct {
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
pluginsDir string
|
pluginsDir string
|
||||||
lockPath string
|
|
||||||
gitClient GitClient
|
gitClient GitClient
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +28,6 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
|
|||||||
return &Manager{
|
return &Manager{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
pluginsDir: pluginsDir,
|
pluginsDir: pluginsDir,
|
||||||
lockPath: getPluginLockPath(),
|
|
||||||
gitClient: &realGitClient{},
|
gitClient: &realGitClient{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -43,15 +41,6 @@ func getPluginsDir() string {
|
|||||||
return filepath.Join(configDir, "DankMaterialShell", "plugins")
|
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) {
|
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
|
||||||
path, err := m.findInstalledPath(plugin.ID)
|
path, err := m.findInstalledPath(plugin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,12 +118,7 @@ func (m *Manager) findInDir(dir, pluginID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Install(plugin Plugin) 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)
|
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
|
||||||
repoPath := pluginPath
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(m.fs, pluginPath)
|
exists, err := afero.DirExists(m.fs, pluginPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -144,18 +128,6 @@ func (m *Manager) Install(plugin Plugin) error {
|
|||||||
if exists {
|
if exists {
|
||||||
return fmt.Errorf("plugin already installed: %s", plugin.Name)
|
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 {
|
if err := m.fs.MkdirAll(m.pluginsDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create plugins directory: %w", err)
|
return fmt.Errorf("failed to create plugins directory: %w", err)
|
||||||
@@ -168,7 +140,7 @@ func (m *Manager) Install(plugin Plugin) error {
|
|||||||
|
|
||||||
if plugin.Path != "" {
|
if plugin.Path != "" {
|
||||||
repoName := m.getRepoName(plugin.Repo)
|
repoName := m.getRepoName(plugin.Repo)
|
||||||
repoPath = filepath.Join(reposDir, repoName)
|
repoPath := filepath.Join(reposDir, repoName)
|
||||||
|
|
||||||
repoExists, err := afero.DirExists(m.fs, repoPath)
|
repoExists, err := afero.DirExists(m.fs, repoPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -206,13 +178,12 @@ func (m *Manager) Install(plugin Plugin) error {
|
|||||||
if err := m.createSymlink(sourcePath, pluginPath); err != nil {
|
if err := m.createSymlink(sourcePath, pluginPath); err != nil {
|
||||||
return fmt.Errorf("failed to create symlink: %w", err)
|
return fmt.Errorf("failed to create symlink: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metaPath := pluginPath + ".meta"
|
metaPath := pluginPath + ".meta"
|
||||||
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
|
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 {
|
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil {
|
||||||
m.fs.Remove(pluginPath) //nolint:errcheck
|
return fmt.Errorf("failed to write metadata: %w", err)
|
||||||
return fmt.Errorf("failed to write plugin repository metadata: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||||
m.fs.RemoveAll(pluginPath) //nolint:errcheck
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,15 +207,6 @@ func (m *Manager) createSymlink(source, dest string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Update(plugin Plugin) 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)
|
pluginPath, err := m.findInstalledPath(plugin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to find plugin: %w", err)
|
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") {
|
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||||
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
|
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
|
||||||
}
|
}
|
||||||
if plugin.Repo == "" {
|
|
||||||
plugin.Repo, err = m.gitClient.OriginURL(pluginPath)
|
metaPath := pluginPath + ".meta"
|
||||||
|
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read plugin origin: %w", err)
|
return fmt.Errorf("failed to check metadata: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repoPath := m.repositoryPath(plugin.ID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
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 {
|
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 {
|
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||||
return fmt.Errorf("failed to remove corrupted plugin repository: %w", err)
|
return fmt.Errorf("failed to remove corrupted repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
|
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
|
||||||
return fmt.Errorf("failed to re-clone plugin repository: %w", err)
|
return fmt.Errorf("failed to re-clone repository: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to pull, if it fails, delete and re-clone
|
||||||
|
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||||
|
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove corrupted plugin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||||
|
return fmt.Errorf("failed to re-clone plugin: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Uninstall(plugin Plugin) error {
|
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)
|
pluginPath, err := m.findInstalledPath(plugin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to find plugin: %w", err)
|
return fmt.Errorf("failed to find plugin: %w", err)
|
||||||
@@ -315,66 +271,72 @@ func (m *Manager) Uninstall(plugin Plugin) error {
|
|||||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||||
return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
|
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"
|
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")
|
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||||
repoName := m.getRepoName(plugin.Repo)
|
repoName := m.getRepoName(plugin.Repo)
|
||||||
repoPath := filepath.Join(reposDir, repoName)
|
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 {
|
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 {
|
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 {
|
if err := m.fs.Remove(metaPath); err != nil {
|
||||||
return rollbackLock(fmt.Errorf("failed to remove metadata: %w", err))
|
return fmt.Errorf("failed to remove metadata: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldCleanup {
|
if shouldCleanup {
|
||||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
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 {
|
} else {
|
||||||
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) shouldCleanupRepo(repoURL, excludePlugin string) (bool, error) {
|
func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
|
||||||
lock, err := m.ensureLockfile()
|
installed, err := m.ListInstalled()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
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 {
|
if id == excludePlugin {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if plugin.Repo == repoURL && plugin.Path != "" {
|
|
||||||
|
for _, p := range allPlugins {
|
||||||
|
if p.ID == id && p.Repo == repoURL && p.Path != "" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -494,16 +456,6 @@ func (m *Manager) UninstallByIDOrName(idOrName string) error {
|
|||||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||||
return fmt.Errorf("cannot uninstall system plugin: %s", idOrName)
|
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"
|
metaPath := pluginPath + ".meta"
|
||||||
metaExists, _ := afero.Exists(m.fs, metaPath)
|
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") {
|
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||||
return fmt.Errorf("cannot update system plugin: %s", idOrName)
|
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"
|
metaPath := pluginPath + ".meta"
|
||||||
metaExists, _ := afero.Exists(m.fs, metaPath)
|
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 {
|
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||||
return fmt.Errorf("failed to update plugin: %w", err)
|
return fmt.Errorf("failed to update plugin: %w", err)
|
||||||
}
|
}
|
||||||
if manifest == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
repo, err := m.gitClient.OriginURL(pluginPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read plugin origin: %w", err)
|
|
||||||
}
|
|
||||||
return m.recordInstalledPlugin(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: repo}, pluginPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
|
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) {
|
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)
|
pluginPath, err := m.findInstalledPath(pluginID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", fmt.Errorf("failed to find plugin: %w", err)
|
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
|
return false, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
repoPath := pluginPath
|
metaPath := pluginPath + ".meta"
|
||||||
if plugin.Path != "" {
|
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||||
repoPath = m.repositoryPath(pluginID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
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 {
|
if err != nil {
|
||||||
return false, "", err
|
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
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -17,7 +16,6 @@ func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) {
|
|||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
pluginsDir: pluginsDir,
|
pluginsDir: pluginsDir,
|
||||||
lockPath: "/config/plugins.lock.json",
|
|
||||||
gitClient: &mockGitClient{},
|
gitClient: &mockGitClient{},
|
||||||
}
|
}
|
||||||
return manager, fs, pluginsDir
|
return manager, fs, pluginsDir
|
||||||
@@ -247,244 +245,3 @@ func TestManagerGetPluginsDir(t *testing.T) {
|
|||||||
manager, _, pluginsDir := setupTestManager(t)
|
manager, _, pluginsDir := setupTestManager(t)
|
||||||
assert.Equal(t, pluginsDir, manager.GetPluginsDir())
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
|
||||||
"github.com/go-git/go-git/v6"
|
"github.com/go-git/go-git/v6"
|
||||||
"github.com/go-git/go-git/v6/plumbing"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -34,10 +33,7 @@ type Plugin struct {
|
|||||||
type GitClient interface {
|
type GitClient interface {
|
||||||
PlainClone(path string, url string) error
|
PlainClone(path string, url string) error
|
||||||
Pull(path string) error
|
Pull(path string) error
|
||||||
OriginURL(path string) (string, error)
|
|
||||||
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err 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{}
|
type realGitClient struct{}
|
||||||
@@ -69,22 +65,6 @@ func (g *realGitClient) Pull(path string) error {
|
|||||||
return nil
|
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) {
|
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||||
repo, err := git.PlainOpen(path)
|
repo, err := git.PlainOpen(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -138,51 +118,9 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
|||||||
return localHash != remoteHead, localHash, remoteHead, nil
|
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 {
|
type Registry struct {
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
cacheDir string
|
cacheDir string
|
||||||
registries []registries.Source
|
|
||||||
plugins []Plugin
|
plugins []Plugin
|
||||||
git GitClient
|
git GitClient
|
||||||
}
|
}
|
||||||
@@ -192,63 +130,63 @@ func NewRegistry() (*Registry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||||
|
cacheDir := getCacheDir()
|
||||||
return &Registry{
|
return &Registry{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
cacheDir: getCacheDir(),
|
cacheDir: cacheDir,
|
||||||
registries: registries.Load(fs),
|
|
||||||
git: &realGitClient{},
|
git: &realGitClient{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) cacheDirFor(src registries.Source) string {
|
|
||||||
return filepath.Join(r.cacheDir, src.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCacheDir() string {
|
func getCacheDir() string {
|
||||||
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cached clone is reused only when its origin still matches the configured
|
func (r *Registry) Update() error {
|
||||||
// URL; renamed or re-pointed registries re-clone instead of pulling from the
|
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||||
// stale remote.
|
|
||||||
func (r *Registry) updateOne(src registries.Source) error {
|
|
||||||
dir := r.cacheDirFor(src)
|
|
||||||
exists, err := afero.DirExists(r.fs, dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check cache directory: %w", err)
|
return fmt.Errorf("failed to check cache directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if !exists {
|
||||||
origin, originErr := r.git.OriginURL(dir)
|
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
|
||||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := r.fs.RemoveAll(dir); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
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)
|
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||||
|
return fmt.Errorf("failed to clone registry: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.loadPlugins()
|
||||||
}
|
}
|
||||||
|
|
||||||
// A registry without a plugins/ directory is a valid themes-only registry.
|
func (r *Registry) loadPlugins() error {
|
||||||
func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
|
pluginsDir := filepath.Join(r.cacheDir, "plugins")
|
||||||
pluginsDir := filepath.Join(dir, "plugins")
|
|
||||||
entries, err := afero.ReadDir(r.fs, pluginsDir)
|
entries, err := afero.ReadDir(r.fs, pluginsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return fmt.Errorf("failed to read plugins directory: %w", err)
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var plugins []Plugin
|
r.plugins = []Plugin{}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||||
continue
|
continue
|
||||||
@@ -268,51 +206,15 @@ func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
|
|||||||
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
|
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
|
return nil
|
||||||
// layout nests under it, so a leftover clone is deleted wholesale first.
|
|
||||||
func (r *Registry) resetLegacyCache() {
|
|
||||||
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
|
|
||||||
_ = r.fs.RemoveAll(r.cacheDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update refreshes every configured registry, aggregating plugins in
|
|
||||||
// declaration order (first occurrence of an ID wins). A failing registry is
|
|
||||||
// reported in the joined error but does not block the others.
|
|
||||||
func (r *Registry) Update() error {
|
|
||||||
r.resetLegacyCache()
|
|
||||||
r.plugins = []Plugin{}
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
var errs []error
|
|
||||||
for _, src := range r.registries {
|
|
||||||
if err := r.updateOne(src); err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
plugins, err := r.loadPluginsFrom(r.cacheDirFor(src))
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, p := range plugins {
|
|
||||||
if _, dup := seen[p.ID]; dup {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[p.ID] = struct{}{}
|
|
||||||
r.plugins = append(r.plugins, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) List() ([]Plugin, error) {
|
func (r *Registry) List() ([]Plugin, error) {
|
||||||
if len(r.plugins) == 0 {
|
if len(r.plugins) == 0 {
|
||||||
if err := r.Update(); err != nil && len(r.plugins) == 0 {
|
if err := r.Update(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,18 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"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/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testRegistryURL = "https://example.com/test-registry.git"
|
|
||||||
|
|
||||||
type mockGitClient struct {
|
type mockGitClient struct {
|
||||||
cloneFunc func(path string, url string) error
|
cloneFunc func(path string, url string) error
|
||||||
pullFunc func(path string) error
|
pullFunc func(path string) error
|
||||||
originFunc func(path string) (string, error)
|
|
||||||
hasUpdatesFunc func(path string) (bool, 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 {
|
func (m *mockGitClient) PlainClone(path string, url string) error {
|
||||||
@@ -41,13 +30,6 @@ func (m *mockGitClient) Pull(path string) error {
|
|||||||
return nil
|
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) {
|
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||||
if m.hasUpdatesFunc != nil {
|
if m.hasUpdatesFunc != nil {
|
||||||
return m.hasUpdatesFunc(path)
|
return m.hasUpdatesFunc(path)
|
||||||
@@ -55,27 +37,11 @@ func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
|||||||
return false, "", "", nil
|
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) {
|
func TestNewRegistry(t *testing.T) {
|
||||||
registry, err := NewRegistry()
|
registry, err := NewRegistry()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, registry)
|
assert.NotNil(t, registry)
|
||||||
assert.NotEmpty(t, registry.cacheDir)
|
assert.NotEmpty(t, registry.cacheDir)
|
||||||
require.NotEmpty(t, registry.registries)
|
|
||||||
assert.Equal(t, registries.OfficialName, registry.registries[0].Name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCacheDir(t *testing.T) {
|
func TestGetCacheDir(t *testing.T) {
|
||||||
@@ -83,48 +49,12 @@ func TestGetCacheDir(t *testing.T) {
|
|||||||
assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry")
|
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) {
|
func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
tmpDir := "/test-cache"
|
tmpDir := "/test-cache"
|
||||||
registry := &Registry{
|
registry := &Registry{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
cacheDir: tmpDir,
|
cacheDir: tmpDir,
|
||||||
registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
|
|
||||||
plugins: []Plugin{},
|
plugins: []Plugin{},
|
||||||
git: &mockGitClient{},
|
git: &mockGitClient{},
|
||||||
}
|
}
|
||||||
@@ -174,14 +104,14 @@ func TestLoadPlugins(t *testing.T) {
|
|||||||
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
|
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
|
||||||
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
|
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
|
||||||
|
|
||||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
err := registry.loadPlugins()
|
||||||
assert.NoError(t, err)
|
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, "TestPlugin1", registry.plugins[0].Name)
|
||||||
assert.Equal(t, "TestPlugin2", plugins[1].Name)
|
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name)
|
||||||
assert.Equal(t, []string{"dankbar-widget"}, plugins[0].Capabilities)
|
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities)
|
||||||
assert.Equal(t, []string{"dep1", "dep2"}, plugins[1].Dependencies)
|
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("skips non-json files", func(t *testing.T) {
|
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)
|
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||||
|
|
||||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
err = registry.loadPlugins()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, plugins, 1)
|
assert.Len(t, registry.plugins, 1)
|
||||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
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) {
|
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)
|
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||||
|
|
||||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
err = registry.loadPlugins()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, plugins, 1)
|
assert.Len(t, registry.plugins, 1)
|
||||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
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)
|
registry, _, _ := setupTestRegistry(t)
|
||||||
|
|
||||||
plugins, err := registry.loadPluginsFrom(registry.cacheDir)
|
err := registry.loadPlugins()
|
||||||
assert.NoError(t, err)
|
assert.Error(t, err)
|
||||||
assert.Empty(t, plugins)
|
assert.Contains(t, err.Error(), "failed to read plugins directory")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,40 +240,19 @@ func TestList(t *testing.T) {
|
|||||||
Distro: []string{"any"},
|
Distro: []string{"any"},
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.git = &mockGitClient{
|
mockGit := &mockGitClient{
|
||||||
cloneFunc: func(path string, url string) error {
|
cloneFunc: func(path string, url string) error {
|
||||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
registry.git = mockGit
|
||||||
|
|
||||||
plugins, err := registry.List()
|
plugins, err := registry.List()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, plugins, 1)
|
assert.Len(t, plugins, 1)
|
||||||
assert.Equal(t, "NewPlugin", plugins[0].Name)
|
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) {
|
func TestUpdate(t *testing.T) {
|
||||||
@@ -338,15 +271,16 @@ func TestUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cloneCalled := false
|
cloneCalled := false
|
||||||
registry.git = &mockGitClient{
|
mockGit := &mockGitClient{
|
||||||
cloneFunc: func(path string, url string) error {
|
cloneFunc: func(path string, url string) error {
|
||||||
cloneCalled = true
|
cloneCalled = true
|
||||||
assert.Equal(t, testRegistryURL, url)
|
assert.Equal(t, registryRepo, url)
|
||||||
assert.Equal(t, filepath.Join(tmpDir, "test"), path)
|
assert.Equal(t, tmpDir, path)
|
||||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
registry.git = mockGit
|
||||||
|
|
||||||
err := registry.Update()
|
err := registry.Update()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -355,7 +289,7 @@ func TestUpdate(t *testing.T) {
|
|||||||
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
|
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)
|
registry, fs, tmpDir := setupTestRegistry(t)
|
||||||
|
|
||||||
plugin := Plugin{
|
plugin := Plugin{
|
||||||
@@ -369,188 +303,24 @@ func TestUpdate(t *testing.T) {
|
|||||||
Distro: []string{"any"},
|
Distro: []string{"any"},
|
||||||
}
|
}
|
||||||
|
|
||||||
subdir := filepath.Join(tmpDir, "test")
|
err := fs.MkdirAll(tmpDir, 0o755)
|
||||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
require.NoError(t, err)
|
||||||
|
|
||||||
pullCalled := false
|
pullCalled := false
|
||||||
registry.git = &mockGitClient{
|
mockGit := &mockGitClient{
|
||||||
originFunc: func(path string) (string, error) {
|
|
||||||
return testRegistryURL, nil
|
|
||||||
},
|
|
||||||
pullFunc: func(path string) error {
|
pullFunc: func(path string) error {
|
||||||
pullCalled = true
|
pullCalled = true
|
||||||
assert.Equal(t, subdir, path)
|
assert.Equal(t, tmpDir, path)
|
||||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
registry.git = mockGit
|
||||||
|
|
||||||
err := registry.Update()
|
err = registry.Update()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, pullCalled)
|
assert.True(t, pullCalled)
|
||||||
assert.Len(t, registry.plugins, 1)
|
assert.Len(t, registry.plugins, 1)
|
||||||
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
|
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") {
|
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")
|
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) {
|
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")
|
|
||||||
}
|
|
||||||
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
|
|||||||
return nil, fmt.Errorf("failed to find keyboards: %w", err)
|
return nil, fmt.Errorf("failed to find keyboards: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
initialCapsLock, _ := capsLockFromDevices(devices)
|
initialCapsLock := readInitialCapsLockState(devices[0])
|
||||||
|
|
||||||
watcher, err := fsnotify.NewWatcher()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,21 +85,14 @@ func NewManager() (*Manager, error) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func capsLockFromDevices(devices []EvdevDevice) (bool, bool) {
|
func readInitialCapsLockState(device EvdevDevice) bool {
|
||||||
for _, device := range devices {
|
|
||||||
if device == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ledStates, err := device.State(evLedType)
|
ledStates, err := device.State(evLedType)
|
||||||
if err != nil || len(ledStates) == 0 {
|
if err != nil {
|
||||||
continue
|
log.Debugf("Could not read LED state: %v", err)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return ledStates[ledCapslockKey], true
|
return ledStates[ledCapslockKey]
|
||||||
}
|
|
||||||
|
|
||||||
return false, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func findKeyboards() ([]EvdevDevice, error) {
|
func findKeyboards() ([]EvdevDevice, error) {
|
||||||
@@ -304,22 +297,25 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
|
|||||||
m.devicesMutex.RUnlock()
|
m.devicesMutex.RUnlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ordered := make([]EvdevDevice, 0, len(m.devices))
|
device := m.devices[deviceIndex]
|
||||||
ordered = append(ordered, m.devices[deviceIndex])
|
|
||||||
for i, device := range m.devices {
|
|
||||||
if i == deviceIndex {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ordered = append(ordered, device)
|
|
||||||
}
|
|
||||||
m.devicesMutex.RUnlock()
|
m.devicesMutex.RUnlock()
|
||||||
|
|
||||||
capsLockState, ok := capsLockFromDevices(ordered)
|
ledStates, err := device.State(evLedType)
|
||||||
if !ok {
|
if err != nil {
|
||||||
log.Debug("No LED-capable device available for caps lock state")
|
log.Warnf("Failed to read LED state: %v", err)
|
||||||
return
|
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)
|
m.updateCapsLockStateDirect(capsLockState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
|
|||||||
m.Close()
|
m.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCapsLockFromDevices(t *testing.T) {
|
func TestReadInitialCapsLockState(t *testing.T) {
|
||||||
t.Run("caps lock is on", func(t *testing.T) {
|
t.Run("caps lock is on", func(t *testing.T) {
|
||||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||||
ledState := evdev.StateMap{
|
ledState := evdev.StateMap{
|
||||||
@@ -314,8 +314,7 @@ func TestCapsLockFromDevices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||||
|
|
||||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
result := readInitialCapsLockState(mockDevice)
|
||||||
assert.True(t, ok)
|
|
||||||
assert.True(t, result)
|
assert.True(t, result)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -326,8 +325,7 @@ func TestCapsLockFromDevices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||||
|
|
||||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
result := readInitialCapsLockState(mockDevice)
|
||||||
assert.True(t, ok)
|
|
||||||
assert.False(t, result)
|
assert.False(t, result)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -335,25 +333,9 @@ func TestCapsLockFromDevices(t *testing.T) {
|
|||||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
|
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
|
||||||
|
|
||||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
result := readInitialCapsLockState(mockDevice)
|
||||||
assert.False(t, ok)
|
|
||||||
assert.False(t, result)
|
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) {
|
func TestHasInputGroupAccess(t *testing.T) {
|
||||||
|
|||||||
@@ -8,11 +8,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewManager(client geolocation.Client) (*Manager, error) {
|
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{
|
m := &Manager{
|
||||||
client: client,
|
client: client,
|
||||||
dirty: make(chan struct{}),
|
dirty: make(chan struct{}),
|
||||||
stopChan: 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)
|
m.notifierWg.Add(1)
|
||||||
@@ -21,22 +34,6 @@ func NewManager(client geolocation.Client) (*Manager, error) {
|
|||||||
return m, nil
|
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() {
|
func (m *Manager) Close() {
|
||||||
close(m.stopChan)
|
close(m.stopChan)
|
||||||
m.notifierWg.Wait()
|
m.notifierWg.Wait()
|
||||||
@@ -51,7 +48,6 @@ func (m *Manager) Close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Subscribe(id string) chan State {
|
func (m *Manager) Subscribe(id string) chan State {
|
||||||
m.ensureStarted()
|
|
||||||
ch := make(chan State, 64)
|
ch := make(chan State, 64)
|
||||||
m.subscribers.Store(id, ch)
|
m.subscribers.Store(id, ch)
|
||||||
return 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)
|
m.sigWG.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer m.sigWG.Done()
|
defer m.sigWG.Done()
|
||||||
@@ -84,6 +80,8 @@ func (m *Manager) startSignalPump() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) handleLocationChange(location geolocation.Location) {
|
func (m *Manager) handleLocationChange(location geolocation.Location) {
|
||||||
@@ -104,7 +102,6 @@ func (m *Manager) notifySubscribers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetState() State {
|
func (m *Manager) GetState() State {
|
||||||
m.ensureStarted()
|
|
||||||
m.stateMutex.RLock()
|
m.stateMutex.RLock()
|
||||||
defer m.stateMutex.RUnlock()
|
defer m.stateMutex.RUnlock()
|
||||||
if m.state == nil {
|
if m.state == nil {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ type Manager struct {
|
|||||||
stateMutex sync.RWMutex
|
stateMutex sync.RWMutex
|
||||||
|
|
||||||
client geolocation.Client
|
client geolocation.Client
|
||||||
startOnce sync.Once
|
|
||||||
|
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
sigWG sync.WaitGroup
|
sigWG sync.WaitGroup
|
||||||
|
|||||||
@@ -302,17 +302,17 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
}
|
}
|
||||||
a.backend.cachedVPNCredsMu.Unlock()
|
a.backend.cachedVPNCredsMu.Unlock()
|
||||||
|
|
||||||
a.backend.cachedOpenConnectMu.Lock()
|
a.backend.cachedGPSamlMu.Lock()
|
||||||
cachedOpenConnect := a.backend.cachedOpenConnectAuth
|
cachedGPSaml := a.backend.cachedGPSamlCookie
|
||||||
if cachedOpenConnect != nil && cachedOpenConnect.ConnectionUUID == connUuid {
|
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
|
||||||
a.backend.cachedOpenConnectAuth = nil
|
a.backend.cachedGPSamlCookie = nil
|
||||||
a.backend.cachedOpenConnectMu.Unlock()
|
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" {
|
if len(fields) == 1 && fields[0] == "gp-saml" {
|
||||||
gateway := ""
|
gateway := ""
|
||||||
@@ -347,17 +347,17 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
|
|
||||||
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
|
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
|
||||||
|
|
||||||
a.backend.cachedOpenConnectMu.Lock()
|
a.backend.cachedGPSamlMu.Lock()
|
||||||
a.backend.cachedOpenConnectAuth = &cachedOpenConnectAuth{
|
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
|
||||||
ConnectionUUID: connUuid,
|
ConnectionUUID: connUuid,
|
||||||
Cookie: authResult.Cookie,
|
Cookie: authResult.Cookie,
|
||||||
Host: authResult.Host,
|
Host: authResult.Host,
|
||||||
User: authResult.User,
|
User: authResult.User,
|
||||||
Fingerprint: authResult.Fingerprint,
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOpenConnectSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
||||||
out := nmSettingMap{}
|
out := nmSettingMap{}
|
||||||
vpnSec := nmVariantMap{}
|
vpnSec := nmVariantMap{}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func TestNeedsExternalBrowserAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildOpenConnectSecretsResponse(t *testing.T) {
|
func TestBuildGPSamlSecretsResponse(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
settingName string
|
settingName string
|
||||||
@@ -155,7 +155,7 @@ func TestBuildOpenConnectSecretsResponse(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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.NotNil(t, result)
|
||||||
assert.Contains(t, result, tt.settingName)
|
assert.Contains(t, result, tt.settingName)
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ type NetworkManagerBackend struct {
|
|||||||
cachedVPNCredsMu sync.Mutex
|
cachedVPNCredsMu sync.Mutex
|
||||||
cachedPKCS11PIN *cachedPKCS11PIN
|
cachedPKCS11PIN *cachedPKCS11PIN
|
||||||
cachedPKCS11Mu sync.Mutex
|
cachedPKCS11Mu sync.Mutex
|
||||||
cachedOpenConnectAuth *cachedOpenConnectAuth
|
cachedGPSamlCookie *cachedGPSamlCookie
|
||||||
cachedOpenConnectMu sync.Mutex
|
cachedGPSamlMu sync.Mutex
|
||||||
cachedWiFiSecret *cachedWiFiSecret
|
cachedWiFiSecret *cachedWiFiSecret
|
||||||
cachedWiFiSecretMu sync.Mutex
|
cachedWiFiSecretMu sync.Mutex
|
||||||
|
|
||||||
@@ -101,7 +101,6 @@ type pendingVPNCredentials struct {
|
|||||||
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
|
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
|
||||||
// falls back to Password under the "password" key when empty.
|
// falls back to Password under the "password" key when empty.
|
||||||
Secrets map[string]string
|
Secrets map[string]string
|
||||||
PersistentSecrets map[string]string
|
|
||||||
SavePassword bool
|
SavePassword bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +124,7 @@ type cachedWiFiSecret struct {
|
|||||||
Secrets map[string]string
|
Secrets map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedOpenConnectAuth struct {
|
type cachedGPSamlCookie struct {
|
||||||
ConnectionUUID string
|
ConnectionUUID string
|
||||||
Cookie string
|
Cookie string
|
||||||
Host string
|
Host string
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package network
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,29 +10,16 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type openConnectAuthResult struct {
|
type gpSamlAuthResult struct {
|
||||||
Cookie string
|
Cookie string
|
||||||
Host string
|
Host string
|
||||||
User string
|
User string
|
||||||
Fingerprint 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.
|
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
|
||||||
// Only supports protocol=gp. Other protocols need their own implementations.
|
// 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 == "" {
|
if gateway == "" {
|
||||||
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
|
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
|
var allOutput []string
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
scanner := bufio.NewScanner(stdout)
|
||||||
@@ -131,8 +117,13 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*openConnectAuthResult, error) {
|
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
|
||||||
return runOpenConnectAuthenticate(ctx, []string{
|
ocPath, err := exec.LookPath("openconnect")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("openconnect not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
"--protocol=gp",
|
"--protocol=gp",
|
||||||
"--usergroup=gateway:prelogin-cookie",
|
"--usergroup=gateway:prelogin-cookie",
|
||||||
"--user=" + user,
|
"--user=" + user,
|
||||||
@@ -140,83 +131,18 @@ func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user
|
|||||||
"--allow-insecure-crypto",
|
"--allow-insecure-crypto",
|
||||||
"--authenticate",
|
"--authenticate",
|
||||||
gateway,
|
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 := exec.CommandContext(ctx, ocPath, args...)
|
||||||
cmd.Stdin = strings.NewReader(secret + "\n")
|
cmd.Stdin = strings.NewReader(preloginCookie)
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
result := parseOpenConnectAuthenticateOutput(string(output))
|
|
||||||
serverCert := suggestedOpenConnectServerCert(string(output))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("[OpenConnect] Authentication successful: cookie_len=%d, host=%s, has_fingerprint=%v",
|
result := &gpSamlAuthResult{}
|
||||||
len(result.Cookie), result.Host, result.Fingerprint != "")
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
|
|
||||||
result := &openConnectAuthResult{}
|
|
||||||
for _, line := range strings.Split(output, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(line, "COOKIE="):
|
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 {
|
func unshellQuote(s string) string {
|
||||||
@@ -245,7 +179,7 @@ func unshellQuote(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGPSamlFromCommandLine(line string, result *openConnectAuthResult) {
|
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
|
||||||
if !strings.Contains(line, "openconnect") {
|
if !strings.Contains(line, "openconnect") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -75,7 +71,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
line string
|
line string
|
||||||
initialResult *openConnectAuthResult
|
initialResult *gpSamlAuthResult
|
||||||
expectedCookie string
|
expectedCookie string
|
||||||
expectedUser string
|
expectedUser string
|
||||||
expectedFP string
|
expectedFP string
|
||||||
@@ -83,7 +79,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "full openconnect command",
|
name: "full openconnect command",
|
||||||
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
|
line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john",
|
||||||
initialResult: &openConnectAuthResult{},
|
initialResult: &gpSamlAuthResult{},
|
||||||
expectedCookie: "AUTH123",
|
expectedCookie: "AUTH123",
|
||||||
expectedUser: "john",
|
expectedUser: "john",
|
||||||
expectedFP: "pin-sha256:ABC",
|
expectedFP: "pin-sha256:ABC",
|
||||||
@@ -91,7 +87,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "with equals signs in cookie",
|
name: "with equals signs in cookie",
|
||||||
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
|
line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane",
|
||||||
initialResult: &openConnectAuthResult{},
|
initialResult: &gpSamlAuthResult{},
|
||||||
expectedCookie: "authcookie=xyz123&portal=GATE",
|
expectedCookie: "authcookie=xyz123&portal=GATE",
|
||||||
expectedUser: "jane",
|
expectedUser: "jane",
|
||||||
expectedFP: "",
|
expectedFP: "",
|
||||||
@@ -99,7 +95,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "non-openconnect line",
|
name: "non-openconnect line",
|
||||||
line: "some other output",
|
line: "some other output",
|
||||||
initialResult: &openConnectAuthResult{},
|
initialResult: &gpSamlAuthResult{},
|
||||||
expectedCookie: "",
|
expectedCookie: "",
|
||||||
expectedUser: "",
|
expectedUser: "",
|
||||||
expectedFP: "",
|
expectedFP: "",
|
||||||
@@ -107,7 +103,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "preserves existing values",
|
name: "preserves existing values",
|
||||||
line: "openconnect --user=newuser",
|
line: "openconnect --user=newuser",
|
||||||
initialResult: &openConnectAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
|
initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"},
|
||||||
expectedCookie: "existing",
|
expectedCookie: "existing",
|
||||||
expectedUser: "newuser",
|
expectedUser: "newuser",
|
||||||
expectedFP: "existing-fp",
|
expectedFP: "existing-fp",
|
||||||
@@ -115,7 +111,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "only updates empty fields",
|
name: "only updates empty fields",
|
||||||
line: "openconnect --cookie=NEW --user=NEW",
|
line: "openconnect --cookie=NEW --user=NEW",
|
||||||
initialResult: &openConnectAuthResult{Cookie: "OLD"},
|
initialResult: &gpSamlAuthResult{Cookie: "OLD"},
|
||||||
expectedCookie: "OLD",
|
expectedCookie: "OLD",
|
||||||
expectedUser: "NEW",
|
expectedUser: "NEW",
|
||||||
expectedFP: "",
|
expectedFP: "",
|
||||||
@@ -123,7 +119,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "real gp-saml-gui output",
|
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",
|
line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin",
|
||||||
initialResult: &openConnectAuthResult{},
|
initialResult: &gpSamlAuthResult{},
|
||||||
expectedCookie: "",
|
expectedCookie: "",
|
||||||
expectedUser: "john.doe@example.com",
|
expectedUser: "john.doe@example.com",
|
||||||
expectedFP: "",
|
expectedFP: "",
|
||||||
@@ -131,7 +127,7 @@ func TestParseGPSamlFromCommandLine(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "with server cert flag",
|
name: "with server cert flag",
|
||||||
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
|
line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com",
|
||||||
initialResult: &openConnectAuthResult{},
|
initialResult: &gpSamlAuthResult{},
|
||||||
expectedCookie: "",
|
expectedCookie: "",
|
||||||
expectedUser: "",
|
expectedUser: "",
|
||||||
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=",
|
||||||
@@ -162,7 +158,7 @@ func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &openConnectAuthResult{}
|
result := &gpSamlAuthResult{}
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
parseGPSamlFromCommandLine(line, result)
|
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.Cookie, "cookie should not be parsed from command line")
|
||||||
assert.Empty(t, result.Fingerprint)
|
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)
|
authAction := detectVPNAuthAction(vpnServiceType, vpnData)
|
||||||
var openConnectAuth *openConnectAuthResult
|
|
||||||
|
|
||||||
switch authAction {
|
switch authAction {
|
||||||
case "openvpn_username":
|
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 {
|
if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil {
|
||||||
return err
|
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":
|
case "gp_saml":
|
||||||
gateway := vpnData["gateway"]
|
gateway := vpnData["gateway"]
|
||||||
protocol := vpnData["protocol"]
|
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)
|
log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway)
|
||||||
|
|
||||||
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
openConnectAuth, err = b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol)
|
||||||
samlCancel()
|
samlCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := err.Error()
|
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 {
|
if err := targetConn.ClearSecrets(); err != nil {
|
||||||
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
|
log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err)
|
||||||
} else {
|
} 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)
|
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.stateMutex.Lock()
|
||||||
b.state.IsConnectingVPN = true
|
b.state.IsConnectingVPN = true
|
||||||
b.state.ConnectingVPNUUID = targetUUID
|
b.state.ConnectingVPNUUID = targetUUID
|
||||||
@@ -411,13 +394,6 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool)
|
|||||||
nm := b.nmConn.(gonetworkmanager.NetworkManager)
|
nm := b.nmConn.(gonetworkmanager.NetworkManager)
|
||||||
_, err = nm.ActivateConnection(targetConn, nil, nil)
|
_, err = nm.ActivateConnection(targetConn, nil, nil)
|
||||||
if err != 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.stateMutex.Lock()
|
||||||
b.state.IsConnectingVPN = false
|
b.state.IsConnectingVPN = false
|
||||||
b.state.ConnectingVPNUUID = ""
|
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)
|
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"):
|
case strings.Contains(serviceType, "openvpn"):
|
||||||
connType := data["connection-type"]
|
connType := data["connection-type"]
|
||||||
username := data["username"]
|
username := data["username"]
|
||||||
@@ -462,200 +435,6 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string {
|
|||||||
return ""
|
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 {
|
func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error {
|
||||||
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
|
log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation")
|
||||||
|
|
||||||
@@ -979,13 +758,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.VPNErrorUuid = ""
|
b.state.VPNErrorUuid = ""
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached one-shot authentication values on success.
|
// Clear cached PKCS11 PIN and SAML cookie on success
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
b.cachedOpenConnectMu.Lock()
|
b.cachedGPSamlMu.Lock()
|
||||||
b.cachedOpenConnectAuth = nil
|
b.cachedGPSamlCookie = nil
|
||||||
b.cachedOpenConnectMu.Unlock()
|
b.cachedGPSamlMu.Unlock()
|
||||||
|
|
||||||
b.pendingVPNSaveMu.Lock()
|
b.pendingVPNSaveMu.Lock()
|
||||||
pending := b.pendingVPNSave
|
pending := b.pendingVPNSave
|
||||||
@@ -1008,16 +787,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.VPNErrorUuid = connectingVPNUUID
|
b.state.VPNErrorUuid = connectingVPNUUID
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached one-shot authentication values on failure.
|
// Clear cached PKCS11 PIN and SAML cookie on failure
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
b.cachedOpenConnectMu.Lock()
|
b.cachedGPSamlMu.Lock()
|
||||||
b.cachedOpenConnectAuth = nil
|
b.cachedGPSamlCookie = nil
|
||||||
b.cachedOpenConnectMu.Unlock()
|
b.cachedGPSamlMu.Unlock()
|
||||||
b.pendingVPNSaveMu.Lock()
|
|
||||||
b.pendingVPNSave = nil
|
|
||||||
b.pendingVPNSaveMu.Unlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1035,16 +811,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() {
|
|||||||
b.state.VPNErrorUuid = connectingVPNUUID
|
b.state.VPNErrorUuid = connectingVPNUUID
|
||||||
b.stateMutex.Unlock()
|
b.stateMutex.Unlock()
|
||||||
|
|
||||||
// Clear cached one-shot authentication values.
|
// Clear cached PKCS11 PIN and SAML cookie
|
||||||
b.cachedPKCS11Mu.Lock()
|
b.cachedPKCS11Mu.Lock()
|
||||||
b.cachedPKCS11PIN = nil
|
b.cachedPKCS11PIN = nil
|
||||||
b.cachedPKCS11Mu.Unlock()
|
b.cachedPKCS11Mu.Unlock()
|
||||||
b.cachedOpenConnectMu.Lock()
|
b.cachedGPSamlMu.Lock()
|
||||||
b.cachedOpenConnectAuth = nil
|
b.cachedGPSamlCookie = nil
|
||||||
b.cachedOpenConnectMu.Unlock()
|
b.cachedGPSamlMu.Unlock()
|
||||||
b.pendingVPNSaveMu.Lock()
|
|
||||||
b.pendingVPNSave = nil
|
|
||||||
b.pendingVPNSaveMu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1090,40 +863,17 @@ func (b *NetworkManagerBackend) saveVPNCredentials(creds *pendingVPNCredentials)
|
|||||||
log.Infof("[saveVPNCredentials] Saving username")
|
log.Infof("[saveVPNCredentials] Saving username")
|
||||||
}
|
}
|
||||||
|
|
||||||
secs := map[string]string{}
|
// Save secrets if requested
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if creds.SavePassword {
|
if creds.SavePassword {
|
||||||
toSave := creds.Secrets
|
secs := creds.Secrets
|
||||||
if len(toSave) == 0 {
|
if len(secs) == 0 {
|
||||||
toSave = map[string]string{"password": creds.Password}
|
secs = map[string]string{"password": creds.Password}
|
||||||
}
|
}
|
||||||
for field, value := range toSave {
|
for field := range secs {
|
||||||
secs[field] = value
|
|
||||||
data[field+"-flags"] = "0"
|
data[field+"-flags"] = "0"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if len(secs) > 0 {
|
|
||||||
vpn["secrets"] = dbus.MakeVariant(secs)
|
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)
|
vpn["data"] = dbus.MakeVariant(data)
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
|
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
|
||||||
"github.com/Wifx/gonetworkmanager/v2"
|
"github.com/Wifx/gonetworkmanager/v2"
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,137 +136,3 @@ func TestNetworkManagerBackend_UpdateVPNConnectionState_EmptyUUID(t *testing.T)
|
|||||||
backend.updateVPNConnectionState()
|
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)
|
handleGetNetworkQRCode(conn, req, manager)
|
||||||
case "network.qrcode-content":
|
case "network.qrcode-content":
|
||||||
handleGetNetworkQRCodeContent(conn, req, manager)
|
handleGetNetworkQRCodeContent(conn, req, manager)
|
||||||
case "network.generate-qrcode":
|
|
||||||
handleGenerateQRCode(conn, req)
|
|
||||||
case "network.delete-qrcode":
|
case "network.delete-qrcode":
|
||||||
handleDeleteQRCode(conn, req, manager)
|
handleDeleteQRCode(conn, req, manager)
|
||||||
case "network.ethernet.info":
|
case "network.ethernet.info":
|
||||||
@@ -367,22 +365,6 @@ func handleGetNetworkQRCodeContent(conn *models.Conn, req models.Request, manage
|
|||||||
models.Respond(conn, req.ID, content)
|
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) {
|
func handleDeleteQRCode(conn *models.Conn, req models.Request, _ *Manager) {
|
||||||
path, err := params.String(req.Params, "path")
|
path, err := params.String(req.Params, "path")
|
||||||
if err != nil {
|
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 {
|
func isValidQRCodePath(path string) bool {
|
||||||
clean := filepath.Clean(path)
|
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_-]`)
|
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/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
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/sysupdate"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||||
@@ -48,11 +47,6 @@ func RouteRequest(conn *models.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "registries.") {
|
|
||||||
serverRegistries.HandleRequest(conn, req)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "theme.auto.") {
|
if strings.HasPrefix(req.Method, "theme.auto.") {
|
||||||
if themeModeManager == nil {
|
if themeModeManager == nil {
|
||||||
models.RespondError(conn, req.ID, "theme mode manager not initialized")
|
models.RespondError(conn, req.ID, "theme mode manager not initialized")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import (
|
|||||||
"github.com/AvengeMedia/dankgo/syncmap"
|
"github.com/AvengeMedia/dankgo/syncmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const APIVersion = 29
|
const APIVersion = 28
|
||||||
|
|
||||||
var CLIVersion = "dev"
|
var CLIVersion = "dev"
|
||||||
|
|
||||||
@@ -1183,38 +1183,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 {
|
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
|
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
|
||||||
|
|||||||
@@ -759,9 +759,6 @@ func (m *Manager) schedulerLoop() {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
m.recalcSchedule(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
|
waitDur := 24 * time.Hour
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -1107,15 +1104,13 @@ func (m *Manager) SetTemperature(low, high int) error {
|
|||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
updated := m.config
|
m.config.LowTemp = low
|
||||||
updated.LowTemp = low
|
m.config.HighTemp = high
|
||||||
updated.HighTemp = high
|
err := m.config.Validate()
|
||||||
if err := updated.Validate(); err != nil {
|
|
||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.config = updated
|
|
||||||
m.configMutex.Unlock()
|
|
||||||
m.triggerUpdate()
|
m.triggerUpdate()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1127,16 +1122,14 @@ func (m *Manager) SetLocation(lat, lon float64) error {
|
|||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
updated := m.config
|
m.config.Latitude = &lat
|
||||||
updated.Latitude = &lat
|
m.config.Longitude = &lon
|
||||||
updated.Longitude = &lon
|
m.config.UseIPLocation = false
|
||||||
updated.UseIPLocation = false
|
err := m.config.Validate()
|
||||||
if err := updated.Validate(); err != nil {
|
|
||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.config = updated
|
|
||||||
m.configMutex.Unlock()
|
|
||||||
m.triggerUpdate()
|
m.triggerUpdate()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1171,15 +1164,13 @@ func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
|
|||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
updated := m.config
|
m.config.ManualSunrise = &sunrise
|
||||||
updated.ManualSunrise = &sunrise
|
m.config.ManualSunset = &sunset
|
||||||
updated.ManualSunset = &sunset
|
err := m.config.Validate()
|
||||||
if err := updated.Validate(); err != nil {
|
|
||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.config = updated
|
|
||||||
m.configMutex.Unlock()
|
|
||||||
m.triggerUpdate()
|
m.triggerUpdate()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1202,14 +1193,12 @@ func (m *Manager) SetGamma(gamma float64) error {
|
|||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
updated := m.config
|
m.config.Gamma = gamma
|
||||||
updated.Gamma = gamma
|
err := m.config.Validate()
|
||||||
if err := updated.Validate(); err != nil {
|
|
||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.config = updated
|
|
||||||
m.configMutex.Unlock()
|
|
||||||
m.triggerUpdate()
|
m.triggerUpdate()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
|
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) {
|
func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
|
||||||
@@ -413,75 +412,3 @@ func TestNewManager_InvalidConfig(t *testing.T) {
|
|||||||
_, err := NewManager(mockDisplay, config)
|
_, err := NewManager(mockDisplay, config)
|
||||||
assert.Error(t, err)
|
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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
|
||||||
"github.com/go-git/go-git/v6"
|
"github.com/go-git/go-git/v6"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||||
|
|
||||||
type ColorScheme struct {
|
type ColorScheme struct {
|
||||||
Primary string `json:"primary,omitempty"`
|
Primary string `json:"primary,omitempty"`
|
||||||
PrimaryText string `json:"primaryText,omitempty"`
|
PrimaryText string `json:"primaryText,omitempty"`
|
||||||
@@ -150,7 +151,6 @@ type Theme struct {
|
|||||||
type GitClient interface {
|
type GitClient interface {
|
||||||
PlainClone(path string, url string) error
|
PlainClone(path string, url string) error
|
||||||
Pull(path string) error
|
Pull(path string) error
|
||||||
OriginURL(path string) (string, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type realGitClient struct{}
|
type realGitClient struct{}
|
||||||
@@ -182,26 +182,9 @@ func (g *realGitClient) Pull(path string) error {
|
|||||||
return nil
|
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 {
|
type Registry struct {
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
cacheDir string
|
cacheDir string
|
||||||
registries []registries.Source
|
|
||||||
themes []Theme
|
themes []Theme
|
||||||
git GitClient
|
git GitClient
|
||||||
}
|
}
|
||||||
@@ -211,63 +194,61 @@ func NewRegistry() (*Registry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||||
|
cacheDir := getCacheDir()
|
||||||
return &Registry{
|
return &Registry{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
cacheDir: getCacheDir(),
|
cacheDir: cacheDir,
|
||||||
registries: registries.Load(fs),
|
|
||||||
git: &realGitClient{},
|
git: &realGitClient{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) cacheDirFor(src registries.Source) string {
|
|
||||||
return filepath.Join(r.cacheDir, src.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCacheDir() string {
|
func getCacheDir() string {
|
||||||
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cached clone is reused only when its origin still matches the configured
|
func (r *Registry) Update() error {
|
||||||
// URL; renamed or re-pointed registries re-clone instead of pulling from the
|
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||||
// stale remote.
|
|
||||||
func (r *Registry) updateOne(src registries.Source) error {
|
|
||||||
dir := r.cacheDirFor(src)
|
|
||||||
exists, err := afero.DirExists(r.fs, dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check cache directory: %w", err)
|
return fmt.Errorf("failed to check cache directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if !exists {
|
||||||
origin, originErr := r.git.OriginURL(dir)
|
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
|
||||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := r.fs.RemoveAll(dir); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
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)
|
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||||
|
return fmt.Errorf("failed to clone registry: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.loadThemes()
|
||||||
}
|
}
|
||||||
|
|
||||||
// A registry without a themes/ directory is a valid plugins-only registry.
|
func (r *Registry) loadThemes() error {
|
||||||
func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
|
themesDir := filepath.Join(r.cacheDir, "themes")
|
||||||
themesDir := filepath.Join(dir, "themes")
|
|
||||||
entries, err := afero.ReadDir(r.fs, themesDir)
|
entries, err := afero.ReadDir(r.fs, themesDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return fmt.Errorf("failed to read themes directory: %w", err)
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to read themes directory: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var themes []Theme
|
r.themes = []Theme{}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
@@ -297,46 +278,10 @@ func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
|
|||||||
theme.PreviewPath = previewPath
|
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
|
return nil
|
||||||
// layout nests under it, so a leftover clone is deleted wholesale first.
|
|
||||||
func (r *Registry) resetLegacyCache() {
|
|
||||||
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
|
|
||||||
_ = r.fs.RemoveAll(r.cacheDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update refreshes every configured registry, aggregating themes in
|
|
||||||
// declaration order (first occurrence of an ID wins). A failing registry is
|
|
||||||
// reported in the joined error but does not block the others.
|
|
||||||
func (r *Registry) Update() error {
|
|
||||||
r.resetLegacyCache()
|
|
||||||
r.themes = []Theme{}
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
var errs []error
|
|
||||||
for _, src := range r.registries {
|
|
||||||
if err := r.updateOne(src); err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
themes, err := r.loadThemesFrom(r.cacheDirFor(src))
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, t := range themes {
|
|
||||||
if _, dup := seen[t.ID]; dup {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[t.ID] = struct{}{}
|
|
||||||
r.themes = append(r.themes, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
|
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) {
|
func (r *Registry) List() ([]Theme, error) {
|
||||||
if len(r.themes) == 0 {
|
if len(r.themes) == 0 {
|
||||||
if err := r.Update(); err != nil && len(r.themes) == 0 {
|
if err := r.Update(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,25 +343,11 @@ func (r *Registry) Get(idOrName string) (*Theme, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) GetThemeSourcePath(themeID string) string {
|
func (r *Registry) GetThemeSourcePath(themeID string) string {
|
||||||
// Themes may live under any registry's subdir. Search them all; first hit wins.
|
return filepath.Join(r.cacheDir, "themes", themeID, "theme.json")
|
||||||
for _, cfg := range r.registries {
|
|
||||||
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID, "theme.json")
|
|
||||||
if exists, _ := afero.Exists(r.fs, candidate); exists {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback to first registry (legacy path semantics).
|
|
||||||
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID, "theme.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) GetThemeDir(themeID string) string {
|
func (r *Registry) GetThemeDir(themeID string) string {
|
||||||
for _, cfg := range r.registries {
|
return filepath.Join(r.cacheDir, "themes", themeID)
|
||||||
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID)
|
|
||||||
if exists, _ := afero.DirExists(r.fs, candidate); exists {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SortByFirstParty(themes []Theme) []Theme {
|
func SortByFirstParty(themes []Theme) []Theme {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package themes
|
package themes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,73 +64,3 @@ func TestLoadThemeWCAGInvalidJSON(t *testing.T) {
|
|||||||
t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ type NiriWindowRule struct {
|
|||||||
MatchIsUrgent *bool
|
MatchIsUrgent *bool
|
||||||
MatchAtStartup *bool
|
MatchAtStartup *bool
|
||||||
Matches []NiriMatch
|
Matches []NiriMatch
|
||||||
Excludes []NiriMatch
|
|
||||||
Opacity *float64
|
Opacity *float64
|
||||||
OpenFloating *bool
|
OpenFloating *bool
|
||||||
OpenMaximized *bool
|
OpenMaximized *bool
|
||||||
@@ -213,8 +212,6 @@ func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) {
|
|||||||
switch childName {
|
switch childName {
|
||||||
case "match":
|
case "match":
|
||||||
rule.Matches = append(rule.Matches, p.parseMatchNode(child))
|
rule.Matches = append(rule.Matches, p.parseMatchNode(child))
|
||||||
case "exclude":
|
|
||||||
rule.Excludes = append(rule.Excludes, p.parseMatchNode(child))
|
|
||||||
case "opacity":
|
case "opacity":
|
||||||
if len(child.Arguments) > 0 {
|
if len(child.Arguments) > 0 {
|
||||||
val := child.Arguments[0].ResolvedValue()
|
val := child.Arguments[0].ResolvedValue()
|
||||||
@@ -601,7 +598,6 @@ func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.Win
|
|||||||
AtStartup: nr.MatchAtStartup,
|
AtStartup: nr.MatchAtStartup,
|
||||||
},
|
},
|
||||||
Matches: convertNiriMatches(nr.Matches),
|
Matches: convertNiriMatches(nr.Matches),
|
||||||
Excludes: convertNiriMatches(nr.Excludes),
|
|
||||||
Actions: windowrules.Actions{
|
Actions: windowrules.Actions{
|
||||||
Opacity: nr.Opacity,
|
Opacity: nr.Opacity,
|
||||||
OpenFloating: nr.OpenFloating,
|
OpenFloating: nr.OpenFloating,
|
||||||
@@ -815,7 +811,6 @@ func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error)
|
|||||||
AtStartup: nr.MatchAtStartup,
|
AtStartup: nr.MatchAtStartup,
|
||||||
},
|
},
|
||||||
Matches: convertNiriMatches(nr.Matches),
|
Matches: convertNiriMatches(nr.Matches),
|
||||||
Excludes: convertNiriMatches(nr.Excludes),
|
|
||||||
Actions: windowrules.Actions{
|
Actions: windowrules.Actions{
|
||||||
Opacity: nr.Opacity,
|
Opacity: nr.Opacity,
|
||||||
OpenFloating: nr.OpenFloating,
|
OpenFloating: nr.OpenFloating,
|
||||||
@@ -879,7 +874,7 @@ func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) err
|
|||||||
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
|
return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatNiriMatchLine(keyword string, m windowrules.MatchCriteria) (string, bool) {
|
func formatNiriMatchLine(m windowrules.MatchCriteria) (string, bool) {
|
||||||
var matchProps []string
|
var matchProps []string
|
||||||
if m.AppID != "" {
|
if m.AppID != "" {
|
||||||
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
|
matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID))
|
||||||
@@ -911,7 +906,7 @@ func formatNiriMatchLine(keyword string, m windowrules.MatchCriteria) (string, b
|
|||||||
if len(matchProps) == 0 {
|
if len(matchProps) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return " " + keyword + " " + strings.Join(matchProps, " "), true
|
return " match " + strings.Join(matchProps, " "), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
||||||
@@ -924,12 +919,7 @@ func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string {
|
|||||||
matches = []windowrules.MatchCriteria{rule.MatchCriteria}
|
matches = []windowrules.MatchCriteria{rule.MatchCriteria}
|
||||||
}
|
}
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
if line, ok := formatNiriMatchLine("match", m); ok {
|
if line, ok := formatNiriMatchLine(m); ok {
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range rule.Excludes {
|
|
||||||
if line, ok := formatNiriMatchLine("exclude", m); ok {
|
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,56 +333,3 @@ window-rule {
|
|||||||
t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist")
|
t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNiriExcludesSurviveEditOfOtherRule(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
provider := NewNiriWritableProvider(tmpDir)
|
|
||||||
|
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
|
||||||
if err := os.MkdirAll(dmsDir, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
existing := `// @id=thunderbird @name=Thunderbird
|
|
||||||
window-rule {
|
|
||||||
match app-id="^org.mozilla.Thunderbird$"
|
|
||||||
open-floating true
|
|
||||||
exclude title="^Mozilla Thunderbird$"
|
|
||||||
exclude title="^Verfassen:.*"
|
|
||||||
}
|
|
||||||
|
|
||||||
// @id=firefox @name=Firefox
|
|
||||||
window-rule {
|
|
||||||
match app-id="^firefox$"
|
|
||||||
open-floating true
|
|
||||||
}
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(dmsDir, "windowrules.kdl"), []byte(existing), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
edited := newTestWindowRule("firefox", "Firefox", "^firefox$")
|
|
||||||
edited.Actions.OpenFloating = boolPtr(false)
|
|
||||||
if err := provider.SetRule(edited); err != nil {
|
|
||||||
t.Fatalf("SetRule failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rules, err := provider.LoadDMSRules()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadDMSRules failed: %v", err)
|
|
||||||
}
|
|
||||||
if len(rules) != 2 {
|
|
||||||
t.Fatalf("expected 2 rules, got %d", len(rules))
|
|
||||||
}
|
|
||||||
if len(rules[0].Excludes) != 2 {
|
|
||||||
t.Fatalf("expected 2 excludes on untouched rule, got %d", len(rules[0].Excludes))
|
|
||||||
}
|
|
||||||
if rules[0].Excludes[0].Title != "^Mozilla Thunderbird$" {
|
|
||||||
t.Errorf("Excludes[0].Title = %q", rules[0].Excludes[0].Title)
|
|
||||||
}
|
|
||||||
if rules[0].Excludes[1].Title != "^Verfassen:.*" {
|
|
||||||
t.Errorf("Excludes[1].Title = %q", rules[0].Excludes[1].Title)
|
|
||||||
}
|
|
||||||
if rules[1].Actions.OpenFloating == nil || *rules[1].Actions.OpenFloating {
|
|
||||||
t.Error("edited rule should have open-floating false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ type WindowRule struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
MatchCriteria MatchCriteria `json:"matchCriteria"`
|
MatchCriteria MatchCriteria `json:"matchCriteria"`
|
||||||
Matches []MatchCriteria `json:"matches,omitempty"`
|
Matches []MatchCriteria `json:"matches,omitempty"`
|
||||||
Excludes []MatchCriteria `json:"excludes,omitempty"`
|
|
||||||
Actions Actions `json:"actions"`
|
Actions Actions `json:"actions"`
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
Submodule dank-qml-common updated: 28fde73112...7cc4564e59
Generated
+3
-3
@@ -3,11 +3,11 @@
|
|||||||
"dank-qml-common": {
|
"dank-qml-common": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786121412,
|
"lastModified": 1785121997,
|
||||||
"narHash": "sha256-XHNpDNfQfjP6nIscd26GK6ydgBdVN5gknYGOVxXBWLw=",
|
"narHash": "sha256-/MslqFCpjxws8DZqipEKCCTBa0m/SCV3+v1NRT6EuvQ=",
|
||||||
"owner": "AvengeMedia",
|
"owner": "AvengeMedia",
|
||||||
"repo": "dank-qml-common",
|
"repo": "dank-qml-common",
|
||||||
"rev": "28fde7311296cbd041e5b704e54a481082b92b18",
|
"rev": "7cc4564e5903a2955fe7da76969f20252cacf9bf",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
inherit version;
|
inherit version;
|
||||||
pname = "dms-shell";
|
pname = "dms-shell";
|
||||||
src = ./core;
|
src = ./core;
|
||||||
vendorHash = "sha256-pjaRyB6E2TZvVd5a4xcdGSRVr9Dg9wEG/5e+HdtZJCg=";
|
vendorHash = "sha256-ZvaOPC92ZFRPqSyLJa2TA9OUKQ3QnWCIMxrnYLGnC58=";
|
||||||
|
|
||||||
subPackages = [ "cmd/dms" ];
|
subPackages = [ "cmd/dms" ];
|
||||||
|
|
||||||
|
|||||||
@@ -271,61 +271,3 @@ function getConflictingBinds(keyCombo, currentAction, allBinds, modKey) {
|
|||||||
}
|
}
|
||||||
return conflicts;
|
return conflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function qtKeyFromName(name) {
|
|
||||||
var n = (name || "").toUpperCase();
|
|
||||||
if (n.length === 1 && n >= "A" && n <= "Z")
|
|
||||||
return Qt.Key_A + (n.charCodeAt(0) - 65);
|
|
||||||
if (n.length === 1 && n >= "0" && n <= "9")
|
|
||||||
return Qt.Key_0 + (n.charCodeAt(0) - 48);
|
|
||||||
if (n.length >= 2 && n[0] === "F") {
|
|
||||||
var f = parseInt(n.slice(1), 10);
|
|
||||||
if (f >= 1 && f <= 12)
|
|
||||||
return Qt.Key_F1 + (f - 1);
|
|
||||||
}
|
|
||||||
var named = {
|
|
||||||
"SPACE": Qt.Key_Space,
|
|
||||||
"TAB": Qt.Key_Tab,
|
|
||||||
"RETURN": Qt.Key_Return,
|
|
||||||
"ENTER": Qt.Key_Enter,
|
|
||||||
"BACKSPACE": Qt.Key_Backspace,
|
|
||||||
"DELETE": Qt.Key_Delete,
|
|
||||||
"HOME": Qt.Key_Home,
|
|
||||||
"END": Qt.Key_End,
|
|
||||||
"UP": Qt.Key_Up,
|
|
||||||
"DOWN": Qt.Key_Down,
|
|
||||||
"LEFT": Qt.Key_Left,
|
|
||||||
"RIGHT": Qt.Key_Right
|
|
||||||
};
|
|
||||||
return named[n] || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isModifierKey(qk) {
|
|
||||||
return qk === Qt.Key_Control || qk === Qt.Key_Shift || qk === Qt.Key_Alt || qk === Qt.Key_Meta
|
|
||||||
|| qk === Qt.Key_NumLock || qk === Qt.Key_CapsLock || qk === Qt.Key_ScrollLock;
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventMatchesCombo(event, combo) {
|
|
||||||
if (!combo)
|
|
||||||
return false;
|
|
||||||
var parts = combo.split("+");
|
|
||||||
var keyName = parts[parts.length - 1].trim().toUpperCase();
|
|
||||||
var wantsShift = false;
|
|
||||||
var hasCtrl = false;
|
|
||||||
for (var i = 0; i < parts.length - 1; i++) {
|
|
||||||
var mod = parts[i].trim().toLowerCase();
|
|
||||||
if (mod === "shift")
|
|
||||||
wantsShift = true;
|
|
||||||
else if (mod === "ctrl" || mod === "control")
|
|
||||||
hasCtrl = true;
|
|
||||||
else
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (hasCtrl && !(event.modifiers & Qt.ControlModifier))
|
|
||||||
return false;
|
|
||||||
if (event.modifiers & (Qt.AltModifier | Qt.MetaModifier))
|
|
||||||
return false;
|
|
||||||
if (((event.modifiers & Qt.ShiftModifier) !== 0) !== wantsShift)
|
|
||||||
return false;
|
|
||||||
return event.key === qtKeyFromName(keyName);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ Singleton {
|
|||||||
function openModal(modal) {
|
function openModal(modal) {
|
||||||
PopoutManager.screenshotActive = false;
|
PopoutManager.screenshotActive = false;
|
||||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||||
var next = {};
|
currentModalsByScreen[screenName] = modal;
|
||||||
for (var k in currentModalsByScreen)
|
|
||||||
next[k] = currentModalsByScreen[k];
|
|
||||||
next[screenName] = modal;
|
|
||||||
currentModalsByScreen = next;
|
|
||||||
modalChanged();
|
modalChanged();
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
if (!modal.allowStacking)
|
if (!modal.allowStacking)
|
||||||
@@ -38,12 +34,7 @@ Singleton {
|
|||||||
function closeModal(modal) {
|
function closeModal(modal) {
|
||||||
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
const screenName = modal.effectiveScreen?.name ?? "unknown";
|
||||||
if (currentModalsByScreen[screenName] === modal) {
|
if (currentModalsByScreen[screenName] === modal) {
|
||||||
var next = {};
|
delete currentModalsByScreen[screenName];
|
||||||
for (var k in currentModalsByScreen) {
|
|
||||||
if (k !== screenName)
|
|
||||||
next[k] = currentModalsByScreen[k];
|
|
||||||
}
|
|
||||||
currentModalsByScreen = next;
|
|
||||||
modalChanged();
|
modalChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ Singleton {
|
|||||||
property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none")
|
property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none")
|
||||||
|
|
||||||
property bool wallpaperCyclingEnabled: false
|
property bool wallpaperCyclingEnabled: false
|
||||||
property bool wallpaperCyclingRandom: false
|
|
||||||
property string wallpaperCyclingMode: "interval"
|
property string wallpaperCyclingMode: "interval"
|
||||||
property int wallpaperCyclingInterval: 300
|
property int wallpaperCyclingInterval: 300
|
||||||
property string wallpaperCyclingTime: "06:00"
|
property string wallpaperCyclingTime: "06:00"
|
||||||
@@ -647,11 +646,6 @@ Singleton {
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setWallpaperCyclingRandom(random) {
|
|
||||||
wallpaperCyclingRandom = random;
|
|
||||||
saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setWallpaperCyclingMode(mode) {
|
function setWallpaperCyclingMode(mode) {
|
||||||
wallpaperCyclingMode = mode;
|
wallpaperCyclingMode = mode;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
@@ -698,37 +692,6 @@ Singleton {
|
|||||||
saveSettings();
|
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) {
|
function setMonitorCyclingMode(screenName, mode) {
|
||||||
var screen = null;
|
var screen = null;
|
||||||
var screens = Quickshell.screens;
|
var screens = Quickshell.screens;
|
||||||
@@ -1359,7 +1322,6 @@ Singleton {
|
|||||||
function getMonitorCyclingSettings(screenName) {
|
function getMonitorCyclingSettings(screenName) {
|
||||||
var defaults = {
|
var defaults = {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"random": false,
|
|
||||||
"mode": "interval",
|
"mode": "interval",
|
||||||
"interval": 300,
|
"interval": 300,
|
||||||
"time": "06:00"
|
"time": "06:00"
|
||||||
|
|||||||
@@ -164,11 +164,6 @@ Singleton {
|
|||||||
property string matugenTargetMonitor: ""
|
property string matugenTargetMonitor: ""
|
||||||
property real popupTransparency: 1.0
|
property real popupTransparency: 1.0
|
||||||
property real dockTransparency: 1
|
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 widgetBackgroundColor: "sch"
|
||||||
property string widgetBackgroundCustomColor: "#6750A4"
|
property string widgetBackgroundCustomColor: "#6750A4"
|
||||||
property real widgetBackgroundCustomStrength: 0.50
|
property real widgetBackgroundCustomStrength: 0.50
|
||||||
@@ -208,16 +203,6 @@ Singleton {
|
|||||||
property bool touchpadTapAndDrag: true
|
property bool touchpadTapAndDrag: true
|
||||||
property bool touchpadTapToClick: 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 int firstDayOfWeek: -1
|
||||||
property bool showWeekNumber: false
|
property bool showWeekNumber: false
|
||||||
property string calendarBackend: "auto"
|
property string calendarBackend: "auto"
|
||||||
@@ -269,7 +254,6 @@ Singleton {
|
|||||||
onBlurEnabledChanged: saveSettings()
|
onBlurEnabledChanged: saveSettings()
|
||||||
property bool blurForegroundLayers: true
|
property bool blurForegroundLayers: true
|
||||||
onBlurForegroundLayersChanged: saveSettings()
|
onBlurForegroundLayersChanged: saveSettings()
|
||||||
property real foregroundLayerTransparency: 1.0
|
|
||||||
property real blurLayerOutlineOpacity: 0.12
|
property real blurLayerOutlineOpacity: 0.12
|
||||||
onBlurLayerOutlineOpacityChanged: saveSettings()
|
onBlurLayerOutlineOpacityChanged: saveSettings()
|
||||||
property bool blurBorderEnabled: true
|
property bool blurBorderEnabled: true
|
||||||
@@ -495,7 +479,6 @@ Singleton {
|
|||||||
property bool mediaAdaptiveWidthEnabled: true
|
property bool mediaAdaptiveWidthEnabled: true
|
||||||
property bool audioVisualizerEnabled: true
|
property bool audioVisualizerEnabled: true
|
||||||
property bool mediaUseAlbumArtAccent: false
|
property bool mediaUseAlbumArtAccent: false
|
||||||
property bool appleMusicAnimatedArtEnabled: false
|
|
||||||
property string audioScrollMode: "volume"
|
property string audioScrollMode: "volume"
|
||||||
property int audioWheelScrollAmount: 5
|
property int audioWheelScrollAmount: 5
|
||||||
property bool audioDeviceScrollVolumeEnabled: false
|
property bool audioDeviceScrollVolumeEnabled: false
|
||||||
@@ -676,6 +659,9 @@ Singleton {
|
|||||||
readonly property string iconTheme: resolveIconTheme()
|
readonly property string iconTheme: resolveIconTheme()
|
||||||
property var availableIconThemes: ["System Default"]
|
property var availableIconThemes: ["System Default"]
|
||||||
property string systemDefaultIconTheme: ""
|
property string systemDefaultIconTheme: ""
|
||||||
|
property bool qt5ctAvailable: false
|
||||||
|
property bool qt6ctAvailable: false
|
||||||
|
property bool gtkAvailable: false
|
||||||
|
|
||||||
property var cursorSettings: ({
|
property var cursorSettings: ({
|
||||||
"theme": "System Default",
|
"theme": "System Default",
|
||||||
@@ -812,6 +798,8 @@ Singleton {
|
|||||||
property int fadeToDpmsGracePeriod: 5
|
property int fadeToDpmsGracePeriod: 5
|
||||||
property string launchPrefix: ""
|
property string launchPrefix: ""
|
||||||
|
|
||||||
|
property bool gtkThemingEnabled: false
|
||||||
|
property bool qtThemingEnabled: false
|
||||||
property bool syncModeWithPortal: true
|
property bool syncModeWithPortal: true
|
||||||
property bool terminalsAlwaysDark: false
|
property bool terminalsAlwaysDark: false
|
||||||
|
|
||||||
@@ -934,8 +922,6 @@ Singleton {
|
|||||||
property bool lockPamInlineU2f: false
|
property bool lockPamInlineU2f: false
|
||||||
property bool lockPamExternallyManaged: false
|
property bool lockPamExternallyManaged: false
|
||||||
property string lockU2fPamPath: ""
|
property string lockU2fPamPath: ""
|
||||||
property string lockScreenSecurityKeyShortcut: "Ctrl+Q"
|
|
||||||
property bool lockScreenSecurityKeyShortcutEnabled: false
|
|
||||||
property bool greeterPamExternallyManaged: false
|
property bool greeterPamExternallyManaged: false
|
||||||
property string lockScreenInactiveColor: "#000000"
|
property string lockScreenInactiveColor: "#000000"
|
||||||
property int lockScreenNotificationMode: 0
|
property int lockScreenNotificationMode: 0
|
||||||
@@ -990,7 +976,6 @@ Singleton {
|
|||||||
property string customPowerActionHibernate: ""
|
property string customPowerActionHibernate: ""
|
||||||
property string customPowerActionReboot: ""
|
property string customPowerActionReboot: ""
|
||||||
property string customPowerActionPowerOff: ""
|
property string customPowerActionPowerOff: ""
|
||||||
property var customPowerButtons: []
|
|
||||||
|
|
||||||
property bool updaterHideWidget: false
|
property bool updaterHideWidget: false
|
||||||
property bool updaterCheckOnStart: false
|
property bool updaterCheckOnStart: false
|
||||||
@@ -1698,6 +1683,7 @@ Singleton {
|
|||||||
_hasLoaded = true;
|
_hasLoaded = true;
|
||||||
applyStoredTheme();
|
applyStoredTheme();
|
||||||
updateCompositorCursor();
|
updateCompositorCursor();
|
||||||
|
Processes.detectQtTools();
|
||||||
Qt.callLater(checkIconThemeDrift);
|
Qt.callLater(checkIconThemeDrift);
|
||||||
|
|
||||||
_checkSettingsWritable();
|
_checkSettingsWritable();
|
||||||
@@ -2408,7 +2394,8 @@ Singleton {
|
|||||||
const bc = bars[i];
|
const bc = bars[i];
|
||||||
if (bc.position !== sidePos)
|
if (bc.position !== sidePos)
|
||||||
continue;
|
continue;
|
||||||
if (barConfigCoversScreen(bc, screen))
|
const prefs = bc.screenPreferences || ["all"];
|
||||||
|
if (prefs.includes("all") || isScreenInPreferences(screen, prefs))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -2511,13 +2498,6 @@ Singleton {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
function barConfigCoversScreen(bc, screen) {
|
|
||||||
var prefs = bc?.screenPreferences || ["all"];
|
|
||||||
if (prefs.includes("all") || isScreenInPreferences(screen, prefs))
|
|
||||||
return true;
|
|
||||||
return (bc?.showOnLastDisplay ?? false) && Quickshell.screens.length === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveBarEdgesForScreen(screen) {
|
function getActiveBarEdgesForScreen(screen) {
|
||||||
if (!screen)
|
if (!screen)
|
||||||
return [];
|
return [];
|
||||||
@@ -2526,7 +2506,8 @@ Singleton {
|
|||||||
var bc = barConfigs[i];
|
var bc = barConfigs[i];
|
||||||
if (!bc.enabled)
|
if (!bc.enabled)
|
||||||
continue;
|
continue;
|
||||||
if (!barConfigCoversScreen(bc, screen))
|
var prefs = bc.screenPreferences || ["all"];
|
||||||
|
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||||
continue;
|
continue;
|
||||||
switch (bc.position ?? 0) {
|
switch (bc.position ?? 0) {
|
||||||
case SettingsData.Position.Top:
|
case SettingsData.Position.Top:
|
||||||
@@ -2554,7 +2535,8 @@ Singleton {
|
|||||||
var bc = barConfigs[i];
|
var bc = barConfigs[i];
|
||||||
if (!bc.enabled || !(bc.useOverlayLayer ?? false))
|
if (!bc.enabled || !(bc.useOverlayLayer ?? false))
|
||||||
continue;
|
continue;
|
||||||
if (!barConfigCoversScreen(bc, screen))
|
var prefs = bc.screenPreferences || ["all"];
|
||||||
|
if (!prefs.includes("all") && !isScreenInPreferences(screen, prefs))
|
||||||
continue;
|
continue;
|
||||||
switch (bc.position ?? 0) {
|
switch (bc.position ?? 0) {
|
||||||
case SettingsData.Position.Top:
|
case SettingsData.Position.Top:
|
||||||
|
|||||||
+4
-113
@@ -96,6 +96,8 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
property bool matugenAvailable: false
|
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 workerRunning: false
|
||||||
property var pendingThemeRequest: null
|
property var pendingThemeRequest: null
|
||||||
|
|
||||||
@@ -201,10 +203,6 @@ Singleton {
|
|||||||
|
|
||||||
function getMatugenColor(path, fallback) {
|
function getMatugenColor(path, fallback) {
|
||||||
const colorMode = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "light" : "dark";
|
const colorMode = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "light" : "dark";
|
||||||
return getMatugenColorForMode(colorMode, path, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMatugenColorForMode(colorMode, path, fallback) {
|
|
||||||
let cur = matugenColors && matugenColors.colors && matugenColors.colors[colorMode];
|
let cur = matugenColors && matugenColors.colors && matugenColors.colors[colorMode];
|
||||||
for (const part of path.split(".")) {
|
for (const part of path.split(".")) {
|
||||||
if (!cur || typeof cur !== "object" || !(part in cur))
|
if (!cur || typeof cur !== "object" || !(part in cur))
|
||||||
@@ -214,82 +212,6 @@ Singleton {
|
|||||||
return cur || fallback;
|
return cur || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractCurrentTheme(themeName) {
|
|
||||||
var name = themeName || "Extracted Theme";
|
|
||||||
var dark = {};
|
|
||||||
var light = {};
|
|
||||||
|
|
||||||
if (currentTheme === dynamic) {
|
|
||||||
dark = buildExtractedDynamicMode("dark", name + " Dark");
|
|
||||||
light = buildExtractedDynamicMode("light", name + " Light");
|
|
||||||
} else if (currentTheme === custom && customThemeRawData) {
|
|
||||||
var rawDark = customThemeRawData.dark || null;
|
|
||||||
var rawLight = customThemeRawData.light || null;
|
|
||||||
if (rawDark) {
|
|
||||||
dark = JSON.parse(JSON.stringify(rawDark));
|
|
||||||
if (!dark.name)
|
|
||||||
dark.name = name + " Dark";
|
|
||||||
} else if (rawLight) {
|
|
||||||
dark = buildExtractedDynamicMode("dark", name + " Dark");
|
|
||||||
} else {
|
|
||||||
dark = currentThemeData ? JSON.parse(JSON.stringify(currentThemeData)) : {};
|
|
||||||
dark.name = name + " Dark";
|
|
||||||
}
|
|
||||||
if (rawLight) {
|
|
||||||
light = JSON.parse(JSON.stringify(rawLight));
|
|
||||||
if (!light.name)
|
|
||||||
light.name = name + " Light";
|
|
||||||
} else if (rawDark) {
|
|
||||||
light = buildExtractedDynamicMode("light", name + " Light");
|
|
||||||
} else {
|
|
||||||
light = currentThemeData ? JSON.parse(JSON.stringify(currentThemeData)) : {};
|
|
||||||
light.name = name + " Light";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var darkTheme = StockThemes.getThemeByName(currentTheme, false);
|
|
||||||
var lightTheme = StockThemes.getThemeByName(currentTheme, true);
|
|
||||||
dark = darkTheme ? JSON.parse(JSON.stringify(darkTheme)) : {};
|
|
||||||
light = lightTheme ? JSON.parse(JSON.stringify(lightTheme)) : {};
|
|
||||||
dark.name = name + " Dark";
|
|
||||||
light.name = name + " Light";
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
dark: dark,
|
|
||||||
light: light
|
|
||||||
}, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExtractedDynamicMode(colorMode, name) {
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"primary": getMatugenColorForMode(colorMode, "primary", "#42a5f5"),
|
|
||||||
"primaryText": getMatugenColorForMode(colorMode, "on_primary", "#ffffff"),
|
|
||||||
"primaryContainer": getMatugenColorForMode(colorMode, "primary_container", "#1976d2"),
|
|
||||||
"secondary": getMatugenColorForMode(colorMode, "secondary", "#8ab4f8"),
|
|
||||||
"secondaryContainer": getMatugenColorForMode(colorMode, "secondary_container", getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f")),
|
|
||||||
"tertiary": getMatugenColorForMode(colorMode, "tertiary", "#efb8c8"),
|
|
||||||
"tertiaryContainer": getMatugenColorForMode(colorMode, "tertiary_container", getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f")),
|
|
||||||
"surface": getMatugenColorForMode(colorMode, "surface", "#1a1c1e"),
|
|
||||||
"surfaceText": getMatugenColorForMode(colorMode, "on_background", "#e3e8ef"),
|
|
||||||
"surfaceVariant": getMatugenColorForMode(colorMode, "surface_variant", "#44464f"),
|
|
||||||
"surfaceVariantText": getMatugenColorForMode(colorMode, "on_surface_variant", "#c4c7c5"),
|
|
||||||
"surfaceTint": getMatugenColorForMode(colorMode, "surface_tint", "#8ab4f8"),
|
|
||||||
"background": getMatugenColorForMode(colorMode, "background", "#1a1c1e"),
|
|
||||||
"backgroundText": getMatugenColorForMode(colorMode, "on_background", "#e3e8ef"),
|
|
||||||
"outline": getMatugenColorForMode(colorMode, "outline", "#8e918f"),
|
|
||||||
"surfaceContainerLowest": getMatugenColorForMode(colorMode, "surface_container_lowest", "#0e1013"),
|
|
||||||
"surfaceContainerLow": getMatugenColorForMode(colorMode, "surface_container_low", "#181a1d"),
|
|
||||||
"surfaceContainer": getMatugenColorForMode(colorMode, "surface_container", "#1e2023"),
|
|
||||||
"surfaceContainerHigh": getMatugenColorForMode(colorMode, "surface_container_high", "#292b2f"),
|
|
||||||
"surfaceContainerHighest": getMatugenColorForMode(colorMode, "surface_container_highest", "#343740"),
|
|
||||||
"error": getMatugenColorForMode(colorMode, "error", "#F2B8B5"),
|
|
||||||
"warning": "#FF9800",
|
|
||||||
"info": "#2196F3",
|
|
||||||
"success": "#4CAF50"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property var currentThemeData: {
|
readonly property var currentThemeData: {
|
||||||
if (currentTheme === "custom") {
|
if (currentTheme === "custom") {
|
||||||
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
|
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
|
||||||
@@ -425,28 +347,11 @@ Singleton {
|
|||||||
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
|
readonly property bool foregroundLayers: typeof SettingsData === "undefined" || (SettingsData.blurForegroundLayers ?? true)
|
||||||
readonly property bool blurForegroundLayers: blurLayersActive && foregroundLayers
|
readonly property bool blurForegroundLayers: blurLayersActive && foregroundLayers
|
||||||
readonly property bool transparentBlurLayers: blurLayersActive && !foregroundLayers
|
readonly property bool transparentBlurLayers: blurLayersActive && !foregroundLayers
|
||||||
readonly property real foregroundLayerTransparency: typeof SettingsData === "undefined" ? 1.0 : (SettingsData.foregroundLayerTransparency ?? 1.0)
|
|
||||||
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
|
readonly property bool notificationForegroundLayers: typeof SettingsData === "undefined" || (SettingsData.notificationForegroundLayers ?? true)
|
||||||
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
|
readonly property color readableSurface: withAlpha(surfaceContainer, popupTransparency)
|
||||||
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
readonly property color readableSurfaceHigh: withAlpha(surfaceContainerHigh, popupTransparency)
|
||||||
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
readonly property color floatingSurface: foregroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||||
readonly property color floatingSurfaceHigh: foregroundLayers ? withAlpha(surfaceContainerHigh, foregroundLayerTransparency) : withAlpha(surfaceContainerHigh, 0)
|
readonly property color floatingSurfaceHigh: foregroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||||
readonly property bool floatingWindowSynced: typeof SettingsData === "undefined" || (SettingsData.floatingWindowSyncGlobal ?? true)
|
|
||||||
readonly property real floatingWindowTransparency: {
|
|
||||||
if (typeof SettingsData === "undefined" || floatingWindowSynced)
|
|
||||||
return popupTransparency;
|
|
||||||
return SettingsData.floatingWindowTransparency ?? 1.0;
|
|
||||||
}
|
|
||||||
readonly property bool floatingWindowForegroundLayers: floatingWindowSynced ? foregroundLayers : (SettingsData.floatingWindowForegroundLayers ?? true)
|
|
||||||
readonly property real floatingWindowForegroundTransparency: {
|
|
||||||
if (typeof SettingsData === "undefined" || floatingWindowSynced)
|
|
||||||
return foregroundLayerTransparency;
|
|
||||||
return SettingsData.floatingWindowForegroundTransparency ?? 1.0;
|
|
||||||
}
|
|
||||||
readonly property color floatingWindowSurface: withAlpha(surfaceContainer, floatingWindowTransparency)
|
|
||||||
readonly property color floatingWindowSurfaceHigh: floatingWindowForegroundLayers ? withAlpha(surfaceContainerHigh, floatingWindowForegroundTransparency) : withAlpha(surfaceContainerHigh, 0)
|
|
||||||
readonly property color floatingWindowNestedSurface: floatingWindowSurfaceHigh
|
|
||||||
readonly property color notepadWindowSurface: withAlpha(surfaceContainer, notepadTransparency)
|
|
||||||
readonly property color nestedSurface: floatingSurfaceHigh
|
readonly property color nestedSurface: floatingSurfaceHigh
|
||||||
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
readonly property color notificationFloatingSurface: notificationForegroundLayers ? readableSurface : withAlpha(readableSurface, 0)
|
||||||
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
readonly property color notificationFloatingSurfaceHigh: notificationForegroundLayers ? readableSurfaceHigh : withAlpha(readableSurfaceHigh, 0)
|
||||||
@@ -454,20 +359,6 @@ Singleton {
|
|||||||
readonly property real blurLayerOutlineOpacity: Math.max(0, Math.min(1, typeof SettingsData === "undefined" ? 0.12 : (SettingsData.blurLayerOutlineOpacity ?? 0.12)))
|
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 real layerOutlineOpacity: blurLayerOutlineOpacity
|
||||||
readonly property int layerOutlineWidth: layerOutlineOpacity > 0 ? 1 : 0
|
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 surfaceTextHover: withAlpha(surfaceText, 0.08)
|
||||||
property color surfaceTextAlpha: withAlpha(surfaceText, 0.3)
|
property color surfaceTextAlpha: withAlpha(surfaceText, 0.3)
|
||||||
|
|
||||||
@@ -1305,7 +1196,7 @@ Singleton {
|
|||||||
readonly property var _availableThemeNames: StockThemes.getAllThemeNames()
|
readonly property var _availableThemeNames: StockThemes.getAllThemeNames()
|
||||||
property string currentThemeName: currentTheme
|
property string currentThemeName: currentTheme
|
||||||
|
|
||||||
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : floatingWindowTransparency
|
property real notepadTransparency: SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : popupTransparency
|
||||||
|
|
||||||
property bool widgetBackgroundHasAlpha: {
|
property bool widgetBackgroundHasAlpha: {
|
||||||
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
|
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
|
||||||
|
|||||||
@@ -1,69 +1,31 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Services.SystemTray
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var activeTrayMenus: ({})
|
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) {
|
function registerMenu(screenName, menu) {
|
||||||
if (!screenName || !menu)
|
if (!screenName || !menu) return
|
||||||
return;
|
const newMenus = Object.assign({}, activeTrayMenus)
|
||||||
const newMenus = Object.assign({}, activeTrayMenus);
|
newMenus[screenName] = menu
|
||||||
newMenus[screenName] = menu;
|
activeTrayMenus = newMenus
|
||||||
activeTrayMenus = newMenus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function unregisterMenu(screenName) {
|
function unregisterMenu(screenName) {
|
||||||
if (!screenName)
|
if (!screenName) return
|
||||||
return;
|
const newMenus = Object.assign({}, activeTrayMenus)
|
||||||
const newMenus = Object.assign({}, activeTrayMenus);
|
delete newMenus[screenName]
|
||||||
delete newMenus[screenName];
|
activeTrayMenus = newMenus
|
||||||
activeTrayMenus = newMenus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeHoverMenus() {
|
function closeAllMenus() {
|
||||||
for (const screenName in activeTrayMenus) {
|
for (const screenName in activeTrayMenus) {
|
||||||
const menu = activeTrayMenus[screenName]
|
const menu = activeTrayMenus[screenName]
|
||||||
if (!menu || menu.openedByHover !== true) continue
|
if (!menu) continue
|
||||||
if (typeof menu.close === "function") {
|
if (typeof menu.close === "function") {
|
||||||
menu.close()
|
menu.close()
|
||||||
} else if (menu.showMenu !== undefined) {
|
} 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -487,10 +487,41 @@ Singleton {
|
|||||||
return pamFprintDetected ? "probe_failed" : "missing_pam_support";
|
return pamFprintDetected ? "probe_failed" : "missing_pam_support";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Qt tools detection ---
|
||||||
|
|
||||||
|
function detectQtTools() {
|
||||||
|
qtToolsDetectionProcess.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
function checkPluginSettings() {
|
function checkPluginSettings() {
|
||||||
pluginSettingsCheckProcess.running = true;
|
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 {
|
Timer {
|
||||||
id: authApplyDebounce
|
id: authApplyDebounce
|
||||||
interval: 300
|
interval: 300
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ var SPEC = {
|
|||||||
includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] },
|
includedTransitions: { def: ["fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] },
|
||||||
|
|
||||||
wallpaperCyclingEnabled: { def: false },
|
wallpaperCyclingEnabled: { def: false },
|
||||||
wallpaperCyclingRandom: { def: false },
|
|
||||||
wallpaperCyclingMode: { def: "interval" },
|
wallpaperCyclingMode: { def: "interval" },
|
||||||
wallpaperCyclingInterval: { def: 300 },
|
wallpaperCyclingInterval: { def: 300 },
|
||||||
wallpaperCyclingTime: { def: "06:00" },
|
wallpaperCyclingTime: { def: "06:00" },
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ var SPEC = {
|
|||||||
|
|
||||||
popupTransparency: { def: 1.0, coerce: percentToUnit },
|
popupTransparency: { def: 1.0, coerce: percentToUnit },
|
||||||
dockTransparency: { 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" },
|
widgetBackgroundColor: { def: "sch" },
|
||||||
widgetBackgroundCustomColor: { def: "#6750A4" },
|
widgetBackgroundCustomColor: { def: "#6750A4" },
|
||||||
@@ -62,16 +57,6 @@ var SPEC = {
|
|||||||
touchpadTapAndDrag: { def: true, onChange: "updateCompositorInput" },
|
touchpadTapAndDrag: { def: true, onChange: "updateCompositorInput" },
|
||||||
touchpadTapToClick: { 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 },
|
firstDayOfWeek: { def: -1 },
|
||||||
showWeekNumber: { def: false },
|
showWeekNumber: { def: false },
|
||||||
calendarBackend: { def: "auto" },
|
calendarBackend: { def: "auto" },
|
||||||
@@ -102,7 +87,6 @@ var SPEC = {
|
|||||||
barElevationEnabled: { def: true },
|
barElevationEnabled: { def: true },
|
||||||
blurEnabled: { def: false },
|
blurEnabled: { def: false },
|
||||||
blurForegroundLayers: { def: true },
|
blurForegroundLayers: { def: true },
|
||||||
foregroundLayerTransparency: { def: 1.0, coerce: percentToUnit },
|
|
||||||
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
blurLayerOutlineOpacity: { def: 0.12, coerce: percentToUnit },
|
||||||
blurBorderEnabled: { def: true },
|
blurBorderEnabled: { def: true },
|
||||||
blurBorderColor: { def: "outline" },
|
blurBorderColor: { def: "outline" },
|
||||||
@@ -221,7 +205,6 @@ var SPEC = {
|
|||||||
mediaAdaptiveWidthEnabled: { def: true },
|
mediaAdaptiveWidthEnabled: { def: true },
|
||||||
audioVisualizerEnabled: { def: true },
|
audioVisualizerEnabled: { def: true },
|
||||||
mediaUseAlbumArtAccent: { def: false },
|
mediaUseAlbumArtAccent: { def: false },
|
||||||
appleMusicAnimatedArtEnabled: { def: false },
|
|
||||||
audioScrollMode: { def: "volume" },
|
audioScrollMode: { def: "volume" },
|
||||||
audioWheelScrollAmount: { def: 5 },
|
audioWheelScrollAmount: { def: 5 },
|
||||||
audioDeviceScrollVolumeEnabled: { def: false },
|
audioDeviceScrollVolumeEnabled: { def: false },
|
||||||
@@ -315,6 +298,9 @@ var SPEC = {
|
|||||||
lastAppliedIconTheme: { def: "" },
|
lastAppliedIconTheme: { def: "" },
|
||||||
availableIconThemes: { def: ["System Default"], persist: false },
|
availableIconThemes: { def: ["System Default"], persist: false },
|
||||||
systemDefaultIconTheme: { def: "", 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" },
|
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 },
|
availableCursorThemes: { def: ["System Default"], persist: false },
|
||||||
@@ -388,6 +374,8 @@ var SPEC = {
|
|||||||
fadeToDpmsGracePeriod: { def: 5 },
|
fadeToDpmsGracePeriod: { def: 5 },
|
||||||
launchPrefix: { def: "" },
|
launchPrefix: { def: "" },
|
||||||
|
|
||||||
|
gtkThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
||||||
|
qtThemingEnabled: { def: false, onChange: "regenSystemThemes" },
|
||||||
syncModeWithPortal: { def: true },
|
syncModeWithPortal: { def: true },
|
||||||
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
|
terminalsAlwaysDark: { def: false, onChange: "regenSystemThemes" },
|
||||||
|
|
||||||
@@ -485,8 +473,6 @@ var SPEC = {
|
|||||||
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||||
u2fMode: { def: "or" },
|
u2fMode: { def: "or" },
|
||||||
lockPamPath: { def: "" },
|
lockPamPath: { def: "" },
|
||||||
lockScreenSecurityKeyShortcut: { def: "Ctrl+Q" },
|
|
||||||
lockScreenSecurityKeyShortcutEnabled: { def: false },
|
|
||||||
lockPamInlineFprint: { def: false },
|
lockPamInlineFprint: { def: false },
|
||||||
lockPamInlineU2f: { def: false },
|
lockPamInlineU2f: { def: false },
|
||||||
lockPamExternallyManaged: { def: false },
|
lockPamExternallyManaged: { def: false },
|
||||||
@@ -543,7 +529,6 @@ var SPEC = {
|
|||||||
customPowerActionHibernate: { def: "" },
|
customPowerActionHibernate: { def: "" },
|
||||||
customPowerActionReboot: { def: "" },
|
customPowerActionReboot: { def: "" },
|
||||||
customPowerActionPowerOff: { def: "" },
|
customPowerActionPowerOff: { def: "" },
|
||||||
customPowerButtons: { def: [] },
|
|
||||||
|
|
||||||
updaterHideWidget: { def: false },
|
updaterHideWidget: { def: false },
|
||||||
updaterCheckOnStart: { def: false },
|
updaterCheckOnStart: { def: false },
|
||||||
|
|||||||
+3
-22
@@ -31,6 +31,8 @@ Item {
|
|||||||
id: root
|
id: root
|
||||||
readonly property var log: Log.scoped("DMSShell")
|
readonly property var log: Log.scoped("DMSShell")
|
||||||
readonly property var _sessionsServiceRef: SessionsService
|
readonly property var _sessionsServiceRef: SessionsService
|
||||||
|
// Keep the optional integration service alive
|
||||||
|
readonly property var _dankConnectServiceRef: DankConnectService
|
||||||
|
|
||||||
property var core: null
|
property var core: null
|
||||||
|
|
||||||
@@ -445,23 +447,6 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyLoader {
|
|
||||||
id: qrGeneratorModalLoader
|
|
||||||
active: false
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
|
||||||
PopoutService.qrGeneratorModalLoader = qrGeneratorModalLoader;
|
|
||||||
}
|
|
||||||
|
|
||||||
QRGeneratorModal {
|
|
||||||
id: qrGeneratorModalItem
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
|
||||||
PopoutService.qrGeneratorModal = qrGeneratorModalItem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyLoader {
|
LazyLoader {
|
||||||
id: polkitAuthModalLoader
|
id: polkitAuthModalLoader
|
||||||
active: false
|
active: false
|
||||||
@@ -635,7 +620,7 @@ Item {
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
wasShown = true;
|
wasShown = true;
|
||||||
} else if (wasShown) {
|
} else if (wasShown) {
|
||||||
Qt.callLater(() => PopoutService.unloadSettingsNow());
|
PopoutService.unloadSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -940,7 +925,6 @@ Item {
|
|||||||
expandedWidthValue: 960
|
expandedWidthValue: 960
|
||||||
edgeGap: SettingsData.notepadEffectiveEdgeGap
|
edgeGap: SettingsData.notepadEffectiveEdgeGap
|
||||||
slideEdge: SettingsData.notepadSlideoutSide
|
slideEdge: SettingsData.notepadSlideoutSide
|
||||||
customTransparency: Theme.notepadTransparency
|
|
||||||
|
|
||||||
onIsVisibleChanged: {
|
onIsVisibleChanged: {
|
||||||
if (isVisible)
|
if (isVisible)
|
||||||
@@ -1016,9 +1000,6 @@ Item {
|
|||||||
case "reboot":
|
case "reboot":
|
||||||
SessionService.reboot();
|
SessionService.reboot();
|
||||||
break;
|
break;
|
||||||
case "softreboot":
|
|
||||||
SessionService.softReboot();
|
|
||||||
break;
|
|
||||||
case "poweroff":
|
case "poweroff":
|
||||||
SessionService.poweroff();
|
SessionService.poweroff();
|
||||||
break;
|
break;
|
||||||
|
|||||||
+25
-23
@@ -25,19 +25,20 @@ Item {
|
|||||||
required property var windowRuleModalLoader
|
required property var windowRuleModalLoader
|
||||||
|
|
||||||
function getPreferredBar(refPropertyName) {
|
function getPreferredBar(refPropertyName) {
|
||||||
|
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
const focusedScreenName = BarWidgetService.getFocusedScreenName();
|
const focusedScreenName = BarWidgetService.getFocusedScreenName();
|
||||||
|
|
||||||
const bars = [];
|
const loaders = Array.from({
|
||||||
if (root.dankBarRepeater) {
|
length: root.dankBarRepeater.count
|
||||||
for (let i = 0; i < root.dankBarRepeater.count; i++)
|
}, (_, i) => root.dankBarRepeater.itemAt(i));
|
||||||
bars.push(...(root.dankBarRepeater.itemAt(i)?.item?.barVariants?.instances || []));
|
|
||||||
}
|
|
||||||
const frameBars = BarWidgetService.frameHostedBars;
|
|
||||||
for (const screenName in frameBars)
|
|
||||||
bars.push(frameBars[screenName]);
|
|
||||||
|
|
||||||
let currentBar = null;
|
let currentBar = null;
|
||||||
for (const bar of bars) {
|
|
||||||
|
for (const loader of loaders) {
|
||||||
|
const instances = loader?.item?.barVariants?.instances || [];
|
||||||
|
for (const bar of instances) {
|
||||||
if (!bar)
|
if (!bar)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ Item {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return currentBar;
|
return currentBar;
|
||||||
}
|
}
|
||||||
@@ -2023,6 +2025,18 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
IpcHandler {
|
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 {
|
function list(): string {
|
||||||
const items = SystemTray.items.values;
|
const items = SystemTray.items.values;
|
||||||
if (items.length === 0)
|
if (items.length === 0)
|
||||||
@@ -2038,7 +2052,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function activate(itemId: string): string {
|
function activate(itemId: string): string {
|
||||||
const item = TrayMenuManager.findTrayItem(itemId);
|
const item = findTrayItem(itemId);
|
||||||
if (!item)
|
if (!item)
|
||||||
return `ERROR: Tray item not found: ${itemId}`;
|
return `ERROR: Tray item not found: ${itemId}`;
|
||||||
|
|
||||||
@@ -2046,20 +2060,8 @@ Item {
|
|||||||
return `SUCCESS: Activated ${itemId}`;
|
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 {
|
function status(itemId: string): string {
|
||||||
const item = TrayMenuManager.findTrayItem(itemId);
|
const item = findTrayItem(itemId);
|
||||||
if (!item)
|
if (!item)
|
||||||
return `ERROR: Tray item not found: ${itemId}`;
|
return `ERROR: Tray item not found: ${itemId}`;
|
||||||
|
|
||||||
|
|||||||
@@ -318,6 +318,10 @@ DankModal {
|
|||||||
width: parent.width - Theme.spacingS * 2
|
width: parent.width - Theme.spacingS * 2
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
height: 52
|
height: 52
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||||
|
normalBorderColor: Theme.outlineMedium
|
||||||
|
focusedBorderColor: Theme.primary
|
||||||
leftIconName: "search"
|
leftIconName: "search"
|
||||||
leftIconSize: Theme.iconSize
|
leftIconSize: Theme.iconSize
|
||||||
leftIconColor: Theme.surfaceVariantText
|
leftIconColor: Theme.surfaceVariantText
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Rectangle {
|
|||||||
|
|
||||||
height: Math.round(Theme.fontSizeMedium * 4.2)
|
height: Math.round(Theme.fontSizeMedium * 4.2)
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import qs.Common
|
|||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
DankFloatingWindow {
|
FloatingWindow {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property bool disablePopupTransparency: true
|
||||||
readonly property int modalWidth: 680
|
readonly property int modalWidth: 680
|
||||||
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
|
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ DankFloatingWindow {
|
|||||||
title: i18n("What's New")
|
title: i18n("What's New")
|
||||||
minimumSize: Qt.size(modalWidth, modalHeight)
|
minimumSize: Qt.size(modalWidth, modalHeight)
|
||||||
maximumSize: Qt.size(modalWidth, modalHeight)
|
maximumSize: Qt.size(modalWidth, modalHeight)
|
||||||
|
color: Theme.surfaceContainer
|
||||||
visible: false
|
visible: false
|
||||||
|
|
||||||
onClosed: visible = false
|
onClosed: visible = false
|
||||||
@@ -110,7 +112,7 @@ DankFloatingWindow {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
height: Math.round(Theme.fontSizeMedium * 4.5)
|
height: Math.round(Theme.fontSizeMedium * 4.5)
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
|
|||||||
@@ -819,13 +819,6 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCategoryFiltered) {
|
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);
|
var rawApps = AppSearchService.getAppsInCategory(appCategory);
|
||||||
for (var i = 0; i < rawApps.length; i++) {
|
for (var i = 0; i < rawApps.length; i++) {
|
||||||
allItems.push(getOrTransformApp(rawApps[i]));
|
allItems.push(getOrTransformApp(rawApps[i]));
|
||||||
@@ -837,7 +830,6 @@ Item {
|
|||||||
if (coreAppCats.indexOf(appCategory) !== -1)
|
if (coreAppCats.indexOf(appCategory) !== -1)
|
||||||
allItems.push(transformCoreApp(allCoreApps[i]));
|
allItems.push(transformCoreApp(allCoreApps[i]));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
var apps = searchApps(searchQuery);
|
var apps = searchApps(searchQuery);
|
||||||
for (var i = 0; i < apps.length; i++) {
|
for (var i = 0; i < apps.length; i++) {
|
||||||
@@ -847,7 +839,7 @@ Item {
|
|||||||
|
|
||||||
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
|
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);
|
||||||
var sortAlpha = !searchQuery && SettingsData.sortAppsAlphabetically;
|
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 sid in collapsedSections) {
|
||||||
for (var i = 0; i < newSections.length; i++) {
|
for (var i = 0; i < newSections.length; i++) {
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ FocusScope {
|
|||||||
property bool editMode: false
|
property bool editMode: false
|
||||||
property var editingApp: null
|
property var editingApp: null
|
||||||
property string editAppId: ""
|
property string editAppId: ""
|
||||||
|
readonly property bool _blurActive: Theme.blurForegroundLayers || Theme.transparentBlurLayers
|
||||||
|
readonly property real _launcherFieldAlpha: {
|
||||||
|
if (Theme.transparentBlurLayers)
|
||||||
|
return 0.28;
|
||||||
|
if (Theme.blurForegroundLayers)
|
||||||
|
return Math.max(Theme.popupTransparency, 0.62);
|
||||||
|
return Theme.popupTransparency;
|
||||||
|
}
|
||||||
|
readonly property color _launcherSearchFieldColor: Theme.withAlpha(Theme.surfaceContainerHigh, _launcherFieldAlpha)
|
||||||
|
readonly property color _launcherSearchBorderColor: Theme.withAlpha(Theme.outline, _blurActive ? 0.16 : Theme.layerOutlineOpacity)
|
||||||
|
readonly property color _launcherSearchFocusedBorderColor: Theme.withAlpha(Theme.primary, _blurActive ? 0.72 : 1.0)
|
||||||
|
|
||||||
function resetScroll() {
|
function resetScroll() {
|
||||||
resultsList.resetScroll();
|
resultsList.resetScroll();
|
||||||
@@ -303,7 +314,7 @@ FocusScope {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.topMargin: -Theme.cornerRadius
|
anchors.topMargin: -Theme.cornerRadius
|
||||||
// In connected mode the launcher provides the surface so update the toolbar for arcs
|
// In connected mode the launcher provides the surface so update the toolbar for arcs
|
||||||
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !Theme.blurLayersActive
|
visible: !(root.parentModal?.frameOwnsConnectedChrome ?? false) && !root._blurActive
|
||||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
}
|
}
|
||||||
@@ -461,6 +472,12 @@ FocusScope {
|
|||||||
DankTextField {
|
DankTextField {
|
||||||
id: searchField
|
id: searchField
|
||||||
width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0)
|
width: parent.width - (pluginBadge.visible ? pluginBadge.width + Theme.spacingS : 0)
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
backgroundColor: root._launcherSearchFieldColor
|
||||||
|
normalBorderColor: root._launcherSearchBorderColor
|
||||||
|
focusedBorderColor: root._launcherSearchFocusedBorderColor
|
||||||
|
borderWidth: 1
|
||||||
|
focusedBorderWidth: 2
|
||||||
leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search"
|
leftIconName: controller.activePluginId ? "extension" : controller.searchQuery.startsWith("/") ? "folder" : "search"
|
||||||
leftIconSize: Theme.iconSize
|
leftIconSize: Theme.iconSize
|
||||||
leftIconColor: Theme.surfaceVariantText
|
leftIconColor: Theme.surfaceVariantText
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Item {
|
|||||||
|
|
||||||
property string source: ""
|
property string source: ""
|
||||||
property int glyphSize: 14
|
property int glyphSize: 14
|
||||||
property bool badgeVisible: true
|
|
||||||
|
|
||||||
readonly property var sourceAsset: ({
|
readonly property var sourceAsset: ({
|
||||||
"flatpak": "../../assets/package-sources/flatpak.svg",
|
"flatpak": "../../assets/package-sources/flatpak.svg",
|
||||||
@@ -18,7 +17,7 @@ Item {
|
|||||||
|
|
||||||
readonly property string assetPath: sourceAsset[source] || ""
|
readonly property string assetPath: sourceAsset[source] || ""
|
||||||
|
|
||||||
visible: badgeVisible && SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
|
visible: SettingsData.dankLauncherV2ShowSourceBadges && assetPath.length > 0
|
||||||
implicitWidth: glyphSize
|
implicitWidth: glyphSize
|
||||||
implicitHeight: glyphSize
|
implicitHeight: glyphSize
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ Rectangle {
|
|||||||
anchors.margins: Theme.spacingXS
|
anchors.margins: Theme.spacingXS
|
||||||
source: root.item?.type === "app" ? (root.item.source || "") : ""
|
source: root.item?.type === "app" ? (root.item.source || "") : ""
|
||||||
glyphSize: 16
|
glyphSize: 16
|
||||||
badgeVisible: !root.isSelected
|
visible: !root.isSelected && !!source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ DankModal {
|
|||||||
property alias filterExtensions: fileBrowserSurfaceModal.fileExtensions
|
property alias filterExtensions: fileBrowserSurfaceModal.fileExtensions
|
||||||
property bool showHiddenFiles: false
|
property bool showHiddenFiles: false
|
||||||
property bool saveMode: false
|
property bool saveMode: false
|
||||||
property bool folderMode: false
|
|
||||||
property string defaultFileName: ""
|
property string defaultFileName: ""
|
||||||
property var parentPopout: null
|
property var parentPopout: null
|
||||||
|
|
||||||
@@ -58,7 +57,6 @@ DankModal {
|
|||||||
fileExtensions: fileBrowserSurfaceModal.fileExtensions
|
fileExtensions: fileBrowserSurfaceModal.fileExtensions
|
||||||
showHiddenFiles: fileBrowserSurfaceModal.showHiddenFiles
|
showHiddenFiles: fileBrowserSurfaceModal.showHiddenFiles
|
||||||
saveMode: fileBrowserSurfaceModal.saveMode
|
saveMode: fileBrowserSurfaceModal.saveMode
|
||||||
folderMode: fileBrowserSurfaceModal.folderMode
|
|
||||||
defaultFileName: fileBrowserSurfaceModal.defaultFileName
|
defaultFileName: fileBrowserSurfaceModal.defaultFileName
|
||||||
|
|
||||||
Component.onCompleted: initialize()
|
Component.onCompleted: initialize()
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ Item {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: keybindsGrid.height + Theme.spacingM * 2
|
height: keybindsGrid.height + Theme.spacingM * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
readonly property bool useTwoColumns: width > 500
|
readonly property bool useTwoColumns: width > 500
|
||||||
readonly property int columnCount: useTwoColumns ? 2 : 1
|
readonly property int columnCount: useTwoColumns ? 2 : 1
|
||||||
@@ -240,7 +240,7 @@ Item {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
height: noKeybindsColumn.height + Theme.spacingM * 2
|
height: noKeybindsColumn.height + Theme.spacingM * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
visible: !root.hasKeybinds
|
visible: !root.hasKeybinds
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ Item {
|
|||||||
anchors.leftMargin: Theme.spacingXL
|
anchors.leftMargin: Theme.spacingXL
|
||||||
anchors.rightMargin: Theme.spacingXL
|
anchors.rightMargin: Theme.spacingXL
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Rectangle {
|
|||||||
|
|
||||||
height: Math.round(Theme.fontSizeMedium * 6.4)
|
height: Math.round(Theme.fontSizeMedium * 6.4)
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import qs.Common
|
|||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
DankFloatingWindow {
|
FloatingWindow {
|
||||||
id: root
|
id: root
|
||||||
readonly property var log: Log.scoped("GreeterModal")
|
readonly property var log: Log.scoped("GreeterModal")
|
||||||
|
|
||||||
|
property bool disablePopupTransparency: true
|
||||||
property int currentPage: 0
|
property int currentPage: 0
|
||||||
readonly property int totalPages: 3
|
readonly property int totalPages: 3
|
||||||
readonly property var pageComponents: [welcomePage, doctorPage, completePage]
|
readonly property var pageComponents: [welcomePage, doctorPage, completePage]
|
||||||
@@ -89,6 +90,7 @@ DankFloatingWindow {
|
|||||||
title: I18n.tr("Welcome", "greeter modal window title")
|
title: I18n.tr("Welcome", "greeter modal window title")
|
||||||
minimumSize: Qt.size(modalWidth, modalHeight)
|
minimumSize: Qt.size(modalWidth, modalHeight)
|
||||||
maximumSize: Qt.size(modalWidth, modalHeight)
|
maximumSize: Qt.size(modalWidth, modalHeight)
|
||||||
|
color: Theme.surfaceContainer
|
||||||
visible: false
|
visible: false
|
||||||
|
|
||||||
onClosed: visible = false
|
onClosed: visible = false
|
||||||
@@ -171,7 +173,7 @@ DankFloatingWindow {
|
|||||||
width: pageIndicatorRow.width + Theme.spacingM * 2
|
width: pageIndicatorRow.width + Theme.spacingM * 2
|
||||||
height: indicatorHeight
|
height: indicatorHeight
|
||||||
radius: indicatorHeight / 2
|
radius: indicatorHeight / 2
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: pageIndicatorRow
|
id: pageIndicatorRow
|
||||||
@@ -257,7 +259,7 @@ DankFloatingWindow {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
height: Math.round(Theme.fontSizeMedium * 4.5)
|
height: Math.round(Theme.fontSizeMedium * 4.5)
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Rectangle {
|
|||||||
|
|
||||||
height: Math.round(Theme.fontSizeMedium * 3.1)
|
height: Math.round(Theme.fontSizeMedium * 3.1)
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Rectangle {
|
|||||||
|
|
||||||
height: Math.round(Theme.fontSizeMedium * 4.5)
|
height: Math.round(Theme.fontSizeMedium * 4.5)
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Rectangle {
|
|||||||
property string label: ""
|
property string label: ""
|
||||||
property string iconName: ""
|
property string iconName: ""
|
||||||
property color iconColor: Theme.surfaceText
|
property color iconColor: Theme.surfaceText
|
||||||
property color bgColor: Theme.floatingWindowNestedSurface
|
property color bgColor: Theme.surfaceContainerHigh
|
||||||
property bool selected: false
|
property bool selected: false
|
||||||
|
|
||||||
signal clicked
|
signal clicked
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import qs.Modals
|
|||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
DankFloatingWindow {
|
FloatingWindow {
|
||||||
id: win
|
id: win
|
||||||
|
|
||||||
|
property bool disablePopupTransparency: true
|
||||||
property alias shouldBeVisible: win.visible
|
property alias shouldBeVisible: win.visible
|
||||||
|
|
||||||
signal floatingToggleRequested
|
signal floatingToggleRequested
|
||||||
@@ -29,6 +30,7 @@ DankFloatingWindow {
|
|||||||
minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height))
|
minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height))
|
||||||
implicitWidth: 1000
|
implicitWidth: 1000
|
||||||
implicitHeight: screen ? Math.min(820, screen.height - 100) : 820
|
implicitHeight: screen ? Math.min(820, screen.height - 100) : 820
|
||||||
|
color: Theme.surfaceContainer
|
||||||
visible: false
|
visible: false
|
||||||
|
|
||||||
onVisibleChanged: {
|
onVisibleChanged: {
|
||||||
@@ -59,6 +61,12 @@ DankFloatingWindow {
|
|||||||
onDoubleClicked: windowControls.tryToggleMaximize()
|
onDoubleClicked: windowControls.tryToggleMaximize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: Theme.spacingL
|
anchors.leftMargin: Theme.spacingL
|
||||||
|
|||||||
@@ -313,6 +313,10 @@ DankModal {
|
|||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 48
|
height: 48
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
backgroundColor: Theme.surfaceContainerHigh
|
||||||
|
normalBorderColor: Theme.outlineMedium
|
||||||
|
focusedBorderColor: Theme.primary
|
||||||
leftIconName: "search"
|
leftIconName: "search"
|
||||||
leftIconSize: Theme.iconSize
|
leftIconSize: Theme.iconSize
|
||||||
leftIconColor: Theme.surfaceVariantText
|
leftIconColor: Theme.surfaceVariantText
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ FocusScope {
|
|||||||
StyledText {
|
StyledText {
|
||||||
text: root.currentFlow?.message ?? ""
|
text: root.currentFlow?.message ?? ""
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
color: Theme.surfaceText
|
color: Theme.surfaceTextMedium
|
||||||
width: parent.width
|
width: parent.width
|
||||||
wrapMode: Text.Wrap
|
wrapMode: Text.Wrap
|
||||||
maximumLineCount: 2
|
maximumLineCount: 2
|
||||||
@@ -272,6 +272,11 @@ FocusScope {
|
|||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: root.inputFieldHeight
|
height: root.inputFieldHeight
|
||||||
|
backgroundColor: Theme.surfaceHover
|
||||||
|
normalBorderColor: Theme.outlineStrong
|
||||||
|
focusedBorderColor: Theme.primary
|
||||||
|
borderWidth: 1
|
||||||
|
focusedBorderWidth: 2
|
||||||
leftIconName: root.polkitPamHasFprint ? "fingerprint" : ""
|
leftIconName: root.polkitPamHasFprint ? "fingerprint" : ""
|
||||||
leftIconSize: 20
|
leftIconSize: 20
|
||||||
leftIconColor: Theme.primary
|
leftIconColor: Theme.primary
|
||||||
@@ -347,7 +352,7 @@ FocusScope {
|
|||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: I18n.tr("Authenticate")
|
text: I18n.tr("Authenticate")
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
color: Theme.primaryText
|
color: Theme.background
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import qs.Common
|
|||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
DankFloatingWindow {
|
FloatingWindow {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property bool disablePopupTransparency: true
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
if (contentLoader.item)
|
if (contentLoader.item)
|
||||||
contentLoader.item.reset();
|
contentLoader.item.reset();
|
||||||
@@ -27,6 +29,7 @@ DankFloatingWindow {
|
|||||||
title: I18n.tr("Authentication")
|
title: I18n.tr("Authentication")
|
||||||
minimumSize: Qt.size(460, 220)
|
minimumSize: Qt.size(460, 220)
|
||||||
maximumSize: Qt.size(460, 220)
|
maximumSize: Qt.size(460, 220)
|
||||||
|
color: Theme.surfaceContainer
|
||||||
visible: false
|
visible: false
|
||||||
|
|
||||||
onClosed: hide()
|
onClosed: hide()
|
||||||
|
|||||||
@@ -129,13 +129,6 @@ DankModal {
|
|||||||
switchUserRequested();
|
switchUserRequested();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action.startsWith("custom:")) {
|
|
||||||
const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)];
|
|
||||||
close();
|
|
||||||
if (button?.command)
|
|
||||||
Quickshell.execDetached(["sh", "-c", button.command]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
close();
|
close();
|
||||||
root.powerActionRequested(action, "", "");
|
root.powerActionRequested(action, "", "");
|
||||||
}
|
}
|
||||||
@@ -179,14 +172,11 @@ DankModal {
|
|||||||
|
|
||||||
function updateVisibleActions() {
|
function updateVisibleActions() {
|
||||||
const allActions = SettingsData.powerMenuActions || ["reboot", "logout", "poweroff", "lock", "suspend", "restart"];
|
const allActions = SettingsData.powerMenuActions || ["reboot", "logout", "poweroff", "lock", "suspend", "restart"];
|
||||||
const customButtons = SettingsData.customPowerButtons || [];
|
|
||||||
visibleActions = allActions.filter(action => {
|
visibleActions = allActions.filter(action => {
|
||||||
if (action === "hibernate" && !SessionService.hibernateSupported)
|
if (action === "hibernate" && !SessionService.hibernateSupported)
|
||||||
return false;
|
return false;
|
||||||
if (action === "softreboot" && !SessionService.softRebootSupported)
|
|
||||||
return false;
|
|
||||||
return true;
|
return true;
|
||||||
}).concat(customButtons.map((button, i) => "custom:" + i));
|
});
|
||||||
|
|
||||||
if (!SettingsData.powerMenuGridLayout)
|
if (!SettingsData.powerMenuGridLayout)
|
||||||
return;
|
return;
|
||||||
@@ -226,14 +216,6 @@ DankModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActionData(action) {
|
function getActionData(action) {
|
||||||
if (action.startsWith("custom:")) {
|
|
||||||
const button = (SettingsData.customPowerButtons || [])[parseInt(action.slice(7), 10)];
|
|
||||||
return {
|
|
||||||
"icon": button?.icon || "terminal",
|
|
||||||
"label": button?.label || button?.command || "",
|
|
||||||
"key": ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "reboot":
|
case "reboot":
|
||||||
return {
|
return {
|
||||||
@@ -241,12 +223,6 @@ DankModal {
|
|||||||
"label": I18n.tr("Reboot"),
|
"label": I18n.tr("Reboot"),
|
||||||
"key": "R"
|
"key": "R"
|
||||||
};
|
};
|
||||||
case "softreboot":
|
|
||||||
return {
|
|
||||||
"icon": "autorenew",
|
|
||||||
"label": I18n.tr("Soft Reboot"),
|
|
||||||
"key": "B"
|
|
||||||
};
|
|
||||||
case "logout":
|
case "logout":
|
||||||
return {
|
return {
|
||||||
"icon": "logout",
|
"icon": "logout",
|
||||||
@@ -394,7 +370,7 @@ DankModal {
|
|||||||
|
|
||||||
function handleListNavigation(event, isPressed) {
|
function handleListNavigation(event, isPressed) {
|
||||||
if (!isPressed) {
|
if (!isPressed) {
|
||||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
||||||
cancelHold();
|
cancelHold();
|
||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
@@ -453,12 +429,6 @@ DankModal {
|
|||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Qt.Key_B:
|
|
||||||
if (visibleActions.includes("softreboot")) {
|
|
||||||
startHold("softreboot", visibleActions.indexOf("softreboot"));
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Qt.Key_X:
|
case Qt.Key_X:
|
||||||
if (visibleActions.includes("logout")) {
|
if (visibleActions.includes("logout")) {
|
||||||
startHold("logout", visibleActions.indexOf("logout"));
|
startHold("logout", visibleActions.indexOf("logout"));
|
||||||
@@ -494,7 +464,7 @@ DankModal {
|
|||||||
|
|
||||||
function handleGridNavigation(event, isPressed) {
|
function handleGridNavigation(event, isPressed) {
|
||||||
if (!isPressed) {
|
if (!isPressed) {
|
||||||
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_B || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_R || event.key === Qt.Key_X || event.key === Qt.Key_L || event.key === Qt.Key_S || event.key === Qt.Key_H || event.key === Qt.Key_D || (event.key === Qt.Key_P && !(event.modifiers & Qt.ControlModifier))) {
|
||||||
cancelHold();
|
cancelHold();
|
||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
@@ -569,12 +539,6 @@ DankModal {
|
|||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Qt.Key_B:
|
|
||||||
if (visibleActions.includes("softreboot")) {
|
|
||||||
startHold("softreboot", visibleActions.indexOf("softreboot"));
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Qt.Key_X:
|
case Qt.Key_X:
|
||||||
if (visibleActions.includes("logout")) {
|
if (visibleActions.includes("logout")) {
|
||||||
startHold("logout", visibleActions.indexOf("logout"));
|
startHold("logout", visibleActions.indexOf("logout"));
|
||||||
@@ -633,7 +597,7 @@ DankModal {
|
|||||||
|
|
||||||
readonly property var actionData: root.getActionData(modelData)
|
readonly property var actionData: root.getActionData(modelData)
|
||||||
readonly property bool isSelected: root.selectedIndex === index
|
readonly property bool isSelected: root.selectedIndex === index
|
||||||
readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
|
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff"
|
||||||
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
|
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
|
||||||
|
|
||||||
width: (root.modalWidth - Theme.spacingL * 2 - Theme.spacingS * (root.gridColumns - 1)) / root.gridColumns
|
width: (root.modalWidth - Theme.spacingL * 2 - Theme.spacingS * (root.gridColumns - 1)) / root.gridColumns
|
||||||
@@ -663,7 +627,7 @@ DankModal {
|
|||||||
color: {
|
color: {
|
||||||
if (gridButtonRect.modelData === "poweroff")
|
if (gridButtonRect.modelData === "poweroff")
|
||||||
return Theme.errorSelected;
|
return Theme.errorSelected;
|
||||||
if (gridButtonRect.modelData === "reboot" || gridButtonRect.modelData === "softreboot")
|
if (gridButtonRect.modelData === "reboot")
|
||||||
return Theme.withAlpha(Theme.warning, 0.3);
|
return Theme.withAlpha(Theme.warning, 0.3);
|
||||||
return Theme.primarySelected;
|
return Theme.primarySelected;
|
||||||
}
|
}
|
||||||
@@ -704,7 +668,6 @@ DankModal {
|
|||||||
height: 16
|
height: 16
|
||||||
radius: 4
|
radius: 4
|
||||||
color: Theme.onSurface_12
|
color: Theme.onSurface_12
|
||||||
visible: gridButtonRect.actionData.key !== ""
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
@@ -759,7 +722,7 @@ DankModal {
|
|||||||
|
|
||||||
readonly property var actionData: root.getActionData(modelData)
|
readonly property var actionData: root.getActionData(modelData)
|
||||||
readonly property bool isSelected: root.selectedIndex === index
|
readonly property bool isSelected: root.selectedIndex === index
|
||||||
readonly property bool showWarning: modelData === "reboot" || modelData === "softreboot" || modelData === "poweroff"
|
readonly property bool showWarning: modelData === "reboot" || modelData === "poweroff"
|
||||||
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
|
readonly property bool isHolding: root.holdActionIndex === index && root.holdProgress > 0
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
@@ -789,7 +752,7 @@ DankModal {
|
|||||||
color: {
|
color: {
|
||||||
if (listButtonRect.modelData === "poweroff")
|
if (listButtonRect.modelData === "poweroff")
|
||||||
return Theme.errorSelected;
|
return Theme.errorSelected;
|
||||||
if (listButtonRect.modelData === "reboot" || listButtonRect.modelData === "softreboot")
|
if (listButtonRect.modelData === "reboot")
|
||||||
return Theme.withAlpha(Theme.warning, 0.3);
|
return Theme.withAlpha(Theme.warning, 0.3);
|
||||||
return Theme.primarySelected;
|
return Theme.primarySelected;
|
||||||
}
|
}
|
||||||
@@ -837,7 +800,6 @@ DankModal {
|
|||||||
height: 20
|
height: 20
|
||||||
radius: 4
|
radius: 4
|
||||||
color: Theme.onSurface_12
|
color: Theme.onSurface_12
|
||||||
visible: listButtonRect.actionData.key !== ""
|
|
||||||
anchors {
|
anchors {
|
||||||
right: parent.right
|
right: parent.right
|
||||||
rightMargin: Theme.spacingM
|
rightMargin: Theme.spacingM
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import qs.Services
|
|||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
import "../Common/Format.js" as Format
|
import "../Common/Format.js" as Format
|
||||||
|
|
||||||
DankFloatingWindow {
|
FloatingWindow {
|
||||||
id: processListModal
|
id: processListModal
|
||||||
readonly property var log: Log.scoped("ProcessListModal")
|
readonly property var log: Log.scoped("ProcessListModal")
|
||||||
|
|
||||||
|
property bool disablePopupTransparency: true
|
||||||
property int currentTab: 0
|
property int currentTab: 0
|
||||||
property string searchText: ""
|
property string searchText: ""
|
||||||
property string expandedPid: ""
|
property string expandedPid: ""
|
||||||
@@ -80,6 +81,7 @@ DankFloatingWindow {
|
|||||||
minimumSize: Qt.size(Math.min(Math.round(Theme.fontSizeMedium * 48), Screen.width), Math.min(Math.round(Theme.fontSizeMedium * 34), Screen.height))
|
minimumSize: Qt.size(Math.min(Math.round(Theme.fontSizeMedium * 48), Screen.width), Math.min(Math.round(Theme.fontSizeMedium * 34), Screen.height))
|
||||||
implicitWidth: Math.round(Theme.fontSizeMedium * 71)
|
implicitWidth: Math.round(Theme.fontSizeMedium * 71)
|
||||||
implicitHeight: Math.round(Theme.fontSizeMedium * 51)
|
implicitHeight: Math.round(Theme.fontSizeMedium * 51)
|
||||||
|
color: Theme.surfaceContainer
|
||||||
visible: false
|
visible: false
|
||||||
|
|
||||||
onClosed: hide()
|
onClosed: hide()
|
||||||
@@ -418,7 +420,7 @@ DankFloatingWindow {
|
|||||||
Layout.margins: Theme.spacingL
|
Layout.margins: Theme.spacingL
|
||||||
Layout.topMargin: Theme.spacingM
|
Layout.topMargin: Theme.spacingM
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||||
border.color: Theme.outlineLight
|
border.color: Theme.outlineLight
|
||||||
border.width: 1
|
border.width: 1
|
||||||
clip: true
|
clip: true
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import QtQuick.Effects
|
|
||||||
import Quickshell
|
|
||||||
import Quickshell.Io
|
|
||||||
import qs.Modals.Common
|
|
||||||
import qs.Modals.FileBrowser
|
|
||||||
import qs.Common
|
|
||||||
import qs.Services
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
DankModal {
|
|
||||||
id: root
|
|
||||||
visible: false
|
|
||||||
layerNamespace: "dms:qr-generator"
|
|
||||||
|
|
||||||
property bool disablePopupTransparency: true
|
|
||||||
property bool generating: false
|
|
||||||
property string themedQrCodePath: ""
|
|
||||||
property string normalQrCodePath: ""
|
|
||||||
property string initialText: ""
|
|
||||||
property string _pendingPayload: ""
|
|
||||||
property string _generatingPayload: ""
|
|
||||||
property string _displayedPayload: ""
|
|
||||||
modalWidth: 420
|
|
||||||
modalHeight: 440
|
|
||||||
onBackgroundClicked: hide()
|
|
||||||
onOpened: {
|
|
||||||
Qt.callLater(() => {
|
|
||||||
modalFocusScope.forceActiveFocus();
|
|
||||||
const item = contentLoader.item;
|
|
||||||
if (!item)
|
|
||||||
return;
|
|
||||||
item.saveBrowserLoader = saveBrowserLoader;
|
|
||||||
if (item.textInput) {
|
|
||||||
item.textInput.text = initialText;
|
|
||||||
item.textInput.forceActiveFocus();
|
|
||||||
}
|
|
||||||
if (initialText.length > 0) {
|
|
||||||
_pendingPayload = initialText;
|
|
||||||
generateQR(initialText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(text) {
|
|
||||||
generating = false;
|
|
||||||
initialText = text || "";
|
|
||||||
_pendingPayload = "";
|
|
||||||
_generatingPayload = "";
|
|
||||||
_displayedPayload = "";
|
|
||||||
themedQrCodePath = "";
|
|
||||||
normalQrCodePath = "";
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
deleteQrCodeFiles(themedQrCodePath, normalQrCodePath);
|
|
||||||
themedQrCodePath = "";
|
|
||||||
normalQrCodePath = "";
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteQrCodeFiles(themed, normal) {
|
|
||||||
if (themed.length > 0)
|
|
||||||
DMSService.sendRequest("network.delete-qrcode", {
|
|
||||||
path: themed
|
|
||||||
});
|
|
||||||
if (normal.length > 0)
|
|
||||||
DMSService.sendRequest("network.delete-qrcode", {
|
|
||||||
path: normal
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer {
|
|
||||||
id: genTimer
|
|
||||||
interval: 200
|
|
||||||
repeat: false
|
|
||||||
onTriggered: root.generateQR(root._pendingPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateQR(text) {
|
|
||||||
const trimmed = (text || "").trim();
|
|
||||||
if (trimmed.length === 0 || trimmed === _displayedPayload || generating)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_generatingPayload = trimmed;
|
|
||||||
generating = true;
|
|
||||||
|
|
||||||
DMSService.sendRequest("network.generate-qrcode", {
|
|
||||||
text: trimmed
|
|
||||||
}, response => {
|
|
||||||
root.generating = false;
|
|
||||||
if (response.error) {
|
|
||||||
ToastService.showError(I18n.tr("Failed to generate QR code: %1").arg(JSON.stringify(response.error)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.result)
|
|
||||||
return;
|
|
||||||
if (root._generatingPayload !== root._pendingPayload.trim()) {
|
|
||||||
root.deleteQrCodeFiles(response.result[0], response.result[1]);
|
|
||||||
genTimer.restart();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldThemed = root.themedQrCodePath;
|
|
||||||
const oldNormal = root.normalQrCodePath;
|
|
||||||
root._displayedPayload = root._generatingPayload;
|
|
||||||
root.themedQrCodePath = response.result[0];
|
|
||||||
root.normalQrCodePath = response.result[1];
|
|
||||||
root.deleteQrCodeFiles(oldThemed, oldNormal);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTextChanged(text) {
|
|
||||||
_pendingPayload = text;
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (trimmed.length === 0 || trimmed === _displayedPayload) {
|
|
||||||
genTimer.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
genTimer.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyLoader {
|
|
||||||
id: saveBrowserLoader
|
|
||||||
active: false
|
|
||||||
|
|
||||||
FileBrowserSurfaceModal {
|
|
||||||
id: saveBrowser
|
|
||||||
|
|
||||||
browserTitle: I18n.tr("Save QR Code")
|
|
||||||
browserIcon: "qr_code"
|
|
||||||
browserType: "default"
|
|
||||||
fileExtensions: ["*.png"]
|
|
||||||
allowStacking: true
|
|
||||||
saveMode: true
|
|
||||||
defaultFileName: "qrcode.png"
|
|
||||||
onFileSelected: path => {
|
|
||||||
const cleanPath = decodeURI(path.toString().replace(/^file:\/\//, ''));
|
|
||||||
copyQrCodeProcess.exec(["cp", "-f", root.normalQrCodePath, cleanPath]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: copyQrCodeProcess
|
|
||||||
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
saveBrowser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content: Component {
|
|
||||||
Item {
|
|
||||||
id: contentItem
|
|
||||||
|
|
||||||
property alias textInput: textInput
|
|
||||||
property var saveBrowserLoader: null
|
|
||||||
|
|
||||||
anchors.fill: parent
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacingL
|
|
||||||
spacing: Theme.spacingL
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
id: modalTitle
|
|
||||||
width: parent.width
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("QR Generator")
|
|
||||||
font.pixelSize: Theme.fontSizeLarge
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Bold
|
|
||||||
Layout.alignment: Qt.AlignLeft
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
iconName: "close"
|
|
||||||
iconSize: Theme.iconSize - 4
|
|
||||||
iconColor: Theme.surfaceText
|
|
||||||
onClicked: root.hide()
|
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankTextField {
|
|
||||||
id: textInput
|
|
||||||
width: parent.width
|
|
||||||
placeholderText: I18n.tr("Enter text to encode")
|
|
||||||
showClearButton: true
|
|
||||||
focus: true
|
|
||||||
onTextEdited: root.onTextChanged(text)
|
|
||||||
Keys.onEscapePressed: event => {
|
|
||||||
event.accepted = true;
|
|
||||||
root.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
id: qrContainer
|
|
||||||
height: Math.min(parent.height - parent.spacing - modalTitle.height - textInput.height - parent.spacing * 4, 260)
|
|
||||||
width: height
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
opacity: 1
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation {
|
|
||||||
duration: 80
|
|
||||||
easing.type: Easing.OutCubic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Image {
|
|
||||||
id: qrCodeImg
|
|
||||||
anchors.fill: parent
|
|
||||||
source: root.themedQrCodePath
|
|
||||||
fillMode: Image.PreserveAspectFit
|
|
||||||
asynchronous: true
|
|
||||||
cache: false
|
|
||||||
visible: false
|
|
||||||
|
|
||||||
onSourceChanged: qrContainer.opacity = 0
|
|
||||||
onStatusChanged: {
|
|
||||||
if (status === Image.Ready)
|
|
||||||
qrContainer.opacity = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MultiEffect {
|
|
||||||
source: qrCodeImg
|
|
||||||
anchors.fill: qrCodeImg
|
|
||||||
colorization: 1.0
|
|
||||||
colorizationColor: Theme.primary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
width: parent.width
|
|
||||||
visible: root.themedQrCodePath.length > 0
|
|
||||||
Layout.alignment: Qt.AlignHCenter
|
|
||||||
|
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
DankButton {
|
|
||||||
text: I18n.tr("Save")
|
|
||||||
iconName: "save"
|
|
||||||
backgroundColor: Theme.surfaceContainer
|
|
||||||
textColor: Theme.surfaceText
|
|
||||||
onClicked: {
|
|
||||||
contentItem.saveBrowserLoader.active = true;
|
|
||||||
if (contentItem.saveBrowserLoader.item) {
|
|
||||||
contentItem.saveBrowserLoader.item.open();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankButton {
|
|
||||||
text: I18n.tr("Copy")
|
|
||||||
iconName: "content_copy"
|
|
||||||
backgroundColor: Theme.primary
|
|
||||||
textColor: Theme.onPrimary
|
|
||||||
onClicked: {
|
|
||||||
if (root.normalQrCodePath.length > 0)
|
|
||||||
DMSService.sendRequest("clipboard.copyFile", {
|
|
||||||
filePath: root.normalQrCodePath
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -733,20 +733,5 @@ FocusScope {
|
|||||||
Qt.callLater(() => item.forceActiveFocus());
|
Qt.callLater(() => item.forceActiveFocus());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Loader {
|
|
||||||
id: keyboardLoader
|
|
||||||
anchors.fill: parent
|
|
||||||
active: root.currentIndex === 45
|
|
||||||
visible: active
|
|
||||||
focus: active
|
|
||||||
|
|
||||||
sourceComponent: KeyboardTab {}
|
|
||||||
|
|
||||||
onActiveChanged: {
|
|
||||||
if (active && item)
|
|
||||||
Qt.callLater(() => item.forceActiveFocus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import qs.Modals.FileBrowser
|
|||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
DankFloatingWindow {
|
FloatingWindow {
|
||||||
id: settingsModal
|
id: settingsModal
|
||||||
|
|
||||||
property var profileBrowser: profileBrowserLoader.item
|
property var profileBrowser: profileBrowserLoader.item
|
||||||
@@ -95,6 +95,7 @@ DankFloatingWindow {
|
|||||||
minimumSize: Qt.size(500, 400)
|
minimumSize: Qt.size(500, 400)
|
||||||
implicitWidth: 900
|
implicitWidth: 900
|
||||||
implicitHeight: screen ? Math.min(940, screen.height - 100) : 940
|
implicitHeight: screen ? Math.min(940, screen.height - 100) : 940
|
||||||
|
color: Theme.surfaceContainer
|
||||||
visible: false
|
visible: false
|
||||||
|
|
||||||
onClosed: hide()
|
onClosed: hide()
|
||||||
@@ -179,6 +180,8 @@ DankFloatingWindow {
|
|||||||
FocusScope {
|
FocusScope {
|
||||||
id: contentFocusScope
|
id: contentFocusScope
|
||||||
|
|
||||||
|
property bool disablePopupTransparency: true
|
||||||
|
|
||||||
LayoutMirroring.enabled: I18n.isRtl
|
LayoutMirroring.enabled: I18n.isRtl
|
||||||
LayoutMirroring.childrenInherit: true
|
LayoutMirroring.childrenInherit: true
|
||||||
|
|
||||||
@@ -200,6 +203,12 @@ DankFloatingWindow {
|
|||||||
onDoubleClicked: windowControls.tryToggleMaximize()
|
onDoubleClicked: windowControls.tryToggleMaximize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
opacity: 0.5
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: Theme.spacingL
|
anchors.leftMargin: Theme.spacingL
|
||||||
@@ -267,7 +276,7 @@ DankFloatingWindow {
|
|||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: showBanner ? bannerContent.implicitHeight + Theme.spacingM * 2 : 0
|
height: showBanner ? bannerContent.implicitHeight + Theme.spacingM * 2 : 0
|
||||||
color: Theme.floatingWindowNestedSurface
|
color: Theme.surfaceContainerHigh
|
||||||
visible: showBanner
|
visible: showBanner
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
|
|||||||
@@ -326,13 +326,6 @@ Rectangle {
|
|||||||
"tabIndex": 44,
|
"tabIndex": 44,
|
||||||
"niriOnly": true
|
"niriOnly": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "keyboard",
|
|
||||||
"text": I18n.tr("Keyboard"),
|
|
||||||
"icon": "keyboard",
|
|
||||||
"tabIndex": 45,
|
|
||||||
"niriOnly": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "locale",
|
"id": "locale",
|
||||||
"text": I18n.tr("Locale"),
|
"text": I18n.tr("Locale"),
|
||||||
@@ -648,7 +641,7 @@ Rectangle {
|
|||||||
implicitWidth: __calculatedWidth
|
implicitWidth: __calculatedWidth
|
||||||
width: __calculatedWidth
|
width: __calculatedWidth
|
||||||
height: parent.height
|
height: parent.height
|
||||||
color: "transparent"
|
color: Theme.surfaceContainer
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
@@ -749,9 +742,12 @@ Rectangle {
|
|||||||
id: searchField
|
id: searchField
|
||||||
width: parent.width - parent.leftPadding - parent.rightPadding
|
width: parent.width - parent.leftPadding - parent.rightPadding
|
||||||
placeholderText: I18n.tr("Search...")
|
placeholderText: I18n.tr("Search...")
|
||||||
|
normalBorderColor: Theme.outlineMedium
|
||||||
|
focusedBorderColor: Theme.primary
|
||||||
leftIconName: "search"
|
leftIconName: "search"
|
||||||
leftIconSize: Theme.iconSize - 4
|
leftIconSize: Theme.iconSize - 4
|
||||||
showClearButton: text.length > 0
|
showClearButton: text.length > 0
|
||||||
|
usePopupTransparency: false
|
||||||
onTextChanged: {
|
onTextChanged: {
|
||||||
SettingsSearchService.search(text);
|
SettingsSearchService.search(text);
|
||||||
root.searchSelectedIndex = 0;
|
root.searchSelectedIndex = 0;
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ DankModal {
|
|||||||
property string promptToken: ""
|
property string promptToken: ""
|
||||||
property string promptReason: ""
|
property string promptReason: ""
|
||||||
property var promptFields: []
|
property var promptFields: []
|
||||||
property var promptHints: []
|
|
||||||
property string promptSetting: ""
|
property string promptSetting: ""
|
||||||
|
|
||||||
property bool isVpnPrompt: false
|
property bool isVpnPrompt: false
|
||||||
@@ -41,21 +40,17 @@ DankModal {
|
|||||||
property var fieldsInfo: []
|
property var fieldsInfo: []
|
||||||
property var secretValues: ({})
|
property var secretValues: ({})
|
||||||
|
|
||||||
readonly property bool isCertificateChangedPrompt: promptReason === "server-certificate-changed"
|
|
||||||
readonly property bool isCertificatePrompt: promptReason === "server-certificate" || isCertificateChangedPrompt
|
|
||||||
readonly property string serverCertificateFingerprint: promptHints.length > 0 ? promptHints[0] : ""
|
|
||||||
readonly property bool showUsernameField: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0
|
readonly property bool showUsernameField: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0
|
||||||
readonly property bool showPasswordField: fieldsInfo.length === 0 && !isCertificatePrompt
|
readonly property bool showPasswordField: fieldsInfo.length === 0
|
||||||
readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt
|
readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt
|
||||||
readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt
|
readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt
|
||||||
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11" && !isCertificatePrompt
|
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11"
|
||||||
|
|
||||||
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
||||||
readonly property int inputFieldWithSpacing: inputFieldHeight + Theme.spacingM
|
readonly property int inputFieldWithSpacing: inputFieldHeight + Theme.spacingM
|
||||||
readonly property int checkboxRowHeight: Theme.fontSizeMedium + Theme.spacingS
|
readonly property int checkboxRowHeight: Theme.fontSizeMedium + Theme.spacingS
|
||||||
readonly property int headerHeight: Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingM * 2
|
readonly property int headerHeight: Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingM * 2
|
||||||
readonly property int buttonRowHeight: 36 + Theme.spacingM
|
readonly property int buttonRowHeight: 36 + Theme.spacingM
|
||||||
readonly property int certificateWarningHeight: certificateWarningColumn.implicitHeight + Theme.spacingM * 2
|
|
||||||
|
|
||||||
property int calculatedHeight: {
|
property int calculatedHeight: {
|
||||||
let h = headerHeight + buttonRowHeight + Theme.spacingL * 2;
|
let h = headerHeight + buttonRowHeight + Theme.spacingL * 2;
|
||||||
@@ -72,16 +67,10 @@ DankModal {
|
|||||||
h += inputFieldWithSpacing;
|
h += inputFieldWithSpacing;
|
||||||
if (showSavePasswordCheckbox)
|
if (showSavePasswordCheckbox)
|
||||||
h += checkboxRowHeight;
|
h += checkboxRowHeight;
|
||||||
if (isCertificatePrompt)
|
|
||||||
h += certificateWarningHeight + Theme.spacingM;
|
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusFirstField() {
|
function focusFirstField() {
|
||||||
if (isCertificatePrompt) {
|
|
||||||
connectButton.forceActiveFocus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (fieldsInfo.length > 0) {
|
if (fieldsInfo.length > 0) {
|
||||||
if (dynamicFieldsRepeater.count > 0) {
|
if (dynamicFieldsRepeater.count > 0) {
|
||||||
const firstItem = dynamicFieldsRepeater.itemAt(0);
|
const firstItem = dynamicFieldsRepeater.itemAt(0);
|
||||||
@@ -112,7 +101,6 @@ DankModal {
|
|||||||
promptToken = "";
|
promptToken = "";
|
||||||
promptReason = "";
|
promptReason = "";
|
||||||
promptFields = [];
|
promptFields = [];
|
||||||
promptHints = [];
|
|
||||||
promptSetting = "";
|
promptSetting = "";
|
||||||
isVpnPrompt = false;
|
isVpnPrompt = false;
|
||||||
connectionName = "";
|
connectionName = "";
|
||||||
@@ -139,7 +127,6 @@ DankModal {
|
|||||||
promptToken = "";
|
promptToken = "";
|
||||||
promptReason = "";
|
promptReason = "";
|
||||||
promptFields = [];
|
promptFields = [];
|
||||||
promptHints = [];
|
|
||||||
promptSetting = "";
|
promptSetting = "";
|
||||||
isVpnPrompt = false;
|
isVpnPrompt = false;
|
||||||
connectionName = "";
|
connectionName = "";
|
||||||
@@ -158,7 +145,6 @@ DankModal {
|
|||||||
promptToken = token;
|
promptToken = token;
|
||||||
promptReason = reason;
|
promptReason = reason;
|
||||||
promptFields = fields || [];
|
promptFields = fields || [];
|
||||||
promptHints = hints || [];
|
|
||||||
promptSetting = setting || "802-11-wireless-security";
|
promptSetting = setting || "802-11-wireless-security";
|
||||||
connectionType = connType || "802-11-wireless";
|
connectionType = connType || "802-11-wireless";
|
||||||
connectionName = connName || ssid || "";
|
connectionName = connName || ssid || "";
|
||||||
@@ -338,8 +324,6 @@ DankModal {
|
|||||||
text: {
|
text: {
|
||||||
if (promptReason === "pkcs11")
|
if (promptReason === "pkcs11")
|
||||||
return I18n.tr("Smartcard Authentication");
|
return I18n.tr("Smartcard Authentication");
|
||||||
if (isCertificatePrompt)
|
|
||||||
return I18n.tr("Untrusted VPN certificate", "Title for VPN server certificate trust confirmation");
|
|
||||||
if (isVpnPrompt)
|
if (isVpnPrompt)
|
||||||
return I18n.tr("Connect to VPN");
|
return I18n.tr("Connect to VPN");
|
||||||
if (isHiddenNetwork)
|
if (isHiddenNetwork)
|
||||||
@@ -359,8 +343,6 @@ DankModal {
|
|||||||
text: {
|
text: {
|
||||||
if (promptReason === "pkcs11")
|
if (promptReason === "pkcs11")
|
||||||
return I18n.tr("Enter PIN for ") + wifiPasswordSSID;
|
return I18n.tr("Enter PIN for ") + wifiPasswordSSID;
|
||||||
if (isCertificatePrompt)
|
|
||||||
return wifiPasswordSSID;
|
|
||||||
if (fieldsInfo.length > 0)
|
if (fieldsInfo.length > 0)
|
||||||
return I18n.tr("Enter credentials for ") + wifiPasswordSSID;
|
return I18n.tr("Enter credentials for ") + wifiPasswordSSID;
|
||||||
if (isVpnPrompt)
|
if (isVpnPrompt)
|
||||||
@@ -401,45 +383,6 @@ DankModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: certificateWarningBox
|
|
||||||
|
|
||||||
readonly property color warningTone: isCertificateChangedPrompt ? Theme.error : Theme.warning
|
|
||||||
|
|
||||||
width: parent.width
|
|
||||||
height: certificateWarningHeight
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: Theme.withAlpha(warningTone, 0.12)
|
|
||||||
border.color: Theme.withAlpha(warningTone, 0.5)
|
|
||||||
border.width: 1
|
|
||||||
visible: isCertificatePrompt
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: certificateWarningColumn
|
|
||||||
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacingM
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
width: parent.width
|
|
||||||
text: isCertificateChangedPrompt ? I18n.tr("The server certificate has changed since it was last trusted. Only continue if you recognize the new fingerprint.", "Warning shown when a trusted VPN server certificate no longer matches") : I18n.tr("Only continue if you recognize this server certificate fingerprint.", "Warning shown before trusting an unverified VPN server certificate")
|
|
||||||
wrapMode: Text.Wrap
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
width: parent.width
|
|
||||||
text: serverCertificateFingerprint
|
|
||||||
wrapMode: Text.WrapAnywhere
|
|
||||||
font.family: SettingsData.monoFontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: certificateWarningBox.warningTone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: inputFieldHeight
|
height: inputFieldHeight
|
||||||
@@ -747,15 +690,10 @@ DankModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: connectButton
|
|
||||||
|
|
||||||
width: Math.max(80, connectText.contentWidth + Theme.spacingM * 2)
|
width: Math.max(80, connectText.contentWidth + Theme.spacingM * 2)
|
||||||
height: 36
|
height: 36
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: connectArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
|
color: connectArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary
|
||||||
border.color: activeFocus ? Theme.surfaceText : "transparent"
|
|
||||||
border.width: activeFocus ? 2 : 0
|
|
||||||
activeFocusOnTab: true
|
|
||||||
enabled: {
|
enabled: {
|
||||||
if (fieldsInfo.length > 0) {
|
if (fieldsInfo.length > 0) {
|
||||||
for (var i = 0; i < fieldsInfo.length; i++) {
|
for (var i = 0; i < fieldsInfo.length; i++) {
|
||||||
@@ -767,8 +705,6 @@ DankModal {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (isCertificatePrompt)
|
|
||||||
return serverCertificateFingerprint.length > 0;
|
|
||||||
if (isVpnPrompt)
|
if (isVpnPrompt)
|
||||||
return passwordInput.text.length > 0;
|
return passwordInput.text.length > 0;
|
||||||
if (isHiddenNetwork)
|
if (isHiddenNetwork)
|
||||||
@@ -780,7 +716,7 @@ DankModal {
|
|||||||
StyledText {
|
StyledText {
|
||||||
id: connectText
|
id: connectText
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: isCertificatePrompt ? I18n.tr("Trust", "Button that approves a VPN server certificate fingerprint") : I18n.tr("Connect")
|
text: I18n.tr("Connect")
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
color: Theme.background
|
color: Theme.background
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
@@ -795,22 +731,6 @@ DankModal {
|
|||||||
onClicked: submitCredentialsAndClose()
|
onClicked: submitCredentialsAndClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
Keys.onReturnPressed: event => {
|
|
||||||
if (enabled)
|
|
||||||
submitCredentialsAndClose();
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
Keys.onEnterPressed: event => {
|
|
||||||
if (enabled)
|
|
||||||
submitCredentialsAndClose();
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
Keys.onSpacePressed: event => {
|
|
||||||
if (enabled)
|
|
||||||
submitCredentialsAndClose();
|
|
||||||
event.accepted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
ColorAnimation {
|
ColorAnimation {
|
||||||
duration: Theme.shortDuration
|
duration: Theme.shortDuration
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import qs.Common
|
|||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
DankFloatingWindow {
|
FloatingWindow {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property bool disablePopupTransparency: true
|
||||||
property var editingRule: null
|
property var editingRule: null
|
||||||
property bool isEditMode: editingRule !== null
|
property bool isEditMode: editingRule !== null
|
||||||
property bool isNiri: CompositorService.isNiri
|
property bool isNiri: CompositorService.isNiri
|
||||||
@@ -28,6 +29,7 @@ DankFloatingWindow {
|
|||||||
title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule")
|
title: isEditMode ? I18n.tr("Edit Window Rule") : I18n.tr("Create Window Rule")
|
||||||
minimumSize: Qt.size(500, 600)
|
minimumSize: Qt.size(500, 600)
|
||||||
maximumSize: Qt.size(500, 600)
|
maximumSize: Qt.size(500, 600)
|
||||||
|
color: Theme.surfaceContainer
|
||||||
visible: false
|
visible: false
|
||||||
|
|
||||||
onClosed: hide()
|
onClosed: hide()
|
||||||
@@ -455,9 +457,6 @@ DankFloatingWindow {
|
|||||||
};
|
};
|
||||||
if (isNiri && extraMatchModel.count > 0)
|
if (isNiri && extraMatchModel.count > 0)
|
||||||
ruleData.matches = matches;
|
ruleData.matches = matches;
|
||||||
// No exclude editor yet — carry existing excludes through so an edit doesn't delete them (#2996)
|
|
||||||
if (isEditMode && (editingRule.excludes?.length ?? 0) > 0)
|
|
||||||
ruleData.excludes = editingRule.excludes;
|
|
||||||
|
|
||||||
submitting = true;
|
submitting = true;
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ import qs.Common
|
|||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
DankFloatingWindow {
|
FloatingWindow {
|
||||||
id: root
|
id: root
|
||||||
readonly property var log: Log.scoped("WorkspaceRenameModal")
|
readonly property var log: Log.scoped("WorkspaceRenameModal")
|
||||||
|
|
||||||
|
property bool disablePopupTransparency: true
|
||||||
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
|
||||||
|
|
||||||
objectName: "workspaceRenameModal"
|
objectName: "workspaceRenameModal"
|
||||||
title: I18n.tr("Rename Workspace")
|
title: I18n.tr("Rename Workspace")
|
||||||
minimumSize: Qt.size(400, 160)
|
minimumSize: Qt.size(400, 160)
|
||||||
maximumSize: Qt.size(400, 160)
|
maximumSize: Qt.size(400, 160)
|
||||||
|
color: Theme.surfaceContainer
|
||||||
visible: false
|
visible: false
|
||||||
|
|
||||||
onClosed: hide()
|
onClosed: hide()
|
||||||
|
|||||||
@@ -469,8 +469,6 @@ PluginComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: peerMouseArea
|
|
||||||
|
|
||||||
z: -1
|
z: -1
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
|||||||
@@ -41,11 +41,7 @@ Rectangle {
|
|||||||
property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0
|
property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0
|
||||||
property bool hasBothConnectionTypes: hasEthernetAvailable && hasWifiAvailable
|
property bool hasBothConnectionTypes: hasEthernetAvailable && hasWifiAvailable
|
||||||
property int maxPinnedNetworks: 3
|
property int maxPinnedNetworks: 3
|
||||||
// Hosting on the only wifi adapter with no ethernet uplink just drops connectivity,
|
readonly property int hotspotContentHeight: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable ? 56 + Theme.spacingS : 0
|
||||||
// so the hotspot row only shows where sharing can actually work (or is already on).
|
|
||||||
readonly property bool hotspotRelevant: NetworkService.hotspotEnabled || NetworkService.hotspotActivating || NetworkService.hotspotBusy || NetworkService.ethernetConnected || (NetworkService.wifiDevices?.length ?? 0) > 1
|
|
||||||
readonly property bool showHotspotRow: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable && hotspotRelevant
|
|
||||||
readonly property int hotspotContentHeight: showHotspotRow ? 56 + Theme.spacingS : 0
|
|
||||||
|
|
||||||
property var hotspotStartConfirm: ConfirmModal {}
|
property var hotspotStartConfirm: ConfirmModal {}
|
||||||
|
|
||||||
@@ -188,7 +184,7 @@ Rectangle {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.margins: Theme.spacingM
|
anchors.margins: Theme.spacingM
|
||||||
anchors.topMargin: Theme.spacingM
|
anchors.topMargin: Theme.spacingM
|
||||||
visible: root.showHotspotRow
|
visible: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable
|
||||||
height: visible ? 56 : 0
|
height: visible ? 56 : 0
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
|||||||
@@ -201,16 +201,7 @@ Item {
|
|||||||
direction: root.effectiveShadowDirection
|
direction: root.effectiveShadowDirection
|
||||||
fallbackOffset: 4
|
fallbackOffset: 4
|
||||||
targetRadius: root.rt
|
targetRadius: root.rt
|
||||||
// wing-side body corners are square where the gothic fillets attach;
|
targetColor: barWindow._bgColor
|
||||||
// rounding the shadow there leaves a shadow-filled notch under the
|
|
||||||
// translucent bar fill (#2975)
|
|
||||||
topLeftRadius: root.gothEnabled && (root.isBottom || root.isRight) ? 0 : root.rt
|
|
||||||
topRightRadius: root.gothEnabled && (root.isBottom || root.isLeft) ? 0 : root.rt
|
|
||||||
bottomLeftRadius: root.gothEnabled && (root.isTop || root.isRight) ? 0 : root.rt
|
|
||||||
bottomRightRadius: root.gothEnabled && (root.isTop || root.isLeft) ? 0 : root.rt
|
|
||||||
// barShape below is the sole painter of the bar; a fill here doubles
|
|
||||||
// the alpha of translucent bars while the wings stay single-painted
|
|
||||||
targetColor: "transparent"
|
|
||||||
|
|
||||||
shadowBlurPx: root.shadowBlurPx
|
shadowBlurPx: root.shadowBlurPx
|
||||||
shadowOffsetX: root.shadowOffsetX
|
shadowOffsetX: root.shadowOffsetX
|
||||||
|
|||||||
@@ -163,7 +163,16 @@ Item {
|
|||||||
Variants {
|
Variants {
|
||||||
id: barVariants
|
id: barVariants
|
||||||
model: {
|
model: {
|
||||||
const base = Quickshell.screens.filter(screen => SettingsData.barConfigCoversScreen(root.barConfig, screen));
|
const prefs = root.barConfig?.screenPreferences || ["all"];
|
||||||
|
const wantsAll = prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all");
|
||||||
|
let base;
|
||||||
|
if (wantsAll) {
|
||||||
|
base = Quickshell.screens;
|
||||||
|
} else {
|
||||||
|
base = Quickshell.screens.filter(screen => SettingsData.isScreenInPreferences(screen, prefs));
|
||||||
|
if (base.length === 0 && root.barConfig?.showOnLastDisplay && Quickshell.screens.length === 1)
|
||||||
|
base = Quickshell.screens;
|
||||||
|
}
|
||||||
// Connected frame mode renders the bar inside the frame surface; skip the standalone window there
|
// Connected frame mode renders the bar inside the frame surface; skip the standalone window there
|
||||||
// unless this bar wants the overlay layer, which the frame surface cannot provide.
|
// unless this bar wants the overlay layer, which the frame surface cannot provide.
|
||||||
return base.filter(screen => !CompositorService.frameHostsBarForConfig(screen, root.barConfig));
|
return base.filter(screen => !CompositorService.frameHostsBarForConfig(screen, root.barConfig));
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ Item {
|
|||||||
_closeHoverNotepad();
|
_closeHoverNotepad();
|
||||||
activeHoverTrigger = "";
|
activeHoverTrigger = "";
|
||||||
PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen);
|
PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen);
|
||||||
TrayMenuManager.closeHoverMenus();
|
TrayMenuManager.closeAllMenus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _beginSupersededCloseForActive() {
|
function _beginSupersededCloseForActive() {
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ BasePill {
|
|||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
acceptedButtons: Qt.MiddleButton
|
acceptedButtons: Qt.MiddleButton
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onPressed: mouse => {
|
onPressed: mouse => {
|
||||||
root.triggerRipple(this, mouse.x, mouse.y);
|
root.triggerRipple(this, mouse.x, mouse.y);
|
||||||
SessionData.setDoNotDisturb(!SessionData.doNotDisturb);
|
SessionData.setDoNotDisturb(!SessionData.doNotDisturb);
|
||||||
|
|||||||
@@ -83,22 +83,6 @@ BasePill {
|
|||||||
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
root.showForTrayItem(trayItem, anchorItem, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: TrayMenuManager
|
|
||||||
|
|
||||||
function onOpenTrayMenuRequested() {
|
|
||||||
const request = TrayMenuManager.claimMenuRequest(root.parentScreen?.name);
|
|
||||||
if (!request)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const item = TrayMenuManager.findTrayItem(request.itemId);
|
|
||||||
if (!item || !item.hasMenu)
|
|
||||||
return;
|
|
||||||
|
|
||||||
root.showForTrayItem(item, root, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openInlineTrayContextMenu(trayItem, areaItem, mouse, anchorItem) {
|
function openInlineTrayContextMenu(trayItem, areaItem, mouse, anchorItem) {
|
||||||
if (!trayItem) {
|
if (!trayItem) {
|
||||||
return;
|
return;
|
||||||
@@ -1500,7 +1484,6 @@ BasePill {
|
|||||||
property bool isVertical: false
|
property bool isVertical: false
|
||||||
property var axis: null
|
property var axis: null
|
||||||
property bool showMenu: false
|
property bool showMenu: false
|
||||||
property bool openedByHover: false
|
|
||||||
property var menuHandle: null
|
property var menuHandle: null
|
||||||
|
|
||||||
ListModel {
|
ListModel {
|
||||||
@@ -1510,8 +1493,7 @@ BasePill {
|
|||||||
return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null;
|
return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
|
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
|
||||||
openedByHover = byHover === true;
|
|
||||||
trayItem = item;
|
trayItem = item;
|
||||||
anchorItem = anchor;
|
anchorItem = anchor;
|
||||||
parentScreen = screen;
|
parentScreen = screen;
|
||||||
@@ -2102,7 +2084,7 @@ BasePill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj, byHover) {
|
function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) {
|
||||||
if (!screen)
|
if (!screen)
|
||||||
return;
|
return;
|
||||||
if (currentTrayMenu) {
|
if (currentTrayMenu) {
|
||||||
@@ -2117,7 +2099,7 @@ BasePill {
|
|||||||
currentTrayMenu = trayMenuComponent.createObject(null);
|
currentTrayMenu = trayMenuComponent.createObject(null);
|
||||||
if (!currentTrayMenu)
|
if (!currentTrayMenu)
|
||||||
return;
|
return;
|
||||||
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj, byHover === true);
|
currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _trayLayoutRoot() {
|
function _trayLayoutRoot() {
|
||||||
@@ -2165,7 +2147,7 @@ BasePill {
|
|||||||
if (!hit?.trayItem?.hasMenu)
|
if (!hit?.trayItem?.hasMenu)
|
||||||
return false;
|
return false;
|
||||||
const anchor = hit.children?.length > 0 ? hit.children[0] : hit;
|
const anchor = hit.children?.length > 0 ? hit.children[0] : hit;
|
||||||
showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis, true);
|
showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user