# Bugfix Plan v1.0.1

**Document Version:** 1.0  
**Target Release:** v1.0.1  
**Created:** 2026-03-03  
**Status:** Planning

---

## Executive Summary

This document outlines the implementation plan for bug fixes targeting version v1.0.1. Two main issues are addressed:

1. **Issue #9**: FFmpeg Binary Update fails with "7-zip Not Found" on Windows
2. **Issue #1**: Linux Sudo Escalation for Package Manager Installs in Web UI Mode

---

## Issue #9: FFmpeg Binary Update - 7-zip Not Found

**Priority:** Critical  
**Platform:** Windows  
**Impact:** Users cannot update FFmpeg binary

### Problem Statement

When updating FFmpeg on Windows, the error **"7zip not found. Please install 7zip"** occurs even when `7zr.exe` was previously downloaded during MPV installation.

### Root Cause Analysis

**Verified through code inspection:**

| Function | File | Line | Sets 7zip Path? |
|----------|------|------|-----------------|
| `UpdateMPVWithOutput()` | `pkg/installer/windows.go` | 475 | ✅ Yes |
| `UpdateFFmpegWithOutput()` | `pkg/installer/installer.go` | 486-575 | ❌ No |

**Code Flow:**

```
UpdateMPVWithOutput() [windows.go:470]
  ├─ wi.Installer.SetSevenZipPath(wi.get7zipPath())  ✅ Line 475
  └─ downloadAndExtract7z() → Uses sevenZipPath

UpdateFFmpegWithOutput() [installer.go:486]
  ├─ (No SetSevenZipPath call)  ❌
  └─ ExtractArchiveWithOutput() → extract7zWithOutput()
       └─ sevenZip = i.sevenZipPath  (empty!)
            └─ Find7zip() → Searches PATH only
                 └─ Returns "" if not in PATH → Error
```

### Solution

Add 7zip path setup in `UpdateFFmpegWithOutput()` before calling extraction.

**Implementation in `pkg/installer/installer.go`:**

```go
// In UpdateFFmpegWithOutput(), after "Updating FFmpeg..." message:

// Set 7zip path for Windows (use downloaded 7zr.exe if available)
if runtime.GOOS == "windows" {
    sevenZipPath := filepath.Join(installDir, constants.SevenZipFileName)
    if _, err := os.Stat(sevenZipPath); err == nil {
        i.SetSevenZipPath(sevenZipPath)
    }
}
```

### Files to Modify

| File | Changes |
|------|---------|
| `pkg/installer/installer.go` | Add 7zip path setup in `UpdateFFmpegWithOutput()` |

### Testing Checklist

- [ ] Test FFmpeg update on Windows (should use downloaded 7zr.exe)
- [ ] Test MPV update still works (regression test)
- [ ] Test on system with 7zip installed in PATH (should still work)
- [ ] Test on system without any 7zip (should fail with clear error)

---

## Issue #1: Linux Sudo Escalation for Package Manager Installs

**Priority:** High  
**Platform:** Linux (Fedora, Ubuntu, etc.)  
**Impact:** Package manager installs fail in Web UI mode

### Problem Statement

On Fedora (and other Linux distributions), package manager installs require sudo privileges. In Web UI mode, sudo cannot prompt for password because no TTY is attached.

**Error Message:**
```
sudo: no tty present and no askpass program specified
```

**Affected Install Methods:**
- `mpv-package` (apt, dnf, pacman)
- `celluloid-package` (apt, dnf, pacman)
- Any operation using `GetPrivilegePrefix()`

### Root Cause Analysis

**Current Implementation:**

1. **`GetPrivilegePrefix()` in `pkg/installer/common.go:229-238`**:
   ```go
   func GetPrivilegePrefix() []string {
       if os.Geteuid() == 0 {
           return []string{}
       }
       if CommandExists(constants.CommandSudo) {
           return []string{constants.CommandSudo}  // Always returns sudo
       }
       return []string{}
   }
   ```

2. **`CommandRunner` in `pkg/installer/command_runner.go:77-79`**:
   ```go
   if cmd.Stdin == nil {
       cmd.Stdin = os.Stdin  // TTY only in TUI mode
   }
   ```

**Mode Comparison:**

| Mode | `os.Stdin` | Sudo Behavior |
|------|-----------|---------------|
| TUI | TTY attached | ✅ Password prompt works |
| Web UI | No TTY | ❌ "no tty present" error |

### Solutions Considered

#### Option A: System Keyring Integration
- Store sudo password in system keyring (libsecret/gnome-keyring)
- Retrieve password when needed for sudo operations
- **Pros:** Secure, standard approach, works across sessions
- **Cons:** Requires CGO for Linux builds, adds complexity, requires keyring daemon

#### Option B: TTY Detection + Clear Error Message (Recommended for v1.0.1)
- Detect if running without TTY
- Show clear error with instructions
- Suggest alternatives (run with sudo, use Flatpak)
- **Pros:** Simple, no new dependencies, immediate fix
- **Cons:** Requires user action, less convenient

#### Option C: pkexec Pre-launch
- Restart app with `pkexec` to get GUI authentication upfront
- **Pros:** Clean solution, user authenticates once
- **Cons:** Changes $HOME to /root, requires full app restart, loses user context

#### Option D: Flatpak-first for Web UI
- In Web UI mode, prioritize/recommend Flatpak methods
- Mark package manager methods as "requires terminal"
- **Pros:** No privilege escalation needed
- **Cons:** Limits user choice

### Recommended Approach: System Keyring Integration (Option A)

**Chosen for v1.0.1:**
- Use `github.com/99designs/keyring` for multi-backend support
- Supports: Secret Service (GNOME), KWallet (KDE), Pass, Encrypted File fallback
- Detect missing dependencies and show installation instructions
- Graceful fallback for custom WMs (Hyprland, i3, sway)

### Keyring Backend Support

| Backend | DE/Environment | Detection Method |
|---------|----------------|------------------|
| Secret Service | GNOME, GTK apps | D-Bus `org.freedesktop.secrets` |
| KWallet | KDE Plasma | D-Bus `org.kde.kwalletd5/6` |
| Pass | Custom WMs, CLI | Check `pass` binary |
| File (encrypted) | Fallback | No detection needed |

### Implementation Plan

#### Phase 1: Keyring Package

**New File: `pkg/keyring/keyring.go`**

```go
package keyring

import (
    "fmt"
    "os"
    "os/exec"
    "strings"
    
    "github.com/99designs/keyring"
)

const (
    ServiceName  = "mpv-manager"
    KeySudoPass  = "sudo-password"
)

type Backend string

const (
    BackendSecretService Backend = "secret-service"
    BackendKWallet       Backend = "kwallet"
    BackendPass          Backend = "pass"
    BackendFile          Backend = "file"
    BackendNone          Backend = "none"
)

type Status struct {
    AvailableBackends []Backend
    ActiveBackend     Backend
    DaemonRunning     bool
    InstallHint       string
    Distro            string
    DesktopEnv        string
}

// DetectStatus checks available backends and returns installation hints
func DetectStatus() Status {
    status := Status{
        Distro:     detectDistro(),
        DesktopEnv: detectDesktopEnvironment(),
    }
    
    // Check what backends are available
    for _, backend := range keyring.AvailableBackends() {
        switch backend {
        case keyring.SecretServiceBackend:
            status.AvailableBackends = append(status.AvailableBackends, BackendSecretService)
        case keyring.KWalletBackend:
            status.AvailableBackends = append(status.AvailableBackends, BackendKWallet)
        case keyring.PassBackend:
            status.AvailableBackends = append(status.AvailableBackends, BackendPass)
        case keyring.FileBackend:
            status.AvailableBackends = append(status.AvailableBackends, BackendFile)
        }
    }
    
    // Try to open keyring to check if daemon is running
    ring, err := keyring.Open(keyring.Config{
        ServiceName: ServiceName + "-test",
        AllowedBackends: []keyring.BackendType{
            keyring.SecretServiceBackend,
            keyring.KWalletBackend,
        },
    })
    if err == nil {
        status.DaemonRunning = true
        if len(status.AvailableBackends) > 0 {
            status.ActiveBackend = status.AvailableBackends[0]
        }
    }
    
    status.InstallHint = generateInstallHint(status.Distro, status.DesktopEnv)
    
    return status
}

// Open opens the keyring with all available backends
func Open() (keyring.Keyring, error) {
    return keyring.Open(keyring.Config{
        ServiceName: ServiceName,
        AllowedBackends: []keyring.BackendType{
            keyring.SecretServiceBackend,
            keyring.KWalletBackend,
            keyring.PassBackend,
            keyring.FileBackend,
        },
    })
}

// StorePassword stores the sudo password
func StorePassword(password string) error {
    ring, err := Open()
    if err != nil {
        return fmt.Errorf("failed to open keyring: %w", err)
    }
    
    return ring.Set(keyring.Item{
        Key:  KeySudoPass,
        Data: []byte(password),
    })
}

// GetPassword retrieves the sudo password
func GetPassword() (string, error) {
    ring, err := Open()
    if err != nil {
        return "", fmt.Errorf("failed to open keyring: %w", err)
    }
    
    item, err := ring.Get(KeySudoPass)
    if err != nil {
        return "", fmt.Errorf("password not found: %w", err)
    }
    
    return string(item.Data), nil
}

// HasPassword checks if a password is stored
func HasPassword() bool {
    ring, err := Open()
    if err != nil {
        return false
    }
    
    _, err = ring.Get(KeySudoPass)
    return err == nil
}

// DeletePassword removes the stored password
func DeletePassword() error {
    ring, err := Open()
    if err != nil {
        return err
    }
    
    return ring.Remove(KeySudoPass)
}

func detectDistro() string {
    if data, err := os.ReadFile("/etc/os-release"); err == nil {
        content := string(data)
        switch {
        case strings.Contains(content, "Ubuntu"), strings.Contains(content, "Debian"):
            return "debian"
        case strings.Contains(content, "Fedora"):
            return "fedora"
        case strings.Contains(content, "Arch"):
            return "arch"
        case strings.Contains(content, "openSUSE"):
            return "suse"
        }
    }
    return "unknown"
}

func detectDesktopEnvironment() string {
    xdg := os.Getenv("XDG_CURRENT_DESKTOP")
    session := os.Getenv("DESKTOP_SESSION")
    
    switch {
    case strings.Contains(xdg, "GNOME"), session == "gnome":
        return "gnome"
    case strings.Contains(xdg, "KDE"), strings.Contains(xdg, "Plasma"), session == "plasma":
        return "kde"
    case strings.Contains(xdg, "Hyprland"):
        return "hyprland"
    case strings.Contains(xdg, "sway"):
        return "sway"
    case strings.Contains(xdg, "i3"):
        return "i3"
    default:
        return "unknown"
    }
}

func generateInstallHint(distro, de string) string {
    // For GNOME/KDE, should be pre-installed
    if de == "gnome" || de == "kde" {
        return "Keyring should be available. If not, reinstall your desktop environment's keyring package."
    }
    
    // For custom WMs, provide distro-specific instructions
    switch distro {
    case "debian":
        return "sudo apt install gnome-keyring libsecret-1-dev"
    case "fedora":
        return "sudo dnf install gnome-keyring libsecret-devel"
    case "arch":
        return "sudo pacman -S gnome-keyring libsecret"
    case "suse":
        return "sudo zypper install gnome-keyring libsecret-devel"
    default:
        return "Install gnome-keyring and libsecret packages for your distribution"
    }
}
```

#### Phase 2: Startup Dependency Check

**New File: `pkg/keyring/check.go`**

```go
package keyring

import (
    "fmt"
    "runtime"
)

// CheckDependencies verifies keyring dependencies are available
// Returns error with installation instructions if not
func CheckDependencies() error {
    if runtime.GOOS != "linux" {
        return nil // Windows/macOS use native keyring APIs
    }
    
    status := DetectStatus()
    
    // If we have a working backend, all good
    if status.DaemonRunning && len(status.AvailableBackends) > 0 {
        return nil
    }
    
    // No backend available - need to install
    if len(status.AvailableBackends) == 0 {
        return fmt.Errorf(`keyring backend not detected.

Your Desktop Environment: %s
Your Distribution: %s

To enable password storage for Web UI mode, install:

    %s

After installation, restart MPV Manager.`, 
            status.DesktopEnv, 
            status.Distro, 
            status.InstallHint)
    }
    
    // Backend available but daemon not running
    return fmt.Errorf(`keyring backend detected but daemon not running.

For custom window managers (%s), add to your startup:
    gnome-keyring-daemon --start --components=secrets

Or run in TUI mode for password prompts.`, 
        status.DesktopEnv)
}

// NeedsInstallPrompt returns true if user needs to install keyring deps
func NeedsInstallPrompt() bool {
    if runtime.GOOS != "linux" {
        return false
    }
    
    status := DetectStatus()
    return !status.DaemonRunning || len(status.AvailableBackends) == 0
}
```

#### Phase 3: Sudo Authentication

**Modify `pkg/installer/command_runner.go`:**

Add method for sudo with password from keyring:

```go
import "gitgud.io/mike/mpv-manager/pkg/keyring"

// RunSudoCommand runs a command with sudo, using keyring password if available
func (cr *CommandRunner) RunSudoCommand(name string, args ...string) error {
    // Already root - no sudo needed
    if os.Geteuid() == 0 {
        return cr.RunCommand(name, args...)
    }
    
    // Try to get password from keyring
    password, err := keyring.GetPassword()
    if err != nil {
        // No password stored - fall back to regular sudo (may fail in Web UI)
        return cr.RunCommand("sudo", append([]string{name}, args...)...)
    }
    
    // Validate and cache sudo credentials
    if err := cr.cacheSudoCredentials(password); err != nil {
        return fmt.Errorf("sudo authentication failed: %w", err)
    }
    
    // Run command with cached sudo
    return cr.RunCommand("sudo", append([]string{name}, args...)...)
}

// cacheSudoCredentials validates password and refreshes sudo timestamp
func (cr *CommandRunner) cacheSudoCredentials(password string) error {
    cmd := exec.CommandContext(cr.ctx, "sudo", "-S", "-v")
    cmd.Stdin = bytes.NewBufferString(password + "\n")
    
    var stderr bytes.Buffer
    cmd.Stderr = &stderr
    
    if err := cmd.Run(); err != nil {
        return fmt.Errorf("invalid password: %s", stderr.String())
    }
    return nil
}
```

#### Phase 4: Web UI API Endpoints

**Add to `pkg/web/api.go`:**

```go
// Keyring status endpoint
func (s *Server) handleKeyringStatus(w http.ResponseWriter, r *http.Request) {
    status := keyring.DetectStatus()
    respondJSON(w, http.StatusOK, map[string]interface{}{
        "availableBackends": status.AvailableBackends,
        "activeBackend":     status.ActiveBackend,
        "daemonRunning":     status.DaemonRunning,
        "hasPassword":       keyring.HasPassword(),
        "installHint":       status.InstallHint,
        "desktopEnv":        status.DesktopEnv,
    })
}

// Store sudo password
func (s *Server) handleSudoAuth(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Password string `json:"password"`
    }
    
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    
    // Validate password first
    cmd := exec.Command("sudo", "-S", "-v")
    cmd.Stdin = bytes.NewBufferString(req.Password + "\n")
    if err := cmd.Run(); err != nil {
        respondJSON(w, http.StatusOK, map[string]interface{}{
            "success": false,
            "error":   "Invalid password",
        })
        return
    }
    
    // Store in keyring
    if err := keyring.StorePassword(req.Password); err != nil {
        respondJSON(w, http.StatusOK, map[string]interface{}{
            "success": false,
            "error":   "Failed to store password: " + err.Error(),
        })
        return
    }
    
    respondJSON(w, http.StatusOK, map[string]interface{}{
        "success": true,
    })
}
```

#### Phase 5: Startup Check for Double-Click Users

**Modify `cmd/mpv-manager/main.go`:**

```go
func checkKeyringStartup() {
    if runtime.GOOS != "linux" {
        return
    }
    
    if keyring.NeedsInstallPrompt() {
        status := keyring.DetectStatus()
        
        fmt.Println()
        fmt.Println("╔════════════════════════════════════════════════════════════╗")
        fmt.Println("║         Keyring Backend Not Detected                       ║")
        fmt.Println("╠════════════════════════════════════════════════════════════╣")
        fmt.Printf("║ Desktop Environment: %-38s║\n", status.DesktopEnv)
        fmt.Printf("║ Distribution: %-44s║\n", status.Distro)
        fmt.Println("╠════════════════════════════════════════════════════════════╣")
        fmt.Println("║ To enable password storage for Web UI mode, run:          ║")
        fmt.Printf("║   %-56s║\n", status.InstallHint)
        fmt.Println("║                                                           ║")
        fmt.Println("║ After installation, restart MPV Manager.                  ║")
        fmt.Println("║                                                           ║")
        fmt.Println("║ Press ENTER to continue in limited mode, or               ║")
        fmt.Println("║ Press CTRL+C to exit and install dependencies.            ║")
        fmt.Println("╚════════════════════════════════════════════════════════════╝")
        fmt.Println()
        
        // Wait for user input (so they can read the message)
        reader := bufio.NewReader(os.Stdin)
        reader.ReadString('\n')
    }
}
```

### Files to Modify/Create

| File | Changes |
|------|---------|
| `go.mod` | Add `github.com/99designs/keyring` dependency |
| `pkg/keyring/keyring.go` | **NEW** - Keyring management with multi-backend support |
| `pkg/keyring/check.go` | **NEW** - Dependency checking |
| `pkg/installer/command_runner.go` | Add `RunSudoCommand()` with keyring support |
| `pkg/installer/linux_package.go` | Use `RunSudoCommand()` for package installs |
| `pkg/web/api.go` | Add keyring status and auth endpoints |
| `pkg/web/server.go` | Add keyring status to template data |
| `cmd/mpv-manager/main.go` | Add startup check with pause for reading |
| `internal/webassets/templates/*.html` | Add password modal, keyring status |

### Testing Checklist

- [ ] Test on GNOME (should use Secret Service)
- [ ] Test on KDE Plasma (should use KWallet)
- [ ] Test on Hyprland/i3 without keyring (should show install prompt)
- [ ] Test on Hyprland/i3 with gnome-keyring installed (should work)
- [ ] Test password storage and retrieval
- [ ] Test Web UI mode with stored password
- [ ] Test TUI mode (should still prompt normally)
- [ ] Test Flatpak installs (should work without sudo)
- [ ] Verify startup message is readable before continuing

---

## Feature #1 Part 2: Detect Existing MPV Installs

**Priority:** Medium  
**Status:** Deferred to v1.1.0

This is a feature request, not a bug. Implement after bug fixes are complete.

### Planned Implementation (Future)

```go
// DetectExistingMPVInstalls checks for MPV via package manager, Flatpak, Snap
func DetectExistingMPVInstalls() []InstalledApp {
    var installed []InstalledApp
    
    // Check package manager
    if _, err := exec.LookPath("mpv"); err == nil {
        installed = append(installed, InstalledApp{
            Method: "mpv-package",
            Name:   "MPV (Package)",
        })
    }
    
    // Check Flatpak
    if isFlatpakInstalled("io.mpv.Mpv") {
        installed = append(installed, InstalledApp{
            Method: "mpv-flatpak",
            Name:   "MPV (Flatpak)",
        })
    }
    
    // Check Snap
    if isSnapInstalled("mpv") {
        installed = append(installed, InstalledApp{
            Method: "mpv-snap",
            Name:   "MPV (Snap)",
        })
    }
    
    return installed
}
```

---

## Release Plan

### Version: v1.0.1

**Focus:** Critical bug fixes + Linux keyring integration

**Timeline:**
- Day 1: Issue #9 fix (FFmpeg 7zip) - Simple fix
- Day 2-4: Issue #1 implementation (Keyring integration)
  - Day 2: Create keyring package with multi-backend support
  - Day 3: Add startup check and Web UI endpoints
  - Day 4: Password modal UI and testing
- Day 5: Testing on multiple environments (GNOME, KDE, custom WMs)
- Day 6: Release

### Version: v1.1.0 (Future)

**Focus:** Feature enhancements
- Detect existing MPV installs
- Additional UI improvements

---

## Commit Message Style

```
fix(windows): set 7zip path before FFmpeg extraction (#9)

UpdateFFmpegWithOutput was not calling SetSevenZipPath before
extracting the FFmpeg archive, causing "7zip not found" errors
when the system doesn't have 7zip in PATH.

Fix by adding 7zip path setup similar to UpdateMPVWithOutput.
```

```
feat(linux): add keyring integration for sudo authentication (#1)

Package manager installs fail in Web UI mode because sudo
cannot prompt for password without a TTY.

Add keyring package using github.com/99designs/keyring with
multi-backend support:
- Secret Service (GNOME Keyring)
- KWallet (KDE Plasma)
- Pass (custom WMs)
- Encrypted file (fallback)

Include startup check with install instructions for users
missing keyring dependencies.
```

---

## Dependencies

### Go Modules

```
github.com/99designs/keyring v1.2.2
```

### System Requirements (Linux only)

| Distribution | Command |
|-------------|---------|
| Ubuntu/Debian | `sudo apt install gnome-keyring libsecret-1-dev` |
| Fedora | `sudo dnf install gnome-keyring libsecret-devel` |
| Arch | `sudo pacman -S gnome-keyring libsecret` |
| openSUSE | `sudo zypper install gnome-keyring libsecret-devel` |

### Cross-Compilation Note

The keyring library requires CGO on Linux. Build options:
1. Build natively on each target platform (recommended)
2. Use Docker with glibc cross-compilation
3. Provide distro-specific packages (.deb, .rpm, etc.)

Windows and macOS builds are unaffected (use native APIs, no CGO).

---

**Document End**
