diff --git a/app.go b/app.go index 3eb1da8f..442e9a1e 100644 --- a/app.go +++ b/app.go @@ -2047,11 +2047,7 @@ type DialogRequest struct { Steps []string `json:"steps"` ConfirmText string `json:"confirmText"` InProgressMessage string `json:"inProgressMessage"` -} - -type BlockedUninstallInfo struct { - RequestedPackages []string `json:"requestedPackages"` - BlockedBy map[string][]string `json:"blockedBy"` + InfoOnly bool `json:"infoOnly"` } // InstallSimulationResult contains the result of simulating an install @@ -2065,6 +2061,8 @@ type UninstallSimulationResult struct { Packages []string `json:"packages"` // Packages that will be removed Blocked map[string][]string `json:"blocked"` // Packages blocked by dependents RecursivePackages []string `json:"recursivePackages"` // All packages if recursive removal is needed + WorldDeps []string `json:"worldDeps"` // World packages that must be removed + AllAffected []string `json:"allAffected"` // Complete list that will be purged } // SimulateInstall returns all packages that will be installed (including dependencies) @@ -2100,8 +2098,13 @@ func (a *App) SimulateUninstall(packageNames []string) (*UninstallSimulationResu return &UninstallSimulationResult{Packages: packageNames}, nil } + packages := simResult.Packages + if packages == nil { + packages = []string{} + } + result := &UninstallSimulationResult{ - Packages: simResult.Packages, + Packages: packages, Blocked: simResult.Blocked, } @@ -2110,14 +2113,107 @@ func (a *App) SimulateUninstall(packageNames []string) (*UninstallSimulationResu recursiveList, err := a.vellumClient.SimulateDelRecursive(packageNames...) if err != nil { debug.Printf("[DEBUG] SimulateDelRecursive failed: %v\n", err) - } else { + } else if len(recursiveList) > 0 { result.RecursivePackages = recursiveList } + + // If recursive simulation didn't return packages, build list from blocked info + if len(result.RecursivePackages) == 0 { + seen := make(map[string]bool) + for _, name := range packageNames { + seen[name] = true + } + for _, dependents := range simResult.Blocked { + for _, dep := range dependents { + seen[dep] = true + } + } + all := make([]string, 0, len(seen)) + for name := range seen { + all = append(all, name) + } + result.RecursivePackages = all + } + } + + // If nothing would be removed or packages are blocked, resolve world deps + if a.vellumClient != nil && (len(result.Packages) == 0 || len(result.Blocked) > 0) { + worldToRemove, allAffected, err := a.resolveWorldDeps(packageNames) + if err != nil { + debug.Printf("[DEBUG] resolveWorldDeps failed: %v\n", err) + } else if len(worldToRemove) > 0 { + result.WorldDeps = worldToRemove + result.AllAffected = allAffected + } } return result, nil } +func (a *App) resolveWorldDeps(targets []string) (worldToRemove []string, allAffected []string, err error) { + worldPkgs, err := a.vellumClient.GetWorldPackages() + if err != nil { + return nil, nil, fmt.Errorf("failed to read world file: %w", err) + } + + worldSet := make(map[string]bool, len(worldPkgs)) + for _, pkg := range worldPkgs { + worldSet[pkg] = true + } + + visited := make(map[string]bool) + queue := make([]string, len(targets)) + copy(queue, targets) + for _, t := range targets { + visited[t] = true + } + + for len(queue) > 0 { + pkg := queue[0] + queue = queue[1:] + + rdeps, err := a.vellumClient.GetReverseDeps(pkg) + if err != nil { + debug.Printf("[DEBUG] GetReverseDeps(%s) failed: %v\n", pkg, err) + continue + } + + for _, rdep := range rdeps { + if !visited[rdep] { + visited[rdep] = true + queue = append(queue, rdep) + } + } + } + + for pkg := range visited { + allAffected = append(allAffected, pkg) + if worldSet[pkg] { + worldToRemove = append(worldToRemove, pkg) + } + } + + if len(worldToRemove) == 0 { + return nil, nil, nil + } + + // Simulate deleting the world packages to get the full cascade list + simResult, err := a.vellumClient.SimulateDel(worldToRemove...) + if err != nil { + debug.Printf("[DEBUG] SimulateDel for world deps failed: %v\n", err) + return worldToRemove, allAffected, nil + } + + for _, pkg := range simResult.Packages { + if !visited[pkg] { + visited[pkg] = true + allAffected = append(allAffected, pkg) + } + } + + return worldToRemove, allAffected, nil +} + func (a *App) RespondToDialog(confirmed bool) { if a.dialogResponse != nil { a.dialogResponse <- confirmed @@ -2240,6 +2336,7 @@ func (a *App) InstallPackages(packageNames []string, deviceType string) { Steps: hookResult.DialogConfig.Steps, ConfirmText: hookResult.DialogConfig.ConfirmText, InProgressMessage: hookResult.DialogConfig.InProgressMessage, + InfoOnly: hookResult.DialogConfig.InfoOnly, }) confirmed := <-a.dialogResponse @@ -2310,35 +2407,27 @@ func (a *App) UninstallPackages(packageNames []string, deviceType string) { // Simulate uninstall to check for blockers and get full package list var allPackages []string useRecursive := false + useBatch := false if a.vellumClient != nil { simResult, err := a.vellumClient.SimulateDel(packageNames...) if err != nil { debug.Printf("[DEBUG] SimulateDel failed: %v, using packageNames only\n", err) allPackages = packageNames - } else if len(simResult.Blocked) > 0 { - // Packages are blocked by dependents - prompt user - debug.Printf("[DEBUG] Packages blocked: %v\n", simResult.Blocked) - runtime.EventsEmit(a.ctx, "uninstall:blocked", BlockedUninstallInfo{ - RequestedPackages: packageNames, - BlockedBy: simResult.Blocked, - }) - - confirmed := <-a.dialogResponse - if !confirmed { - runtime.EventsEmit(a.ctx, "install:complete", InstallResult{ - Success: false, - Errors: []string{"Uninstall cancelled by user"}, - }) - return - } - - // User confirmed - use recursive deletion - useRecursive = true - allPackages, err = a.vellumClient.SimulateDelRecursive(packageNames...) - if err != nil { - debug.Printf("[DEBUG] SimulateDelRecursive failed: %v\n", err) - allPackages = packageNames + } else if len(simResult.Blocked) > 0 || len(simResult.Packages) == 0 { + worldToRemove, allAffected, wErr := a.resolveWorldDeps(packageNames) + if wErr != nil || len(worldToRemove) == 0 { + debug.Printf("[DEBUG] resolveWorldDeps failed or empty: %v\n", wErr) + useRecursive = true + allPackages, err = a.vellumClient.SimulateDelRecursive(packageNames...) + if err != nil { + debug.Printf("[DEBUG] SimulateDelRecursive failed: %v\n", err) + allPackages = packageNames + } + } else { + packageNames = worldToRemove + allPackages = allAffected + useBatch = true } } else { allPackages = simResult.Packages @@ -2365,6 +2454,7 @@ func (a *App) UninstallPackages(packageNames []string, deviceType string) { packageNames, allPackages, useRecursive, + useBatch, ctx, func(progress executor.ProgressInfo) { runtime.EventsEmit(a.ctx, "install:progress", InstallProgress{ @@ -2383,6 +2473,7 @@ func (a *App) UninstallPackages(packageNames []string, deviceType string) { Steps: hookResult.DialogConfig.Steps, ConfirmText: hookResult.DialogConfig.ConfirmText, InProgressMessage: hookResult.DialogConfig.InProgressMessage, + InfoOnly: hookResult.DialogConfig.InfoOnly, }) confirmed := <-a.dialogResponse @@ -2460,6 +2551,7 @@ func (a *App) RunMaintenanceCommand(pkgName, commandID, deviceType string) { Steps: hookResult.DialogConfig.Steps, ConfirmText: hookResult.DialogConfig.ConfirmText, InProgressMessage: hookResult.DialogConfig.InProgressMessage, + InfoOnly: hookResult.DialogConfig.InfoOnly, }) confirmed := <-a.dialogResponse diff --git a/cmd/remanager/main.go b/cmd/remanager/main.go index f1ab3869..60763c87 100644 --- a/cmd/remanager/main.go +++ b/cmd/remanager/main.go @@ -350,6 +350,7 @@ func uninstallCmd() *cobra.Command { args, allPackages, useRecursive, + false, ctx, func(progress executor.ProgressInfo) { fmt.Printf("[%d/%d] %s: %s\n", progress.CurrentIndex+1, progress.TotalComponents, progress.CurrentComponent, progress.Message) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bf069ba4..054b5f2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,12 +25,13 @@ import { InteractiveTerminal } from '@/components/InteractiveTerminal' import { FileBrowser } from '@/components/FileBrowser' import { ConfigEditor } from '@/components/ConfigEditor' import { BackupRestoreDialog } from '@/components/BackupRestore' +import { CheckOSDialog } from '@/components/CheckOSDialog' import { DnsErrorModal } from '@/components/DnsErrorModal' import { FilesystemRestoreErrorDialog } from '@/components/FilesystemRestoreErrorDialog' import { TimezoneCombobox } from '@/components/TimezoneCombobox' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { Badge } from '@/components/ui/badge' -import { Loader2, Unplug, Check, AlertTriangle, AlertCircle, Trash2, Plus, X, Search, Settings, WifiOff, Eye, EyeOff, RefreshCw } from 'lucide-react' +import { Loader2, Unplug, Check, AlertTriangle, AlertCircle, Trash2, Plus, X, Search, Settings, WifiOff, Eye, EyeOff, RefreshCw, Info } from 'lucide-react' interface PackageInfo { name: string @@ -103,6 +104,7 @@ interface DialogRequest { steps: string[] confirmText: string inProgressMessage: string + infoOnly: boolean } interface InstallSimulationResult { @@ -114,6 +116,8 @@ interface UninstallSimulationResult { packages: string[] blocked: Record | null recursivePackages: string[] | null + worldDeps: string[] | null + allAffected: string[] | null } interface InstalledPackagesResult { @@ -365,6 +369,7 @@ export default function App() { packages: string[] blocked: Record | null useRecursive: boolean + worldDeps?: string[] } | null>(null) const [simulatingInstall, setSimulatingInstall] = useState(false) const [simulatingUninstall, setSimulatingUninstall] = useState(false) @@ -376,6 +381,7 @@ export default function App() { const [pendingPackageUpgrade, setPendingPackageUpgrade] = useState(null) const [simulatingUpgrade, setSimulatingUpgrade] = useState(false) const [showNoUpgradesDialog, setShowNoUpgradesDialog] = useState(false) + const [showCheckOSDialog, setShowCheckOSDialog] = useState(false) const [osMismatchDetected, setOsMismatchDetected] = useState(false) const [storedOsVersion, setStoredOsVersion] = useState('') @@ -2216,8 +2222,16 @@ export default function App() { placeholder="Search mods..." value={search} onChange={(e) => setSearch(e.target.value)} - className="pl-9" + className={`pl-9 ${search ? 'pr-8' : ''}`} /> + {search && ( + + )} setTargetVersion(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCheck()} + disabled={loading || !isConnected} + /> + + + + + {error && ( + + + {error} + + )} + + {result && !error && ( +
+ 0 ? ["incompatible"] : ["compatible"]}> + {compatiblePackages.length > 0 && ( + + + Compatible Packages ({compatiblePackages.length}) + + +
+ {compatiblePackages.map(pkg => ( +
+ + {pkg} +
+ ))} +
+
+
+ )} + + {incompatiblePackages.length > 0 && ( + + + Incompatible Packages ({incompatiblePackages.length}) + + +
+ {incompatiblePackages.map(pkg => ( +
+ + {pkg} +
+ ))} +
+
+
+ )} +
+ + {compatiblePackages.length === 0 && incompatiblePackages.length === 0 && ( +

No packages installed.

+ )} +
+ )} + + + + + + + + ) +} diff --git a/internal/component/types.go b/internal/component/types.go index 933f7679..7f18d1ff 100644 --- a/internal/component/types.go +++ b/internal/component/types.go @@ -33,6 +33,7 @@ type DialogConfig struct { Steps []string ConfirmText string InProgressMessage string + InfoOnly bool } type HookExecutionResult struct { diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 3d336d00..def36007 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -161,13 +161,14 @@ func (i *Installer) Uninstall( packageNames []string, allPackages []string, useRecursive bool, + batch bool, ctx component.CommandContext, onProgress executor.ProgressCallback, onHook HookCallback, ) InstallResult { var errors []string - debug.Printf("[DEBUG] Uninstall starting for packages: %v (all: %v, recursive: %v)\n", packageNames, allPackages, useRecursive) + debug.Printf("[DEBUG] Uninstall starting for packages: %v (all: %v, recursive: %v, batch: %v)\n", packageNames, allPackages, useRecursive, batch) // Run preUninstall hooks for ALL packages first (before any actual uninstall) for _, pkgName := range allPackages { @@ -191,67 +192,106 @@ func (i *Installer) Uninstall( } } - // Perform actual uninstall for requested packages - for idx, pkgName := range packageNames { - pkg := i.metadata.GetPackage(pkgName) - displayName := pkgName - if pkg != nil { - displayName = pkg.Name - } - + if batch { + label := strings.Join(packageNames, ", ") if onProgress != nil { onProgress(executor.ProgressInfo{ - CurrentComponent: displayName, - TotalComponents: len(packageNames), - CurrentIndex: idx, + CurrentComponent: label, + TotalComponents: 1, + CurrentIndex: 0, Status: executor.StatusInstalling, - Message: fmt.Sprintf("Uninstalling %s...", displayName), + Message: fmt.Sprintf("Uninstalling %d packages...", len(packageNames)), }) } - var err error - if useRecursive { - err = i.vellum.DelRecursiveStreaming(func(line string) { - if onProgress != nil { - onProgress(executor.ProgressInfo{ - CurrentComponent: displayName, - TotalComponents: len(packageNames), - CurrentIndex: idx, - Status: executor.StatusInstalling, - Message: line, - }) - } - }, pkgName) - } else { - err = i.vellum.DelStreaming(func(line string) { - if onProgress != nil { - onProgress(executor.ProgressInfo{ - CurrentComponent: displayName, - TotalComponents: len(packageNames), - CurrentIndex: idx, - Status: executor.StatusInstalling, - Message: line, - }) - } - }, pkgName) - } + err := i.vellum.DelStreaming(func(line string) { + if onProgress != nil { + onProgress(executor.ProgressInfo{ + CurrentComponent: label, + TotalComponents: 1, + CurrentIndex: 0, + Status: executor.StatusInstalling, + Message: line, + }) + } + }, packageNames...) if err != nil { - errMsg := fmt.Sprintf("Uninstall failed for %s: %v", displayName, err) + errMsg := fmt.Sprintf("Uninstall failed: %v", err) errors = append(errors, errMsg) - reportError(onProgress, displayName, len(packageNames), idx, errMsg) - continue - } - - if onProgress != nil { + reportError(onProgress, label, 1, 0, errMsg) + } else if onProgress != nil { onProgress(executor.ProgressInfo{ - CurrentComponent: displayName, - TotalComponents: len(packageNames), - CurrentIndex: idx, + CurrentComponent: label, + TotalComponents: 1, + CurrentIndex: 0, Status: executor.StatusCompleted, - Message: fmt.Sprintf("%s uninstalled successfully", displayName), + Message: "Packages uninstalled successfully", }) } + } else { + // Perform actual uninstall for requested packages one at a time + for idx, pkgName := range packageNames { + pkg := i.metadata.GetPackage(pkgName) + displayName := pkgName + if pkg != nil { + displayName = pkg.Name + } + + if onProgress != nil { + onProgress(executor.ProgressInfo{ + CurrentComponent: displayName, + TotalComponents: len(packageNames), + CurrentIndex: idx, + Status: executor.StatusInstalling, + Message: fmt.Sprintf("Uninstalling %s...", displayName), + }) + } + + var err error + if useRecursive { + err = i.vellum.DelRecursiveStreaming(func(line string) { + if onProgress != nil { + onProgress(executor.ProgressInfo{ + CurrentComponent: displayName, + TotalComponents: len(packageNames), + CurrentIndex: idx, + Status: executor.StatusInstalling, + Message: line, + }) + } + }, pkgName) + } else { + err = i.vellum.DelStreaming(func(line string) { + if onProgress != nil { + onProgress(executor.ProgressInfo{ + CurrentComponent: displayName, + TotalComponents: len(packageNames), + CurrentIndex: idx, + Status: executor.StatusInstalling, + Message: line, + }) + } + }, pkgName) + } + + if err != nil { + errMsg := fmt.Sprintf("Uninstall failed for %s: %v", displayName, err) + errors = append(errors, errMsg) + reportError(onProgress, displayName, len(packageNames), idx, errMsg) + continue + } + + if onProgress != nil { + onProgress(executor.ProgressInfo{ + CurrentComponent: displayName, + TotalComponents: len(packageNames), + CurrentIndex: idx, + Status: executor.StatusCompleted, + Message: fmt.Sprintf("%s uninstalled successfully", displayName), + }) + } + } } // Run postUninstall hooks for ALL packages (including orphaned deps) diff --git a/internal/vellum/client.go b/internal/vellum/client.go index 3956d9b1..01f44544 100644 --- a/internal/vellum/client.go +++ b/internal/vellum/client.go @@ -429,6 +429,65 @@ func parseBlockedPackages(output string) map[string][]string { return blocked } +func (c *Client) GetWorldPackages() ([]string, error) { + cmd := fmt.Sprintf("cat %s/etc/apk/world 2>/dev/null", VellumRoot) + output, err := c.executor.ExecuteWithOutput(cmd) + if err != nil { + return nil, err + } + + var packages []string + for _, line := range strings.Split(output, "\n") { + pkg := strings.TrimSpace(line) + if pkg == "" { + continue + } + if idx := strings.Index(pkg, "@"); idx > 0 { + pkg = pkg[:idx] + } + packages = append(packages, pkg) + } + return packages, nil +} + +func (c *Client) GetReverseDeps(pkg string) ([]string, error) { + cmd := fmt.Sprintf("%s info -r %s 2>/dev/null", VellumBin, pkg) + output, err := c.executor.ExecuteWithOutput(cmd) + if err != nil { + return nil, err + } + + var deps []string + inDepList := false + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasSuffix(trimmed, "is required by:") { + inDepList = true + continue + } + if inDepList { + name := parsePackageNameFromInfo(trimmed) + if name != "" { + deps = append(deps, name) + } + } + } + return deps, nil +} + +var infoPackageRegex = regexp.MustCompile(`^(.+)-\d+[\d.]*`) + +func parsePackageNameFromInfo(line string) string { + matches := infoPackageRegex.FindStringSubmatch(line) + if len(matches) >= 2 { + return matches[1] + } + return "" +} + func (c *Client) UninstallVellum(removeAllPackages bool, onOutput func(line string)) error { var cmd string if removeAllPackages { diff --git a/internal/vellum/fallback_remanager.json b/internal/vellum/fallback_remanager.json index 7f6beb19..6e67b08a 100644 --- a/internal/vellum/fallback_remanager.json +++ b/internal/vellum/fallback_remanager.json @@ -1,6 +1,9 @@ { "packages": { "xovi": { + "hooks": { + "postInstall": "xovi_post_install_info" + }, "maintenanceCommands": [ { "id": "start", diff --git a/internal/vellum/hooks.go b/internal/vellum/hooks.go index 23a04bc2..7d89512a 100644 --- a/internal/vellum/hooks.go +++ b/internal/vellum/hooks.go @@ -5,13 +5,30 @@ import "reManager/internal/component" type HookFunc func(ctx component.CommandContext) (*component.HookExecutionResult, error) var hookRegistry = map[string]HookFunc{ - "rebuild_hashtable_dialog": RebuildHashtableDialog, + "rebuild_hashtable_dialog": RebuildHashtableDialog, + "xovi_post_install_info": XoviPostInstallInfo, } func GetHook(name string) HookFunc { return hookRegistry[name] } +func XoviPostInstallInfo(ctx component.CommandContext) (*component.HookExecutionResult, error) { + return &component.HookExecutionResult{ + DialogConfig: &component.DialogConfig{ + Title: "Xovi Does Not Auto-Start", + Message: "For technical reasons, xovi does not auto-start after reboots. Each time your device restarts, you will need to manually start xovi using one of the following methods:", + Steps: []string{ + "(Recommended) Install the tripletap package, then triple-press the power button on every boot", + "Use the xovi Start maintenance command in reManager", + "Connect via SSH and run: /home/root/xovi/start", + }, + InfoOnly: true, + ConfirmText: "Got it", + }, + }, nil +} + func RebuildHashtableDialog(ctx component.CommandContext) (*component.HookExecutionResult, error) { return &component.HookExecutionResult{ DialogConfig: &component.DialogConfig{