mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 11:14:01 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc307aff98 | ||
|
|
cee7957e15 | ||
|
|
602697ef51 |
@@ -83,7 +83,7 @@ One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, o
|
||||
Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16.
|
||||
|
||||
**System Monitoring**
|
||||
Real-time CPU, RAM, GPU metrics and temperatures built into the dms daemon (powered by [dgop](https://github.com/AvengeMedia/dgop)). Process list with search and management.
|
||||
Real-time CPU, RAM, GPU metrics and temperatures with [dgop](https://github.com/AvengeMedia/dgop). Process list with search and management.
|
||||
|
||||
**Powerful Launcher**
|
||||
Spotlight-style search for applications, files ([dsearch](https://github.com/AvengeMedia/danksearch)), emojis, running windows, calculator, and commands. Extensible with plugins.
|
||||
@@ -101,9 +101,7 @@ MPRIS player controls, calendar sync, weather widgets, and clipboard history wit
|
||||
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter).
|
||||
|
||||
**Plugin System**
|
||||
Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
|
||||
`~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and
|
||||
their exact Git commits, so the same plugins can be reproduced on another machine.
|
||||
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
|
||||
|
||||
## Supported Compositors
|
||||
|
||||
@@ -122,8 +120,6 @@ dms ipc call audio setvolume 50
|
||||
dms ipc call wallpaper set /path/to/image.jpg
|
||||
dms brightness list # List available displays
|
||||
dms plugins search # Browse plugin registry
|
||||
dms plugins lock # Refresh the portable plugin lockfile
|
||||
dms plugins restore ~/plugins.lock.json
|
||||
```
|
||||
|
||||
[Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
|
||||
@@ -142,7 +138,7 @@ DMS is one piece of the suite. The rest lives in its own repos:
|
||||
|
||||
- [dank-greeter](https://github.com/AvengeMedia/dank-greeter) - greetd login screen with the Dank Material aesthetic. The Greeter tab in DMS settings is the front-end for it.
|
||||
- [dankcalendar](https://github.com/AvengeMedia/dankcalendar) - Local, Google, Microsoft, and CalDAV calendars for the dank desktop.
|
||||
- [dgop](https://github.com/AvengeMedia/dgop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
|
||||
- [dgop](https://github.com/AvengeMedia/dgop) - System monitoring CLI and API that powers the process list and dashboard widgets.
|
||||
- [dsearch](https://github.com/AvengeMedia/danksearch) - Fast file search that powers file results in the launcher.
|
||||
- [dank-qml-common](https://github.com/AvengeMedia/dank-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar.
|
||||
- [dankgo](https://github.com/AvengeMedia/dankgo) - Common Go modules behind the single binary apps.
|
||||
|
||||
@@ -93,6 +93,11 @@ linters:
|
||||
- linters:
|
||||
- errcheck
|
||||
text: "Error return value of `.+\\.AddMatchSignal` is not checked"
|
||||
# Exclude wayland pkg from errcheck and ineffassign (generated code patterns)
|
||||
- linters:
|
||||
- errcheck
|
||||
- ineffassign
|
||||
path: pkg/go-wayland/
|
||||
# Exclude proto pkg from ineffassign (generated protocol code)
|
||||
- linters:
|
||||
- ineffassign
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ packages:
|
||||
pkgname: mocks_version
|
||||
interfaces:
|
||||
VersionFetcher: {}
|
||||
github.com/AvengeMedia/dankgo/wayland/client:
|
||||
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
|
||||
config:
|
||||
dir: internal/mocks/wlclient
|
||||
pkgname: mocks_wlclient
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
|
||||
- `dms run [-d]` - Start shell (optionally as daemon)
|
||||
- `dms restart` / `dms kill` - Manage running processes
|
||||
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
|
||||
- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles
|
||||
- `dms plugins [install|browse|search]` - Plugin management
|
||||
- `dms brightness [list|set]` - Control display/monitor brightness
|
||||
- `dms color pick` - Native color picker (see below)
|
||||
- `dms update` - Update DMS and dependencies (disabled in distro packages)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/backup"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var backupOutputPath string
|
||||
|
||||
var backupCmd = &cobra.Command{
|
||||
Use: "backup",
|
||||
Short: "Backup and restore DMS configuration",
|
||||
}
|
||||
|
||||
var backupCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a tar.gz backup of ~/.config/DankMaterialShell (settings, plugins, themes)",
|
||||
Args: cobra.NoArgs,
|
||||
Run: runBackupCreate,
|
||||
}
|
||||
|
||||
var backupRestoreCmd = &cobra.Command{
|
||||
Use: "restore <archive>",
|
||||
Short: "Restore a DMS configuration backup; the current configuration is moved aside first",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runBackupRestore,
|
||||
}
|
||||
|
||||
func init() {
|
||||
backupCreateCmd.Flags().StringVarP(&backupOutputPath, "output", "o", "", "Output archive path (default dms-backup-<timestamp>.tar.gz)")
|
||||
backupCmd.AddCommand(backupCreateCmd, backupRestoreCmd)
|
||||
rootCmd.AddCommand(backupCmd)
|
||||
}
|
||||
|
||||
func runBackupCreate(cmd *cobra.Command, args []string) {
|
||||
output := backupOutputPath
|
||||
if output == "" {
|
||||
output = backup.DefaultArchiveName()
|
||||
}
|
||||
|
||||
if err := backup.Create(output); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(output)
|
||||
}
|
||||
|
||||
func runBackupRestore(cmd *cobra.Command, args []string) {
|
||||
previous, err := backup.Restore(args[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if previous != "" {
|
||||
fmt.Printf("Previous configuration moved to %s\n", previous)
|
||||
}
|
||||
fmt.Println("Restore complete. Restart the shell to apply.")
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/dankgo/wlclipboard"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -284,8 +283,8 @@ func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseMultiOffers(data []byte) ([]wlclipboard.Offer, error) {
|
||||
var offers []wlclipboard.Offer
|
||||
func parseMultiOffers(data []byte) ([]clipboard.Offer, error) {
|
||||
var offers []clipboard.Offer
|
||||
pos := 0
|
||||
|
||||
for pos < len(data) {
|
||||
@@ -312,14 +311,14 @@ func parseMultiOffers(data []byte) ([]wlclipboard.Offer, error) {
|
||||
offerData := data[pos : pos+dataLen]
|
||||
pos += dataLen
|
||||
|
||||
offers = append(offers, wlclipboard.Offer{MimeType: mimeType, Data: offerData})
|
||||
offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData})
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func runClipPaste(cmd *cobra.Command, args []string) {
|
||||
data, _, err := wlclipboard.Paste()
|
||||
data, _, err := clipboard.Paste()
|
||||
if err != nil {
|
||||
log.Fatalf("paste: %v", err)
|
||||
}
|
||||
@@ -345,13 +344,13 @@ func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
|
||||
switch {
|
||||
case len(args) > 0:
|
||||
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
runCommand(args, data)
|
||||
}); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipWatchStore:
|
||||
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
if err := clipboard.Store(data, mimeType); err != nil {
|
||||
log.Errorf("store: %v", err)
|
||||
}
|
||||
@@ -383,7 +382,7 @@ func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
case clipJSONOutput:
|
||||
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
out := map[string]any{
|
||||
"data": string(data),
|
||||
"mimeType": mimeType,
|
||||
@@ -396,7 +395,7 @@ func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
log.Fatalf("Watch error: %v", err)
|
||||
}
|
||||
default:
|
||||
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
|
||||
os.Stdout.Write(data)
|
||||
os.Stdout.WriteString("\n")
|
||||
}); err != nil && err != context.Canceled {
|
||||
|
||||
@@ -51,8 +51,6 @@ func init() {
|
||||
})
|
||||
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
|
||||
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
|
||||
pluginsLockCmd.Flags().StringP("output", "o", "", "Also write the lockfile to this path")
|
||||
pluginsRestoreCmd.Flags().Bool("prune", false, "Remove managed plugins that are not in the lockfile")
|
||||
}
|
||||
|
||||
var debugSrvCmd = &cobra.Command{
|
||||
@@ -180,36 +178,6 @@ var pluginsUpdateCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var pluginsLockCmd = &cobra.Command{
|
||||
Use: "lock",
|
||||
Short: "Record installed plugins and their exact revisions",
|
||||
Long: "Write a portable plugins.lock.json containing every managed user plugin and its current Git commit.",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if err := lockPluginsCLI(output); err != nil {
|
||||
log.Fatalf("Error writing plugin lockfile: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var pluginsRestoreCmd = &cobra.Command{
|
||||
Use: "restore [lockfile]",
|
||||
Short: "Restore plugins from exact revisions in a lockfile",
|
||||
Long: "Install or reset all plugins in a portable plugins.lock.json. Existing managed plugins are retained unless --prune is specified.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
path := ""
|
||||
if len(args) == 1 {
|
||||
path = args[0]
|
||||
}
|
||||
prune, _ := cmd.Flags().GetBool("prune")
|
||||
if err := restorePluginsCLI(path, prune); err != nil {
|
||||
log.Fatalf("Error restoring plugins: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func runVersion(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("%s\n", formatVersion(Version))
|
||||
}
|
||||
@@ -465,40 +433,6 @@ func installPluginCLI(idOrName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockPluginsCLI(outputPath string) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
warnings, err := manager.WriteCurrentLockfile(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
|
||||
}
|
||||
fmt.Printf("Plugin lockfile written: %s\n", manager.GetLockfilePath())
|
||||
if outputPath != "" && outputPath != manager.GetLockfilePath() {
|
||||
fmt.Printf("Plugin lockfile exported: %s\n", outputPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restorePluginsCLI(path string, prune bool) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
if err := manager.RestoreFromLockfile(path, prune); err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
path = manager.GetLockfilePath()
|
||||
}
|
||||
fmt.Printf("Plugins restored from lockfile: %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAvailablePluginIDs() []string {
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
@@ -770,7 +704,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
ipcCmd,
|
||||
debugSrvCmd,
|
||||
pluginsCmd,
|
||||
registryCmd,
|
||||
dank16Cmd,
|
||||
brightnessCmd,
|
||||
dpmsCmd,
|
||||
|
||||
@@ -40,7 +40,6 @@ var resolveIncludeCmd = &cobra.Command{
|
||||
"layout.kdl",
|
||||
"outputs.kdl",
|
||||
"binds.kdl",
|
||||
"input.kdl",
|
||||
"cursor.conf",
|
||||
"layout.conf",
|
||||
"outputs.conf",
|
||||
|
||||
+83
-160
@@ -449,7 +449,12 @@ func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) {
|
||||
func checkDMSInstallation() []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 == "" {
|
||||
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
|
||||
@@ -903,6 +908,7 @@ func checkOptionalDependencies() []checkResult {
|
||||
important bool
|
||||
}{
|
||||
{"matugen", "matugen", "Dynamic theming", true},
|
||||
{"dgop", "dgop", "System monitoring", true},
|
||||
{"cava", "cava", "Audio visualizer", true},
|
||||
{"khal", "khal", "Calendar events", false},
|
||||
{"danksearch", "dsearch", "File search", false},
|
||||
@@ -1163,182 +1169,99 @@ func formatResultsPlain(results []checkResult) 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 {
|
||||
var results []checkResult
|
||||
url := doctorDocsURL + "#fonts"
|
||||
|
||||
fontFamily := defaultDoctorFontFamily
|
||||
monoFontFamily := defaultDoctorMonoFontFamily
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
||||
|
||||
if configDir, err := os.UserConfigDir(); err == nil {
|
||||
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
|
||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||
var settings struct {
|
||||
FontFamily string `json:"fontFamily"`
|
||||
MonoFontFamily string `json:"monoFontFamily"`
|
||||
fontFamily := "Inter Variable"
|
||||
monoFontFamily := "Fira Code"
|
||||
|
||||
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||
var settings struct {
|
||||
FontFamily string `json:"fontFamily"`
|
||||
MonoFontFamily string `json:"monoFontFamily"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err == nil {
|
||||
if settings.FontFamily != "" {
|
||||
fontFamily = settings.FontFamily
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err == nil {
|
||||
if settings.FontFamily != "" {
|
||||
fontFamily = settings.FontFamily
|
||||
}
|
||||
if settings.MonoFontFamily != "" {
|
||||
monoFontFamily = settings.MonoFontFamily
|
||||
}
|
||||
if settings.MonoFontFamily != "" {
|
||||
monoFontFamily = settings.MonoFontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shellPath := resolveDoctorShellPath()
|
||||
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily)
|
||||
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
|
||||
}
|
||||
|
||||
fcListAvailable := utils.CommandExists("fc-list")
|
||||
fcCache := ""
|
||||
// Retrieve font list
|
||||
output, err := exec.Command("fc-list", ":", "family").Output()
|
||||
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})
|
||||
return results
|
||||
}
|
||||
|
||||
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()
|
||||
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})
|
||||
} else {
|
||||
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})
|
||||
outStr := string(output)
|
||||
if len(strings.TrimSpace(outStr)) == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
results = append(results,
|
||||
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
|
||||
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
|
||||
)
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var registryCmd = &cobra.Command{
|
||||
Use: "registry",
|
||||
Short: "Manage plugin and theme registries",
|
||||
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
|
||||
}
|
||||
|
||||
var registryListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured registries",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
for _, s := range registries.Load(afero.NewOsFs()) {
|
||||
suffix := ""
|
||||
if s.Official() {
|
||||
suffix = " (official)"
|
||||
}
|
||||
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var registryAddCmd = &cobra.Command{
|
||||
Use: "add <name> <url>",
|
||||
Short: "Add a registry",
|
||||
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
|
||||
log.Fatalf("Error adding registry: %v", err)
|
||||
}
|
||||
fmt.Printf("Registry added: %s\n", args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var registryRemoveCmd = &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a registry",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) != 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
var names []string
|
||||
for _, s := range registries.Load(afero.NewOsFs()) {
|
||||
if !s.Official() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
|
||||
log.Fatalf("Error removing registry: %v", err)
|
||||
}
|
||||
fmt.Printf("Registry removed: %s\n", args[0])
|
||||
},
|
||||
}
|
||||
@@ -143,10 +143,6 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
|
||||
niriFile: "alttab.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriAlttabConfig },
|
||||
},
|
||||
"input": {
|
||||
niriFile: "input.kdl",
|
||||
niriContent: func(_ string) string { return config.NiriInputConfig },
|
||||
},
|
||||
"outputs": {
|
||||
niriFile: "outputs.kdl",
|
||||
hyprFile: "outputs.lua",
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_power"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type cmd struct {
|
||||
|
||||
@@ -15,8 +15,7 @@ func init() {
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
||||
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
@@ -14,7 +14,7 @@ var Version = "dev"
|
||||
func init() {
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type randrOutput struct {
|
||||
|
||||
+6
-16
@@ -3,7 +3,6 @@ module github.com/AvengeMedia/DankMaterialShell/core
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260808141551-f559f2f31d2a
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
@@ -36,10 +35,8 @@ require (
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.4 // indirect
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1 // indirect
|
||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.5.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
@@ -47,39 +44,32 @@ require (
|
||||
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||
github.com/mdlayher/netlink v1.11.2 // indirect
|
||||
github.com/mdlayher/socket v0.6.1 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260812195758-5935f34d40ce
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
@@ -91,10 +81,10 @@ require (
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.4
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1
|
||||
github.com/mattn/go-isatty v0.0.24
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.27 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||
github.com/mattn/go-isatty v0.0.23
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
|
||||
+10
-38
@@ -1,9 +1,7 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260812195758-5935f34d40ce h1:vNccNlfXze7Fmg9PXrsyxhfAMaifK04bsQHWUjVfe2w=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260812195758-5935f34d40ce/go.mod h1:xt8RldAfti0QCWidwYIzsSSoJWsE61WgEhTu2H9UpD4=
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260808141551-f559f2f31d2a h1:bGwK4G22ixHUs5NBeNy4JYMKUMMFoZtQz180OnbSFKE=
|
||||
github.com/AvengeMedia/dgop v0.2.4-0.20260808141551-f559f2f31d2a/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d h1:EZN2x2uAX975Uvw4MByqWaaVTp2fOJrNTl4V4EQbSn8=
|
||||
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d/go.mod h1:7p7cfydr4WM1G6eOPFlANXF3IV5du3FoA4CbDPprHAo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
@@ -59,8 +57,6 @@ github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g=
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -71,8 +67,6 @@ github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
|
||||
github.com/dlclark/regexp2/v2 v2.5.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
@@ -89,8 +83,6 @@ github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ=
|
||||
@@ -103,9 +95,6 @@ github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSy
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
@@ -116,8 +105,6 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
@@ -139,16 +126,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
|
||||
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
|
||||
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
|
||||
github.com/mdlayher/socket v0.6.1 h1:M7uj2NtuujUY4mYr1C57NmfNiRHbkKpnBxO856lsc3A=
|
||||
@@ -170,8 +155,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -181,8 +164,6 @@ github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4Xl
|
||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
@@ -201,10 +182,6 @@ github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
@@ -220,8 +197,6 @@ github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
|
||||
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
@@ -231,8 +206,8 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
|
||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g=
|
||||
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
@@ -241,10 +216,7 @@ golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dirName = "DankMaterialShell"
|
||||
|
||||
func ConfigDir() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(configDir, dirName), nil
|
||||
}
|
||||
|
||||
func DefaultArchiveName() string {
|
||||
return fmt.Sprintf("dms-backup-%s.tar.gz", time.Now().Format("20060102-150405"))
|
||||
}
|
||||
|
||||
func Create(outputPath string) error {
|
||||
srcDir, err := ConfigDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(srcDir); err != nil {
|
||||
return fmt.Errorf("no DMS configuration found at %s: %w", srcDir, err)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
gz := gzip.NewWriter(out)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
var link string
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
if link, err = os.Readlink(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(info, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(filepath.Join(dirName, rel))
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(tw, f)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func Restore(archivePath string) (string, error) {
|
||||
dstDir, err := ConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := validateArchive(archivePath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
previous := ""
|
||||
if _, err := os.Stat(dstDir); err == nil {
|
||||
previous = dstDir + ".pre-restore-" + time.Now().Format("20060102-150405")
|
||||
if err := os.Rename(dstDir, previous); err != nil {
|
||||
return "", fmt.Errorf("failed to move existing configuration aside: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := extract(archivePath, filepath.Dir(dstDir)); err != nil {
|
||||
if previous != "" {
|
||||
os.RemoveAll(dstDir)
|
||||
os.Rename(previous, dstDir)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return previous, nil
|
||||
}
|
||||
|
||||
func validateArchive(archivePath string) error {
|
||||
hasSettings := false
|
||||
err := walkArchive(archivePath, func(header *tar.Header, _ *tar.Reader) error {
|
||||
name := filepath.ToSlash(filepath.Clean(header.Name))
|
||||
if strings.HasPrefix(name, "..") || filepath.IsAbs(header.Name) {
|
||||
return fmt.Errorf("unsafe path in archive: %s", header.Name)
|
||||
}
|
||||
if !strings.HasPrefix(name, dirName+"/") && name != dirName {
|
||||
return fmt.Errorf("not a DMS backup: unexpected entry %s", header.Name)
|
||||
}
|
||||
if name == dirName+"/settings.json" {
|
||||
hasSettings = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasSettings {
|
||||
return fmt.Errorf("not a DMS backup: settings.json missing from archive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extract(archivePath, destParent string) error {
|
||||
return walkArchive(archivePath, func(header *tar.Header, tr *tar.Reader) error {
|
||||
target := filepath.Join(destParent, filepath.Clean(header.Name))
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
return os.MkdirAll(target, os.FileMode(header.Mode))
|
||||
case tar.TypeSymlink:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(header.Linkname, target)
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, tr)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func walkArchive(archivePath string, visit func(*tar.Header, *tar.Reader) error) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("not a gzip archive: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := visit(header, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package blur
|
||||
|
||||
import (
|
||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||
client "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
const extBackgroundEffectInterface = "ext_background_effect_manager_v1"
|
||||
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/AvengeMedia/dankgo/wayland/ext_data_control"
|
||||
"github.com/AvengeMedia/dankgo/wlclipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
)
|
||||
|
||||
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||
@@ -17,6 +16,37 @@ const envMime = "_DMS_CLIPBOARD_MIME"
|
||||
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
||||
|
||||
type Offer struct {
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// textMimeAliases are offered alongside plain-text content so legacy X11
|
||||
// clients bridged through XWayland find a target they can convert.
|
||||
var textMimeAliases = []string{
|
||||
"text/plain",
|
||||
"text/plain;charset=utf-8",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
}
|
||||
|
||||
// ExpandOffers turns raw clipboard data into the full offer list to serve,
|
||||
// adding the standard alias set for text content.
|
||||
func ExpandOffers(data []byte, mimeType string) []Offer {
|
||||
offers := []Offer{{MimeType: mimeType, Data: data}}
|
||||
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
|
||||
return offers
|
||||
}
|
||||
for _, alias := range textMimeAliases {
|
||||
if alias == mimeType {
|
||||
continue
|
||||
}
|
||||
offers = append(offers, Offer{MimeType: alias, Data: data})
|
||||
}
|
||||
return offers
|
||||
}
|
||||
|
||||
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||
func MaybeServeAndExit() {
|
||||
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := serveOffers(wlclipboard.ExpandOffers(data, mimeType), pasteOnce); err != nil {
|
||||
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -61,7 +91,7 @@ func CopyText(text string) error {
|
||||
|
||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveOffers(wlclipboard.ExpandOffers(data, mimeType), pasteOnce)
|
||||
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
|
||||
}
|
||||
return copyForkCached(data, mimeType, pasteOnce)
|
||||
}
|
||||
@@ -74,10 +104,10 @@ func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) err
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return serveOffers(wlclipboard.ExpandOffers(buf, mimeType), pasteOnce)
|
||||
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
|
||||
}
|
||||
|
||||
func CopyMulti(offers []wlclipboard.Offer, foreground, pasteOnce bool) error {
|
||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveOffers(offers, pasteOnce)
|
||||
}
|
||||
@@ -176,7 +206,7 @@ func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMultiFork(offers []wlclipboard.Offer, pasteOnce bool) error {
|
||||
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
@@ -238,7 +268,7 @@ func createClipboardCacheFile() (*os.File, error) {
|
||||
|
||||
// serveOffers owns the Wayland selection until cancelled (or first paste when
|
||||
// pasteOnce is set), answering every offered mime type with its data.
|
||||
func serveOffers(offers []wlclipboard.Offer, pasteOnce bool) error {
|
||||
func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
if len(offers) == 0 {
|
||||
return fmt.Errorf("no offers to serve")
|
||||
}
|
||||
@@ -318,6 +348,111 @@ func serveOffers(offers []wlclipboard.Offer, pasteOnce bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
func Paste() ([]byte, string, error) {
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
dataControlMgr, err := s.requireDataControl()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
defer device.Destroy()
|
||||
|
||||
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
|
||||
|
||||
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
offerMimeTypes[e.Id] = nil
|
||||
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
|
||||
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
|
||||
})
|
||||
})
|
||||
|
||||
var selectionOffer *ext_data_control.ExtDataControlOfferV1
|
||||
gotSelection := false
|
||||
|
||||
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||
selectionOffer = e.Id
|
||||
gotSelection = true
|
||||
})
|
||||
|
||||
s.display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
|
||||
if !gotSelection || selectionOffer == nil {
|
||||
return nil, "", fmt.Errorf("no clipboard data")
|
||||
}
|
||||
|
||||
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
|
||||
if selectedMime == "" {
|
||||
return nil, "", fmt.Errorf("no supported mime type")
|
||||
}
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create pipe: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := selectionOffer.Receive(selectedMime, int(w.Fd())); err != nil {
|
||||
w.Close()
|
||||
return nil, "", fmt.Errorf("receive: %w", err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
s.display.Roundtrip()
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read: %w", err)
|
||||
}
|
||||
|
||||
return data, selectedMime, nil
|
||||
}
|
||||
|
||||
func PasteText() (string, error) {
|
||||
data, _, err := Paste()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func selectPreferredMimeType(mimes []string) string {
|
||||
preferred := []string{
|
||||
"text/plain;charset=utf-8",
|
||||
"text/plain",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
}
|
||||
|
||||
for _, pref := range preferred {
|
||||
for _, mime := range mimes {
|
||||
if mime == pref {
|
||||
return mime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mimes) > 0 {
|
||||
return mimes[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func IsImageMimeType(mime string) bool {
|
||||
return len(mime) > 6 && mime[:6] == "image/"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
|
||||
@@ -78,3 +78,28 @@ func TestResolveKeycodes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandOffers(t *testing.T) {
|
||||
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
|
||||
if len(text) != 5 {
|
||||
t.Fatalf("expected 5 text offers, got %d", len(text))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, o := range text {
|
||||
if string(o.Data) != "hi" {
|
||||
t.Errorf("offer %s has wrong data", o.MimeType)
|
||||
}
|
||||
if seen[o.MimeType] {
|
||||
t.Errorf("duplicate offer %s", o.MimeType)
|
||||
}
|
||||
seen[o.MimeType] = true
|
||||
}
|
||||
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
|
||||
t.Errorf("missing X11 alias offers: %v", seen)
|
||||
}
|
||||
|
||||
img := ExpandOffers([]byte{1}, "image/png")
|
||||
if len(img) != 1 || img[0].MimeType != "image/png" {
|
||||
t.Errorf("non-text mime should not expand, got %+v", img)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,24 +8,191 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/dankgo/wayland/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
// WatchAll subscribes to clipboard selection changes like wlclipboard.Watch,
|
||||
// additionally reporting every mime type each selection offers.
|
||||
type ClipboardChange struct {
|
||||
Data []byte
|
||||
MimeType string
|
||||
MimeTypes []string
|
||||
}
|
||||
|
||||
func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer display.Destroy()
|
||||
|
||||
wlCtx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(wlCtx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
defer device.Destroy()
|
||||
|
||||
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
|
||||
|
||||
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
offerMimeTypes[e.Id] = nil
|
||||
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
|
||||
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
|
||||
})
|
||||
})
|
||||
|
||||
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||
if e.Id == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mimes := offerMimeTypes[e.Id]
|
||||
selectedMime := selectPreferredMimeType(mimes)
|
||||
if selectedMime == "" {
|
||||
return
|
||||
}
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil {
|
||||
w.Close()
|
||||
r.Close()
|
||||
return
|
||||
}
|
||||
w.Close()
|
||||
|
||||
go func() {
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
callback(data, selectedMime)
|
||||
}()
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||
return fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
if err := wlCtx.Dispatch(); err != nil {
|
||||
if isTimeoutError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("dispatch: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, allMimeTypes []string)) error {
|
||||
s, err := connectSession()
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
defer display.Destroy()
|
||||
|
||||
dataControlMgr, err := s.requireDataControl()
|
||||
wlCtx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(wlCtx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
@@ -79,18 +246,18 @@ func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, a
|
||||
}()
|
||||
})
|
||||
|
||||
s.display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := s.ctx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||
return fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
if err := s.ctx.Dispatch(); err != nil {
|
||||
if err := wlCtx.Dispatch(); err != nil {
|
||||
if isTimeoutError(err) {
|
||||
continue
|
||||
}
|
||||
@@ -100,31 +267,6 @@ func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, a
|
||||
}
|
||||
}
|
||||
|
||||
func selectPreferredMimeType(mimes []string) string {
|
||||
preferred := []string{
|
||||
"text/plain;charset=utf-8",
|
||||
"text/plain",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
}
|
||||
|
||||
for _, pref := range preferred {
|
||||
for _, mime := range mimes {
|
||||
if mime == pref {
|
||||
return mime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mimes) > 0 {
|
||||
return mimes[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isTimeoutError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -137,3 +279,25 @@ func isTimeoutError(err error) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) {
|
||||
ch := make(chan ClipboardChange, 16)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := Watch(ctx, func(data []byte, mimeType string) {
|
||||
select {
|
||||
case ch <- ClipboardChange{Data: data, MimeType: mimeType}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
if err != nil && err != context.Canceled {
|
||||
errCh <- err
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return ch, errCh
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package clipboard
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/dankgo/wayland/ext_data_control"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type session struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
|
||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
||||
@@ -264,7 +264,6 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
|
||||
{"layout.kdl", NiriLayoutConfig},
|
||||
{"alttab.kdl", NiriAlttabConfig},
|
||||
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
|
||||
{"input.kdl", NiriInputConfig},
|
||||
{"outputs.kdl", ""},
|
||||
{"cursor.kdl", ""},
|
||||
{"windowrules.kdl", ""},
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// ! DO NOT EDIT !
|
||||
// ! AUTO-GENERATED BY DMS !
|
||||
// ! CHANGES WILL BE OVERWRITTEN !
|
||||
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
|
||||
|
||||
input {
|
||||
mouse {
|
||||
accel-speed 0.0
|
||||
}
|
||||
touchpad {
|
||||
tap
|
||||
accel-speed 0.0
|
||||
natural-scroll
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,31 @@ input {
|
||||
numlock
|
||||
}
|
||||
|
||||
// Next sections include libinput settings.
|
||||
// Omitting settings disables them, or leaves them at their default values.
|
||||
// All commented-out settings here are examples, not defaults.
|
||||
touchpad {
|
||||
// off
|
||||
tap
|
||||
// dwt
|
||||
// dwtp
|
||||
// drag false
|
||||
// drag-lock
|
||||
natural-scroll
|
||||
// accel-speed 0.2
|
||||
// accel-profile "flat"
|
||||
// scroll-method "two-finger"
|
||||
// disabled-on-external-mouse
|
||||
}
|
||||
|
||||
mouse {
|
||||
// off
|
||||
// natural-scroll
|
||||
// accel-speed 0.2
|
||||
// accel-profile "flat"
|
||||
// scroll-method "no-scroll"
|
||||
}
|
||||
|
||||
trackpoint {
|
||||
// off
|
||||
// natural-scroll
|
||||
@@ -52,7 +77,6 @@ input {
|
||||
// Setting max-scroll-amount="0%" makes it work only on windows already fully on screen.
|
||||
// focus-follows-mouse max-scroll-amount="0%"
|
||||
}
|
||||
|
||||
// You can configure outputs by their name, which you can find
|
||||
// by running `niri msg outputs` while inside a niri instance.
|
||||
// The built-in laptop monitor is usually called "eDP-1".
|
||||
@@ -247,4 +271,3 @@ include optional=true "dms/alttab.kdl"
|
||||
include optional=true "dms/binds.kdl"
|
||||
include optional=true "dms/outputs.kdl"
|
||||
include optional=true "dms/cursor.kdl"
|
||||
include optional=true "dms/input.kdl"
|
||||
|
||||
@@ -16,6 +16,3 @@ var NiriAlttabConfig string
|
||||
|
||||
//go:embed embedded/niri-binds.kdl
|
||||
var NiriBindsConfig string
|
||||
|
||||
//go:embed embedded/niri-input.kdl
|
||||
var NiriInputConfig string
|
||||
|
||||
@@ -313,7 +313,6 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
|
||||
fg := HexToRGB(hexColor)
|
||||
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
||||
Lf, af, bf := cf.Lab()
|
||||
Lf *= 100.0
|
||||
|
||||
dir := 1.0
|
||||
if isLightMode {
|
||||
@@ -342,7 +341,6 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
|
||||
fg := HexToRGB(hexColor)
|
||||
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
|
||||
origL, af, bf := cf.Lab()
|
||||
origL *= 100.0
|
||||
|
||||
var darkerResult, lighterResult string
|
||||
darkerL, lighterL := origL, origL
|
||||
@@ -422,24 +420,6 @@ func blendHue(base, target, factor float64) float64 {
|
||||
return result
|
||||
}
|
||||
|
||||
// color8 sits a fixed L* offset from the background so it keeps its dim role
|
||||
// regardless of primary brightness (conventional palettes put ANSI bright
|
||||
// black ~2-2.5:1 from the background, e.g. catppuccin-mocha #585b70 on #1e1e2e)
|
||||
func DeriveDim(bgHex string, hue, sat float64, isLight bool) string {
|
||||
offset := 22.0
|
||||
if isLight {
|
||||
offset = -offset
|
||||
}
|
||||
|
||||
bgL := getLstar(bgHex)
|
||||
targetL := math.Max(0, math.Min(100, bgL+offset))
|
||||
|
||||
tint := HSVToRGB(HSV{H: hue, S: sat, V: 0.5})
|
||||
c := colorful.Color{R: tint.R, G: tint.G, B: tint.B}
|
||||
_, af, bf := c.Lab()
|
||||
return labToHex(targetL, af, bf)
|
||||
}
|
||||
|
||||
func DeriveContainer(primary string, isLight bool) string {
|
||||
rgb := HexToRGB(primary)
|
||||
hsv := RGBToHSV(rgb)
|
||||
@@ -520,7 +500,10 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
gray7V := baseVal * 0.28
|
||||
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
|
||||
|
||||
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
|
||||
gray8S := baseSat * 0.05
|
||||
gray8V := baseVal * 0.85
|
||||
dimTarget := secondaryTarget * 0.5
|
||||
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
|
||||
|
||||
brightRedS := math.Min(baseSat*1.0, 1.0)
|
||||
brightRedV := math.Min(baseVal*1.2, 1.0)
|
||||
@@ -576,7 +559,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
|
||||
gray7V := math.Min(baseVal*1.05, 1.0)
|
||||
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
|
||||
|
||||
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
|
||||
gray8S := baseSat * 0.15
|
||||
gray8V := baseVal * 0.65
|
||||
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
|
||||
|
||||
brightRedS := math.Min(baseSat*0.75, 1.0)
|
||||
brightRedV := math.Min(baseVal*1.35, 1.0)
|
||||
|
||||
@@ -679,73 +679,3 @@ func TestContrastAlgorithmComparison(t *testing.T) {
|
||||
|
||||
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
|
||||
}
|
||||
|
||||
func TestEnsureContrastDPSLightModeStaysLight(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result string
|
||||
bg string
|
||||
minLstar float64
|
||||
}{
|
||||
{
|
||||
name: "bidirectional adjustment",
|
||||
result: EnsureContrastDPSBidirectional("#d0ccc6", "#f8f8f8", 17.5, true),
|
||||
bg: "#f8f8f8",
|
||||
minLstar: 20.0,
|
||||
},
|
||||
{
|
||||
name: "lstar adjustment",
|
||||
result: EnsureContrastDPSLstar("#c0c0c0", "#f8f8f8", 30.0, true),
|
||||
bg: "#f8f8f8",
|
||||
minLstar: 20.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lstar := getLstar(tt.result)
|
||||
if lstar < tt.minLstar {
|
||||
t.Errorf("result %s has L* %.2f on light bg %s, expected >= %.2f (collapsed to near-black)",
|
||||
tt.result, lstar, tt.bg, tt.minLstar)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteColor8Dim(t *testing.T) {
|
||||
hues := []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#ef4444"}
|
||||
|
||||
for _, base := range hues {
|
||||
t.Run(base, func(t *testing.T) {
|
||||
palette := GeneratePalette(base, PaletteOptions{IsLight: false, UseDPS: true})
|
||||
|
||||
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
|
||||
if bgRatio < 1.5 || bgRatio > 3.0 {
|
||||
t.Errorf("Color8 %s vs bg %s ratio %.2f, expected 1.5-3.0 (bright black stays near bg)",
|
||||
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
|
||||
}
|
||||
|
||||
sepRatio := ContrastRatio(palette.Color4.Hex, palette.Color8.Hex)
|
||||
if sepRatio < 2.0 {
|
||||
t.Errorf("Color4 %s vs Color8 %s ratio %.2f, expected >= 2.0 (blue must not collide with bright black)",
|
||||
palette.Color4.Hex, palette.Color8.Hex, sepRatio)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
|
||||
palette := GeneratePalette("#f59e0b", PaletteOptions{IsLight: true, UseDPS: true})
|
||||
|
||||
lstar := getLstar(palette.Color8.Hex)
|
||||
if lstar < 60.0 {
|
||||
t.Errorf("light mode Color8 %s has L* %.2f, expected >= 60 (dim grey, not near-black)",
|
||||
palette.Color8.Hex, lstar)
|
||||
}
|
||||
|
||||
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
|
||||
if bgRatio > 3.0 {
|
||||
t.Errorf("light mode Color8 %s vs bg %s ratio %.2f, expected <= 3.0",
|
||||
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,22 +302,6 @@ func AppsForMime(mimeType string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// HandlersForMime lists launchable handler entries for a mime type,
|
||||
// excluding Terminal=true and NoDisplay=true desktop entries so pickers
|
||||
// only offer graphical applications.
|
||||
func HandlersForMime(mimeType string) []*Entry {
|
||||
ids := AppsForMime(mimeType)
|
||||
out := make([]*Entry, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
entry := EntryByID(id)
|
||||
if entry == nil || entry.Hidden || entry.NoDisplay || entry.Terminal {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func QueryDefaults(mimeTypes []string) map[string]string {
|
||||
out := make(map[string]string, len(mimeTypes))
|
||||
for _, m := range mimeTypes {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -82,10 +81,7 @@ func readAssociations(path string) (*MimeAssociations, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return associationsFromData(data), nil
|
||||
}
|
||||
|
||||
func associationsFromData(data []byte) *MimeAssociations {
|
||||
groups := parseGroups(data)
|
||||
assoc := newAssociations()
|
||||
|
||||
@@ -110,7 +106,7 @@ func associationsFromData(data []byte) *MimeAssociations {
|
||||
}
|
||||
}
|
||||
|
||||
return assoc
|
||||
return assoc, nil
|
||||
}
|
||||
|
||||
func mergedAssociations() *MimeAssociations {
|
||||
@@ -148,22 +144,40 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||
|
||||
path := mimeappsWritePath()
|
||||
|
||||
original, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
assoc, err := readAssociations(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
assoc = newAssociations()
|
||||
}
|
||||
|
||||
assoc := associationsFromData(original)
|
||||
update(assoc)
|
||||
|
||||
rendered, err := renderMimeapps(original, assoc)
|
||||
if err != nil {
|
||||
return err
|
||||
var buf bytes.Buffer
|
||||
w := bufio.NewWriter(&buf)
|
||||
|
||||
var writeErr error
|
||||
writeSection := func(name string, entries map[string]string) {
|
||||
fmt.Fprintf(w, "[%s]\n", name)
|
||||
keys := make([]string, 0, len(entries))
|
||||
for k := range entries {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
v := entries[k]
|
||||
if !isSafeIniField(k) || !isSafeIniField(v) {
|
||||
if writeErr == nil {
|
||||
writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s=%s\n", k, v)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
return atomicWriteFile(path, rendered)
|
||||
}
|
||||
|
||||
func managedGroups(assoc *MimeAssociations) (map[string]map[string]string, error) {
|
||||
flatten := func(m map[string][]string) map[string]string {
|
||||
out := make(map[string]string, len(m))
|
||||
for k, list := range m {
|
||||
@@ -172,172 +186,22 @@ func managedGroups(assoc *MimeAssociations) (map[string]map[string]string, error
|
||||
return out
|
||||
}
|
||||
|
||||
groups := map[string]map[string]string{
|
||||
groupDefaults: maps.Clone(assoc.Defaults),
|
||||
groupAdded: flatten(assoc.Added),
|
||||
groupRemoved: flatten(assoc.Removed),
|
||||
}
|
||||
for name, entries := range groups {
|
||||
for k, v := range entries {
|
||||
if isSafeIniField(k) && isSafeIniField(v) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("invalid mimeapps.list field in [%s]: %q=%q", name, k, v)
|
||||
}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
writeSection(groupDefaults, assoc.Defaults)
|
||||
writeSection(groupAdded, flatten(assoc.Added))
|
||||
writeSection(groupRemoved, flatten(assoc.Removed))
|
||||
|
||||
// renderMimeapps rewrites only the three association groups defined by the
|
||||
// mime-apps spec (https://specifications.freedesktop.org/mime-apps-spec/),
|
||||
// keeping comments, blank lines, and unrelated groups verbatim.
|
||||
func renderMimeapps(original []byte, assoc *MimeAssociations) ([]byte, error) {
|
||||
managed, err := managedGroups(assoc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
|
||||
written := map[string]map[string]bool{
|
||||
groupDefaults: {},
|
||||
groupAdded: {},
|
||||
groupRemoved: {},
|
||||
}
|
||||
|
||||
var out []string
|
||||
|
||||
flushMissing := func(group string) {
|
||||
entries, ok := managed[group]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(entries))
|
||||
for k := range entries {
|
||||
if !written[group][k] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
insertAt := len(out)
|
||||
for insertAt > 0 && strings.TrimSpace(out[insertAt-1]) == "" {
|
||||
insertAt--
|
||||
}
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
written[group][k] = true
|
||||
lines = append(lines, k+"="+entries[k])
|
||||
}
|
||||
tail := append([]string(nil), out[insertAt:]...)
|
||||
out = append(out[:insertAt], append(lines, tail...)...)
|
||||
}
|
||||
|
||||
current := ""
|
||||
scanner := bufio.NewScanner(bytes.NewReader(original))
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
flushMissing(current)
|
||||
current = trimmed[1 : len(trimmed)-1]
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
|
||||
entries, isManaged := managed[current]
|
||||
if !isManaged {
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
|
||||
eq := strings.IndexByte(trimmed, '=')
|
||||
if eq <= 0 {
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(trimmed[:eq])
|
||||
val, keep := entries[key]
|
||||
if !keep || written[current][key] {
|
||||
continue
|
||||
}
|
||||
written[current][key] = true
|
||||
out = append(out, key+"="+val)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flushMissing(current)
|
||||
|
||||
for _, group := range []string{groupDefaults, groupAdded, groupRemoved} {
|
||||
entries := managed[group]
|
||||
pending := make([]string, 0, len(entries))
|
||||
for k := range entries {
|
||||
if !written[group][k] {
|
||||
pending = append(pending, k)
|
||||
}
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(pending)
|
||||
|
||||
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
|
||||
out = append(out, "")
|
||||
}
|
||||
out = append(out, "["+group+"]")
|
||||
for _, k := range pending {
|
||||
written[group][k] = true
|
||||
out = append(out, k+"="+entries[k])
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(strings.Join(out, "\n") + "\n"), nil
|
||||
}
|
||||
|
||||
func atomicWriteFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".mimeapps-*.tmp")
|
||||
if err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return os.WriteFile(path, buf.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
func setDefaultAssociation(mimeType, desktopID string) error {
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetDefaultsPreservesUnrelatedContent(t *testing.T) {
|
||||
configHome, _ := setupFakeXDG(t)
|
||||
path := filepath.Join(configHome, "mimeapps.list")
|
||||
writeFile(t, path, `# managed by hand
|
||||
[Default Applications]
|
||||
text/html=firefox.desktop
|
||||
# pinned image viewer
|
||||
image/png=eog.desktop
|
||||
|
||||
[Added Associations]
|
||||
text/html=firefox.desktop;
|
||||
|
||||
[Custom Section]
|
||||
foo=bar
|
||||
`)
|
||||
|
||||
if err := SetDefaults([]string{"inode/directory", "x-scheme-handler/sftp"}, "org.kde.dolphin.desktop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
|
||||
preserved := []string{
|
||||
"# managed by hand",
|
||||
"# pinned image viewer",
|
||||
"text/html=firefox.desktop",
|
||||
"image/png=eog.desktop",
|
||||
"[Custom Section]",
|
||||
"foo=bar",
|
||||
}
|
||||
for _, want := range preserved {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("rewrite lost %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
written := []string{
|
||||
"inode/directory=org.kde.dolphin.desktop",
|
||||
"x-scheme-handler/sftp=org.kde.dolphin.desktop",
|
||||
}
|
||||
for _, want := range written {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("rewrite missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
if got := GetDefault("inode/directory"); got != "org.kde.dolphin.desktop" {
|
||||
t.Errorf("GetDefault(inode/directory) = %q", got)
|
||||
}
|
||||
if got := GetDefault("x-scheme-handler/sftp"); got != "org.kde.dolphin.desktop" {
|
||||
t.Errorf("GetDefault(x-scheme-handler/sftp) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultReplacesKeyInPlace(t *testing.T) {
|
||||
configHome, _ := setupFakeXDG(t)
|
||||
path := filepath.Join(configHome, "mimeapps.list")
|
||||
writeFile(t, path, `[Default Applications]
|
||||
inode/directory=foot.desktop
|
||||
text/html=firefox.desktop
|
||||
`)
|
||||
|
||||
if err := SetDefault("inode/directory", "org.gnome.Nautilus.desktop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
|
||||
if n := strings.Count(got, "inode/directory=org.gnome.Nautilus.desktop\n"); n != 1 {
|
||||
t.Errorf("expected exactly one replaced default line, got %d:\n%s", n, got)
|
||||
}
|
||||
if strings.Contains(got, "foot.desktop") {
|
||||
t.Errorf("old default still present:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "text/html=firefox.desktop") {
|
||||
t.Errorf("unrelated default lost:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultsCreatesFileWhenAbsent(t *testing.T) {
|
||||
configHome, _ := setupFakeXDG(t)
|
||||
path := filepath.Join(configHome, "mimeapps.list")
|
||||
|
||||
if err := SetDefaults([]string{"inode/directory", "x-scheme-handler/sftp"}, "org.kde.dolphin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
|
||||
expected := []string{
|
||||
"[Default Applications]",
|
||||
"inode/directory=org.kde.dolphin.desktop",
|
||||
"x-scheme-handler/sftp=org.kde.dolphin.desktop",
|
||||
"[Added Associations]",
|
||||
}
|
||||
for _, want := range expected {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("new file missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(configHome)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Errorf("leftover temp file %q", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultClearsRemovedAssociation(t *testing.T) {
|
||||
configHome, _ := setupFakeXDG(t)
|
||||
path := filepath.Join(configHome, "mimeapps.list")
|
||||
writeFile(t, path, `[Removed Associations]
|
||||
inode/directory=org.kde.dolphin.desktop;other.desktop;
|
||||
`)
|
||||
|
||||
if err := SetDefault("inode/directory", "org.kde.dolphin.desktop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
|
||||
if !strings.Contains(got, "inode/directory=other.desktop;") {
|
||||
t.Errorf("unrelated removed entry lost:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "org.kde.dolphin.desktop;other.desktop") {
|
||||
t.Errorf("chosen app still in removed list:\n%s", got)
|
||||
}
|
||||
if got := GetDefault("inode/directory"); got != "org.kde.dolphin.desktop" {
|
||||
t.Errorf("GetDefault = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersForMimeExcludesTerminalAndNoDisplay(t *testing.T) {
|
||||
_, dataHome := setupFakeXDG(t)
|
||||
apps := filepath.Join(dataHome, "applications")
|
||||
writeFile(t, filepath.Join(apps, "org.kde.dolphin.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Dolphin
|
||||
Icon=system-file-manager
|
||||
MimeType=inode/directory;
|
||||
`)
|
||||
writeFile(t, filepath.Join(apps, "ghost-term.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Ghost Terminal
|
||||
Terminal=true
|
||||
MimeType=inode/directory;
|
||||
`)
|
||||
writeFile(t, filepath.Join(apps, "hidden-fm.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Hidden FM
|
||||
NoDisplay=true
|
||||
MimeType=inode/directory;
|
||||
`)
|
||||
writeFile(t, filepath.Join(apps, "mimeinfo.cache"), `[MIME Cache]
|
||||
inode/directory=ghost-term.desktop;org.kde.dolphin.desktop;hidden-fm.desktop;stale.desktop;
|
||||
`)
|
||||
|
||||
handlers := HandlersForMime("inode/directory")
|
||||
byID := make(map[string]*Entry, len(handlers))
|
||||
for _, h := range handlers {
|
||||
byID[h.ID] = h
|
||||
}
|
||||
|
||||
dolphin := byID["org.kde.dolphin.desktop"]
|
||||
if dolphin == nil {
|
||||
t.Fatal("org.kde.dolphin.desktop missing from handlers")
|
||||
}
|
||||
if dolphin.Name != "Dolphin" {
|
||||
t.Errorf("Name = %q", dolphin.Name)
|
||||
}
|
||||
if dolphin.Icon != "system-file-manager" {
|
||||
t.Errorf("Icon = %q", dolphin.Icon)
|
||||
}
|
||||
|
||||
excluded := []string{"ghost-term.desktop", "hidden-fm.desktop", "stale.desktop"}
|
||||
for _, id := range excluded {
|
||||
if byID[id] != nil {
|
||||
t.Errorf("%s should be excluded from handlers", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
||||
}
|
||||
|
||||
dependencies = append(dependencies, a.detectMatugen())
|
||||
dependencies = append(dependencies, a.detectDgop())
|
||||
dependencies = append(dependencies, a.detectDanksearch())
|
||||
dependencies = append(dependencies, a.detectDankCalendar())
|
||||
|
||||
@@ -218,6 +219,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": a.getDMSGreeterMapping(variants["dms-greeter"]),
|
||||
"matugen": a.getMatugenMapping(variants["matugen"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
@@ -657,7 +659,7 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
||||
dmsDepencies := []string{"quickshell", "quickshell-git"}
|
||||
dmsDepencies := []string{"quickshell", "quickshell-git", "dgop"}
|
||||
|
||||
var deps []string
|
||||
var others []string
|
||||
|
||||
@@ -103,6 +103,10 @@ func (b *BaseDistribution) detectMatugen() deps.Dependency {
|
||||
return b.detectCommand("matugen", "Material Design color generation tool")
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDgop() deps.Dependency {
|
||||
return b.detectCommand("dgop", "Desktop portal management tool")
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDanksearch() deps.Dependency {
|
||||
return b.detectOptionalPackage("danksearch", "File indexing and search service", b.commandExists("dsearch") || b.commandExists("danksearch"))
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
}
|
||||
|
||||
dependencies = append(dependencies, d.detectMatugen())
|
||||
dependencies = append(dependencies, d.detectDgop())
|
||||
dependencies = append(dependencies, d.detectDanksearch())
|
||||
dependencies = append(dependencies, d.detectDankCalendar())
|
||||
|
||||
@@ -134,6 +135,7 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
@@ -472,8 +474,13 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
||||
return fmt.Errorf("failed to get OS info: %w", err)
|
||||
}
|
||||
|
||||
obsRepo := debianOBSRepoVersion(osInfo, readDebianAPTSuites())
|
||||
d.log(fmt.Sprintf("Using OBS Debian repository: %s", obsRepo))
|
||||
// Determine Debian version for OBS repository URL
|
||||
debianVersion := "Debian_13"
|
||||
if osInfo.VersionID == "testing" {
|
||||
debianVersion = "Debian_Testing"
|
||||
} else if osInfo.VersionCodename == "sid" || osInfo.VersionID == "sid" || strings.Contains(strings.ToLower(osInfo.PrettyName), "sid") || strings.Contains(strings.ToLower(osInfo.PrettyName), "unstable") {
|
||||
debianVersion = "Debian_Unstable"
|
||||
}
|
||||
|
||||
for _, pkg := range obsPkgs {
|
||||
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
|
||||
@@ -482,11 +489,12 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
||||
// RepoURL format: "home:AvengeMedia:danklinux"
|
||||
repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/")
|
||||
repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-")
|
||||
baseURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s", repoPath, obsRepo)
|
||||
baseURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s", repoPath, debianVersion)
|
||||
|
||||
// Check if repository already exists
|
||||
listFile := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", repoName)
|
||||
if debianOBSListFileMatches(listFile, obsRepo) {
|
||||
checkCmd := exec.CommandContext(ctx, "test", "-f", listFile)
|
||||
if checkCmd.Run() == nil {
|
||||
d.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
|
||||
enabledRepos[pkg.RepoURL] = true
|
||||
continue
|
||||
@@ -660,6 +668,10 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
if err := d.installRust(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Rust: %w", err)
|
||||
}
|
||||
case "dgop":
|
||||
if err := d.installGo(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Go: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +730,24 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if d.commandExists("go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.87,
|
||||
Step: "Installing Go...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo apt-get install golang-go",
|
||||
}
|
||||
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
|
||||
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if len(packages) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package distros
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
obsRepoDebian13 = "Debian_13"
|
||||
obsRepoDebianTesting = "Debian_Testing"
|
||||
obsRepoDebianUnstable = "Debian_Unstable"
|
||||
)
|
||||
|
||||
func debianOBSRepoVersion(info *OSInfo, aptSuites []string) string {
|
||||
hasSuite := make(map[string]bool, len(aptSuites))
|
||||
for _, suite := range aptSuites {
|
||||
hasSuite[normalizeDebianSuite(suite)] = true
|
||||
}
|
||||
|
||||
// APT suites distinguish testing from sid. os-release cannot: both currently
|
||||
// report VERSION_CODENAME=forky and PRETTY_NAME="Debian GNU/Linux forky/sid"
|
||||
if hasSuite["sid"] || hasSuite["unstable"] {
|
||||
return obsRepoDebianUnstable
|
||||
}
|
||||
if hasSuite["testing"] || hasSuite["forky"] {
|
||||
return obsRepoDebianTesting
|
||||
}
|
||||
if hasSuite["trixie"] || hasSuite["stable"] {
|
||||
return obsRepoDebian13
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
return obsRepoDebian13
|
||||
}
|
||||
|
||||
id := strings.ToLower(strings.TrimSpace(info.VersionID))
|
||||
code := strings.ToLower(strings.TrimSpace(info.VersionCodename))
|
||||
|
||||
if code == "sid" || id == "sid" {
|
||||
return obsRepoDebianUnstable
|
||||
}
|
||||
if id == "13" || code == "trixie" {
|
||||
return obsRepoDebian13
|
||||
}
|
||||
if id != "" && id != "testing" {
|
||||
return obsRepoDebian13
|
||||
}
|
||||
// Prefer Testing when os-release is the shared forky/sid string. Unstable
|
||||
// OBS packages can require a newer glibc than Testing ships.
|
||||
return obsRepoDebianTesting
|
||||
}
|
||||
|
||||
func debianOBSListFileMatches(path, debianVersion string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return debianOBSListPointsAt(string(data), debianVersion)
|
||||
}
|
||||
|
||||
func debianOBSListPointsAt(contents, debianVersion string) bool {
|
||||
return strings.Contains(contents, "/"+debianVersion+"/")
|
||||
}
|
||||
|
||||
func readDebianAPTSuites() []string {
|
||||
var chunks []string
|
||||
if data, err := os.ReadFile("/etc/apt/sources.list"); err == nil {
|
||||
chunks = append(chunks, string(data))
|
||||
}
|
||||
matches, _ := filepath.Glob("/etc/apt/sources.list.d/*")
|
||||
for _, path := range matches {
|
||||
if !strings.HasSuffix(path, ".list") && !strings.HasSuffix(path, ".sources") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
chunks = append(chunks, string(data))
|
||||
}
|
||||
return debianAPTSuites(strings.Join(chunks, "\n"))
|
||||
}
|
||||
|
||||
func debianAPTSuites(sources string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var suites []string
|
||||
add := func(raw string) {
|
||||
suite := normalizeDebianSuite(raw)
|
||||
if suite == "" || seen[suite] {
|
||||
return
|
||||
}
|
||||
seen[suite] = true
|
||||
suites = append(suites, suite)
|
||||
}
|
||||
|
||||
var stanza []string
|
||||
flushStanza := func() {
|
||||
if len(stanza) == 0 {
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
var stanzaSuites []string
|
||||
for _, line := range stanza {
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "enabled":
|
||||
switch strings.ToLower(strings.TrimSpace(val)) {
|
||||
case "no", "false", "0":
|
||||
enabled = false
|
||||
}
|
||||
case "suites":
|
||||
stanzaSuites = strings.Fields(val)
|
||||
}
|
||||
}
|
||||
if enabled {
|
||||
for _, suite := range stanzaSuites {
|
||||
add(suite)
|
||||
}
|
||||
}
|
||||
stanza = nil
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(sources, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
flushStanza()
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
if strings.HasPrefix(lower, "deb ") || strings.HasPrefix(lower, "deb-src ") {
|
||||
flushStanza()
|
||||
if suite := suiteFromDebLine(line); suite != "" {
|
||||
add(suite)
|
||||
}
|
||||
continue
|
||||
}
|
||||
stanza = append(stanza, line)
|
||||
}
|
||||
flushStanza()
|
||||
return suites
|
||||
}
|
||||
|
||||
func suiteFromDebLine(line string) string {
|
||||
if i := strings.Index(line, "["); i != -1 {
|
||||
if j := strings.Index(line, "]"); j > i {
|
||||
line = strings.TrimSpace(line[:i] + " " + line[j+1:])
|
||||
}
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
return ""
|
||||
}
|
||||
return fields[2]
|
||||
}
|
||||
|
||||
func normalizeDebianSuite(suite string) string {
|
||||
suite = strings.ToLower(strings.TrimSpace(suite))
|
||||
suite = strings.Trim(suite, "/")
|
||||
for _, suffix := range []string{"-security", "-updates", "-backports", "-proposed-updates"} {
|
||||
if strings.HasSuffix(suite, suffix) {
|
||||
return strings.TrimSuffix(suite, suffix)
|
||||
}
|
||||
}
|
||||
return suite
|
||||
}
|
||||
@@ -103,6 +103,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
}
|
||||
|
||||
dependencies = append(dependencies, f.detectMatugen())
|
||||
dependencies = append(dependencies, f.detectDgop())
|
||||
dependencies = append(dependencies, f.detectDanksearch())
|
||||
dependencies = append(dependencies, f.detectDankCalendar())
|
||||
|
||||
@@ -138,6 +139,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
@@ -53,11 +53,6 @@ func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDi
|
||||
}
|
||||
}
|
||||
|
||||
func emergeInstallArgs(packages []string) []string {
|
||||
args := []string{"emerge", "--ask=n", "--quiet", "--autounmask-continue=y", "--autounmask-keep-keywords=y", "--autounmask-keep-masks=y"}
|
||||
return append(args, packages...)
|
||||
}
|
||||
|
||||
func (g *GentooDistribution) getArchKeyword() string {
|
||||
arch := runtime.GOARCH
|
||||
switch arch {
|
||||
@@ -117,6 +112,7 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
}
|
||||
|
||||
dependencies = append(dependencies, g.detectMatugen())
|
||||
dependencies = append(dependencies, g.detectDgop())
|
||||
dependencies = append(dependencies, g.detectDanksearch())
|
||||
|
||||
return dependencies, nil
|
||||
@@ -175,6 +171,7 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"dms (DankMaterialShell)": g.getDmsMapping(),
|
||||
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"danksearch": {Name: "gui-apps/danksearch", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
}
|
||||
|
||||
@@ -322,7 +319,6 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
}
|
||||
g.log("Portage tree synced successfully")
|
||||
|
||||
args := emergeInstallArgs(missingPkgs)
|
||||
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhasePrerequisites,
|
||||
@@ -330,10 +326,12 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
|
||||
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
|
||||
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
|
||||
}
|
||||
|
||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||
args = append(args, missingPkgs...)
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -523,7 +521,8 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
|
||||
}
|
||||
}
|
||||
|
||||
args := emergeInstallArgs(packageNames)
|
||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||
args = append(args, packageNames...)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
@@ -714,7 +713,8 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
|
||||
guruPackages[i] = pkg + "::guru"
|
||||
}
|
||||
|
||||
args := emergeInstallArgs(guruPackages)
|
||||
args := []string{"emerge", "--ask=n", "--quiet"}
|
||||
args = append(args, guruPackages...)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseAURPackages,
|
||||
|
||||
@@ -59,6 +59,10 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
|
||||
if err := m.installDankMaterialShell(ctx, variant, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install DankMaterialShell: %w", err)
|
||||
}
|
||||
case "dgop":
|
||||
if err := m.installDgop(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install dgop: %w", err)
|
||||
}
|
||||
case "niri":
|
||||
if err := m.installNiri(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install niri: %w", err)
|
||||
@@ -91,6 +95,66 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
m.log("Installing dgop from source...")
|
||||
|
||||
homeDir := os.Getenv("HOME")
|
||||
if homeDir == "" {
|
||||
return fmt.Errorf("HOME environment variable not set")
|
||||
}
|
||||
|
||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
tmpDir := filepath.Join(cacheDir, "dgop-build")
|
||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.1,
|
||||
Step: "Cloning dgop repository...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "git clone https://github.com/AvengeMedia/dgop.git",
|
||||
}
|
||||
|
||||
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/AvengeMedia/dgop.git", tmpDir)
|
||||
if err := cloneCmd.Run(); err != nil {
|
||||
m.logError("failed to clone dgop repository", err)
|
||||
return fmt.Errorf("failed to clone dgop repository: %w", err)
|
||||
}
|
||||
|
||||
buildCmd := exec.CommandContext(ctx, "make")
|
||||
buildCmd.Dir = tmpDir
|
||||
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
|
||||
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.7, "Building dgop..."); err != nil {
|
||||
return fmt.Errorf("failed to build dgop: %w", err)
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.7,
|
||||
Step: "Installing dgop...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo make install",
|
||||
}
|
||||
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
|
||||
installCmd.Dir = tmpDir
|
||||
if err := installCmd.Run(); err != nil {
|
||||
m.logError("failed to install dgop", err)
|
||||
return fmt.Errorf("failed to install dgop: %w", err)
|
||||
}
|
||||
|
||||
m.log("dgop installed successfully from source")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
m.log("Installing niri from source...")
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
||||
}
|
||||
|
||||
dependencies = append(dependencies, o.detectMatugen())
|
||||
dependencies = append(dependencies, o.detectDgop())
|
||||
dependencies = append(dependencies, o.detectDanksearch())
|
||||
dependencies = append(dependencies, o.detectDankCalendar())
|
||||
|
||||
@@ -129,6 +130,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func IsUnsupportedDistro(distroID, versionID string) bool {
|
||||
|
||||
if distroID == "debian" {
|
||||
// unstable/sid support
|
||||
if versionID == "sid" || versionID == "testing" {
|
||||
if versionID == "sid" {
|
||||
return false
|
||||
}
|
||||
if versionID == "" {
|
||||
|
||||
@@ -79,6 +79,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
}
|
||||
|
||||
dependencies = append(dependencies, u.detectMatugen())
|
||||
dependencies = append(dependencies, u.detectDgop())
|
||||
dependencies = append(dependencies, u.detectDanksearch())
|
||||
dependencies = append(dependencies, u.detectDankCalendar())
|
||||
|
||||
@@ -123,6 +124,7 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
@@ -575,6 +577,10 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
if err := u.installRust(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Rust: %w", err)
|
||||
}
|
||||
case "dgop":
|
||||
if err := u.installGo(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Go: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,6 +640,53 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if u.commandExists("go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.87,
|
||||
Step: "Adding Go PPA repository...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
|
||||
}
|
||||
|
||||
addPPACmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
"add-apt-repository -y ppa:longsleep/golang-backports")
|
||||
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
|
||||
return fmt.Errorf("failed to add Go PPA: %w", err)
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.88,
|
||||
Step: "Updating package lists...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo apt-get update",
|
||||
}
|
||||
|
||||
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
|
||||
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
|
||||
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.89,
|
||||
Step: "Installing Go...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo apt-get install golang-go",
|
||||
}
|
||||
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y golang-go")
|
||||
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
|
||||
}
|
||||
|
||||
func (u *UbuntuDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if len(packages) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -83,6 +83,7 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
||||
}
|
||||
|
||||
dependencies = append(dependencies, v.detectMatugen())
|
||||
dependencies = append(dependencies, v.detectDgop())
|
||||
dependencies = append(dependencies, v.detectDanksearch())
|
||||
dependencies = append(dependencies, v.detectDankCalendar())
|
||||
|
||||
@@ -182,6 +183,7 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
}
|
||||
|
||||
@@ -6,7 +6,37 @@ func NewClient() Client {
|
||||
geoclueClient, err := newGeoClueClient()
|
||||
if err != nil {
|
||||
log.Warnf("GeoClue2 unavailable: %v", err)
|
||||
return newIpClient()
|
||||
return newSeededIpClient()
|
||||
}
|
||||
|
||||
loc, _ := geoclueClient.GetLocation()
|
||||
if loc.Latitude != 0 || loc.Longitude != 0 {
|
||||
log.Info("Using GeoClue2 location")
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
log.Info("GeoClue2 has no fix yet, seeding with IP location")
|
||||
ipLoc, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
log.Warnf("IP location seed failed: %v", err)
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
log.Info("Seeded GeoClue2 with IP location")
|
||||
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
|
||||
return geoclueClient
|
||||
}
|
||||
|
||||
func newSeededIpClient() *IpClient {
|
||||
client := newIpClient()
|
||||
ipLoc, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
log.Warnf("IP location also failed: %v", err)
|
||||
return client
|
||||
}
|
||||
|
||||
log.Info("Using IP location")
|
||||
client.currLocation.Latitude = ipLoc.Latitude
|
||||
client.currLocation.Longitude = ipLoc.Longitude
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ const (
|
||||
type GeoClueClient struct {
|
||||
currLocation *Location
|
||||
locationMutex sync.RWMutex
|
||||
seedOnce sync.Once
|
||||
|
||||
dbusConn *dbus.Conn
|
||||
clientPath dbus.ObjectPath
|
||||
@@ -231,29 +230,14 @@ func (c *GeoClueClient) SeedLocation(loc Location) {
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) GetLocation() (Location, error) {
|
||||
loc := c.currentLocation()
|
||||
if loc.Latitude != 0 || loc.Longitude != 0 {
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
c.seedOnce.Do(func() {
|
||||
ipLoc, err := fetchIPLocation()
|
||||
if err != nil {
|
||||
log.Warnf("GeoClue2 has no fix, IP location seed failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("Seeded GeoClue2 with IP location")
|
||||
c.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
|
||||
})
|
||||
|
||||
return c.currentLocation(), nil
|
||||
}
|
||||
|
||||
func (c *GeoClueClient) currentLocation() Location {
|
||||
c.locationMutex.RLock()
|
||||
defer c.locationMutex.RUnlock()
|
||||
if c.currLocation == nil {
|
||||
return Location{}
|
||||
return Location{
|
||||
Latitude: 0.0,
|
||||
Longitude: 0.0,
|
||||
}, nil
|
||||
}
|
||||
return *c.currLocation
|
||||
stateCopy := *c.currLocation
|
||||
return stateCopy, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -225,44 +224,6 @@ func (h *HyprlandProvider) validateAction(action string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var luaExprActionPattern = regexp.MustCompile(`^(function\s*\(|hl\.)`)
|
||||
|
||||
// isRawLuaActionText reports that action is a Lua expression to re-emit
|
||||
// verbatim rather than freeform dispatcher text to wrap for hyprctl. The
|
||||
// balance check keeps malformed input from corrupting the generated file.
|
||||
func isRawLuaActionText(action string) bool {
|
||||
if !luaExprActionPattern.MatchString(action) {
|
||||
return false
|
||||
}
|
||||
depth := 0
|
||||
var quote byte
|
||||
escaped := false
|
||||
for i := 0; i < len(action); i++ {
|
||||
c := action[i]
|
||||
switch {
|
||||
case escaped:
|
||||
escaped = false
|
||||
case quote != 0:
|
||||
switch c {
|
||||
case '\\':
|
||||
escaped = true
|
||||
case quote:
|
||||
quote = 0
|
||||
}
|
||||
case c == '"' || c == '\'':
|
||||
quote = c
|
||||
case c == '(':
|
||||
depth++
|
||||
case c == ')':
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return depth == 0 && quote == 0 && !escaped
|
||||
}
|
||||
|
||||
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
|
||||
if err := h.ensureWritableConfig(); err != nil {
|
||||
return err
|
||||
@@ -293,12 +254,11 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[
|
||||
canonicalKey := canonicalHyprlandOverrideKey(key)
|
||||
normalizedKey := hyprlandOverrideMapKey(canonicalKey)
|
||||
existingBinds[normalizedKey] = &hyprlandOverrideBind{
|
||||
Key: canonicalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
Options: options,
|
||||
RawLuaAction: isRawLuaActionText(action),
|
||||
Key: canonicalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
Options: options,
|
||||
}
|
||||
|
||||
return h.writeOverrideBinds(existingBinds)
|
||||
|
||||
@@ -463,56 +463,6 @@ func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRawLuaActionText(t *testing.T) {
|
||||
cases := []struct {
|
||||
action string
|
||||
want bool
|
||||
}{
|
||||
{`function() hl.plugin.scrolloverview.overview("toggle") end`, true},
|
||||
{`hl.dsp.exec_cmd("foo")`, true},
|
||||
{`hl.dsp.no_op()`, true},
|
||||
{"workspace 3", false},
|
||||
{"exec zeditor", false},
|
||||
{`function() hl.foo( end`, false},
|
||||
{`function() hl.foo("a) end`, false},
|
||||
{`hl.foo()) hl.bar((`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isRawLuaActionText(tc.action); got != tc.want {
|
||||
t.Errorf("isRawLuaActionText(%q) = %v, want %v", tc.action, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte("-- DMS user keybind overrides\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := NewHyprlandProvider(tmpDir)
|
||||
rawAction := `function() hl.plugin.scrolloverview.overview("toggle") end`
|
||||
if err := provider.SetBind("SUPER + G", rawAction, "Toggle overview", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if !strings.Contains(got, `hl.bind("SUPER + G", `+rawAction) {
|
||||
t.Fatalf("expected raw Lua action to be written verbatim, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "hyprctl dispatch function") {
|
||||
t.Fatalf("expected raw Lua action to not be wrapped in hyprctl dispatch, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dmsDir := filepath.Join(tmpDir, "dms")
|
||||
|
||||
@@ -386,8 +386,8 @@ func buildOnce(opts *Options) (bool, error) {
|
||||
default:
|
||||
syncAccentColor(primaryDark)
|
||||
}
|
||||
refreshGTKTheme(opts.Mode)
|
||||
refreshGTKColorScheme()
|
||||
refreshGTK(opts.Mode)
|
||||
refreshGTK4()
|
||||
}
|
||||
|
||||
if isDMSKDEColorSchemeActive(opts.ConfigDir) {
|
||||
@@ -471,9 +471,12 @@ output_path = '%s'
|
||||
case TemplateKindTerminal:
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
case TemplateKindVSCode:
|
||||
for _, editor := range vscodeEditors {
|
||||
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
|
||||
}
|
||||
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
|
||||
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
||||
case TemplateKindEmacs:
|
||||
if utils.EmacsConfigDir() != "" {
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||
@@ -630,23 +633,6 @@ func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []stri
|
||||
return false
|
||||
}
|
||||
|
||||
type vscodeEditor struct {
|
||||
name string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var vscodeEditors = []vscodeEditor{
|
||||
{"vscode", ".vscode"},
|
||||
{"codium", ".vscode-oss"},
|
||||
{"cursor", ".cursor"},
|
||||
{"windsurf", ".windsurf"},
|
||||
{"vscode-insiders", ".vscode-insiders"},
|
||||
}
|
||||
|
||||
func (e vscodeEditor) extensionsDir(homeDir string) string {
|
||||
return filepath.Join(homeDir, e.dataDir, "extensions")
|
||||
}
|
||||
|
||||
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
|
||||
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
@@ -925,7 +911,7 @@ func generateDank16Variants(primaryDark, primaryLight, surface string, mode Colo
|
||||
}
|
||||
|
||||
func isDMSGTKActive(configDir string) bool {
|
||||
gtkCSS := filepath.Join(configDir, "gtk-4.0", "gtk.css")
|
||||
gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
|
||||
|
||||
info, err := os.Lstat(gtkCSS)
|
||||
if err != nil {
|
||||
@@ -982,32 +968,11 @@ func applyKDEColorScheme(mode ColorMode) {
|
||||
}
|
||||
}
|
||||
|
||||
func gtkThemeInstalled(theme string) bool {
|
||||
home, _ := os.UserHomeDir()
|
||||
candidates := []string{
|
||||
filepath.Join(home, ".local/share/themes", theme),
|
||||
filepath.Join(home, ".themes", theme),
|
||||
filepath.Join("/usr/share/themes", theme),
|
||||
filepath.Join("/usr/local/share/themes", theme),
|
||||
}
|
||||
for _, dir := range candidates {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func refreshGTKTheme(mode ColorMode) {
|
||||
theme := mode.GTKTheme()
|
||||
if !gtkThemeInstalled(theme) {
|
||||
log.Infof("Skipping gtk-theme refresh: %s is not installed", theme)
|
||||
return
|
||||
}
|
||||
func refreshGTK(mode ColorMode) {
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil {
|
||||
log.Warnf("Failed to reset gtk-theme: %v", err)
|
||||
}
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", theme); err != nil {
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil {
|
||||
log.Warnf("Failed to set gtk-theme: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1028,8 +993,8 @@ func expectColorSchemeEcho(scheme string) {
|
||||
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
|
||||
// following the portal color-scheme (Chromium) can drop the restore signal
|
||||
// mid-repaint and latch the wrong mode, so this is opt-in.
|
||||
func refreshGTKColorScheme() {
|
||||
if os.Getenv("DMS_ENABLE_GTK_REFRESH") != "1" {
|
||||
func refreshGTK4() {
|
||||
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
|
||||
return
|
||||
}
|
||||
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||
@@ -1047,13 +1012,13 @@ func refreshGTKColorScheme() {
|
||||
|
||||
expectColorSchemeEcho(toggle)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
|
||||
log.Warnf("Failed to toggle color-scheme for GTK refresh: %v", err)
|
||||
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
expectColorSchemeEcho(current)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
|
||||
log.Warnf("Failed to restore color-scheme for GTK refresh: %v", err)
|
||||
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,8 +1147,16 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
||||
}
|
||||
|
||||
func checkVSCodeExtension(homeDir string) bool {
|
||||
for _, editor := range vscodeEditors {
|
||||
pattern := filepath.Join(editor.extensionsDir(homeDir), "danklinux.dms-theme-*")
|
||||
extDirs := []string{
|
||||
filepath.Join(homeDir, ".vscode/extensions"),
|
||||
filepath.Join(homeDir, ".vscode-oss/extensions"),
|
||||
filepath.Join(homeDir, ".config/Code - OSS/extensions"),
|
||||
filepath.Join(homeDir, ".cursor/extensions"),
|
||||
filepath.Join(homeDir, ".windsurf/extensions"),
|
||||
}
|
||||
|
||||
for _, extDir := range extDirs {
|
||||
pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
|
||||
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
package mocks_wlclient
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
package mocks_wlcontext
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
|
||||
@@ -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://[email protected]/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:[email protected]/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://[email protected]/user/plugin.git",
|
||||
"[email protected]:user/plugin.git",
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
t.Run(repo, func(t *testing.T) {
|
||||
lock := PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{
|
||||
"plugin": {Repo: repo, Commit: testCommit},
|
||||
}}
|
||||
assert.NoError(t, lock.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingPluginLockfileIsEmpty(t *testing.T) {
|
||||
store := NewLockStore(afero.NewMemMapFs(), "/config/plugins.lock.json")
|
||||
lock, err := store.Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pluginLockfileVersion, lock.LockfileVersion)
|
||||
assert.Empty(t, lock.Plugins)
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
type Manager struct {
|
||||
fs afero.Fs
|
||||
pluginsDir string
|
||||
lockPath string
|
||||
gitClient GitClient
|
||||
}
|
||||
|
||||
@@ -29,7 +28,6 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
|
||||
return &Manager{
|
||||
fs: fs,
|
||||
pluginsDir: pluginsDir,
|
||||
lockPath: getPluginLockPath(),
|
||||
gitClient: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
@@ -43,15 +41,6 @@ func getPluginsDir() string {
|
||||
return filepath.Join(configDir, "DankMaterialShell", "plugins")
|
||||
}
|
||||
|
||||
func getPluginLockPath() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Error("failed to get user config dir", "err", err)
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(configDir, "DankMaterialShell", "plugins.lock.json")
|
||||
}
|
||||
|
||||
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
|
||||
path, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
@@ -129,12 +118,7 @@ func (m *Manager) findInDir(dir, pluginID string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) Install(plugin Plugin) error {
|
||||
if !isSafePluginPathComponent(plugin.ID) {
|
||||
return fmt.Errorf("invalid plugin id: %q", plugin.ID)
|
||||
}
|
||||
|
||||
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
|
||||
repoPath := pluginPath
|
||||
|
||||
exists, err := afero.DirExists(m.fs, pluginPath)
|
||||
if err != nil {
|
||||
@@ -144,18 +128,6 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
if exists {
|
||||
return fmt.Errorf("plugin already installed: %s", plugin.Name)
|
||||
}
|
||||
if strings.TrimSpace(plugin.Repo) == "" {
|
||||
return fmt.Errorf("plugin repository is required")
|
||||
}
|
||||
if err := validatePluginRepo(plugin.Repo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePluginRepoPath(plugin.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.ensureLockfile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.fs.MkdirAll(m.pluginsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create plugins directory: %w", err)
|
||||
@@ -168,7 +140,7 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
|
||||
if plugin.Path != "" {
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath = filepath.Join(reposDir, repoName)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
repoExists, err := afero.DirExists(m.fs, repoPath)
|
||||
if err != nil {
|
||||
@@ -206,13 +178,12 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
if err := m.createSymlink(sourcePath, pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to create symlink: %w", err)
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
|
||||
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil {
|
||||
m.fs.Remove(pluginPath) //nolint:errcheck
|
||||
return fmt.Errorf("failed to write plugin repository metadata: %w", err)
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||
m.fs.RemoveAll(pluginPath) //nolint:errcheck
|
||||
@@ -220,16 +191,6 @@ func (m *Manager) Install(plugin Plugin) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
if plugin.Path != "" {
|
||||
m.fs.Remove(pluginPath) //nolint:errcheck
|
||||
m.fs.Remove(pluginPath + ".meta") //nolint:errcheck
|
||||
} else {
|
||||
m.fs.RemoveAll(pluginPath) //nolint:errcheck
|
||||
}
|
||||
return fmt.Errorf("failed to update plugin lockfile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -246,15 +207,6 @@ func (m *Manager) createSymlink(source, dest string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) Update(plugin Plugin) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -267,42 +219,46 @@ func (m *Manager) Update(plugin Plugin) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
|
||||
}
|
||||
if plugin.Repo == "" {
|
||||
plugin.Repo, err = m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugin origin: %w", err)
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
if metaExists {
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
|
||||
if err := m.gitClient.Pull(repoPath); err != nil {
|
||||
// Repository is likely corrupted or has issues, delete and re-clone
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted repository: %w", err)
|
||||
}
|
||||
|
||||
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone repository: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try to pull, if it fails, delete and re-clone
|
||||
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted plugin: %w", err)
|
||||
}
|
||||
|
||||
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone plugin: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := m.repositoryPath(plugin.ID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
||||
if err := m.gitClient.Pull(repoPath); err != nil {
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted plugin repository: %w", err)
|
||||
}
|
||||
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone plugin repository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
m.gitClient.CheckoutRevision(repoPath, locked.Commit) //nolint:errcheck
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Uninstall(plugin Plugin) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[plugin.ID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(plugin.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -315,64 +271,70 @@ func (m *Manager) Uninstall(plugin Plugin) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
|
||||
}
|
||||
updatedLock := lock.Clone()
|
||||
delete(updatedLock.Plugins, plugin.ID)
|
||||
if err := m.lockStore().Write(updatedLock); err != nil {
|
||||
return err
|
||||
}
|
||||
rollbackLock := func(err error) error {
|
||||
if rollbackErr := m.lockStore().Write(lock); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to restore plugin lockfile: %v)", err, rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
if plugin.Path != "" {
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
if metaExists {
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
shouldCleanup, err := m.shouldCleanupRepo(plugin.Repo, plugin.ID)
|
||||
shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID)
|
||||
if err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to check repo cleanup: %w", err))
|
||||
return fmt.Errorf("failed to check repo cleanup: %w", err)
|
||||
}
|
||||
|
||||
if err := m.fs.Remove(pluginPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to remove symlink: %w", err))
|
||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
||||
}
|
||||
|
||||
if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists {
|
||||
if err := m.fs.Remove(metaPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to remove metadata: %w", err))
|
||||
}
|
||||
if err := m.fs.Remove(metaPath); err != nil {
|
||||
return fmt.Errorf("failed to remove metadata: %w", err)
|
||||
}
|
||||
|
||||
if shouldCleanup {
|
||||
if err := m.fs.RemoveAll(repoPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to cleanup repository: %w", err))
|
||||
return fmt.Errorf("failed to cleanup repository: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := m.fs.RemoveAll(pluginPath); err != nil {
|
||||
return rollbackLock(fmt.Errorf("failed to remove plugin: %w", err))
|
||||
return fmt.Errorf("failed to remove plugin: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) shouldCleanupRepo(repoURL, excludePlugin string) (bool, error) {
|
||||
lock, err := m.ensureLockfile()
|
||||
func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
|
||||
installed, err := m.ListInstalled()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for id, plugin := range lock.Plugins {
|
||||
|
||||
registry, err := NewRegistry()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
allPlugins, err := registry.List()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, id := range installed {
|
||||
if id == excludePlugin {
|
||||
continue
|
||||
}
|
||||
if plugin.Repo == repoURL && plugin.Path != "" {
|
||||
return false, nil
|
||||
|
||||
for _, p := range allPlugins {
|
||||
if p.ID == id && p.Repo == repoURL && p.Path != "" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,16 +456,6 @@ func (m *Manager) UninstallByIDOrName(idOrName string) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot uninstall system plugin: %s", idOrName)
|
||||
}
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest != nil {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[manifest.ID]; ok {
|
||||
return m.Uninstall(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path})
|
||||
}
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, _ := afero.Exists(m.fs, metaPath)
|
||||
@@ -536,16 +488,6 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return fmt.Errorf("cannot update system plugin: %s", idOrName)
|
||||
}
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest != nil {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if locked, ok := lock.Plugins[manifest.ID]; ok {
|
||||
return m.Update(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path})
|
||||
}
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, _ := afero.Exists(m.fs, metaPath)
|
||||
@@ -560,14 +502,8 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error {
|
||||
if err := m.gitClient.Pull(pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to update plugin: %w", err)
|
||||
}
|
||||
if manifest == nil {
|
||||
return nil
|
||||
}
|
||||
repo, err := m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugin origin: %w", err)
|
||||
}
|
||||
return m.recordInstalledPlugin(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: repo}, pluginPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
|
||||
@@ -636,15 +572,6 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if locked, ok := lock.Plugins[pluginID]; ok {
|
||||
plugin.Repo = locked.Repo
|
||||
plugin.Path = locked.Path
|
||||
}
|
||||
|
||||
pluginPath, err := m.findInstalledPath(pluginID)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to find plugin: %w", err)
|
||||
@@ -658,11 +585,25 @@ func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, d
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
repoPath := pluginPath
|
||||
if plugin.Path != "" {
|
||||
repoPath = m.repositoryPath(pluginID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path})
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
var hasUp bool
|
||||
var localHash, remoteHash string
|
||||
if metaExists {
|
||||
// Plugin is from a monorepo, check the repo directory
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
|
||||
} else {
|
||||
// Plugin is a standalone repo
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
|
||||
}
|
||||
hasUp, localHash, remoteHash, err := m.gitClient.HasUpdates(repoPath)
|
||||
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func (m *Manager) lockStore() *LockStore {
|
||||
lockPath := m.lockPath
|
||||
if lockPath == "" {
|
||||
lockPath = filepath.Join(filepath.Dir(m.pluginsDir), "plugins.lock.json")
|
||||
}
|
||||
return NewLockStore(m.fs, lockPath)
|
||||
}
|
||||
|
||||
func (m *Manager) GetLockfilePath() string {
|
||||
return m.lockStore().Path()
|
||||
}
|
||||
|
||||
func (m *Manager) ensureLockfile() (PluginLockfile, error) {
|
||||
store := m.lockStore()
|
||||
exists, err := store.Exists()
|
||||
if err != nil {
|
||||
return PluginLockfile{}, fmt.Errorf("failed to check plugin lockfile: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return store.Load()
|
||||
}
|
||||
|
||||
lock, _, err := m.SnapshotLockfile()
|
||||
if err != nil {
|
||||
return PluginLockfile{}, err
|
||||
}
|
||||
if err := store.Write(lock); err != nil {
|
||||
return PluginLockfile{}, err
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SnapshotLockfile() (PluginLockfile, []string, error) {
|
||||
lock := NewPluginLockfile()
|
||||
warnings := []string{}
|
||||
|
||||
exists, err := afero.DirExists(m.fs, m.pluginsDir)
|
||||
if err != nil {
|
||||
return PluginLockfile{}, nil, fmt.Errorf("failed to check plugins directory: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return lock, warnings, nil
|
||||
}
|
||||
|
||||
entries, err := afero.ReadDir(m.fs, m.pluginsDir)
|
||||
if err != nil {
|
||||
return PluginLockfile{}, nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == ".repos" || strings.HasSuffix(name, ".meta") {
|
||||
continue
|
||||
}
|
||||
|
||||
pluginPath := filepath.Join(m.pluginsDir, name)
|
||||
manifest := m.getPluginManifest(pluginPath)
|
||||
if manifest == nil || manifest.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
locked, repoPath, err := m.lockedPluginFromInstall(pluginPath)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: %v", manifest.ID, err))
|
||||
continue
|
||||
}
|
||||
commit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("%s: cannot read git revision: %v", manifest.ID, err))
|
||||
continue
|
||||
}
|
||||
locked.Commit = commit
|
||||
lock.SetRepositoryCommit(locked.Repo, commit)
|
||||
lock.Plugins[manifest.ID] = locked
|
||||
}
|
||||
|
||||
if err := lock.Validate(); err != nil {
|
||||
return PluginLockfile{}, warnings, err
|
||||
}
|
||||
return lock, warnings, nil
|
||||
}
|
||||
|
||||
func (m *Manager) lockedPluginFromInstall(pluginPath string) (LockedPlugin, string, error) {
|
||||
metaPath := pluginPath + ".meta"
|
||||
if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists {
|
||||
data, err := afero.ReadFile(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return LockedPlugin{}, "", fmt.Errorf("failed to read legacy metadata: %w", err)
|
||||
}
|
||||
metadata := parseLegacyPluginMetadata(string(data))
|
||||
repo := metadata["repo"]
|
||||
path := metadata["path"]
|
||||
repoDir := metadata["repodir"]
|
||||
if repo == "" || repoDir == "" {
|
||||
return LockedPlugin{}, "", fmt.Errorf("legacy metadata is incomplete")
|
||||
}
|
||||
if err := validatePluginRepo(repo); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
if !isSafePluginPathComponent(repoDir) {
|
||||
return LockedPlugin{}, "", fmt.Errorf("legacy repository directory is invalid")
|
||||
}
|
||||
if err := validatePluginRepoPath(path); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
return LockedPlugin{Repo: repo, Path: path}, filepath.Join(m.pluginsDir, ".repos", repoDir), nil
|
||||
}
|
||||
|
||||
repo, err := m.gitClient.OriginURL(pluginPath)
|
||||
if err != nil {
|
||||
return LockedPlugin{}, "", fmt.Errorf("plugin is not a managed git checkout")
|
||||
}
|
||||
if err := validatePluginRepo(repo); err != nil {
|
||||
return LockedPlugin{}, "", err
|
||||
}
|
||||
return LockedPlugin{Repo: repo}, pluginPath, nil
|
||||
}
|
||||
|
||||
func parseLegacyPluginMetadata(data string) map[string]string {
|
||||
metadata := make(map[string]string)
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if ok {
|
||||
metadata[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (m *Manager) recordInstalledPlugin(plugin Plugin, repoPath string) error {
|
||||
lock, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read installed plugin revision: %w", err)
|
||||
}
|
||||
|
||||
lock.SetRepositoryCommit(plugin.Repo, commit)
|
||||
lock.Plugins[plugin.ID] = LockedPlugin{
|
||||
Repo: plugin.Repo,
|
||||
Path: plugin.Path,
|
||||
Commit: commit,
|
||||
}
|
||||
return m.lockStore().Write(lock)
|
||||
}
|
||||
|
||||
func (m *Manager) WriteCurrentLockfile(outputPath string) ([]string, error) {
|
||||
lock, warnings, err := m.SnapshotLockfile()
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
if err := m.lockStore().Write(lock); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
if outputPath != "" && outputPath != m.GetLockfilePath() {
|
||||
if err := NewLockStore(m.fs, outputPath).Write(lock); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (m *Manager) RestoreFromLockfile(sourcePath string, prune bool) error {
|
||||
if sourcePath == "" {
|
||||
sourcePath = m.GetLockfilePath()
|
||||
}
|
||||
sourceStore := NewLockStore(m.fs, sourcePath)
|
||||
exists, err := sourceStore.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check plugin lockfile: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("plugin lockfile not found: %s", sourcePath)
|
||||
}
|
||||
target, err := sourceStore.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := m.ensureLockfile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range target.IDs() {
|
||||
wanted := target.Plugins[id]
|
||||
installedPath, err := m.findInstalledPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if installedPath != "" {
|
||||
existing, managed := current.Plugins[id]
|
||||
if !managed {
|
||||
return fmt.Errorf("plugin %q is already installed but is not managed by the lockfile", id)
|
||||
}
|
||||
if existing.Repo != wanted.Repo || existing.Path != wanted.Path {
|
||||
if err := m.UninstallByIDOrName(id); err != nil {
|
||||
return err
|
||||
}
|
||||
installedPath = ""
|
||||
}
|
||||
}
|
||||
|
||||
plugin := Plugin{ID: id, Name: id, Repo: wanted.Repo, Path: wanted.Path}
|
||||
newlyInstalled := installedPath == ""
|
||||
if installedPath == "" {
|
||||
if err := m.Install(plugin); err != nil {
|
||||
return fmt.Errorf("failed to restore plugin %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := m.repositoryPath(id, wanted)
|
||||
previousCommit, err := m.gitClient.CurrentRevision(repoPath)
|
||||
if err != nil {
|
||||
if newlyInstalled {
|
||||
m.UninstallByIDOrName(id) //nolint:errcheck
|
||||
}
|
||||
return fmt.Errorf("failed to read current revision for plugin %q: %w", id, err)
|
||||
}
|
||||
if err := m.gitClient.CheckoutRevision(repoPath, wanted.Commit); err != nil {
|
||||
if newlyInstalled {
|
||||
if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil {
|
||||
return fmt.Errorf("failed to restore plugin %q at %s: %w (also failed to remove the partial install: %v)", id, wanted.Commit, err, rollbackErr)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to restore plugin %q at %s: %w", id, wanted.Commit, err)
|
||||
}
|
||||
rollback := func(restoreErr error) error {
|
||||
if newlyInstalled {
|
||||
if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to remove the partial install: %v)", restoreErr, rollbackErr)
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
if rollbackErr := m.gitClient.CheckoutRevision(repoPath, previousCommit); rollbackErr != nil {
|
||||
return fmt.Errorf("%w (also failed to restore revision %s: %v)", restoreErr, previousCommit, rollbackErr)
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
pluginPath := installedPath
|
||||
if pluginPath == "" {
|
||||
pluginPath = filepath.Join(m.pluginsDir, id)
|
||||
}
|
||||
if actualID := m.getPluginID(pluginPath); actualID != id {
|
||||
return rollback(fmt.Errorf("restored plugin %q has manifest id %q", id, actualID))
|
||||
}
|
||||
if err := m.recordInstalledPlugin(plugin, repoPath); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
current, err = m.lockStore().Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if prune {
|
||||
for _, id := range current.IDs() {
|
||||
if _, wanted := target.Plugins[id]; wanted {
|
||||
continue
|
||||
}
|
||||
if err := m.UninstallByIDOrName(id); err != nil {
|
||||
return fmt.Errorf("failed to prune plugin %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
return m.lockStore().Write(target)
|
||||
}
|
||||
|
||||
merged, err := m.lockStore().Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for id, plugin := range target.Plugins {
|
||||
merged.SetRepositoryCommit(plugin.Repo, plugin.Commit)
|
||||
merged.Plugins[id] = plugin
|
||||
}
|
||||
return m.lockStore().Write(merged)
|
||||
}
|
||||
|
||||
func (m *Manager) repositoryPath(pluginID string, plugin LockedPlugin) string {
|
||||
if plugin.Path != "" {
|
||||
return filepath.Join(m.pluginsDir, ".repos", m.getRepoName(plugin.Repo))
|
||||
}
|
||||
if installedPath, err := m.findInDir(m.pluginsDir, pluginID); err == nil && installedPath != "" {
|
||||
return installedPath
|
||||
}
|
||||
return filepath.Join(m.pluginsDir, pluginID)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -17,7 +16,6 @@ func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) {
|
||||
manager := &Manager{
|
||||
fs: fs,
|
||||
pluginsDir: pluginsDir,
|
||||
lockPath: "/config/plugins.lock.json",
|
||||
gitClient: &mockGitClient{},
|
||||
}
|
||||
return manager, fs, pluginsDir
|
||||
@@ -247,244 +245,3 @@ func TestManagerGetPluginsDir(t *testing.T) {
|
||||
manager, _, pluginsDir := setupTestManager(t)
|
||||
assert.Equal(t, pluginsDir, manager.GetPluginsDir())
|
||||
}
|
||||
|
||||
func TestInstallUpdatesLockfile(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Install(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, LockedPlugin{Repo: plugin.Repo, Commit: testCommit}, lock.Plugins[plugin.ID])
|
||||
exists, err := afero.Exists(fs, filepath.Join(pluginsDir, plugin.ID))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestUpdateRefreshesLockedCommit(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, plugin.ID)
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
newCommit := "1123456789abcdef0123456789abcdef01234567"
|
||||
manager.gitClient = &mockGitClient{
|
||||
pullFunc: func(string) error { return nil },
|
||||
revisionFunc: func(string) (string, error) { return newCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Update(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCommit, lock.Plugins[plugin.ID].Commit)
|
||||
}
|
||||
|
||||
func TestUpdateResolvesRenamedPluginDirectory(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, "renamed-dir")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
var pulledPath string
|
||||
manager.gitClient = &mockGitClient{
|
||||
pullFunc: func(path string) error {
|
||||
pulledPath = path
|
||||
return nil
|
||||
},
|
||||
cloneFunc: func(path, _ string) error {
|
||||
t.Fatalf("unexpected clone to %s", path)
|
||||
return nil
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.Update(plugin))
|
||||
assert.Equal(t, pluginPath, pulledPath)
|
||||
}
|
||||
|
||||
func TestUninstallRemovesLockedPlugin(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"}
|
||||
pluginPath := filepath.Join(pluginsDir, plugin.ID)
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
plugin.ID: {Repo: plugin.Repo, Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
|
||||
require.NoError(t, manager.Uninstall(plugin))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, lock.Plugins, plugin.ID)
|
||||
}
|
||||
|
||||
func TestRestoreFromLockfileInstallsExactCommit(t *testing.T) {
|
||||
manager, fs, _ := setupTestManager(t)
|
||||
wantedCommit := "2123456789abcdef0123456789abcdef01234567"
|
||||
incoming := PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"restored": {Repo: "https://github.com/test/restored.git", Commit: wantedCommit},
|
||||
},
|
||||
}
|
||||
require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming))
|
||||
currentCommit := testCommit
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"restored"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return currentCommit, nil },
|
||||
checkoutFunc: func(_ string, revision string) error {
|
||||
currentCommit = revision
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, manager.RestoreFromLockfile("/incoming.lock.json", false))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantedCommit, lock.Plugins["restored"].Commit)
|
||||
}
|
||||
|
||||
func TestSnapshotLockfileMigratesLegacyMonorepoMetadata(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "legacy")
|
||||
repoPath := filepath.Join(pluginsDir, ".repos", "legacy-repo")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, fs.MkdirAll(repoPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"legacy"}`), 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, pluginPath+".meta", []byte("repo=https://github.com/test/suite.git\npath=plugins/legacy\nrepodir=legacy-repo"), 0o644))
|
||||
manager.gitClient = &mockGitClient{
|
||||
revisionFunc: func(path string) (string, error) {
|
||||
assert.Equal(t, repoPath, path)
|
||||
return testCommit, nil
|
||||
},
|
||||
}
|
||||
|
||||
lock, warnings, err := manager.SnapshotLockfile()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, LockedPlugin{
|
||||
Repo: "https://github.com/test/suite.git",
|
||||
Path: "plugins/legacy",
|
||||
Commit: testCommit,
|
||||
}, lock.Plugins["legacy"])
|
||||
}
|
||||
|
||||
func TestRecordInstalledPluginUpdatesSharedRepositoryCommit(t *testing.T) {
|
||||
manager, _, _ := setupTestManager(t)
|
||||
repo := "https://github.com/test/suite.git"
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"one": {Repo: repo, Path: "plugins/one", Commit: testCommit},
|
||||
"two": {Repo: repo, Path: "plugins/two", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
newCommit := "3123456789abcdef0123456789abcdef01234567"
|
||||
manager.gitClient = &mockGitClient{
|
||||
revisionFunc: func(string) (string, error) { return newCommit, nil },
|
||||
}
|
||||
|
||||
require.NoError(t, manager.recordInstalledPlugin(Plugin{ID: "one", Repo: repo, Path: "plugins/one"}, "/repo"))
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCommit, lock.Plugins["one"].Commit)
|
||||
assert.Equal(t, newCommit, lock.Plugins["two"].Commit)
|
||||
}
|
||||
|
||||
func TestRestoreFromLockfilePrunesManagedPlugins(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "old")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"old","name":"Old"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"old": {Repo: "https://github.com/test/old.git", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, NewLockStore(fs, "/empty.lock.json").Write(NewPluginLockfile()))
|
||||
|
||||
require.NoError(t, manager.RestoreFromLockfile("/empty.lock.json", true))
|
||||
exists, err := afero.Exists(fs, pluginPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
lock, err := manager.lockStore().Load()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, lock.Plugins)
|
||||
}
|
||||
|
||||
func TestRestoreMissingLockfileDoesNotPrune(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
pluginPath := filepath.Join(pluginsDir, "kept")
|
||||
require.NoError(t, fs.MkdirAll(pluginPath, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"kept"}`), 0o644))
|
||||
require.NoError(t, manager.lockStore().Write(PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"kept": {Repo: "https://github.com/test/kept.git", Commit: testCommit},
|
||||
},
|
||||
}))
|
||||
|
||||
err := manager.RestoreFromLockfile("/missing.lock.json", true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
exists, statErr := afero.Exists(fs, pluginPath)
|
||||
require.NoError(t, statErr)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestRestoreFailedRevisionRemovesPartialInstall(t *testing.T) {
|
||||
manager, fs, pluginsDir := setupTestManager(t)
|
||||
incoming := PluginLockfile{
|
||||
LockfileVersion: 1,
|
||||
Plugins: map[string]LockedPlugin{
|
||||
"broken": {Repo: "https://github.com/test/broken.git", Commit: testCommit},
|
||||
},
|
||||
}
|
||||
require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming))
|
||||
manager.gitClient = &mockGitClient{
|
||||
cloneFunc: func(path, _ string) error {
|
||||
require.NoError(t, fs.MkdirAll(path, 0o755))
|
||||
return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"broken"}`), 0o644)
|
||||
},
|
||||
revisionFunc: func(string) (string, error) { return testCommit, nil },
|
||||
checkoutFunc: func(string, string) error {
|
||||
return errors.New("revision unavailable")
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.RestoreFromLockfile("/incoming.lock.json", false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "revision unavailable")
|
||||
exists, statErr := afero.Exists(fs, filepath.Join(pluginsDir, "broken"))
|
||||
require.NoError(t, statErr)
|
||||
assert.False(t, exists)
|
||||
lock, loadErr := manager.lockStore().Load()
|
||||
require.NoError(t, loadErr)
|
||||
assert.NotContains(t, lock.Plugins, "broken")
|
||||
}
|
||||
|
||||
@@ -2,18 +2,17 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/plumbing"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||
|
||||
type Plugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -34,10 +33,7 @@ type Plugin struct {
|
||||
type GitClient interface {
|
||||
PlainClone(path string, url string) error
|
||||
Pull(path string) error
|
||||
OriginURL(path string) (string, error)
|
||||
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
|
||||
CurrentRevision(path string) (string, error)
|
||||
CheckoutRevision(path string, revision string) error
|
||||
}
|
||||
|
||||
type realGitClient struct{}
|
||||
@@ -69,22 +65,6 @@ func (g *realGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) OriginURL(path string) (string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
urls := remote.Config().URLs
|
||||
if len(urls) == 0 {
|
||||
return "", errors.New("origin remote has no URL")
|
||||
}
|
||||
return urls[0], nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
@@ -138,53 +118,11 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
return localHash != remoteHead, localHash, remoteHead, nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) CurrentRevision(path string) (string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return head.Hash().String(), nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) CheckoutRevision(path string, revision string) error {
|
||||
if !gitCommitPattern.MatchString(revision) {
|
||||
return fmt.Errorf("invalid git revision %q", revision)
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := plumbing.NewHash(revision)
|
||||
if _, objectErr := repo.CommitObject(hash); objectErr != nil {
|
||||
if _, remoteErr := repo.Remote("origin"); remoteErr != nil {
|
||||
return fmt.Errorf("revision %s is not available in repository: %w", revision, objectErr)
|
||||
}
|
||||
fetchErr := repo.Fetch(&git.FetchOptions{})
|
||||
if fetchErr != nil && fetchErr != git.NoErrAlreadyUpToDate {
|
||||
return fmt.Errorf("failed to fetch locked revision: %w", fetchErr)
|
||||
}
|
||||
}
|
||||
if _, err := repo.CommitObject(hash); err != nil {
|
||||
return fmt.Errorf("revision %s is not available in repository: %w", revision, err)
|
||||
}
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return worktree.Reset(&git.ResetOptions{Commit: hash, Mode: git.HardReset})
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
registries []registries.Source
|
||||
plugins []Plugin
|
||||
git GitClient
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
plugins []Plugin
|
||||
git GitClient
|
||||
}
|
||||
|
||||
func NewRegistry() (*Registry, error) {
|
||||
@@ -192,63 +130,63 @@ func NewRegistry() (*Registry, error) {
|
||||
}
|
||||
|
||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||
cacheDir := getCacheDir()
|
||||
return &Registry{
|
||||
fs: fs,
|
||||
cacheDir: getCacheDir(),
|
||||
registries: registries.Load(fs),
|
||||
git: &realGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: cacheDir,
|
||||
git: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) cacheDirFor(src registries.Source) string {
|
||||
return filepath.Join(r.cacheDir, src.Name)
|
||||
}
|
||||
|
||||
func getCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
||||
}
|
||||
|
||||
// A cached clone is reused only when its origin still matches the configured
|
||||
// URL; renamed or re-pointed registries re-clone instead of pulling from the
|
||||
// stale remote.
|
||||
func (r *Registry) updateOne(src registries.Source) error {
|
||||
dir := r.cacheDirFor(src)
|
||||
exists, err := afero.DirExists(r.fs, dir)
|
||||
func (r *Registry) Update() error {
|
||||
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check cache directory: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
origin, originErr := r.git.OriginURL(dir)
|
||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
||||
return nil
|
||||
if !exists {
|
||||
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
if err := r.fs.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
||||
|
||||
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||
return fmt.Errorf("failed to clone registry: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
|
||||
if err := r.git.Pull(r.cacheDir); err != nil {
|
||||
// Repository is likely corrupted or has issues, delete and re-clone
|
||||
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
|
||||
return fmt.Errorf("failed to remove corrupted registry: %w", err)
|
||||
}
|
||||
|
||||
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||
return fmt.Errorf("failed to re-clone registry: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
if err := r.git.PlainClone(dir, src.URL); err != nil {
|
||||
return fmt.Errorf("failed to clone: %w", err)
|
||||
}
|
||||
return nil
|
||||
return r.loadPlugins()
|
||||
}
|
||||
|
||||
// A registry without a plugins/ directory is a valid themes-only registry.
|
||||
func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
|
||||
pluginsDir := filepath.Join(dir, "plugins")
|
||||
func (r *Registry) loadPlugins() error {
|
||||
pluginsDir := filepath.Join(r.cacheDir, "plugins")
|
||||
|
||||
entries, err := afero.ReadDir(r.fs, pluginsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
return fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
}
|
||||
|
||||
var plugins []Plugin
|
||||
r.plugins = []Plugin{}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
@@ -268,51 +206,15 @@ func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
|
||||
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
|
||||
}
|
||||
|
||||
plugins = append(plugins, plugin)
|
||||
r.plugins = append(r.plugins, plugin)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// Pre-multi-registry caches were a single clone at the base dir; the per-name
|
||||
// layout nests under it, so a leftover clone is deleted wholesale first.
|
||||
func (r *Registry) resetLegacyCache() {
|
||||
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
|
||||
_ = r.fs.RemoveAll(r.cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Update refreshes every configured registry, aggregating plugins in
|
||||
// declaration order (first occurrence of an ID wins). A failing registry is
|
||||
// reported in the joined error but does not block the others.
|
||||
func (r *Registry) Update() error {
|
||||
r.resetLegacyCache()
|
||||
r.plugins = []Plugin{}
|
||||
seen := make(map[string]struct{})
|
||||
var errs []error
|
||||
for _, src := range r.registries {
|
||||
if err := r.updateOne(src); err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
plugins, err := r.loadPluginsFrom(r.cacheDirFor(src))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
for _, p := range plugins {
|
||||
if _, dup := seen[p.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = struct{}{}
|
||||
r.plugins = append(r.plugins, p)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() ([]Plugin, error) {
|
||||
if len(r.plugins) == 0 {
|
||||
if err := r.Update(); err != nil && len(r.plugins) == 0 {
|
||||
if err := r.Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,29 +2,18 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/plumbing/object"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testRegistryURL = "https://example.com/test-registry.git"
|
||||
|
||||
type mockGitClient struct {
|
||||
cloneFunc func(path string, url string) error
|
||||
pullFunc func(path string) error
|
||||
originFunc func(path string) (string, error)
|
||||
hasUpdatesFunc func(path string) (bool, string, string, error)
|
||||
revisionFunc func(path string) (string, error)
|
||||
checkoutFunc func(path string, revision string) error
|
||||
}
|
||||
|
||||
func (m *mockGitClient) PlainClone(path string, url string) error {
|
||||
@@ -41,13 +30,6 @@ func (m *mockGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) OriginURL(path string) (string, error) {
|
||||
if m.originFunc != nil {
|
||||
return m.originFunc(path)
|
||||
}
|
||||
return "https://github.com/test/plugin.git", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
if m.hasUpdatesFunc != nil {
|
||||
return m.hasUpdatesFunc(path)
|
||||
@@ -55,27 +37,11 @@ func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
return false, "", "", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) CurrentRevision(path string) (string, error) {
|
||||
if m.revisionFunc != nil {
|
||||
return m.revisionFunc(path)
|
||||
}
|
||||
return "0123456789abcdef0123456789abcdef01234567", nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) CheckoutRevision(path string, revision string) error {
|
||||
if m.checkoutFunc != nil {
|
||||
return m.checkoutFunc(path, revision)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewRegistry(t *testing.T) {
|
||||
registry, err := NewRegistry()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, registry)
|
||||
assert.NotEmpty(t, registry.cacheDir)
|
||||
require.NotEmpty(t, registry.registries)
|
||||
assert.Equal(t, registries.OfficialName, registry.registries[0].Name)
|
||||
}
|
||||
|
||||
func TestGetCacheDir(t *testing.T) {
|
||||
@@ -83,50 +49,14 @@ func TestGetCacheDir(t *testing.T) {
|
||||
assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry")
|
||||
}
|
||||
|
||||
func TestRealGitClientRevisionCheckout(t *testing.T) {
|
||||
repoPath := t.TempDir()
|
||||
repo, err := git.PlainInit(repoPath, false)
|
||||
require.NoError(t, err)
|
||||
worktree, err := repo.Worktree()
|
||||
require.NoError(t, err)
|
||||
filePath := filepath.Join(repoPath, "value.txt")
|
||||
signature := &object.Signature{Name: "DMS Test", Email: "[email protected]", When: time.Unix(1, 0)}
|
||||
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("one"), 0o644))
|
||||
_, err = worktree.Add("value.txt")
|
||||
require.NoError(t, err)
|
||||
first, err := worktree.Commit("first", &git.CommitOptions{Author: signature})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.WriteFile(filePath, []byte("two"), 0o644))
|
||||
_, err = worktree.Add("value.txt")
|
||||
require.NoError(t, err)
|
||||
second, err := worktree.Commit("second", &git.CommitOptions{Author: signature})
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &realGitClient{}
|
||||
revision, err := client.CurrentRevision(repoPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second.String(), revision)
|
||||
require.NoError(t, client.CheckoutRevision(repoPath, first.String()))
|
||||
|
||||
revision, err = client.CurrentRevision(repoPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.String(), revision)
|
||||
data, err := os.ReadFile(filePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "one", string(data))
|
||||
}
|
||||
|
||||
func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
|
||||
fs := afero.NewMemMapFs()
|
||||
tmpDir := "/test-cache"
|
||||
registry := &Registry{
|
||||
fs: fs,
|
||||
cacheDir: tmpDir,
|
||||
registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
|
||||
plugins: []Plugin{},
|
||||
git: &mockGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: tmpDir,
|
||||
plugins: []Plugin{},
|
||||
git: &mockGitClient{},
|
||||
}
|
||||
return registry, fs, tmpDir
|
||||
}
|
||||
@@ -174,14 +104,14 @@ func TestLoadPlugins(t *testing.T) {
|
||||
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
|
||||
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
|
||||
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
err := registry.loadPlugins()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 2)
|
||||
assert.Len(t, registry.plugins, 2)
|
||||
|
||||
assert.Equal(t, "TestPlugin1", plugins[0].Name)
|
||||
assert.Equal(t, "TestPlugin2", plugins[1].Name)
|
||||
assert.Equal(t, []string{"dankbar-widget"}, plugins[0].Capabilities)
|
||||
assert.Equal(t, []string{"dep1", "dep2"}, plugins[1].Dependencies)
|
||||
assert.Equal(t, "TestPlugin1", registry.plugins[0].Name)
|
||||
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name)
|
||||
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities)
|
||||
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies)
|
||||
})
|
||||
|
||||
t.Run("skips non-json files", func(t *testing.T) {
|
||||
@@ -206,10 +136,34 @@ func TestLoadPlugins(t *testing.T) {
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
err = registry.loadPlugins()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("skips directories", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
pluginsDir := filepath.Join(tmpDir, "plugins")
|
||||
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
plugin := Plugin{
|
||||
Name: "ValidPlugin",
|
||||
Capabilities: []string{"test"},
|
||||
Category: "test",
|
||||
Repo: "https://github.com/test/test",
|
||||
Author: "Test",
|
||||
Description: "Test",
|
||||
Compositors: []string{"niri"},
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
err = registry.loadPlugins()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
})
|
||||
|
||||
t.Run("skips invalid json files", func(t *testing.T) {
|
||||
@@ -234,18 +188,18 @@ func TestLoadPlugins(t *testing.T) {
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
err = registry.loadPlugins()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("missing plugins directory is a themes-only registry", func(t *testing.T) {
|
||||
t.Run("returns error when plugins directory missing", func(t *testing.T) {
|
||||
registry, _, _ := setupTestRegistry(t)
|
||||
|
||||
plugins, err := registry.loadPluginsFrom(registry.cacheDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, plugins)
|
||||
err := registry.loadPlugins()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to read plugins directory")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -286,40 +240,19 @@ func TestList(t *testing.T) {
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
mockGit := &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
plugins, err := registry.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "NewPlugin", plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("partial registry failure still returns loaded plugins", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "test", URL: testRegistryURL},
|
||||
{Name: "broken", URL: "https://example.com/broken.git"},
|
||||
}
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
if url != testRegistryURL {
|
||||
return errors.New("clone failed")
|
||||
}
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
plugins, err := registry.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
@@ -338,15 +271,16 @@ func TestUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
cloneCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
mockGit := &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
assert.Equal(t, testRegistryURL, url)
|
||||
assert.Equal(t, filepath.Join(tmpDir, "test"), path)
|
||||
assert.Equal(t, registryRepo, url)
|
||||
assert.Equal(t, tmpDir, path)
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
@@ -355,7 +289,7 @@ func TestUpdate(t *testing.T) {
|
||||
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("pulls when cache exists with matching origin", func(t *testing.T) {
|
||||
t.Run("pulls updates when cache exists", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
plugin := Plugin{
|
||||
@@ -369,188 +303,24 @@ func TestUpdate(t *testing.T) {
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
err := fs.MkdirAll(tmpDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
pullCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return testRegistryURL, nil
|
||||
},
|
||||
mockGit := &mockGitClient{
|
||||
pullFunc: func(path string) error {
|
||||
pullCalled = true
|
||||
assert.Equal(t, subdir, path)
|
||||
assert.Equal(t, tmpDir, path)
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
err := registry.Update()
|
||||
err = registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, pullCalled)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("re-clones when cached origin does not match configured URL", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(subdir, "stale"), []byte("x"), 0o644))
|
||||
|
||||
pullCalled := false
|
||||
cloneCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return "https://example.com/old-origin.git", nil
|
||||
},
|
||||
pullFunc: func(path string) error {
|
||||
pullCalled = true
|
||||
return nil
|
||||
},
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
assert.Equal(t, testRegistryURL, url)
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, pullCalled, "stale origin must not be pulled")
|
||||
assert.True(t, cloneCalled)
|
||||
exists, _ := afero.Exists(fs, filepath.Join(subdir, "stale"))
|
||||
assert.False(t, exists, "stale cache contents removed before re-clone")
|
||||
})
|
||||
|
||||
t.Run("re-clones when pull fails", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
|
||||
cloneCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return testRegistryURL, nil
|
||||
},
|
||||
pullFunc: func(path string) error {
|
||||
return errors.New("shallow clone corruption")
|
||||
},
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, cloneCalled)
|
||||
})
|
||||
|
||||
t.Run("aggregates from multiple registries", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
pluginA := Plugin{ID: "a", Name: "PluginA", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
pluginB := Plugin{ID: "b", Name: "PluginB", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "official", URL: testRegistryURL},
|
||||
{Name: "louzt", URL: "https://example.com/louzt.git"},
|
||||
}
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
switch filepath.Base(path) {
|
||||
case "official":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginA)
|
||||
case "louzt":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginB)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 2)
|
||||
assert.Equal(t, "a", registry.plugins[0].ID)
|
||||
assert.Equal(t, "b", registry.plugins[1].ID)
|
||||
})
|
||||
|
||||
t.Run("dedupes by ID with declaration order priority", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
pluginOfficial := Plugin{ID: "weather", Name: "OfficialWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
pluginLouzt := Plugin{ID: "weather", Name: "LouztWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "official", URL: testRegistryURL},
|
||||
{Name: "louzt", URL: "https://example.com/louzt.git"},
|
||||
}
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
switch filepath.Base(path) {
|
||||
case "official":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginOfficial)
|
||||
case "louzt":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginLouzt)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "OfficialWeather", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("continues past failing registry and reports it", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "broken", URL: "https://example.com/broken.git"},
|
||||
{Name: "test", URL: testRegistryURL},
|
||||
}
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
if url != testRegistryURL {
|
||||
return errors.New("network unreachable")
|
||||
}
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "registry broken")
|
||||
assert.Len(t, registry.plugins, 1, "healthy registry still loads")
|
||||
})
|
||||
|
||||
t.Run("removes legacy single-clone cache at base", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755))
|
||||
createTestPlugin(t, fs, tmpDir, "legacy.json", Plugin{ID: "legacy", Name: "Legacy"})
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
exists, _ := afero.DirExists(fs, filepath.Join(tmpDir, ".git"))
|
||||
assert.False(t, exists, "legacy clone removed")
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "x", registry.plugins[0].ID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
// Generated by go-wayland-scanner
|
||||
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
|
||||
// XML file : internal/proto/xml/ext-data-control-v1.xml
|
||||
//
|
||||
// ext_data_control_v1 Protocol Copyright:
|
||||
//
|
||||
// Copyright © 2018 Simon Ser
|
||||
// Copyright © 2019 Ivan Molodetskikh
|
||||
// Copyright © 2024 Neal Gompa
|
||||
//
|
||||
// Permission to use, copy, modify, distribute, and sell this
|
||||
// software and its documentation for any purpose is hereby granted
|
||||
// without fee, provided that the above copyright notice appear in
|
||||
// all copies and that both that copyright notice and this permission
|
||||
// notice appear in supporting documentation, and that the name of
|
||||
// the copyright holders not be used in advertising or publicity
|
||||
// pertaining to distribution of the software without specific,
|
||||
// written prior permission. The copyright holders make no
|
||||
// representations about the suitability of this software for any
|
||||
// purpose. It is provided "as is" without express or implied
|
||||
// warranty.
|
||||
//
|
||||
// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
|
||||
// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
||||
// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
// THIS SOFTWARE.
|
||||
|
||||
package ext_data_control
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ExtDataControlManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ExtDataControlManagerV1InterfaceName = "ext_data_control_manager_v1"
|
||||
|
||||
// ExtDataControlManagerV1 : manager to control data devices
|
||||
//
|
||||
// This interface is a manager that allows creating per-seat data device
|
||||
// controls.
|
||||
type ExtDataControlManagerV1 struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewExtDataControlManagerV1 : manager to control data devices
|
||||
//
|
||||
// This interface is a manager that allows creating per-seat data device
|
||||
// controls.
|
||||
func NewExtDataControlManagerV1(ctx *client.Context) *ExtDataControlManagerV1 {
|
||||
extDataControlManagerV1 := &ExtDataControlManagerV1{}
|
||||
ctx.Register(extDataControlManagerV1)
|
||||
return extDataControlManagerV1
|
||||
}
|
||||
|
||||
// CreateDataSource : create a new data source
|
||||
//
|
||||
// Create a new data source.
|
||||
func (i *ExtDataControlManagerV1) CreateDataSource() (*ExtDataControlSourceV1, error) {
|
||||
id := NewExtDataControlSourceV1(i.Context())
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], id.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// GetDataDevice : get a data device for a seat
|
||||
//
|
||||
// Create a data device that can be used to manage a seat's selection.
|
||||
func (i *ExtDataControlManagerV1) GetDataDevice(seat *client.Seat) (*ExtDataControlDeviceV1, error) {
|
||||
id := NewExtDataControlDeviceV1(i.Context())
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], id.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], seat.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// GetDataDeviceWithProxy : get a data device for a seat using a pre-created proxy
|
||||
//
|
||||
// Like GetDataDevice, but uses a pre-created ExtDataControlDeviceV1 proxy.
|
||||
// This allows setting up event handlers before the request is sent.
|
||||
func (i *ExtDataControlManagerV1) GetDataDeviceWithProxy(device *ExtDataControlDeviceV1, seat *client.Seat) error {
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], device.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], seat.ID())
|
||||
l += 4
|
||||
return i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
}
|
||||
|
||||
// Destroy : destroy the manager
|
||||
//
|
||||
// All objects created by the manager will still remain valid, until their
|
||||
// appropriate destroy request has been called.
|
||||
func (i *ExtDataControlManagerV1) Destroy() error {
|
||||
defer i.MarkZombie()
|
||||
const opcode = 2
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ExtDataControlDeviceV1InterfaceName = "ext_data_control_device_v1"
|
||||
|
||||
// ExtDataControlDeviceV1 : manage a data device for a seat
|
||||
//
|
||||
// This interface allows a client to manage a seat's selection.
|
||||
//
|
||||
// When the seat is destroyed, this object becomes inert.
|
||||
type ExtDataControlDeviceV1 struct {
|
||||
client.BaseProxy
|
||||
dataOfferHandler ExtDataControlDeviceV1DataOfferHandlerFunc
|
||||
selectionHandler ExtDataControlDeviceV1SelectionHandlerFunc
|
||||
finishedHandler ExtDataControlDeviceV1FinishedHandlerFunc
|
||||
primarySelectionHandler ExtDataControlDeviceV1PrimarySelectionHandlerFunc
|
||||
}
|
||||
|
||||
// NewExtDataControlDeviceV1 : manage a data device for a seat
|
||||
//
|
||||
// This interface allows a client to manage a seat's selection.
|
||||
//
|
||||
// When the seat is destroyed, this object becomes inert.
|
||||
func NewExtDataControlDeviceV1(ctx *client.Context) *ExtDataControlDeviceV1 {
|
||||
extDataControlDeviceV1 := &ExtDataControlDeviceV1{}
|
||||
ctx.Register(extDataControlDeviceV1)
|
||||
return extDataControlDeviceV1
|
||||
}
|
||||
|
||||
// SetSelection : copy data to the selection
|
||||
//
|
||||
// This request asks the compositor to set the selection to the data from
|
||||
// the source on behalf of the client.
|
||||
//
|
||||
// The given source may not be used in any further set_selection or
|
||||
// set_primary_selection requests. Attempting to use a previously used
|
||||
// source triggers the used_source protocol error.
|
||||
//
|
||||
// To unset the selection, set the source to NULL.
|
||||
func (i *ExtDataControlDeviceV1) SetSelection(source *ExtDataControlSourceV1) error {
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
if source == nil {
|
||||
client.PutUint32(_reqBuf[l:l+4], 0)
|
||||
l += 4
|
||||
} else {
|
||||
client.PutUint32(_reqBuf[l:l+4], source.ID())
|
||||
l += 4
|
||||
}
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy this data device
|
||||
//
|
||||
// Destroys the data device object.
|
||||
func (i *ExtDataControlDeviceV1) Destroy() error {
|
||||
defer i.MarkZombie()
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPrimarySelection : copy data to the primary selection
|
||||
//
|
||||
// This request asks the compositor to set the primary selection to the
|
||||
// data from the source on behalf of the client.
|
||||
//
|
||||
// The given source may not be used in any further set_selection or
|
||||
// set_primary_selection requests. Attempting to use a previously used
|
||||
// source triggers the used_source protocol error.
|
||||
//
|
||||
// To unset the primary selection, set the source to NULL.
|
||||
//
|
||||
// The compositor will ignore this request if it does not support primary
|
||||
// selection.
|
||||
func (i *ExtDataControlDeviceV1) SetPrimarySelection(source *ExtDataControlSourceV1) error {
|
||||
const opcode = 2
|
||||
const _reqBufLen = 8 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
if source == nil {
|
||||
client.PutUint32(_reqBuf[l:l+4], 0)
|
||||
l += 4
|
||||
} else {
|
||||
client.PutUint32(_reqBuf[l:l+4], source.ID())
|
||||
l += 4
|
||||
}
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type ExtDataControlDeviceV1Error uint32
|
||||
|
||||
// ExtDataControlDeviceV1Error :
|
||||
const (
|
||||
// ExtDataControlDeviceV1ErrorUsedSource : source given to set_selection or set_primary_selection was already used before
|
||||
ExtDataControlDeviceV1ErrorUsedSource ExtDataControlDeviceV1Error = 1
|
||||
)
|
||||
|
||||
func (e ExtDataControlDeviceV1Error) Name() string {
|
||||
switch e {
|
||||
case ExtDataControlDeviceV1ErrorUsedSource:
|
||||
return "used_source"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ExtDataControlDeviceV1Error) Value() string {
|
||||
switch e {
|
||||
case ExtDataControlDeviceV1ErrorUsedSource:
|
||||
return "1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ExtDataControlDeviceV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1DataOfferEvent : introduce a new ext_data_control_offer
|
||||
//
|
||||
// The data_offer event introduces a new ext_data_control_offer object,
|
||||
// which will subsequently be used in either the
|
||||
// ext_data_control_device.selection event (for the regular clipboard
|
||||
// selections) or the ext_data_control_device.primary_selection event (for
|
||||
// the primary clipboard selections). Immediately following the
|
||||
// ext_data_control_device.data_offer event, the new data_offer object
|
||||
// will send out ext_data_control_offer.offer events to describe the MIME
|
||||
// types it offers.
|
||||
type ExtDataControlDeviceV1DataOfferEvent struct {
|
||||
Id *ExtDataControlOfferV1
|
||||
}
|
||||
type ExtDataControlDeviceV1DataOfferHandlerFunc func(ExtDataControlDeviceV1DataOfferEvent)
|
||||
|
||||
// SetDataOfferHandler : sets handler for ExtDataControlDeviceV1DataOfferEvent
|
||||
func (i *ExtDataControlDeviceV1) SetDataOfferHandler(f ExtDataControlDeviceV1DataOfferHandlerFunc) {
|
||||
i.dataOfferHandler = f
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1SelectionEvent : advertise new selection
|
||||
//
|
||||
// The selection event is sent out to notify the client of a new
|
||||
// ext_data_control_offer for the selection for this device. The
|
||||
// ext_data_control_device.data_offer and the ext_data_control_offer.offer
|
||||
// events are sent out immediately before this event to introduce the data
|
||||
// offer object. The selection event is sent to a client when a new
|
||||
// selection is set. The ext_data_control_offer is valid until a new
|
||||
// ext_data_control_offer or NULL is received. The client must destroy the
|
||||
// previous selection ext_data_control_offer, if any, upon receiving this
|
||||
// event. Regardless, the previous selection will be ignored once a new
|
||||
// selection ext_data_control_offer is received.
|
||||
//
|
||||
// The first selection event is sent upon binding the
|
||||
// ext_data_control_device object.
|
||||
type ExtDataControlDeviceV1SelectionEvent struct {
|
||||
Id *ExtDataControlOfferV1
|
||||
OfferId uint32 // Raw object ID for external registry lookups
|
||||
}
|
||||
type ExtDataControlDeviceV1SelectionHandlerFunc func(ExtDataControlDeviceV1SelectionEvent)
|
||||
|
||||
// SetSelectionHandler : sets handler for ExtDataControlDeviceV1SelectionEvent
|
||||
func (i *ExtDataControlDeviceV1) SetSelectionHandler(f ExtDataControlDeviceV1SelectionHandlerFunc) {
|
||||
i.selectionHandler = f
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1FinishedEvent : this data control is no longer valid
|
||||
//
|
||||
// This data control object is no longer valid and should be destroyed by
|
||||
// the client.
|
||||
type ExtDataControlDeviceV1FinishedEvent struct{}
|
||||
type ExtDataControlDeviceV1FinishedHandlerFunc func(ExtDataControlDeviceV1FinishedEvent)
|
||||
|
||||
// SetFinishedHandler : sets handler for ExtDataControlDeviceV1FinishedEvent
|
||||
func (i *ExtDataControlDeviceV1) SetFinishedHandler(f ExtDataControlDeviceV1FinishedHandlerFunc) {
|
||||
i.finishedHandler = f
|
||||
}
|
||||
|
||||
// ExtDataControlDeviceV1PrimarySelectionEvent : advertise new primary selection
|
||||
//
|
||||
// The primary_selection event is sent out to notify the client of a new
|
||||
// ext_data_control_offer for the primary selection for this device. The
|
||||
// ext_data_control_device.data_offer and the ext_data_control_offer.offer
|
||||
// events are sent out immediately before this event to introduce the data
|
||||
// offer object. The primary_selection event is sent to a client when a
|
||||
// new primary selection is set. The ext_data_control_offer is valid until
|
||||
// a new ext_data_control_offer or NULL is received. The client must
|
||||
// destroy the previous primary selection ext_data_control_offer, if any,
|
||||
// upon receiving this event. Regardless, the previous primary selection
|
||||
// will be ignored once a new primary selection ext_data_control_offer is
|
||||
// received.
|
||||
//
|
||||
// If the compositor supports primary selection, the first
|
||||
// primary_selection event is sent upon binding the
|
||||
// ext_data_control_device object.
|
||||
type ExtDataControlDeviceV1PrimarySelectionEvent struct {
|
||||
Id *ExtDataControlOfferV1
|
||||
OfferId uint32 // Raw object ID for external registry lookups
|
||||
}
|
||||
type ExtDataControlDeviceV1PrimarySelectionHandlerFunc func(ExtDataControlDeviceV1PrimarySelectionEvent)
|
||||
|
||||
// SetPrimarySelectionHandler : sets handler for ExtDataControlDeviceV1PrimarySelectionEvent
|
||||
func (i *ExtDataControlDeviceV1) SetPrimarySelectionHandler(f ExtDataControlDeviceV1PrimarySelectionHandlerFunc) {
|
||||
i.primarySelectionHandler = f
|
||||
}
|
||||
|
||||
func (i *ExtDataControlDeviceV1) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
switch opcode {
|
||||
case 0:
|
||||
// data_offer event: server creates a new object (new_id)
|
||||
if i.dataOfferHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlDeviceV1DataOfferEvent
|
||||
l := 0
|
||||
newID := client.Uint32(data[l : l+4])
|
||||
l += 4
|
||||
|
||||
ctx := i.Context()
|
||||
offer := &ExtDataControlOfferV1{}
|
||||
offer.SetContext(ctx)
|
||||
offer.SetID(newID)
|
||||
ctx.RegisterWithID(offer, newID)
|
||||
e.Id = offer
|
||||
|
||||
i.dataOfferHandler(e)
|
||||
case 1:
|
||||
// selection event: nullable object reference
|
||||
if i.selectionHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlDeviceV1SelectionEvent
|
||||
l := 0
|
||||
objID := client.Uint32(data[l : l+4])
|
||||
l += 4
|
||||
|
||||
e.OfferId = objID
|
||||
if objID != 0 {
|
||||
if p := i.Context().GetProxy(objID); p != nil {
|
||||
e.Id = p.(*ExtDataControlOfferV1)
|
||||
}
|
||||
}
|
||||
|
||||
i.selectionHandler(e)
|
||||
case 2:
|
||||
if i.finishedHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlDeviceV1FinishedEvent
|
||||
|
||||
i.finishedHandler(e)
|
||||
case 3:
|
||||
// primary_selection event: nullable object reference
|
||||
if i.primarySelectionHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlDeviceV1PrimarySelectionEvent
|
||||
l := 0
|
||||
objID := client.Uint32(data[l : l+4])
|
||||
l += 4
|
||||
|
||||
e.OfferId = objID
|
||||
if objID != 0 {
|
||||
if p := i.Context().GetProxy(objID); p != nil {
|
||||
e.Id = p.(*ExtDataControlOfferV1)
|
||||
}
|
||||
}
|
||||
|
||||
i.primarySelectionHandler(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtDataControlSourceV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ExtDataControlSourceV1InterfaceName = "ext_data_control_source_v1"
|
||||
|
||||
// ExtDataControlSourceV1 : offer to transfer data
|
||||
//
|
||||
// The ext_data_control_source object is the source side of a
|
||||
// ext_data_control_offer. It is created by the source client in a data
|
||||
// transfer and provides a way to describe the offered data and a way to
|
||||
// respond to requests to transfer the data.
|
||||
type ExtDataControlSourceV1 struct {
|
||||
client.BaseProxy
|
||||
sendHandler ExtDataControlSourceV1SendHandlerFunc
|
||||
cancelledHandler ExtDataControlSourceV1CancelledHandlerFunc
|
||||
}
|
||||
|
||||
// NewExtDataControlSourceV1 : offer to transfer data
|
||||
//
|
||||
// The ext_data_control_source object is the source side of a
|
||||
// ext_data_control_offer. It is created by the source client in a data
|
||||
// transfer and provides a way to describe the offered data and a way to
|
||||
// respond to requests to transfer the data.
|
||||
func NewExtDataControlSourceV1(ctx *client.Context) *ExtDataControlSourceV1 {
|
||||
extDataControlSourceV1 := &ExtDataControlSourceV1{}
|
||||
ctx.Register(extDataControlSourceV1)
|
||||
return extDataControlSourceV1
|
||||
}
|
||||
|
||||
// Offer : add an offered MIME type
|
||||
//
|
||||
// This request adds a MIME type to the set of MIME types advertised to
|
||||
// targets. Can be called several times to offer multiple types.
|
||||
//
|
||||
// Calling this after ext_data_control_device.set_selection is a protocol
|
||||
// error.
|
||||
//
|
||||
// mimeType: MIME type offered by the data source
|
||||
func (i *ExtDataControlSourceV1) Offer(mimeType string) error {
|
||||
const opcode = 0
|
||||
mimeTypeLen := client.PaddedLen(len(mimeType) + 1)
|
||||
_reqBufLen := 8 + (4 + mimeTypeLen)
|
||||
_reqBuf := make([]byte, _reqBufLen)
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType)
|
||||
l += (4 + mimeTypeLen)
|
||||
err := i.Context().WriteMsg(_reqBuf, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy this source
|
||||
//
|
||||
// Destroys the data source object.
|
||||
func (i *ExtDataControlSourceV1) Destroy() error {
|
||||
defer i.MarkZombie()
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type ExtDataControlSourceV1Error uint32
|
||||
|
||||
// ExtDataControlSourceV1Error :
|
||||
const (
|
||||
// ExtDataControlSourceV1ErrorInvalidOffer : offer sent after ext_data_control_device.set_selection
|
||||
ExtDataControlSourceV1ErrorInvalidOffer ExtDataControlSourceV1Error = 1
|
||||
)
|
||||
|
||||
func (e ExtDataControlSourceV1Error) Name() string {
|
||||
switch e {
|
||||
case ExtDataControlSourceV1ErrorInvalidOffer:
|
||||
return "invalid_offer"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ExtDataControlSourceV1Error) Value() string {
|
||||
switch e {
|
||||
case ExtDataControlSourceV1ErrorInvalidOffer:
|
||||
return "1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ExtDataControlSourceV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
|
||||
// ExtDataControlSourceV1SendEvent : send the data
|
||||
//
|
||||
// Request for data from the client. Send the data as the specified MIME
|
||||
// type over the passed file descriptor, then close it.
|
||||
type ExtDataControlSourceV1SendEvent struct {
|
||||
MimeType string
|
||||
Fd int
|
||||
}
|
||||
type ExtDataControlSourceV1SendHandlerFunc func(ExtDataControlSourceV1SendEvent)
|
||||
|
||||
// SetSendHandler : sets handler for ExtDataControlSourceV1SendEvent
|
||||
func (i *ExtDataControlSourceV1) SetSendHandler(f ExtDataControlSourceV1SendHandlerFunc) {
|
||||
i.sendHandler = f
|
||||
}
|
||||
|
||||
// ExtDataControlSourceV1CancelledEvent : selection was cancelled
|
||||
//
|
||||
// This data source is no longer valid. The data source has been replaced
|
||||
// by another data source.
|
||||
//
|
||||
// The client should clean up and destroy this data source.
|
||||
type ExtDataControlSourceV1CancelledEvent struct{}
|
||||
type ExtDataControlSourceV1CancelledHandlerFunc func(ExtDataControlSourceV1CancelledEvent)
|
||||
|
||||
// SetCancelledHandler : sets handler for ExtDataControlSourceV1CancelledEvent
|
||||
func (i *ExtDataControlSourceV1) SetCancelledHandler(f ExtDataControlSourceV1CancelledHandlerFunc) {
|
||||
i.cancelledHandler = f
|
||||
}
|
||||
|
||||
func (i *ExtDataControlSourceV1) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
switch opcode {
|
||||
case 0:
|
||||
if i.sendHandler == nil {
|
||||
if fd != -1 {
|
||||
unix.Close(fd)
|
||||
}
|
||||
return
|
||||
}
|
||||
var e ExtDataControlSourceV1SendEvent
|
||||
l := 0
|
||||
mimeTypeLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
||||
l += 4
|
||||
e.MimeType = client.String(data[l : l+mimeTypeLen])
|
||||
l += mimeTypeLen
|
||||
e.Fd = fd
|
||||
|
||||
i.sendHandler(e)
|
||||
case 1:
|
||||
if i.cancelledHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlSourceV1CancelledEvent
|
||||
|
||||
i.cancelledHandler(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtDataControlOfferV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ExtDataControlOfferV1InterfaceName = "ext_data_control_offer_v1"
|
||||
|
||||
// ExtDataControlOfferV1 : offer to transfer data
|
||||
//
|
||||
// A ext_data_control_offer represents a piece of data offered for transfer
|
||||
// by another client (the source client). The offer describes the different
|
||||
// MIME types that the data can be converted to and provides the mechanism
|
||||
// for transferring the data directly from the source client.
|
||||
type ExtDataControlOfferV1 struct {
|
||||
client.BaseProxy
|
||||
offerHandler ExtDataControlOfferV1OfferHandlerFunc
|
||||
}
|
||||
|
||||
// NewExtDataControlOfferV1 : offer to transfer data
|
||||
//
|
||||
// A ext_data_control_offer represents a piece of data offered for transfer
|
||||
// by another client (the source client). The offer describes the different
|
||||
// MIME types that the data can be converted to and provides the mechanism
|
||||
// for transferring the data directly from the source client.
|
||||
func NewExtDataControlOfferV1(ctx *client.Context) *ExtDataControlOfferV1 {
|
||||
extDataControlOfferV1 := &ExtDataControlOfferV1{}
|
||||
ctx.Register(extDataControlOfferV1)
|
||||
return extDataControlOfferV1
|
||||
}
|
||||
|
||||
// Receive : request that the data is transferred
|
||||
//
|
||||
// To transfer the offered data, the client issues this request and
|
||||
// indicates the MIME type it wants to receive. The transfer happens
|
||||
// through the passed file descriptor (typically created with the pipe
|
||||
// system call). The source client writes the data in the MIME type
|
||||
// representation requested and then closes the file descriptor.
|
||||
//
|
||||
// The receiving client reads from the read end of the pipe until EOF and
|
||||
// then closes its end, at which point the transfer is complete.
|
||||
//
|
||||
// This request may happen multiple times for different MIME types.
|
||||
//
|
||||
// mimeType: MIME type desired by receiver
|
||||
// fd: file descriptor for data transfer
|
||||
func (i *ExtDataControlOfferV1) Receive(mimeType string, fd int) error {
|
||||
const opcode = 0
|
||||
mimeTypeLen := client.PaddedLen(len(mimeType) + 1)
|
||||
_reqBufLen := 8 + (4 + mimeTypeLen)
|
||||
_reqBuf := make([]byte, _reqBufLen)
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType)
|
||||
l += (4 + mimeTypeLen)
|
||||
oob := unix.UnixRights(int(fd))
|
||||
err := i.Context().WriteMsg(_reqBuf, oob)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy this offer
|
||||
//
|
||||
// Destroys the data offer object.
|
||||
func (i *ExtDataControlOfferV1) Destroy() error {
|
||||
defer i.MarkZombie()
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtDataControlOfferV1OfferEvent : advertise offered MIME type
|
||||
//
|
||||
// Sent immediately after creating the ext_data_control_offer object.
|
||||
// One event per offered MIME type.
|
||||
type ExtDataControlOfferV1OfferEvent struct {
|
||||
MimeType string
|
||||
}
|
||||
type ExtDataControlOfferV1OfferHandlerFunc func(ExtDataControlOfferV1OfferEvent)
|
||||
|
||||
// SetOfferHandler : sets handler for ExtDataControlOfferV1OfferEvent
|
||||
func (i *ExtDataControlOfferV1) SetOfferHandler(f ExtDataControlOfferV1OfferHandlerFunc) {
|
||||
i.offerHandler = f
|
||||
}
|
||||
|
||||
func (i *ExtDataControlOfferV1) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
switch opcode {
|
||||
case 0:
|
||||
if i.offerHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtDataControlOfferV1OfferEvent
|
||||
l := 0
|
||||
mimeTypeLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
||||
l += 4
|
||||
e.MimeType = client.String(data[l : l+mimeTypeLen])
|
||||
l += mimeTypeLen
|
||||
|
||||
i.offerHandler(e)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
package keyboard_shortcuts_inhibit
|
||||
|
||||
import "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
|
||||
// ZwpKeyboardShortcutsInhibitManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
package virtual_keyboard
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
package wlr_gamma_control
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
package wlr_layer_shell
|
||||
|
||||
import (
|
||||
xdg_shell "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/xdg_shell"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
xdg_shell "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/stable/xdg-shell"
|
||||
)
|
||||
|
||||
// ZwlrLayerShellV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
|
||||
@@ -33,8 +33,8 @@ import (
|
||||
"reflect"
|
||||
"unsafe"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"github.com/AvengeMedia/dankgo/syncmap"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
)
|
||||
|
||||
func registerServerProxy(ctx *client.Context, proxy client.Proxy, serverID uint32) {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
package wlr_output_power
|
||||
|
||||
import "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
|
||||
// ZwlrOutputPowerManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
package wlr_screencopy
|
||||
|
||||
import "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
|
||||
// ZwlrScreencopyManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
package wp_viewporter
|
||||
|
||||
import "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
|
||||
// WpViewporterInterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
|
||||
@@ -22,16 +22,6 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
|
||||
if !strings.Contains(content, "Keys.onPressed") || !strings.Contains(content, "event.text") {
|
||||
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
||||
}
|
||||
|
||||
// Wayland IMEs commit unconsumed printable keys as text-input text rather
|
||||
// than forwarding raw keys, so the lock screen needs an IME commit sink
|
||||
// alongside raw key handling.
|
||||
if !strings.Contains(content, "id: imeCommitSink") {
|
||||
t.Fatalf("passwordField must keep the imeCommitSink TextInput so IME-routed keyboards can type (#2950)")
|
||||
}
|
||||
if !strings.Contains(content, "Qt.ImhSensitiveData") {
|
||||
t.Fatalf("imeCommitSink must advertise hidden-text hints so IMEs treat it as a password field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
OfficialName = "official"
|
||||
officialURL = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||
)
|
||||
|
||||
// Source identifies a registry repository. Name doubles as the per-registry
|
||||
// cache subdirectory, so it is restricted to a filesystem-safe slug.
|
||||
type Source struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (s Source) Official() bool {
|
||||
return s.Name == OfficialName
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
|
||||
func configPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "DankMaterialShell", "registries.json"), nil
|
||||
}
|
||||
|
||||
// Load returns the official registry followed by any user-configured extras.
|
||||
// A missing or unreadable config yields just the official registry.
|
||||
func Load(fs afero.Fs) []Source {
|
||||
sources := []Source{{Name: OfficialName, URL: officialURL}}
|
||||
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
return sources
|
||||
}
|
||||
data, err := afero.ReadFile(fs, path)
|
||||
if err != nil {
|
||||
return sources
|
||||
}
|
||||
var extras []Source
|
||||
if err := json.Unmarshal(data, &extras); err != nil {
|
||||
return sources
|
||||
}
|
||||
for _, s := range extras {
|
||||
if !nameRe.MatchString(s.Name) || s.Name == OfficialName || s.URL == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, s)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func saveExtras(fs afero.Fs, extras []Source) error {
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(extras, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return afero.WriteFile(fs, path, append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func loadExtras(fs afero.Fs) []Source {
|
||||
sources := Load(fs)
|
||||
return sources[1:]
|
||||
}
|
||||
|
||||
func Add(fs afero.Fs, name, url string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
url = strings.TrimSpace(url)
|
||||
|
||||
if !nameRe.MatchString(name) {
|
||||
return fmt.Errorf("invalid registry name %q: use 1-32 lowercase letters, digits or hyphens", name)
|
||||
}
|
||||
if name == OfficialName {
|
||||
return fmt.Errorf("registry name %q is reserved", OfficialName)
|
||||
}
|
||||
if url == "" {
|
||||
return fmt.Errorf("registry URL is required")
|
||||
}
|
||||
|
||||
extras := loadExtras(fs)
|
||||
for _, s := range extras {
|
||||
if s.Name == name {
|
||||
return fmt.Errorf("registry %q already exists", name)
|
||||
}
|
||||
if s.URL == url {
|
||||
return fmt.Errorf("registry %q already uses this URL", s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return saveExtras(fs, append(extras, Source{Name: name, URL: url}))
|
||||
}
|
||||
|
||||
func Remove(fs afero.Fs, name string) error {
|
||||
if name == OfficialName {
|
||||
return fmt.Errorf("the official registry cannot be removed")
|
||||
}
|
||||
|
||||
extras := loadExtras(fs)
|
||||
kept := make([]Source, 0, len(extras))
|
||||
for _, s := range extras {
|
||||
if s.Name != name {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(extras) {
|
||||
return fmt.Errorf("registry %q not found", name)
|
||||
}
|
||||
|
||||
return saveExtras(fs, kept)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupFs(t *testing.T) afero.Fs {
|
||||
t.Setenv("XDG_CONFIG_HOME", "/xdg")
|
||||
return afero.NewMemMapFs()
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 1)
|
||||
assert.Equal(t, OfficialName, sources[0].Name)
|
||||
assert.Equal(t, officialURL, sources[0].URL)
|
||||
assert.True(t, sources[0].Official())
|
||||
}
|
||||
|
||||
func TestAddAndLoad(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/extra.git"))
|
||||
require.NoError(t, Add(fs, "another", "https://example.com/another.git"))
|
||||
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 3)
|
||||
assert.Equal(t, OfficialName, sources[0].Name)
|
||||
assert.Equal(t, "extra", sources[1].Name)
|
||||
assert.Equal(t, "another", sources[2].Name)
|
||||
assert.False(t, sources[1].Official())
|
||||
}
|
||||
|
||||
func TestAddValidation(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
assert.Error(t, Add(fs, "", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "Has Spaces", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "UPPER", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "../escape", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, OfficialName, "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "noname", ""))
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "extra", "https://example.com/other.git"), "duplicate name rejected")
|
||||
assert.Error(t, Add(fs, "extra2", "https://example.com/x.git"), "duplicate URL rejected")
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
|
||||
require.NoError(t, Remove(fs, "extra"))
|
||||
assert.Len(t, Load(fs), 1)
|
||||
|
||||
assert.Error(t, Remove(fs, "extra"), "already removed")
|
||||
assert.Error(t, Remove(fs, OfficialName), "official is not removable")
|
||||
}
|
||||
|
||||
func TestLoadIgnoresInvalidConfig(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, fs.MkdirAll("/xdg/DankMaterialShell", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte("{not json"), 0o644))
|
||||
assert.Len(t, Load(fs), 1)
|
||||
|
||||
entries := `[{"name":"ok","url":"https://example.com/ok.git"},{"name":"Bad Name","url":"https://example.com/bad.git"},{"name":"official","url":"https://example.com/spoof.git"},{"name":"nourl","url":""}]`
|
||||
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte(entries), 0o644))
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 2, "invalid entries dropped")
|
||||
assert.Equal(t, "ok", sources[1].Name)
|
||||
assert.Equal(t, officialURL, sources[0].URL, "official cannot be spoofed from config")
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
|
||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type Compositor int
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
|
||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type SelectionState struct {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
func (r *RegionSelector) setupInput() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type WaylandOutput struct {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
||||
"github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
|
||||
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
|
||||
m.promptBroker = broker
|
||||
|
||||
adapter, err := findAdapter(conn)
|
||||
adapter, err := m.findAdapter()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
|
||||
}
|
||||
m.adapterPath = adapter
|
||||
|
||||
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
|
||||
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
|
||||
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
|
||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||
|
||||
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
for path, interfaces := range objects {
|
||||
@@ -89,7 +89,7 @@ func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNoAdapter
|
||||
return "", fmt.Errorf("no adapter found")
|
||||
}
|
||||
|
||||
func (m *Manager) initialize() error {
|
||||
@@ -487,11 +487,6 @@ func (m *Manager) StopDiscovery() error {
|
||||
}
|
||||
|
||||
func (m *Manager) SetPowered(powered bool) error {
|
||||
if powered {
|
||||
if err := rfkillUnblockBluetooth(); err != nil {
|
||||
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
|
||||
}
|
||||
}
|
||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package bluez
|
||||
|
||||
func rfkillUnblockBluetooth() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package bluez
|
||||
|
||||
import "os"
|
||||
|
||||
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
|
||||
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
|
||||
func rfkillUnblockBluetooth() error {
|
||||
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var event [8]byte
|
||||
event[4] = 2
|
||||
event[5] = 3
|
||||
_, err = f.Write(event[:])
|
||||
return err
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
var ErrNoAdapter = errors.New("no bluetooth adapter found")
|
||||
|
||||
func WaitForAdapter() error {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(objectMgrIface),
|
||||
dbus.WithMatchMember("InterfacesAdded"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signals := make(chan *dbus.Signal, 64)
|
||||
conn.Signal(signals)
|
||||
|
||||
if _, err := findAdapter(conn); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for sig := range signals {
|
||||
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
|
||||
continue
|
||||
}
|
||||
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := ifaces[adapter1Iface]; ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("dbus signal stream closed")
|
||||
}
|
||||
@@ -30,11 +30,10 @@ import (
|
||||
|
||||
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
"github.com/AvengeMedia/dankgo/wayland/ext_data_control"
|
||||
"github.com/AvengeMedia/dankgo/wlclipboard"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
var errEntryNotFound = errors.New("entry not found")
|
||||
@@ -1159,7 +1158,7 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
dataCopy := make([]byte, len(data))
|
||||
copy(dataCopy, data)
|
||||
|
||||
m.takeSelection(wlclipboard.ExpandOffers(dataCopy, mimeType))
|
||||
m.takeSelection(clipboardstore.ExpandOffers(dataCopy, mimeType))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1171,9 +1170,9 @@ func (m *Manager) SetClipboardEntry(entry *Entry) error {
|
||||
return fmt.Errorf("data too large")
|
||||
}
|
||||
|
||||
offers := wlclipboard.ExpandOffers(slices.Clone(entry.Data), entry.MimeType)
|
||||
offers := clipboardstore.ExpandOffers(slices.Clone(entry.Data), entry.MimeType)
|
||||
if entry.AltMimeType != "" {
|
||||
offers = append(offers, wlclipboard.ExpandOffers(slices.Clone(entry.AltData), entry.AltMimeType)...)
|
||||
offers = append(offers, clipboardstore.ExpandOffers(slices.Clone(entry.AltData), entry.AltMimeType)...)
|
||||
}
|
||||
|
||||
m.takeSelection(offers)
|
||||
@@ -1182,7 +1181,7 @@ func (m *Manager) SetClipboardEntry(entry *Entry) error {
|
||||
|
||||
// takeSelection makes the daemon the selection owner, serving the given
|
||||
// offers until another client claims the clipboard.
|
||||
func (m *Manager) takeSelection(offers []wlclipboard.Offer) {
|
||||
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
|
||||
m.post(func() {
|
||||
if m.dataControlMgr == nil || m.dataDevice == nil {
|
||||
log.Error("Data control manager or device not initialized")
|
||||
@@ -1915,13 +1914,13 @@ func (m *Manager) CopyFile(filePath string) error {
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
|
||||
offers := []wlclipboard.Offer{
|
||||
offers := []clipboardstore.Offer{
|
||||
{MimeType: "x-special/gnome-copied-files", Data: []byte("copy\n" + fileURI)},
|
||||
{MimeType: "text/uri-list", Data: []byte(fileURI + "\r\n")},
|
||||
{MimeType: "text/plain", Data: []byte(filePath)},
|
||||
}
|
||||
if _, imgMime, err := image.DecodeConfig(bytes.NewReader(fileData)); err == nil {
|
||||
offers = append(offers, wlclipboard.Offer{MimeType: "image/" + imgMime, Data: fileData})
|
||||
offers = append(offers, clipboardstore.Offer{MimeType: "image/" + imgMime, Data: fileData})
|
||||
}
|
||||
|
||||
m.takeSelection(offers)
|
||||
|
||||
@@ -32,8 +32,6 @@ func (c *clipboardTestConn) Write(b []byte) (int, error) {
|
||||
return c.writeBuf.Write(b)
|
||||
}
|
||||
|
||||
func (c *clipboardTestConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func newTestManagerWithDB(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
||||
@@ -229,9 +229,6 @@ func (m *Manager) snapshotState() CUPSState {
|
||||
func (m *Manager) Subscribe(id string) chan CUPSState {
|
||||
ch := make(chan CUPSState, 64)
|
||||
|
||||
m.subLifecycleMu.Lock()
|
||||
defer m.subLifecycleMu.Unlock()
|
||||
|
||||
wasEmpty := true
|
||||
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
|
||||
wasEmpty = false
|
||||
@@ -240,25 +237,19 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
|
||||
|
||||
m.subscribers.Store(id, ch)
|
||||
|
||||
if !wasEmpty || m.subscription == nil {
|
||||
return ch
|
||||
if wasEmpty && m.subscription != nil {
|
||||
if err := m.subscription.Start(); err != nil {
|
||||
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
|
||||
} else {
|
||||
m.eventWG.Add(1)
|
||||
go m.eventHandler()
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.subscription.Start(); err != nil {
|
||||
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
|
||||
return ch
|
||||
}
|
||||
|
||||
m.eventWG.Add(1)
|
||||
go m.eventHandler()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(id string) {
|
||||
m.subLifecycleMu.Lock()
|
||||
defer m.subLifecycleMu.Unlock()
|
||||
|
||||
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||
close(val)
|
||||
}
|
||||
@@ -269,22 +260,18 @@ func (m *Manager) Unsubscribe(id string) {
|
||||
return false
|
||||
})
|
||||
|
||||
if !isEmpty || m.subscription == nil {
|
||||
return
|
||||
if isEmpty && m.subscription != nil {
|
||||
m.subscription.Stop()
|
||||
m.eventWG.Wait()
|
||||
}
|
||||
|
||||
m.subscription.Stop()
|
||||
m.eventWG.Wait()
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
close(m.stopChan)
|
||||
|
||||
m.subLifecycleMu.Lock()
|
||||
if m.subscription != nil {
|
||||
m.subscription.Stop()
|
||||
}
|
||||
m.subLifecycleMu.Unlock()
|
||||
|
||||
m.eventWG.Wait()
|
||||
m.notifierWg.Wait()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package cups
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
|
||||
@@ -78,67 +75,6 @@ func TestManager_Subscribe(t *testing.T) {
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
|
||||
// unsynchronized so overlapping Start/Stop trips the race detector
|
||||
type stubSubscription struct {
|
||||
mu sync.Mutex
|
||||
events chan SubscriptionEvent
|
||||
conn *int
|
||||
running bool
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Start() error {
|
||||
if s.running {
|
||||
return errors.New("already running")
|
||||
}
|
||||
s.running = true
|
||||
|
||||
s.mu.Lock()
|
||||
s.events = make(chan SubscriptionEvent)
|
||||
s.mu.Unlock()
|
||||
|
||||
v := 0
|
||||
s.conn = &v
|
||||
*s.conn++
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Stop() {
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
s.conn = nil
|
||||
|
||||
s.mu.Lock()
|
||||
close(s.events)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.events
|
||||
}
|
||||
|
||||
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
|
||||
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
|
||||
m.subscription = &stubSubscription{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range 8 {
|
||||
wg.Go(func() {
|
||||
id := fmt.Sprintf("client-%d", i)
|
||||
for range 50 {
|
||||
m.Subscribe(id)
|
||||
m.Unsubscribe(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_Close(t *testing.T) {
|
||||
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ type Manager struct {
|
||||
client CUPSClientInterface
|
||||
pkHelper PkHelper
|
||||
subscription SubscriptionManagerInterface
|
||||
subLifecycleMu sync.Mutex
|
||||
stateMutex sync.RWMutex
|
||||
subscribers syncmap.Map[string, chan CUPSState]
|
||||
stopChan chan struct{}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package dgop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/dankgo/ipc/params"
|
||||
"github.com/AvengeMedia/dgop/gops"
|
||||
)
|
||||
|
||||
const metaTimeout = 10 * time.Second
|
||||
|
||||
var util = gops.NewGopsUtil()
|
||||
|
||||
func HandleRequest(conn *models.Conn, req models.Request) {
|
||||
switch req.Method {
|
||||
case "dgop.meta":
|
||||
handleMeta(conn, req)
|
||||
case "dgop.gpu":
|
||||
handleGPU(conn, req)
|
||||
case "dgop.hardware":
|
||||
handleHardware(conn, req)
|
||||
case "dgop.modules":
|
||||
handleModules(conn, req)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func handleMeta(conn *models.Conn, req models.Request) {
|
||||
modules := params.StringSlice(req.Params, "modules")
|
||||
if len(modules) == 0 {
|
||||
models.RespondError(conn, req.ID, "modules is required")
|
||||
return
|
||||
}
|
||||
|
||||
metaParams := gops.MetaParams{
|
||||
SortBy: gops.ProcSortBy(params.StringOpt(req.Params, "sort", string(gops.SortByCPU))),
|
||||
ProcLimit: params.IntOpt(req.Params, "limit", 20),
|
||||
EnableCPU: !params.BoolLoose(req.Params, "noCpu"),
|
||||
MergeChildren: params.BoolLoose(req.Params, "mergeChildren"),
|
||||
GPUPciIds: params.StringSlice(req.Params, "gpuPciIds"),
|
||||
CPUCursor: params.StringOpt(req.Params, "cpuCursor", ""),
|
||||
ProcCursor: params.StringOpt(req.Params, "procCursor", ""),
|
||||
NetRateCursor: params.StringOpt(req.Params, "netRateCursor", ""),
|
||||
DiskRateCursor: params.StringOpt(req.Params, "diskRateCursor", ""),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), metaTimeout)
|
||||
defer cancel()
|
||||
|
||||
meta, err := util.GetMeta(ctx, modules, metaParams)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, meta)
|
||||
}
|
||||
|
||||
func handleGPU(conn *models.Conn, req models.Request) {
|
||||
gpu, err := util.GetGPUInfo()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, gpu)
|
||||
}
|
||||
|
||||
func handleHardware(conn *models.Conn, req models.Request) {
|
||||
hw, err := util.GetSystemHardware()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, hw)
|
||||
}
|
||||
|
||||
func handleModules(conn *models.Conn, req models.Request) {
|
||||
modules, err := util.GetModules()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, modules)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -42,8 +41,6 @@ func (m *mockNetConn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNetConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func TestHandleRequest(t *testing.T) {
|
||||
t.Run("getState request", func(t *testing.T) {
|
||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||
|
||||
@@ -48,7 +48,7 @@ func NewManager() (*Manager, error) {
|
||||
return nil, fmt.Errorf("failed to find keyboards: %w", err)
|
||||
}
|
||||
|
||||
initialCapsLock, _ := capsLockFromDevices(devices)
|
||||
initialCapsLock := readInitialCapsLockState(devices[0])
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
@@ -85,21 +85,14 @@ func NewManager() (*Manager, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func capsLockFromDevices(devices []EvdevDevice) (bool, bool) {
|
||||
for _, device := range devices {
|
||||
if device == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ledStates, err := device.State(evLedType)
|
||||
if err != nil || len(ledStates) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
return ledStates[ledCapslockKey], true
|
||||
func readInitialCapsLockState(device EvdevDevice) bool {
|
||||
ledStates, err := device.State(evLedType)
|
||||
if err != nil {
|
||||
log.Debugf("Could not read LED state: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return false, false
|
||||
return ledStates[ledCapslockKey]
|
||||
}
|
||||
|
||||
func findKeyboards() ([]EvdevDevice, error) {
|
||||
@@ -304,22 +297,25 @@ func (m *Manager) readAndUpdateCapsLockState(deviceIndex int) {
|
||||
m.devicesMutex.RUnlock()
|
||||
return
|
||||
}
|
||||
ordered := make([]EvdevDevice, 0, len(m.devices))
|
||||
ordered = append(ordered, m.devices[deviceIndex])
|
||||
for i, device := range m.devices {
|
||||
if i == deviceIndex {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, device)
|
||||
}
|
||||
device := m.devices[deviceIndex]
|
||||
m.devicesMutex.RUnlock()
|
||||
|
||||
capsLockState, ok := capsLockFromDevices(ordered)
|
||||
if !ok {
|
||||
log.Debug("No LED-capable device available for caps lock state")
|
||||
ledStates, err := device.State(evLedType)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to read LED state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(ledStates) == 0 {
|
||||
log.Debug("No LED state available (empty map)")
|
||||
|
||||
// This means the device either:
|
||||
// - doesn't support LED reporting at all, or
|
||||
// - the kernel returned an empty state
|
||||
return
|
||||
}
|
||||
|
||||
capsLockState := ledStates[ledCapslockKey]
|
||||
m.updateCapsLockStateDirect(capsLockState)
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestNotifySubscribers(t *testing.T) {
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func TestCapsLockFromDevices(t *testing.T) {
|
||||
func TestReadInitialCapsLockState(t *testing.T) {
|
||||
t.Run("caps lock is on", func(t *testing.T) {
|
||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||
ledState := evdev.StateMap{
|
||||
@@ -314,8 +314,7 @@ func TestCapsLockFromDevices(t *testing.T) {
|
||||
}
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.True(t, ok)
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
@@ -326,8 +325,7 @@ func TestCapsLockFromDevices(t *testing.T) {
|
||||
}
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.True(t, ok)
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
@@ -335,25 +333,9 @@ func TestCapsLockFromDevices(t *testing.T) {
|
||||
mockDevice := mocks.NewMockEvdevDevice(t)
|
||||
mockDevice.EXPECT().State(evdev.EvType(evLedType)).Return(nil, errors.New("read error")).Once()
|
||||
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{mockDevice})
|
||||
assert.False(t, ok)
|
||||
result := readInitialCapsLockState(mockDevice)
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("falls back past device without LED state", func(t *testing.T) {
|
||||
noLedDevice := mocks.NewMockEvdevDevice(t)
|
||||
noLedDevice.EXPECT().State(evdev.EvType(evLedType)).Return(evdev.StateMap{}, nil).Once()
|
||||
|
||||
ledDevice := mocks.NewMockEvdevDevice(t)
|
||||
ledState := evdev.StateMap{
|
||||
ledCapslockKey: true,
|
||||
}
|
||||
ledDevice.EXPECT().State(evdev.EvType(evLedType)).Return(ledState, nil).Once()
|
||||
|
||||
result, ok := capsLockFromDevices([]EvdevDevice{noLedDevice, nil, ledDevice})
|
||||
assert.True(t, ok)
|
||||
assert.True(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasInputGroupAccess(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mockdbus "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
@@ -43,8 +42,6 @@ func (m *mockNetConn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNetConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func mockGetAllAccountsProperties() *dbus.Call {
|
||||
props := map[string]dbus.Variant{
|
||||
"IconFile": dbus.MakeVariant("/path/to/icon.png"),
|
||||
|
||||
@@ -8,11 +8,24 @@ import (
|
||||
)
|
||||
|
||||
func NewManager(client geolocation.Client) (*Manager, error) {
|
||||
currLocation, err := client.GetLocation()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to get initial location: %v", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
client: client,
|
||||
dirty: make(chan struct{}),
|
||||
stopChan: make(chan struct{}),
|
||||
state: &State{},
|
||||
|
||||
state: &State{
|
||||
Latitude: currLocation.Latitude,
|
||||
Longitude: currLocation.Longitude,
|
||||
},
|
||||
}
|
||||
|
||||
if err := m.startSignalPump(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.notifierWg.Add(1)
|
||||
@@ -21,22 +34,6 @@ func NewManager(client geolocation.Client) (*Manager, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// The geolocation client may fetch IP location on first use, so nothing
|
||||
// touches it until a consumer actually asks for location data.
|
||||
func (m *Manager) ensureStarted() {
|
||||
m.startOnce.Do(func() {
|
||||
go func() {
|
||||
currLocation, err := m.client.GetLocation()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to get initial location: %v", err)
|
||||
} else {
|
||||
m.handleLocationChange(currLocation)
|
||||
}
|
||||
m.startSignalPump()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
close(m.stopChan)
|
||||
m.notifierWg.Wait()
|
||||
@@ -51,7 +48,6 @@ func (m *Manager) Close() {
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(id string) chan State {
|
||||
m.ensureStarted()
|
||||
ch := make(chan State, 64)
|
||||
m.subscribers.Store(id, ch)
|
||||
return ch
|
||||
@@ -63,7 +59,7 @@ func (m *Manager) Unsubscribe(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) startSignalPump() {
|
||||
func (m *Manager) startSignalPump() error {
|
||||
m.sigWG.Add(1)
|
||||
go func() {
|
||||
defer m.sigWG.Done()
|
||||
@@ -84,6 +80,8 @@ func (m *Manager) startSignalPump() {
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleLocationChange(location geolocation.Location) {
|
||||
@@ -104,7 +102,6 @@ func (m *Manager) notifySubscribers() {
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
m.ensureStarted()
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
if m.state == nil {
|
||||
|
||||
@@ -16,8 +16,7 @@ type Manager struct {
|
||||
state *State
|
||||
stateMutex sync.RWMutex
|
||||
|
||||
client geolocation.Client
|
||||
startOnce sync.Once
|
||||
client geolocation.Client
|
||||
|
||||
stopChan chan struct{}
|
||||
sigWG sync.WaitGroup
|
||||
|
||||
@@ -43,8 +43,6 @@ func (m *mockNetConn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNetConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func TestRespondError_Loginctl(t *testing.T) {
|
||||
mc := newMockNetConn()
|
||||
conn := models.NewConn(mc)
|
||||
|
||||
@@ -18,17 +18,6 @@ type appsResult struct {
|
||||
DesktopIDs []string `json:"desktopIds"`
|
||||
}
|
||||
|
||||
type handlerApp struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type handlersResult struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Apps []handlerApp `json:"apps"`
|
||||
}
|
||||
|
||||
type queryResult struct {
|
||||
Defaults map[string]string `json:"defaults"`
|
||||
}
|
||||
@@ -43,8 +32,6 @@ func HandleRequest(conn *models.Conn, req models.Request) {
|
||||
handleSetDefaults(conn, req)
|
||||
case "mime.appsForMime":
|
||||
handleAppsForMime(conn, req)
|
||||
case "mime.handlersForMime":
|
||||
handleHandlersForMime(conn, req)
|
||||
case "mime.queryDefaults":
|
||||
handleQueryDefaults(conn, req)
|
||||
case "mime.invalidate":
|
||||
@@ -119,27 +106,6 @@ func handleAppsForMime(conn *models.Conn, req models.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleHandlersForMime(conn *models.Conn, req models.Request) {
|
||||
mimeType, err := mimeParam(req.Params, "mimeType")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
entries := desktop.HandlersForMime(mimeType)
|
||||
apps := make([]handlerApp, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
apps = append(apps, handlerApp{
|
||||
ID: entry.ID,
|
||||
Name: entry.Name,
|
||||
Icon: entry.Icon,
|
||||
})
|
||||
}
|
||||
models.Respond(conn, req.ID, handlersResult{
|
||||
MimeType: mimeType,
|
||||
Apps: apps,
|
||||
})
|
||||
}
|
||||
|
||||
func handleQueryDefaults(conn *models.Conn, req models.Request) {
|
||||
mimeTypes, err := mimeListParam(req, "mimeTypes")
|
||||
if err != nil {
|
||||
|
||||
@@ -302,17 +302,17 @@ func (a *SecretAgent) GetSecrets(
|
||||
}
|
||||
a.backend.cachedVPNCredsMu.Unlock()
|
||||
|
||||
a.backend.cachedOpenConnectMu.Lock()
|
||||
cachedOpenConnect := a.backend.cachedOpenConnectAuth
|
||||
if cachedOpenConnect != nil && cachedOpenConnect.ConnectionUUID == connUuid {
|
||||
a.backend.cachedOpenConnectAuth = nil
|
||||
a.backend.cachedOpenConnectMu.Unlock()
|
||||
a.backend.cachedGPSamlMu.Lock()
|
||||
cachedGPSaml := a.backend.cachedGPSamlCookie
|
||||
if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid {
|
||||
a.backend.cachedGPSamlCookie = nil
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
log.Infof("[SecretAgent] Using cached OpenConnect authentication for %s", connUuid)
|
||||
log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid)
|
||||
|
||||
return buildOpenConnectSecretsResponse(settingName, cachedOpenConnect.Cookie, cachedOpenConnect.Host, cachedOpenConnect.Fingerprint), nil
|
||||
return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil
|
||||
}
|
||||
a.backend.cachedOpenConnectMu.Unlock()
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
if len(fields) == 1 && fields[0] == "gp-saml" {
|
||||
gateway := ""
|
||||
@@ -347,17 +347,17 @@ func (a *SecretAgent) GetSecrets(
|
||||
|
||||
log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager")
|
||||
|
||||
a.backend.cachedOpenConnectMu.Lock()
|
||||
a.backend.cachedOpenConnectAuth = &cachedOpenConnectAuth{
|
||||
a.backend.cachedGPSamlMu.Lock()
|
||||
a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{
|
||||
ConnectionUUID: connUuid,
|
||||
Cookie: authResult.Cookie,
|
||||
Host: authResult.Host,
|
||||
User: authResult.User,
|
||||
Fingerprint: authResult.Fingerprint,
|
||||
}
|
||||
a.backend.cachedOpenConnectMu.Unlock()
|
||||
a.backend.cachedGPSamlMu.Unlock()
|
||||
|
||||
return buildOpenConnectSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
||||
return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,7 +987,7 @@ func buildWiFiSecretsResponse(settingName string, secrets map[string]string) nmS
|
||||
return out
|
||||
}
|
||||
|
||||
func buildOpenConnectSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
||||
func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap {
|
||||
out := nmSettingMap{}
|
||||
vpnSec := nmVariantMap{}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestNeedsExternalBrowserAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenConnectSecretsResponse(t *testing.T) {
|
||||
func TestBuildGPSamlSecretsResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settingName string
|
||||
@@ -155,7 +155,7 @@ func TestBuildOpenConnectSecretsResponse(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildOpenConnectSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
|
||||
result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint)
|
||||
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result, tt.settingName)
|
||||
|
||||
@@ -80,16 +80,16 @@ type NetworkManagerBackend struct {
|
||||
|
||||
hotspotPendingDevice string
|
||||
|
||||
pendingVPNSave *pendingVPNCredentials
|
||||
pendingVPNSaveMu sync.Mutex
|
||||
cachedVPNCreds *cachedVPNCredentials
|
||||
cachedVPNCredsMu sync.Mutex
|
||||
cachedPKCS11PIN *cachedPKCS11PIN
|
||||
cachedPKCS11Mu sync.Mutex
|
||||
cachedOpenConnectAuth *cachedOpenConnectAuth
|
||||
cachedOpenConnectMu sync.Mutex
|
||||
cachedWiFiSecret *cachedWiFiSecret
|
||||
cachedWiFiSecretMu sync.Mutex
|
||||
pendingVPNSave *pendingVPNCredentials
|
||||
pendingVPNSaveMu sync.Mutex
|
||||
cachedVPNCreds *cachedVPNCredentials
|
||||
cachedVPNCredsMu sync.Mutex
|
||||
cachedPKCS11PIN *cachedPKCS11PIN
|
||||
cachedPKCS11Mu sync.Mutex
|
||||
cachedGPSamlCookie *cachedGPSamlCookie
|
||||
cachedGPSamlMu sync.Mutex
|
||||
cachedWiFiSecret *cachedWiFiSecret
|
||||
cachedWiFiSecretMu sync.Mutex
|
||||
|
||||
onStateChange func()
|
||||
}
|
||||
@@ -100,9 +100,8 @@ type pendingVPNCredentials struct {
|
||||
Password string
|
||||
// Secrets holds all VPN secret fields keyed by name (e.g. "cert-pass");
|
||||
// falls back to Password under the "password" key when empty.
|
||||
Secrets map[string]string
|
||||
PersistentSecrets map[string]string
|
||||
SavePassword bool
|
||||
Secrets map[string]string
|
||||
SavePassword bool
|
||||
}
|
||||
|
||||
type cachedVPNCredentials struct {
|
||||
@@ -125,7 +124,7 @@ type cachedWiFiSecret struct {
|
||||
Secrets map[string]string
|
||||
}
|
||||
|
||||
type cachedOpenConnectAuth struct {
|
||||
type cachedGPSamlCookie struct {
|
||||
ConnectionUUID string
|
||||
Cookie string
|
||||
Host string
|
||||
|
||||
@@ -3,7 +3,6 @@ package network
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -11,29 +10,16 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
type openConnectAuthResult struct {
|
||||
type gpSamlAuthResult struct {
|
||||
Cookie string
|
||||
Host string
|
||||
User string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type openConnectAuthError struct {
|
||||
cause error
|
||||
serverCert string
|
||||
}
|
||||
|
||||
func (e *openConnectAuthError) Error() string {
|
||||
return fmt.Sprintf("openconnect --authenticate failed: %v", e.cause)
|
||||
}
|
||||
|
||||
func (e *openConnectAuthError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui.
|
||||
// Only supports protocol=gp. Other protocols need their own implementations.
|
||||
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*openConnectAuthResult, error) {
|
||||
func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) {
|
||||
if gateway == "" {
|
||||
return nil, fmt.Errorf("GP SAML auth: gateway is empty")
|
||||
}
|
||||
@@ -77,7 +63,7 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
|
||||
}
|
||||
}()
|
||||
|
||||
result := &openConnectAuthResult{Host: gateway}
|
||||
result := &gpSamlAuthResult{Host: gateway}
|
||||
var allOutput []string
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
@@ -131,8 +117,13 @@ func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, ga
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*openConnectAuthResult, error) {
|
||||
return runOpenConnectAuthenticate(ctx, []string{
|
||||
func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) {
|
||||
ocPath, err := exec.LookPath("openconnect")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openconnect not found: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--protocol=gp",
|
||||
"--usergroup=gateway:prelogin-cookie",
|
||||
"--user=" + user,
|
||||
@@ -140,83 +131,18 @@ func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user
|
||||
"--allow-insecure-crypto",
|
||||
"--authenticate",
|
||||
gateway,
|
||||
}, preloginCookie)
|
||||
}
|
||||
|
||||
func runOpenConnectPasswordAuth(
|
||||
ctx context.Context,
|
||||
data map[string]string,
|
||||
username, password, serverCert string,
|
||||
) (*openConnectAuthResult, error) {
|
||||
if data["protocol"] != "fortinet" {
|
||||
return nil, fmt.Errorf("only Fortinet password authentication is supported")
|
||||
}
|
||||
gateway := data["gateway"]
|
||||
if gateway == "" {
|
||||
return nil, fmt.Errorf("OpenConnect gateway is empty")
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return nil, fmt.Errorf("OpenConnect username and password are required")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--protocol=fortinet",
|
||||
"--user=" + username,
|
||||
"--passwd-on-stdin",
|
||||
"--non-inter",
|
||||
}
|
||||
if usergroup := data["usergroup"]; usergroup != "" {
|
||||
args = append(args, "--usergroup="+usergroup)
|
||||
}
|
||||
if serverCert != "" {
|
||||
args = append(args, "--servercert="+serverCert)
|
||||
}
|
||||
args = append(args, "--authenticate", gateway)
|
||||
|
||||
result, err := runOpenConnectAuthenticate(ctx, args, password)
|
||||
if err == nil {
|
||||
result.Host = gateway
|
||||
if result.Fingerprint == "" {
|
||||
result.Fingerprint = serverCert
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func runOpenConnectAuthenticate(ctx context.Context, args []string, secret string) (*openConnectAuthResult, error) {
|
||||
ocPath, err := exec.LookPath("openconnect")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openconnect not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ocPath, args...)
|
||||
cmd.Stdin = strings.NewReader(secret + "\n")
|
||||
cmd.Stdin = strings.NewReader(preloginCookie)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
result := parseOpenConnectAuthenticateOutput(string(output))
|
||||
serverCert := suggestedOpenConnectServerCert(string(output))
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("openconnect authentication timed out or was cancelled: %w", ctx.Err())
|
||||
}
|
||||
return nil, &openConnectAuthError{cause: err, serverCert: serverCert}
|
||||
}
|
||||
if result.Cookie == "" {
|
||||
return nil, &openConnectAuthError{
|
||||
cause: errors.New("no COOKIE in command output"),
|
||||
serverCert: serverCert,
|
||||
}
|
||||
return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
log.Infof("[OpenConnect] Authentication successful: cookie_len=%d, host=%s, has_fingerprint=%v",
|
||||
len(result.Cookie), result.Host, result.Fingerprint != "")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
|
||||
result := &openConnectAuthResult{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
result := &gpSamlAuthResult{}
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "COOKIE="):
|
||||
@@ -232,7 +158,15 @@ func parseOpenConnectAuthenticateOutput(output string) *openConnectAuthResult {
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
if result.Cookie == "" {
|
||||
return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output))
|
||||
}
|
||||
|
||||
log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s",
|
||||
len(result.Cookie), result.Host, result.Fingerprint)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func unshellQuote(s string) string {
|
||||
@@ -245,7 +179,7 @@ func unshellQuote(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func parseGPSamlFromCommandLine(line string, result *openConnectAuthResult) {
|
||||
func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) {
|
||||
if !strings.Contains(line, "openconnect") {
|
||||
return
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user