mirror of
https://github.com/89luca89/distrobox.git
synced 2026-08-19 01:14:49 -05:00
feat: generate-entry command (#7)
* add GenerateEntryCommand package The package mimics the actual distrobox-generate-entry shell command that installs a desktop entry file so that the host's desktop environment can render the appropriate application icon. This first implementantion scaffolds the command package and implements the simplest scenario for a single container. * delete entry If the `delete` flag is provided, the command remove the desktop entry if present. Deleting a non-existing item does not raise an error. * generate entries for all containers With `opts.All` we implement an abstraction over the `GenerateEntryCommand` that iterates across all the containers to generate or delete the relative desktop entries. `ListCommand` is used to fetch the list of containers. * mount the generate-entry cli command Mount the command so that is reachable from the CLI. Define parameters with type and basic validation. Depending on the flag `--all`, the appropriate set of options is provided to the `GenerateEntryCommand.Execute()` method. If `--all` is set, `container-name` and `icon` arguments will be ignored.
This commit is contained in:
committed by
Alessio Biancalana
parent
fe1f60b074
commit
e78ce996b6
@@ -0,0 +1,95 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/89luca89/distrobox/pkg/commands"
|
||||
"github.com/89luca89/distrobox/pkg/containermanager"
|
||||
)
|
||||
|
||||
func newGenerateEntryCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "generate-entry",
|
||||
Usage: "Generate or delete distrobox entries",
|
||||
Version: "1.0.0",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "delete",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "delete the entry",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "icon",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "specify a custom icon (default auto)",
|
||||
Value: "auto",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "all",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "perform for all distroboxes",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "root",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "perform on rootful distroboxes",
|
||||
},
|
||||
},
|
||||
ArgsUsage: "container-name",
|
||||
Action: generateEntryAction,
|
||||
}
|
||||
}
|
||||
|
||||
func generateEntryAction(ctx context.Context, cmd *cli.Command) error {
|
||||
// The current executable is used as distrobox path
|
||||
distroboxPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get distrobox executable path: %w", err)
|
||||
}
|
||||
|
||||
containerManager, ok := ctx.Value(containerManagerKey).(containermanager.ContainerManager)
|
||||
if !ok {
|
||||
return errors.New("container manager not found in context")
|
||||
}
|
||||
|
||||
listCmd := commands.NewListCommand(containerManager)
|
||||
|
||||
opts := &commands.GenerateEntryOptions{
|
||||
Verbose: cmd.Bool("verbose"),
|
||||
Delete: cmd.Bool("delete"),
|
||||
Root: cmd.Bool("root"),
|
||||
DesktopEntryBaseDir: getDesktopEntryDir(),
|
||||
DistroboxPath: distroboxPath,
|
||||
}
|
||||
if cmd.Bool("all") {
|
||||
opts.All = true
|
||||
} else {
|
||||
opts.ContainerName = cmd.Args().First()
|
||||
opts.Icon = cmd.String("icon")
|
||||
}
|
||||
|
||||
genEntryCmd := commands.NewGenerateEntryCommand(listCmd)
|
||||
|
||||
err = genEntryCmd.Execute(ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute generate entry command: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDesktopEntryDir resolves the system path for the desktop entry file
|
||||
func getDesktopEntryDir() string {
|
||||
xdgDataHome := os.Getenv("XDG_DATA_HOME")
|
||||
if xdgDataHome == "" {
|
||||
home := os.Getenv("HOME")
|
||||
return filepath.Join(home, ".local", "share")
|
||||
}
|
||||
return xdgDataHome
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func NewRootCommand() *cli.Command {
|
||||
Before: beforeAction,
|
||||
Commands: []*cli.Command{
|
||||
newListCommand(),
|
||||
newGenerateEntryCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[Desktop Entry]
|
||||
Name={{.entry_name}}
|
||||
GenericName=Terminal entering {{.entry_name}}
|
||||
Comment=Terminal entering {{.entry_name}}
|
||||
Categories=Distrobox;System;Utility
|
||||
Exec={{.distrobox_path}} enter {{.extra_flags}} {{.container_name}}
|
||||
Icon={{.icon}}
|
||||
Keywords=distrobox;
|
||||
NoDisplay=false
|
||||
Terminal=true
|
||||
TryExec={{.distrobox_path}}
|
||||
Type=Application
|
||||
Actions=Remove;
|
||||
|
||||
[Desktop Action Remove]
|
||||
Name=Remove {{.entry_name}} from system
|
||||
Exec={{.distrobox_path}} rm {{.extra_flags}} {{.container_name}}
|
||||
@@ -0,0 +1,208 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed assets/desktop_entry.toml.tmpl
|
||||
var desktopEntryTmpl string
|
||||
|
||||
const (
|
||||
defaultContainerName = "my-distrobox"
|
||||
defaultEntryIcon = "https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg"
|
||||
defaultContainerDistro = "terminal-distrobox-icon"
|
||||
)
|
||||
|
||||
type GenerateEntryOptions struct {
|
||||
Verbose bool
|
||||
Delete bool
|
||||
Root bool
|
||||
DesktopEntryBaseDir string
|
||||
DistroboxPath string
|
||||
All bool
|
||||
Icon string // ignored when All=true
|
||||
ContainerName string // ignored when All=true
|
||||
}
|
||||
|
||||
type GenerateEntryCommand struct {
|
||||
listCommand *ListCommand
|
||||
}
|
||||
|
||||
func NewGenerateEntryCommand(listCommand *ListCommand) *GenerateEntryCommand {
|
||||
return &GenerateEntryCommand{
|
||||
listCommand: listCommand,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GenerateEntryCommand) Execute(
|
||||
ctx context.Context,
|
||||
opts *GenerateEntryOptions) error {
|
||||
// Determine whether is a single or all entries generation
|
||||
// If all is set, fetch the list of all containers
|
||||
// If not, use the provided container name or the default one
|
||||
var containerNames []string
|
||||
var icon string
|
||||
switch {
|
||||
case opts.All:
|
||||
// Generate entries for all containers
|
||||
listResult, err := c.listCommand.Execute(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
containerNames = make([]string, 0, len(listResult.Containers))
|
||||
for _, container := range listResult.Containers {
|
||||
containerNames = append(containerNames, container.Name)
|
||||
}
|
||||
// Set icon to auto for all entries
|
||||
icon = "auto"
|
||||
case opts.ContainerName != "":
|
||||
containerNames = []string{opts.ContainerName}
|
||||
icon = opts.Icon
|
||||
default:
|
||||
containerNames = []string{defaultContainerName}
|
||||
icon = opts.Icon
|
||||
}
|
||||
|
||||
if opts.Delete {
|
||||
// Delete the desktop entries for all the containers
|
||||
for _, containerName := range containerNames {
|
||||
if err := c.deleteEntry(containerName, opts.DesktopEntryBaseDir); err != nil {
|
||||
return fmt.Errorf("failed to delete desktop entry for container %s: %w", containerName, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create the desktop entries for all the containers
|
||||
for _, containerName := range containerNames {
|
||||
if err := c.createEntry(containerName, icon, opts.DesktopEntryBaseDir, opts.DistroboxPath, opts.Root); err != nil {
|
||||
return fmt.Errorf("failed to create desktop entry for container %s: %w", containerName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GenerateEntryCommand) deleteEntry(containerName string, desktopEntryBaseDir string) error {
|
||||
desktopEntryAppsDir := filepath.Join(desktopEntryBaseDir, "applications")
|
||||
entryFilePath := c.getEntryFilePath(desktopEntryAppsDir, containerName)
|
||||
if _, err := os.Stat(entryFilePath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(entryFilePath); err != nil {
|
||||
return fmt.Errorf("failed to delete desktop entry for container %s: %w", containerName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GenerateEntryCommand) createEntry(
|
||||
containerName string,
|
||||
icon string,
|
||||
desktopEntryBaseDir string,
|
||||
distroboxPath string,
|
||||
root bool,
|
||||
) error {
|
||||
desktopEntryAppsDir, _, err := c.ensureDesktopEntryDirExists(desktopEntryBaseDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to ensure desktop entry directories exist: %w", err)
|
||||
}
|
||||
|
||||
entryFilePath := c.getEntryFilePath(desktopEntryAppsDir, containerName)
|
||||
data := c.composeDesktopEntryData(containerName, icon, distroboxPath, root)
|
||||
if err := c.writeDesktopEntryFile(entryFilePath, data); err != nil {
|
||||
return fmt.Errorf("failed to write desktop entry file for container %s: %w", containerName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GenerateEntryCommand) ensureDesktopEntryDirExists(desktopEntryBaseDir string) (string, string, error) {
|
||||
// Ensure the needed targets directories exist
|
||||
desktopEntryAppsDir := filepath.Join(desktopEntryBaseDir, "applications")
|
||||
if err := os.MkdirAll(desktopEntryAppsDir, 0750); err != nil {
|
||||
return "", "", fmt.Errorf("failed to create desktop entry applications directory: %w", err)
|
||||
}
|
||||
desktopEntryIconsDir := filepath.Join(desktopEntryBaseDir, "icons")
|
||||
if err := os.MkdirAll(desktopEntryIconsDir, 0750); err != nil {
|
||||
return "", "", fmt.Errorf("failed to create desktop entry icons directory: %w", err)
|
||||
}
|
||||
return desktopEntryAppsDir, desktopEntryIconsDir, nil
|
||||
}
|
||||
|
||||
// composeDesktopEntry generates the desktop entry for a single container
|
||||
func (c *GenerateEntryCommand) composeDesktopEntryData(
|
||||
containerName string,
|
||||
icon string,
|
||||
distroboxPath string,
|
||||
root bool,
|
||||
) map[string]string {
|
||||
extraFlags := ""
|
||||
if root {
|
||||
extraFlags += "--root"
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"entry_name": getEntryName(containerName),
|
||||
"container_name": containerName,
|
||||
"distrobox_path": distroboxPath,
|
||||
"icon": c.getDesktopIcon(icon),
|
||||
"extra_flags": extraFlags,
|
||||
}
|
||||
}
|
||||
|
||||
// getEntryName returns the formatted entry name for the desktop entry
|
||||
// based on the container name, capitalizing the first letter.
|
||||
func getEntryName(containerName string) string {
|
||||
if containerName == "" {
|
||||
return ""
|
||||
}
|
||||
first := strings.ToUpper(containerName[:1])
|
||||
if len(containerName) > 1 {
|
||||
return first + containerName[1:]
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func (c *GenerateEntryCommand) writeDesktopEntryFile(
|
||||
entryFilePath string,
|
||||
data map[string]string,
|
||||
) error {
|
||||
//nolint:gosec // 644 is common permission for desktop entry files
|
||||
destFileWriter, err := os.OpenFile(entryFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create desktop entry file: %w", err)
|
||||
}
|
||||
defer destFileWriter.Close()
|
||||
|
||||
t, err := template.New("desktopEntry").Parse(desktopEntryTmpl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse desktop entry template: %w", err)
|
||||
}
|
||||
err = t.Execute(destFileWriter, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute desktop entry template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GenerateEntryCommand) getEntryFilePath(desktopEntryDir, containerName string) string {
|
||||
return filepath.Join(desktopEntryDir, containerName+".desktop")
|
||||
}
|
||||
|
||||
func (c *GenerateEntryCommand) getDesktopIcon(
|
||||
icon string,
|
||||
) string {
|
||||
if icon == "auto" {
|
||||
// TODO: detect the icon for the current container's distro
|
||||
return defaultEntryIcon
|
||||
}
|
||||
|
||||
return icon
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package commands_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/89luca89/distrobox/pkg/commands"
|
||||
"github.com/89luca89/distrobox/pkg/containermanager/providers"
|
||||
)
|
||||
|
||||
func TestGenerateEntryCommand_Execute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// create the list command
|
||||
containerManager := providers.NewDocker(false)
|
||||
listCmd := commands.NewListCommand(containerManager)
|
||||
|
||||
//
|
||||
// Generate the entry
|
||||
//
|
||||
|
||||
generateEntryCmd := commands.NewGenerateEntryCommand(listCmd)
|
||||
|
||||
opts := &commands.GenerateEntryOptions{
|
||||
ContainerName: "test-container",
|
||||
Verbose: true,
|
||||
Delete: false,
|
||||
Icon: "https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg",
|
||||
Root: false,
|
||||
DesktopEntryBaseDir: fmt.Sprintf("%s/.local/share/", tempDir),
|
||||
DistroboxPath: "/usr/bin/distrobox",
|
||||
}
|
||||
|
||||
err := generateEntryCmd.Execute(ctx, opts)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateEntryCommand.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
expectedEntryPath := fmt.Sprintf("%s/.local/share/applications/test-container.desktop", tempDir)
|
||||
if _, err := os.Stat(expectedEntryPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected desktop entry file %s does not exist, %s", expectedEntryPath, tempDir)
|
||||
}
|
||||
|
||||
expectedContent := `[Desktop Entry]
|
||||
Name=Test-container
|
||||
GenericName=Terminal entering Test-container
|
||||
Comment=Terminal entering Test-container
|
||||
Categories=Distrobox;System;Utility
|
||||
Exec=/usr/bin/distrobox enter test-container
|
||||
Icon=https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg
|
||||
Keywords=distrobox;
|
||||
NoDisplay=false
|
||||
Terminal=true
|
||||
TryExec=/usr/bin/distrobox
|
||||
Type=Application
|
||||
Actions=Remove;
|
||||
|
||||
[Desktop Action Remove]
|
||||
Name=Remove Test-container from system
|
||||
Exec=/usr/bin/distrobox rm test-container`
|
||||
|
||||
content, err := os.ReadFile(expectedEntryPath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read desktop entry file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != expectedContent {
|
||||
t.Errorf(
|
||||
"Desktop entry content does not match expected.\nGot:\n'%s'\nExpected:\n'%s'",
|
||||
string(content),
|
||||
expectedContent,
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the entry
|
||||
opts.Delete = true
|
||||
err = generateEntryCmd.Execute(ctx, opts)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateEntryCommand.Execute() error on delete = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(expectedEntryPath); !os.IsNotExist(err) {
|
||||
t.Errorf("Expected desktop entry file %s to be deleted, %s", expectedEntryPath, tempDir)
|
||||
}
|
||||
|
||||
// Try deleting a non-existing entry
|
||||
err = generateEntryCmd.Execute(ctx, opts)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateEntryCommand.Execute() error on delete non-existing = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEntryCommand_Execute_Root(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
containerManager := providers.NewDocker(false)
|
||||
listCmd := commands.NewListCommand(containerManager)
|
||||
|
||||
generateEntryCmd := commands.NewGenerateEntryCommand(listCmd)
|
||||
|
||||
opts := &commands.GenerateEntryOptions{
|
||||
ContainerName: "test-container",
|
||||
Verbose: true,
|
||||
Delete: false,
|
||||
Icon: "https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg",
|
||||
Root: true,
|
||||
DesktopEntryBaseDir: fmt.Sprintf("%s/.local/share/", tempDir),
|
||||
DistroboxPath: "/usr/bin/distrobox",
|
||||
}
|
||||
|
||||
err := generateEntryCmd.Execute(ctx, opts)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateEntryCommand.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
expectedEntryPath := fmt.Sprintf("%s/.local/share/applications/test-container.desktop", tempDir)
|
||||
if _, err := os.Stat(expectedEntryPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected desktop entry file %s does not exist, %s", expectedEntryPath, tempDir)
|
||||
}
|
||||
|
||||
expectedContent := `[Desktop Entry]
|
||||
Name=Test-container
|
||||
GenericName=Terminal entering Test-container
|
||||
Comment=Terminal entering Test-container
|
||||
Categories=Distrobox;System;Utility
|
||||
Exec=/usr/bin/distrobox enter --root test-container
|
||||
Icon=https://raw.githubusercontent.com/89luca89/distrobox/main/icons/terminal-distrobox-icon.svg
|
||||
Keywords=distrobox;
|
||||
NoDisplay=false
|
||||
Terminal=true
|
||||
TryExec=/usr/bin/distrobox
|
||||
Type=Application
|
||||
Actions=Remove;
|
||||
|
||||
[Desktop Action Remove]
|
||||
Name=Remove Test-container from system
|
||||
Exec=/usr/bin/distrobox rm --root test-container`
|
||||
|
||||
content, err := os.ReadFile(expectedEntryPath)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read desktop entry file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != expectedContent {
|
||||
t.Errorf(
|
||||
"Desktop entry content does not match expected.\nGot:\n'%s'\nExpected:\n'%s'",
|
||||
string(content),
|
||||
expectedContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAllEntriesCommand_Execute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// tempDir is the test directory where we expect to create desktop entries
|
||||
tempDir := t.TempDir()
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// create the list command
|
||||
containerManager := providers.NewDocker(false)
|
||||
listCmd := commands.NewListCommand(containerManager)
|
||||
|
||||
// create the generate all entries command
|
||||
genAllEntriesCmd := commands.NewGenerateEntryCommand(listCmd)
|
||||
|
||||
//
|
||||
// Generate the entries
|
||||
//
|
||||
|
||||
opts := &commands.GenerateEntryOptions{
|
||||
All: true,
|
||||
Verbose: false,
|
||||
Delete: false,
|
||||
DesktopEntryBaseDir: fmt.Sprintf("%s/.local/share/", tempDir),
|
||||
DistroboxPath: "/usr/bin/distrobox",
|
||||
}
|
||||
err := genAllEntriesCmd.Execute(ctx, opts)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("GenerateAllEntriesCommand.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
// retrieve the list of containers to verify entries were created
|
||||
listResult, err := listCmd.Execute(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("ListCommand.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
// verify that each container has a corresponding desktop entry
|
||||
for _, container := range listResult.Containers {
|
||||
expectedEntryPath := fmt.Sprintf("%s/.local/share/applications/%s.desktop", tempDir, container.Name)
|
||||
if _, err := os.Stat(expectedEntryPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected desktop entry file %s does not exist", expectedEntryPath)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Delete the entries
|
||||
//
|
||||
|
||||
opts.Delete = true
|
||||
err = genAllEntriesCmd.Execute(ctx, opts)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("GenerateAllEntriesCommand.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
// verify that each container's desktop entry has been deleted
|
||||
for _, container := range listResult.Containers {
|
||||
expectedEntryPath := fmt.Sprintf("%s/.local/share/applications/%s.desktop", tempDir, container.Name)
|
||||
if _, err := os.Stat(expectedEntryPath); !os.IsNotExist(err) {
|
||||
t.Errorf("Expected desktop entry file %s to be deleted", expectedEntryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user