diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c401cb8 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# Ideas, Features, Roadmap + +## Known Bugs + +- the xauthority device changes when x or wayland is restarted. `igloo enter` should validate the existence of the xauthority device on each enter, replacing it if necessary + +## Ideas + +- On a host with no VS Code install, we'll be running code inside each igloo container. It'd be nice to share settings between them. Can't really symlink the ~/.vscode dir from the host since it won't exist. Explore having a Shared State sort of thing where directories like that live in ~/.config/igloo/shared_state and are linked in (by default? configurable?). Implement: `[shared_state]` section in config file that stores listed directories in ~/.config/igloo/shared_state and symlinks listed directories into the container, allowing all igloo instances to share these directories. This feature could be used for one-off script storage too. + +- copy the igloo binary into the container, and add one or more commands intended to run inside the container. Perhaps showing, editing `shared_state` contents? definitely `igloo status` should work inside the container and show that it knows it's inside. + +- modify PS1 in the container to add `[igloo]` prefix, change color of prompt, something visual as an indicator? Or... simply add a tip in the readme showing how to do this. diff --git a/cmd/enter.go b/cmd/enter.go index cfccf13..db410ab 100644 --- a/cmd/enter.go +++ b/cmd/enter.go @@ -117,6 +117,11 @@ func runEnter() error { } } + // Update Xauthority mount if necessary (file path can change on Wayland) + if err := client.UpdateXauthority(cfg.Container.Name); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not update Xauthority: %v", err))) + } + // Get user info username := os.Getenv("USER") cwd, err := os.Getwd() diff --git a/internal/incus/client.go b/internal/incus/client.go index 45076b6..7af866d 100644 --- a/internal/incus/client.go +++ b/internal/incus/client.go @@ -154,6 +154,85 @@ func (c *Client) AddGPUDevice(name string) error { return cmd.Run() } +// RemoveDevice removes a device from an instance +func (c *Client) RemoveDevice(name, deviceName string) error { + cmd := exec.Command("incus", "config", "device", "remove", name, deviceName) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// DeviceExists checks if a device exists on an instance +func (c *Client) DeviceExists(name, deviceName string) (bool, error) { + cmd := exec.Command("incus", "config", "device", "show", name) + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("failed to list devices: %w", err) + } + + // Simple check if device name appears in output + return strings.Contains(string(output), deviceName+":"), nil +} + +// GetDeviceSource gets the source path of a disk device +func (c *Client) GetDeviceSource(name, deviceName string) (string, error) { + cmd := exec.Command("incus", "config", "device", "get", name, deviceName, "source") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get device source: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +// UpdateXauthority updates the xauthority device mount if the source file has changed +// This is necessary because XWayland can create new Xauthority files when restarted +func (c *Client) UpdateXauthority(name string) error { + // Get current Xauthority file from environment + xauthFile := os.Getenv("XAUTHORITY") + if xauthFile == "" { + xauthFile = os.Getenv("HOME") + "/.Xauthority" + } + + // Check if the file exists + if _, err := os.Stat(xauthFile); os.IsNotExist(err) { + // No xauthority file available, nothing to update + return nil + } + + // Check if xauthority device exists + deviceExists, err := c.DeviceExists(name, "xauthority") + if err != nil { + return fmt.Errorf("failed to check xauthority device: %w", err) + } + + username := os.Getenv("USER") + xauthPath := fmt.Sprintf("/home/%s/.Xauthority", username) + + if !deviceExists { + // Device doesn't exist, add it + return c.AddDiskDevice(name, "xauthority", xauthFile, xauthPath) + } + + // Device exists, check if source has changed + currentSource, err := c.GetDeviceSource(name, "xauthority") + if err != nil { + return fmt.Errorf("failed to get current xauthority source: %w", err) + } + + if currentSource != xauthFile { + // Source has changed, remove and re-add the device + if err := c.RemoveDevice(name, "xauthority"); err != nil { + return fmt.Errorf("failed to remove old xauthority device: %w", err) + } + + if err := c.AddDiskDevice(name, "xauthority", xauthFile, xauthPath); err != nil { + return fmt.Errorf("failed to add new xauthority device: %w", err) + } + } + + return nil +} + // SetConfig sets a configuration option on an instance func (c *Client) SetConfig(name, key, value string) error { cmd := exec.Command("incus", "config", "set", name, key+"="+value) diff --git a/internal/incus/client_test.go b/internal/incus/client_test.go new file mode 100644 index 0000000..c11d830 --- /dev/null +++ b/internal/incus/client_test.go @@ -0,0 +1,129 @@ +package incus + +import ( + "os" + "strings" + "testing" +) + +// TestUpdateXauthority_NoFile tests UpdateXauthority when no xauthority file exists +func TestUpdateXauthority_NoFile(t *testing.T) { + // Save current env vars + oldXauth := os.Getenv("XAUTHORITY") + oldHome := os.Getenv("HOME") + defer func() { + if oldXauth != "" { + _ = os.Setenv("XAUTHORITY", oldXauth) + } else { + _ = os.Unsetenv("XAUTHORITY") + } + _ = os.Setenv("HOME", oldHome) + }() + + // Set env to point to non-existent file + tmpDir := t.TempDir() + _ = os.Setenv("HOME", tmpDir) + _ = os.Unsetenv("XAUTHORITY") + + client := NewClient() + + // This should not error when file doesn't exist - it just returns nil + err := client.UpdateXauthority("test-container") + if err != nil { + t.Errorf("UpdateXauthority() should not error when file doesn't exist, got: %v", err) + } +} + +// TestUpdateXauthority_WithFile tests UpdateXauthority when xauthority file exists +// Note: This test can't fully test the incus commands without a running incus instance, +// but it verifies the file detection logic works +func TestUpdateXauthority_WithFile(t *testing.T) { + // Save current env vars + oldXauth := os.Getenv("XAUTHORITY") + oldHome := os.Getenv("HOME") + oldUser := os.Getenv("USER") + defer func() { + if oldXauth != "" { + _ = os.Setenv("XAUTHORITY", oldXauth) + } else { + _ = os.Unsetenv("XAUTHORITY") + } + _ = os.Setenv("HOME", oldHome) + _ = os.Setenv("USER", oldUser) + }() + + // Create a temporary xauthority file + tmpDir := t.TempDir() + xauthFile := tmpDir + "/.Xauthority" + if err := os.WriteFile(xauthFile, []byte("fake xauth data"), 0600); err != nil { + t.Fatalf("Failed to create test xauthority file: %v", err) + } + + _ = os.Setenv("HOME", tmpDir) + _ = os.Setenv("USER", "testuser") + _ = os.Unsetenv("XAUTHORITY") + + client := NewClient() + + // This will attempt to call incus commands which will fail without incus installed + // but we can verify it at least tries to process the file + err := client.UpdateXauthority("test-container") + + // We expect an error because incus isn't available in test environment, + // but we're verifying the logic path is taken when the file exists + // The function should have attempted to check device existence + if err == nil { + // If no error, then either incus is available (unlikely in CI) + // or the logic correctly handled the case + t.Log("UpdateXauthority succeeded (incus may be available)") + } else { + // Expected case: incus command failed + errMsg := err.Error() + if !strings.Contains(errMsg, "incus") && !strings.Contains(errMsg, "device") && + !strings.Contains(errMsg, "executable file not found") && !strings.Contains(errMsg, "command not found") { + t.Errorf("Expected incus-related error, got: %v", err) + } + } +} + +// TestUpdateXauthority_CustomPath tests UpdateXauthority with XAUTHORITY env var set +func TestUpdateXauthority_CustomPath(t *testing.T) { + // Save current env vars + oldXauth := os.Getenv("XAUTHORITY") + oldUser := os.Getenv("USER") + defer func() { + if oldXauth != "" { + _ = os.Setenv("XAUTHORITY", oldXauth) + } else { + _ = os.Unsetenv("XAUTHORITY") + } + _ = os.Setenv("USER", oldUser) + }() + + // Create a temporary xauthority file with custom path + tmpDir := t.TempDir() + xauthFile := tmpDir + "/.mutter-Xwaylandauth.ABC123" + if err := os.WriteFile(xauthFile, []byte("fake xauth data"), 0600); err != nil { + t.Fatalf("Failed to create test xauthority file: %v", err) + } + + _ = os.Setenv("XAUTHORITY", xauthFile) + _ = os.Setenv("USER", "testuser") + + client := NewClient() + + // This will attempt to call incus commands which will fail without incus installed + err := client.UpdateXauthority("test-container") + + // We expect an error because incus isn't available in test environment + if err == nil { + t.Log("UpdateXauthority succeeded (incus may be available)") + } else { + // Expected case: incus command failed + errMsg := err.Error() + if !strings.Contains(errMsg, "incus") && !strings.Contains(errMsg, "device") && + !strings.Contains(errMsg, "executable file not found") && !strings.Contains(errMsg, "command not found") { + t.Errorf("Expected incus-related error, got: %v", err) + } + } +}