diff --git a/doc/toolbox-export.1.md b/doc/toolbox-export.1.md new file mode 100644 index 000000000..d9e7ea685 --- /dev/null +++ b/doc/toolbox-export.1.md @@ -0,0 +1,51 @@ +% toolbox-export 1 + +## NAME +toolbox-export - Export binaries or applications from a toolbox container to your host + +## SYNOPSIS +**toolbox export** [--bin _binary_] [--app _application_] --container _container_ + +## DESCRIPTION +The **toolbox export** command allows you to expose binaries or desktop applications from a toolbox container onto your host system. This is achieved by creating wrapper scripts for binaries in `~/.local/bin` and desktop files for applications in `~/.local/share/applications`. These exported items let you launch containerized tools seamlessly from your host environment. + +## OPTIONS + +**--bin _binary_** +: Export a binary from the toolbox container. The argument can be a binary name or a path inside the container. + +**--app _application_** +: Export a desktop application from the toolbox container. This will search for an appropriate `.desktop` file inside the container and adapt it for host use. + +**--container _container_** +: Name of the toolbox container from which the binary or application should be exported. + +## EXAMPLES + +Export the `vim` binary from the container named `arch`: +``` +toolbox export --bin vim --container arch +``` + +Export the `firefox` application from the container named `fedora`: +``` +toolbox export --app firefox --container fedora +``` + +## FILES + +Exported binaries are placed in: +``` +~/.local/bin/ +``` + +Exported desktop files are placed in: +``` +~/.local/share/applications/ +``` + +## SEE ALSO +toolbox(1), toolbox-unexport(1) + +## AUTHORS +Toolbox contributors diff --git a/doc/toolbox-unexport.1.md b/doc/toolbox-unexport.1.md new file mode 100644 index 000000000..04690f2e5 --- /dev/null +++ b/doc/toolbox-unexport.1.md @@ -0,0 +1,59 @@ +% toolbox-unexport 1 + +## NAME +toolbox-unexport - Remove exported binaries and applications for a toolbox container + +## SYNOPSIS +**toolbox unexport** --container _container_ [--bin _binary_] [--app _application_] [--all] + +## DESCRIPTION +The **toolbox unexport** command removes exported binaries and/or desktop applications that were previously made available on the host from a specified toolbox container. This helps clean up your host from wrappers and desktop files created by the `toolbox export` command. + +## OPTIONS + +**--bin _binary_** +: Remove the exported binary wrapper for the specified binary, for the given container. + +**--app _application_** +: Remove the exported desktop application for the specified app, for the given container. + +**--all** +: Remove all exported binaries and applications for the specified container. + +**--container _container_** +: The container whose exported binaries and applications should be removed. + +## EXAMPLES + +Remove the exported `vim` binary for container `arch`: +``` +toolbox unexport --container arch --bin vim +``` + +Remove the exported `firefox` application for container `fedora`: +``` +toolbox unexport --container fedora --app firefox +``` + +Remove all exported binaries and applications for container `arch`: +``` +toolbox unexport --container arch --all +``` + +## FILES + +Exported binaries are located in: +``` +~/.local/bin/ +``` + +Exported desktop files are located in: +``` +~/.local/share/applications/ +``` + +## SEE ALSO +toolbox(1), toolbox-export(1) + +## AUTHORS +Toolbox contributors diff --git a/doc/toolbox.1.md b/doc/toolbox.1.md index 6102e7b43..e4d8c3b81 100644 --- a/doc/toolbox.1.md +++ b/doc/toolbox.1.md @@ -136,6 +136,14 @@ Create a new Toolbx container. Enter a Toolbx container for interactive use. +**toolbox-export(1)** + +Export binaries or desktop applications from a toolbox container to the host. + +**toolbox-unexport(1)** + +Remove exported binaries and applications for a toolbox container. + **toolbox-help(1)** Display help information about Toolbx. diff --git a/src/cmd/export.go b/src/cmd/export.go new file mode 100644 index 000000000..7096b74e7 --- /dev/null +++ b/src/cmd/export.go @@ -0,0 +1,262 @@ +/* + * Copyright © 2025 Hadi Chokr + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/containers/toolbox/pkg/utils" + "github.com/spf13/cobra" +) + +var ( + exportBin string + exportApp string + exportContainer string +) + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export binaries or applications from a toolbox container", + RunE: runExport, + ValidArgsFunction: completionContainerNamesFiltered, +} + +func init() { + exportCmd.Flags().StringVar(&exportBin, "bin", "", "Path or name of binary to export") + exportCmd.Flags().StringVar(&exportApp, "app", "", "Path or name of application to export") + exportCmd.Flags().StringVar(&exportContainer, "container", "", "Name of the toolbox container") + + if err := exportCmd.RegisterFlagCompletionFunc("container", completionContainerNames); err != nil { + panic(fmt.Sprintf("failed to register flag completion function: %v", err)) + } + + exportCmd.SetHelpFunc(exportHelp) + rootCmd.AddCommand(exportCmd) +} + +func runExport(cmd *cobra.Command, args []string) error { + if exportBin == "" && exportApp == "" { + return errors.New("must specify either --bin or --app") + } + if exportContainer == "" { + return errors.New("must specify --container") + } + + if exportBin != "" { + return exportBinary(exportBin, exportContainer) + } + return exportApplication(exportApp, exportContainer) +} + +func exportBinary(binName, containerName string) error { + // Ensure host export tmp dir exists + tmpDir := "/tmp/toolbox-export" + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return fmt.Errorf("failed to create toolbox-export tmp dir: %v", err) + } + + // Unique tmp file + tmpFile := filepath.Join(tmpDir, fmt.Sprintf("%s-%d.bin", binName, time.Now().UnixNano())) + + // Run inside container, redirecting output to host file + cmd := fmt.Sprintf( + "(command -v %q || which %q || type -P %q || true) > /run/host%s", + binName, binName, binName, tmpFile, + ) + if _, err := runCommandWithOutput( + containerName, + false, "", "", 0, + []string{"sh", "--noprofile", "--norc", "-c", cmd}, + false, false, true, + ); err != nil { + return fmt.Errorf("failed to run command inside container: %v", err) + } + + // Read back file content + content, err := os.ReadFile(tmpFile) + defer os.Remove(tmpFile) + if err != nil { + return fmt.Errorf("failed to read back exported binary path: %v", err) + } + + binPath := strings.TrimSpace(string(content)) + if binPath == "" { + return fmt.Errorf("binary %q not found in container", binName) + } + + // Verify it's executable inside container + if _, err := runCommandWithOutput( + containerName, false, "", "", 0, + []string{"test", "-x", binPath}, false, false, true, + ); err != nil { + return fmt.Errorf("found path %q but it's not executable: %v", binPath, err) + } + + // Create wrapper in ~/.local/bin + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + binDir := filepath.Join(homeDir, ".local", "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + return fmt.Errorf("failed to create bin directory: %v", err) + } + + exportedBinPath := filepath.Join(binDir, binName) + script := fmt.Sprintf(`#!/bin/sh + # toolbox_binary + # name: %s + BIN_PATH="%s" + exec toolbox run -c %s "$BIN_PATH" "$@" + `, containerName, binPath, containerName) + + if err := os.WriteFile(exportedBinPath, []byte(script), 0755); err != nil { + return fmt.Errorf("failed to create wrapper: %v", err) + } + + fmt.Printf("Successfully exported %s from container %s to %s\n", binName, containerName, exportedBinPath) + return nil +} + +func exportApplication(appName, containerName string) error { + tmpDir := "/tmp/toolbox-export" + if err := os.MkdirAll(tmpDir, 0755); err != nil { + return fmt.Errorf("failed to create toolbox-export tmp dir: %v", err) + } + + // Unique tmp files + findFile := filepath.Join(tmpDir, fmt.Sprintf("%s-%d.find", appName, time.Now().UnixNano())) + catFile := filepath.Join(tmpDir, fmt.Sprintf("%s-%d.desktop", appName, time.Now().UnixNano())) + + // Step 1: Run find inside container and redirect to host file + findCmd := fmt.Sprintf("find /usr/share/applications -name '%s.desktop' > /run/host%s", appName, findFile) + if _, err := runCommandWithOutput(containerName, false, "", "", 0, []string{"sh", "-c", findCmd}, false, false, true); err != nil { + return fmt.Errorf("failed to run find inside container: %v", err) + } + + // Read back found desktop path + findOutput, err := os.ReadFile(findFile) + defer os.Remove(findFile) + if err != nil { + return fmt.Errorf("failed to read find output: %v", err) + } + desktopFile := strings.TrimSpace(string(findOutput)) + if desktopFile == "" { + return fmt.Errorf("Error: application %s not found in container", appName) + } + + // Step 2: Cat the desktop file content into host tmp + catCmd := fmt.Sprintf("cat %q > /run/host%s", desktopFile, catFile) + if _, err := runCommandWithOutput(containerName, false, "", "", 0, []string{"sh", "-c", catCmd}, false, false, true); err != nil { + return fmt.Errorf("failed to read desktop file %q: %v", desktopFile, err) + } + + // Read back content + content, err := os.ReadFile(catFile) + defer os.Remove(catFile) + if err != nil { + return fmt.Errorf("failed to read desktop content: %v", err) + } + lines := strings.Split(string(content), "\n") + + var newLines []string + started := false + hasNameTranslations := false + + // Rewrite desktop file fields + for _, line := range lines { + if !started { + if strings.TrimSpace(line) == "[Desktop Entry]" { + started = true + newLines = append(newLines, line) + } + continue + } + + if strings.HasPrefix(line, "Exec=") { + execCmd := line[5:] + line = fmt.Sprintf("Exec=toolbox run -c %s %s", containerName, execCmd) + } else if strings.HasPrefix(line, "Name=") { + line = fmt.Sprintf("Name=%s (on %s)", line[5:], containerName) + } else if strings.HasPrefix(line, "Name[") { + hasNameTranslations = true + } else if strings.HasPrefix(line, "GenericName=") { + line = fmt.Sprintf("GenericName=%s (on %s)", line[12:], containerName) + } else if strings.HasPrefix(line, "TryExec=") || line == "DBusActivatable=true" { + continue + } + newLines = append(newLines, line) + } + + if hasNameTranslations { + for i, line := range newLines { + if strings.HasPrefix(line, "Name[") { + lang := line[5:strings.Index(line, "]")] + value := line[strings.Index(line, "=")+1:] + newLines[i] = fmt.Sprintf("Name[%s]=%s (on %s)", lang, value, containerName) + } + } + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + appsPath := filepath.Join(homeDir, ".local", "share", "applications") + exportedPath := filepath.Join(appsPath, filepath.Base(desktopFile)) + exportedPath = strings.TrimSuffix(exportedPath, ".desktop") + "-" + containerName + ".desktop" + + if err := os.MkdirAll(appsPath, 0755); err != nil { + return fmt.Errorf("failed to create applications directory: %v", err) + } + if err := os.WriteFile(exportedPath, []byte(strings.Join(newLines, "\n")), 0644); err != nil { + return fmt.Errorf("failed to create desktop file: %v", err) + } + + exec.Command("update-desktop-database", appsPath).Run() + + fmt.Printf("Successfully exported %s from container %s to %s\n", appName, containerName, exportedPath) + return nil +} + +func exportHelp(cmd *cobra.Command, args []string) { + if utils.IsInsideContainer() { + if !utils.IsInsideToolboxContainer() { + fmt.Fprintf(os.Stderr, "Error: this is not a Toolbx container\n") + return + } + + if _, err := utils.ForwardToHost(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + return + } + + return + } + + if err := showManual("toolbox-export"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + return + } +} diff --git a/src/cmd/rm.go b/src/cmd/rm.go index 8e7c5b941..6f2366d8b 100644 --- a/src/cmd/rm.go +++ b/src/cmd/rm.go @@ -78,6 +78,9 @@ func rm(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "Error: %s\n", err) continue } + + // Unexport Binaries and Applications from this Container from the Host + UnexportAll(container.Name()) } } else { if len(args) == 0 { @@ -105,6 +108,9 @@ func rm(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "Error: %s\n", err) continue } + + // Unexport Binaries and Applications from this Container from the Host + UnexportAll(container) } } diff --git a/src/cmd/run.go b/src/cmd/run.go index 389ea1615..2f2aa5537 100644 --- a/src/cmd/run.go +++ b/src/cmd/run.go @@ -18,6 +18,7 @@ package cmd import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -25,6 +26,7 @@ import ( "io" "os" "path/filepath" + "slices" "strconv" "strings" "syscall" @@ -489,6 +491,221 @@ func runCommandWithFallbacks(container string, // code should not be reached } +// This function can capture/suppress stdout from toolbox containers +func runCommandWithOutput(container string, + defaultContainer bool, + image, release string, + preserveFDs uint, + command []string, + emitEscapeSequence, fallbackToBash, pedantic bool) (string, error) { + + if !pedantic { + if image == "" { + panic("image not specified") + } + if release == "" { + panic("release not specified") + } + } + + logrus.Debugf("Checking if container %s exists", container) + + if _, err := podman.ContainerExists(container); err != nil { + logrus.Debugf("Container %s not found", container) + + if pedantic { + return "", createErrorContainerNotFound(container) + } + + containers, err := getContainers() + if err != nil { + return "", createErrorContainerNotFound(container) + } + + if len(containers) == 0 { + shouldCreate := rootFlags.assumeYes || askForConfirmation("No Toolbx containers found. Create now? [y/N]") + if !shouldCreate { + fmt.Printf("A container can be created later with the 'create' command.\n") + fmt.Printf("Run '%s --help' for usage.\n", executableBase) + return "", nil + } + if err := createContainer(container, image, release, "", false); err != nil { + return "", err + } + } else if len(containers) == 1 && defaultContainer { + fmt.Fprintf(os.Stderr, "Error: container %s not found\n", container) + container = containers[0].Name() + fmt.Fprintf(os.Stderr, "Entering container %s instead.\n", container) + fmt.Fprintf(os.Stderr, "Use the 'create' command to create a different Toolbx.\n") + fmt.Fprintf(os.Stderr, "Run '%s --help' for usage.\n", executableBase) + } else { + return "", fmt.Errorf("container %s not found\nUse the '--container' option to select a Toolbx.\nRun '%s --help' for usage.", container, executableBase) + } + } + + containerObj, err := podman.InspectContainer(container) + if err != nil { + return "", fmt.Errorf("failed to inspect container %s", container) + } + + if containerObj.EntryPoint() != "toolbox" { + return "", fmt.Errorf("container %s is too old and no longer supported\nRecreate it with Toolbx version 0.0.17 or newer.", container) + } + + if err := callFlatpakSessionHelper(containerObj); err != nil { + return "", err + } + + var cdiEnviron []string + cdiSpec, err := nvidia.GenerateCDISpec() + if err != nil { + if errors.Is(err, nvidia.ErrNVMLDriverLibraryVersionMismatch) { + return "", fmt.Errorf("the proprietary NVIDIA driver's kernel and user space don't match\nCheck the host operating system and systemd journal.") + } else if !errors.Is(err, nvidia.ErrPlatformUnsupported) { + return "", err + } + } else { + cdiEnviron = append(cdiEnviron, cdiSpec.ContainerEdits.Env...) + } + + p11Environ, err := startP11KitServer() + if err != nil { + return "", err + } + + entryPointPID := containerObj.EntryPointPID() + startTime := time.Unix(-1, 0) + + if entryPointPID <= 0 { + if cdiSpec != nil { + cdiFile, err := getCDIFileForNvidia(currentUser) + if err != nil { + return "", err + } + if err := saveCDISpecTo(cdiSpec, cdiFile); err != nil { + return "", err + } + } + + startTime = time.Now() + if err := startContainer(container); err != nil { + return "", err + } + + containerObj, err = podman.InspectContainer(container) + if err != nil { + return "", fmt.Errorf("failed to inspect container %s", container) + } + + entryPointPID = containerObj.EntryPointPID() + if entryPointPID <= 0 { + if err := showEntryPointLogs(container, startTime); err != nil { + logrus.Debugf("Reading logs from container %s failed: %s", container, err) + } + return "", fmt.Errorf("invalid entry point PID of container %s", container) + } + } + + if err := ensureContainerIsInitialized(container, entryPointPID, startTime); err != nil { + return "", err + } + + environ := append(cdiEnviron, p11Environ...) + return runCommandWithFallbacksWithOutput(container, preserveFDs, command, environ, emitEscapeSequence, fallbackToBash) +} + +func runCommandWithFallbacksWithOutput(container string, + preserveFDs uint, + command, environ []string, + emitEscapeSequence, fallbackToBash bool) (string, error) { + + detachKeysSupported := podman.CheckVersion("1.8.1") + + envOptions := utils.GetEnvOptionsForPreservedVariables() + for _, env := range environ { + envOptions = append(envOptions, "--env="+env) + } + + preserveFDsString := fmt.Sprint(preserveFDs) + var ttyNeeded bool + var stdout, stderr bytes.Buffer + + if term.IsTerminal(os.Stdin) && term.IsTerminal(os.Stdout) { + ttyNeeded = true + } + + workDir := workingDirectory + cmdIdx, dirIdx := 0, 0 + + for { + execArgs := constructExecArgs(container, + preserveFDsString, + command, + detachKeysSupported, + envOptions, + fallbackToBash, + ttyNeeded, + workDir) + + if emitEscapeSequence { + fmt.Printf("\033]777;container;push;%s;toolbox;%s\033\\", container, currentUser.Uid) + } + + stdout.Reset() + stderr.Reset() + + exitCode, err := shell.RunWithExitCode("podman", os.Stdin, &stdout, &stderr, execArgs...) + + if emitEscapeSequence { + fmt.Printf("\033]777;container;pop;;;%s\033\\", currentUser.Uid) + } + + switch exitCode { + case 0: + if err != nil { + panic("unexpected error: 'podman exec' succeeded but returned error") + } + return stdout.String(), nil + case 125: + return "", &exitError{exitCode, fmt.Errorf("failed to invoke 'podman exec' in container %s", container)} + case 126: + return "", &exitError{exitCode, fmt.Errorf("failed to invoke command %s in container %s", command[0], container)} + case 127: + if ok, _ := isPathPresent(container, workDir); !ok { + if dirIdx < len(runFallbackWorkDirs) { + fmt.Fprintf(os.Stderr, "Error: directory %s not found in container %s\n", workDir, container) + workDir = runFallbackWorkDirs[dirIdx] + if workDir == "" { + workDir = getCurrentUserHomeDir() + } + fmt.Fprintf(os.Stderr, "Using %s instead.\n", workDir) + dirIdx++ + continue + } + return "", &exitError{exitCode, fmt.Errorf("directory %s not found in container %s", workDir, container)} + } + + if _, err := isCommandPresent(container, command[0]); err != nil { + if fallbackToBash && cmdIdx < len(runFallbackCommands) { + fmt.Fprintf(os.Stderr, "Error: command %s not found in container %s\n", command[0], container) + command = runFallbackCommands[cmdIdx] + fmt.Fprintf(os.Stderr, "Using %s instead.\n", command[0]) + cmdIdx++ + continue + } + return "", &exitError{exitCode, fmt.Errorf("command %s not found in container %s", command[0], container)} + } + + if command[0] == "toolbox" { + return "", &exitError{exitCode, nil} + } + return stdout.String(), nil + default: + return "", &exitError{exitCode, nil} + } + } +} + func runHelp(cmd *cobra.Command, args []string) { if utils.IsInsideContainer() { if !utils.IsInsideToolboxContainer() { @@ -517,12 +734,9 @@ func callFlatpakSessionHelper(container podman.Container) error { var needsFlatpakSessionHelper bool mounts := container.Mounts() - for _, mount := range mounts { - if mount == "/run/host/monitor" { - logrus.Debug("Requires org.freedesktop.Flatpak.SessionHelper") - needsFlatpakSessionHelper = true - break - } + if slices.Contains(mounts, "/run/host/monitor") { + logrus.Debug("Requires org.freedesktop.Flatpak.SessionHelper") + needsFlatpakSessionHelper = true } if !needsFlatpakSessionHelper { diff --git a/src/cmd/unexport.go b/src/cmd/unexport.go new file mode 100644 index 000000000..c5e20a127 --- /dev/null +++ b/src/cmd/unexport.go @@ -0,0 +1,209 @@ +/* + * Copyright © 2025 Hadi Chokr + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/containers/toolbox/pkg/utils" + "github.com/spf13/cobra" +) + +var ( + unexportContainer string + unexportBin string + unexportApp string + unexportAll bool +) + +var unexportCmd = &cobra.Command{ + Use: "unexport", + Short: "Remove exported binaries and applications for a specific toolbox container", + RunE: runUnexport, + ValidArgsFunction: completionContainerNamesFiltered, +} + +func init() { + unexportCmd.Flags().StringVar(&unexportContainer, "container", "", "Name of the toolbox container") + unexportCmd.Flags().StringVar(&unexportBin, "bin", "", "Name of the exported binary to remove") + unexportCmd.Flags().StringVar(&unexportApp, "app", "", "Name of the exported application to remove") + unexportCmd.Flags().BoolVar(&unexportAll, "all", false, "Remove all exported binaries and applications for the container") + + if err := unexportCmd.RegisterFlagCompletionFunc("container", completionContainerNames); err != nil { + panic(fmt.Sprintf("failed to register flag completion function: %v", err)) + } + + unexportCmd.SetHelpFunc(unexportHelp) + rootCmd.AddCommand(unexportCmd) +} + +func runUnexport(cmd *cobra.Command, args []string) error { + if unexportContainer == "" { + return errors.New("must specify --container") + } + + if !unexportAll && unexportBin == "" && unexportApp == "" { + return errors.New("must specify --bin, --app, or --all") + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return err + } + binDir := filepath.Join(homeDir, ".local", "bin") + appsDir := filepath.Join(homeDir, ".local", "share", "applications") + + removedBins := []string{} + removedApps := []string{} + + if unexportBin != "" { + path := filepath.Join(binDir, unexportBin) + if fileContainsContainer(path, unexportContainer) { + if err := os.Remove(path); err == nil { + removedBins = append(removedBins, path) + } + } + if len(removedBins) == 0 { + return fmt.Errorf("Error: binary %s not exported from container", unexportBin) + } + } + + if unexportApp != "" { + matches, _ := filepath.Glob(filepath.Join(appsDir, fmt.Sprintf("*%s-%s.desktop", unexportApp, unexportContainer))) + for _, path := range matches { + if err := os.Remove(path); err == nil { + removedApps = append(removedApps, path) + } + } + if len(removedApps) == 0 { + return fmt.Errorf("Error: application %s not exported from container", unexportApp) + } + } + + if unexportAll { + // Remove all binaries for this container + binFiles, _ := os.ReadDir(binDir) + for _, f := range binFiles { + if f.IsDir() { + continue + } + path := filepath.Join(binDir, f.Name()) + if fileContainsContainer(path, unexportContainer) { + if err := os.Remove(path); err == nil { + removedBins = append(removedBins, path) + } + } + } + + // Remove all desktop files for this container + appFiles, _ := os.ReadDir(appsDir) + for _, f := range appFiles { + name := f.Name() + if strings.HasSuffix(name, "-"+unexportContainer+".desktop") { + path := filepath.Join(appsDir, name) + if err := os.Remove(path); err == nil { + removedApps = append(removedApps, path) + } + } + } + } + + fmt.Printf("Removed binaries:\n") + for _, b := range removedBins { + fmt.Printf(" %s\n", b) + } + fmt.Printf("Removed desktop files:\n") + for _, a := range removedApps { + fmt.Printf(" %s\n", a) + } + if len(removedBins) == 0 && len(removedApps) == 0 { + fmt.Println("No exported binaries or desktop files found to remove for container", unexportContainer) + } + return nil +} + +func fileContainsContainer(path, container string) bool { + content, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.Contains(string(content), "# toolbox_binary") && + strings.Contains(string(content), fmt.Sprintf("name: %s", container)) +} + +func UnexportAll(container string) ([]string, []string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, nil, err + } + binDir := filepath.Join(homeDir, ".local", "bin") + appsDir := filepath.Join(homeDir, ".local", "share", "applications") + + removedBins := []string{} + removedApps := []string{} + + binFiles, _ := os.ReadDir(binDir) + for _, f := range binFiles { + if f.IsDir() { + continue + } + path := filepath.Join(binDir, f.Name()) + if fileContainsContainer(path, container) { + if err := os.Remove(path); err == nil { + removedBins = append(removedBins, path) + } + } + } + + appFiles, _ := os.ReadDir(appsDir) + for _, f := range appFiles { + name := f.Name() + if strings.HasSuffix(name, "-"+container+".desktop") { + path := filepath.Join(appsDir, name) + if err := os.Remove(path); err == nil { + removedApps = append(removedApps, path) + } + } + } + + return removedBins, removedApps, nil +} + +func unexportHelp(cmd *cobra.Command, args []string) { + if utils.IsInsideContainer() { + if !utils.IsInsideToolboxContainer() { + fmt.Fprintf(os.Stderr, "Error: this is not a Toolbx container\n") + return + } + + if _, err := utils.ForwardToHost(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + return + } + + return + } + + if err := showManual("toolbox-unexport"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + return + } +} diff --git a/test/system/109-export.bats b/test/system/109-export.bats new file mode 100644 index 000000000..a3d7d1ca2 --- /dev/null +++ b/test/system/109-export.bats @@ -0,0 +1,81 @@ +# shellcheck shell=bats +# +# Copyright © 2025 Hadi Chokr +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# bats file_tags=commands-options + +load 'libs/bats-support/load' +load 'libs/bats-assert/load' +load 'libs/helpers' + +setup() { + bats_require_minimum_version 1.8.0 + cleanup_all + pushd "$HOME" || return 1 +} + +teardown() { + popd || return 1 + cleanup_all +} + +install_test_apps() { + run "$TOOLBX" run sudo dnf -y install gimp vlc neovim + assert_success +} + +@test "export: Export GIMP app from Fedora container" { + create_default_container + install_test_apps + + run "$TOOLBX" export --app gimp --container "$(get_latest_container_name)" + assert_success + + assert [ -f "$HOME/.local/share/applications/gimp-$(get_latest_container_name).desktop" ] +} + +@test "export: Export VLC app from Fedora container" { + create_default_container + install_test_apps + + run "$TOOLBX" export --app vlc --container "$(get_latest_container_name)" + assert_success + + assert [ -f "$HOME/.local/share/applications/vlc-$(get_latest_container_name).desktop" ] +} + +@test "export: Export Neovim binary from Fedora container" { + create_default_container + install_test_apps + + run "$TOOLBX" export --bin nvim --container "$(get_latest_container_name)" + assert_success + + assert [ -f "$HOME/.local/bin/nvim" ] +} + +@test "export: Fail to export non-installed binary" { + create_default_container + + run "$TOOLBX" export --bin fakeapp --container "$(get_latest_container_name)" + assert_failure +} + +@test "export: Fail to export non-installed app" { + create_default_container + + run "$TOOLBX" export --app fakeapp --container "$(get_latest_container_name)" + assert_failure +} + diff --git a/test/system/110-unexport.bats b/test/system/110-unexport.bats new file mode 100644 index 000000000..94593c091 --- /dev/null +++ b/test/system/110-unexport.bats @@ -0,0 +1,85 @@ +# shellcheck shell=bats +# +# Copyright © 2025 Hadi Chokr +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# bats file_tags=commands-options + +load 'libs/bats-support/load' +load 'libs/bats-assert/load' +load 'libs/helpers' + +setup() { + bats_require_minimum_version 1.8.0 + cleanup_all + pushd "$HOME" || return 1 +} + +teardown() { + popd || return 1 + cleanup_all +} + +install_test_apps() { + run "$TOOLBX" run sudo dnf -y install gimp vlc neovim + assert_success +} + +@test "unexport: Remove exported GIMP app from Fedora container" { + create_default_container + install_test_apps + run "$TOOLBX" export --app gimp --container "$(get_latest_container_name)" + assert_success + assert [ -f "$HOME/.local/share/applications/gimp-$(get_latest_container_name).desktop" ] + + run "$TOOLBX" unexport --app gimp --container "$(get_latest_container_name)" + assert_success + assert [ ! -f "$HOME/.local/share/applications/gimp-$(get_latest_container_name).desktop" ] +} + +@test "unexport: Remove exported Neovim binary from Fedora container" { + create_default_container + install_test_apps + run "$TOOLBX" export --bin nvim --container "$(get_latest_container_name)" + assert_success + assert [ -f "$HOME/.local/bin/nvim" ] + + run "$TOOLBX" unexport --bin nvim --container "$(get_latest_container_name)" + assert_success + assert [ ! -f "$HOME/.local/bin/nvim" ] +} + +@test "unexport: Remove all exported items from Fedora container" { + create_default_container + install_test_apps + run "$TOOLBX" export --app gimp --container "$(get_latest_container_name)" + assert_success + run "$TOOLBX" export --bin nvim --container "$(get_latest_container_name)" + assert_success + assert [ -f "$HOME/.local/share/applications/gimp-$(get_latest_container_name).desktop" ] + assert [ -f "$HOME/.local/bin/nvim" ] + + run "$TOOLBX" unexport --all --container "$(get_latest_container_name)" + assert_success + assert [ ! -f "$HOME/.local/share/applications/gimp-$(get_latest_container_name).desktop" ] + assert [ ! -f "$HOME/.local/bin/nvim" ] +} + +@test "unexport: Fail to remove non-exported app" { + create_default_container + install_test_apps + + run "$TOOLBX" unexport --app fakeapp --container "$(get_latest_container_name)" + assert_failure +} + diff --git a/test/system/meson.build b/test/system/meson.build index c53add0cc..c9609ad46 100644 --- a/test/system/meson.build +++ b/test/system/meson.build @@ -9,6 +9,8 @@ test_system = files( '106-rm.bats', '107-rmi.bats', '108-completion.bats', + '109-export.bats', + '110-unexport.bats', '201-ipc.bats', '203-network.bats', '206-user.bats',