diff --git a/app_packages.go b/app_packages.go index 7c9d8b0..c05f22e 100644 --- a/app_packages.go +++ b/app_packages.go @@ -108,10 +108,11 @@ type InstallResult struct { } type InstallSimulationResult struct { - Packages []string `json:"packages"` - Requested []string `json:"requested"` - Error string `json:"error,omitempty"` - Conflicts []vellum.QueueConflictEntry `json:"conflicts,omitempty"` + Packages []string `json:"packages"` + Requested []string `json:"requested"` + Error string `json:"error,omitempty"` + Conflicts []vellum.QueueConflictEntry `json:"conflicts,omitempty"` + UnresolvedVirtuals []string `json:"unresolvedVirtuals,omitempty"` } type UninstallSimulationResult struct { @@ -668,16 +669,24 @@ func (a *App) SimulateInstall(packageNames []string, deviceType string) (*Instal return &InstallSimulationResult{Packages: packageNames, Requested: packageNames}, nil } - allPackages, err := a.vellumClient.SimulateAdd(packageNames...) + toSimulate := packageNames + for _, choice := range a.GetInstallChoices(packageNames, deviceType) { + if provider := choice.PreferredProvider(); provider != "" { + toSimulate = append([]string{provider}, toSimulate...) + } + } + + allPackages, err := a.vellumClient.SimulateAdd(toSimulate...) if err != nil { debug.Printf("[DEBUG] SimulateAdd failed: %v, using packageNames only\n", err) failure := vellum.ResolutionFailure(err) result := &InstallSimulationResult{ - Packages: packageNames, - Requested: packageNames, - Error: failure, + Packages: packageNames, + Requested: packageNames, + Error: failure, + UnresolvedVirtuals: vellum.ParseUnselectedVirtuals(failure), } - if failure != "" && a.metadata != nil && a.metadata.Ready() { + if failure != "" && len(result.UnresolvedVirtuals) == 0 && a.metadata != nil && a.metadata.Ready() { firmware := "" if osState, osErr := a.vellumClient.GetOSVersionState(); osErr == nil { firmware = osState.CurrentVersion diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0dc65e7..e8d7010 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -83,6 +83,7 @@ export interface InstallSimulationResult { requested: string[] error?: string conflicts?: QueueConflictEntry[] + unresolvedVirtuals?: string[] } export interface InstallSet { diff --git a/frontend/src/tabs/ModsTab.tsx b/frontend/src/tabs/ModsTab.tsx index 9cbe6a6..175b273 100644 --- a/frontend/src/tabs/ModsTab.tsx +++ b/frontend/src/tabs/ModsTab.tsx @@ -302,7 +302,8 @@ export function ModsTab({ window.go.main.App.SimulateInstall([...installQueue], device) .then(sim => { if (stale) return - setQueueConflict(sim.error ? { entries: sim.conflicts || [] } : null) + const awaitingProviderChoice = (sim.unresolvedVirtuals?.length ?? 0) > 0 + setQueueConflict(sim.error && !awaitingProviderChoice ? { entries: sim.conflicts || [] } : null) setImpliedPackages(sim.error ? new Set() : new Set( (sim.packages || []).filter(name => !installQueue.has(name) && !installedPackages.has(name)) )) diff --git a/internal/vellum/client.go b/internal/vellum/client.go index c850dec..41afd26 100644 --- a/internal/vellum/client.go +++ b/internal/vellum/client.go @@ -439,12 +439,28 @@ func (c *Client) SimulateAdd(packages ...string) ([]string, error) { var conflictSubjectRegex = regexp.MustCompile(`^\s{2}(\S+?)-[^-\s]+-r\d+:\s*$`) var conflictBreaksRegex = regexp.MustCompile(`breaks:\s+(\S+?)-[^-\s\[]+-r\d+`) +var unselectedVirtualRegex = regexp.MustCompile(`^\s+(\S+) \(virtual\):\s*$`) type ResolutionConflict struct { Package string Breaks string } +func ParseUnselectedVirtuals(message string) []string { + var virtuals []string + seen := make(map[string]bool) + + for _, line := range strings.Split(message, "\n") { + match := unselectedVirtualRegex.FindStringSubmatch(line) + if match == nil || seen[match[1]] { + continue + } + seen[match[1]] = true + virtuals = append(virtuals, match[1]) + } + return virtuals +} + func ParseResolutionConflicts(message string) []ResolutionConflict { var conflicts []ResolutionConflict seen := make(map[string]bool) diff --git a/internal/vellum/metadata.go b/internal/vellum/metadata.go index 7646b97..d60158d 100644 --- a/internal/vellum/metadata.go +++ b/internal/vellum/metadata.go @@ -660,6 +660,20 @@ type VirtualChoice struct { Default string `json:"default,omitempty"` } +func (c VirtualChoice) PreferredProvider() string { + for _, provider := range c.Providers { + if provider.Compatible && provider.Name == c.Default { + return provider.Name + } + } + for _, provider := range c.Providers { + if provider.Compatible { + return provider.Name + } + } + return "" +} + func anyCompatible(providers []ProviderOption) bool { for _, provider := range providers { if provider.Compatible { diff --git a/internal/vellum/virtual_test.go b/internal/vellum/virtual_test.go new file mode 100644 index 0000000..983a282 --- /dev/null +++ b/internal/vellum/virtual_test.go @@ -0,0 +1,77 @@ +package vellum + +import "testing" + +const unselectedVirtualError = `ERROR: unable to select packages: + launcher (virtual): + note: please select one of the 'provided by' + packages explicitly + provided by: appload-0.5.3-r3 oxide-3.1.1-r3 + required by: koreader-2026.07.1-r1[launcher] +` + +const conflictError = `ERROR: unable to select packages: + chessmarkable-0.8.1-r4: + breaks: rmppure-1.0-r0[rmppure] +` + +func TestParseUnselectedVirtuals(t *testing.T) { + virtuals := ParseUnselectedVirtuals(unselectedVirtualError) + if len(virtuals) != 1 || virtuals[0] != "launcher" { + t.Fatalf("virtuals = %v, want [launcher]", virtuals) + } +} + +func TestParseUnselectedVirtualsIgnoresConflicts(t *testing.T) { + if virtuals := ParseUnselectedVirtuals(conflictError); len(virtuals) != 0 { + t.Fatalf("virtuals = %v, want none", virtuals) + } +} + +func TestParseResolutionConflictsIgnoresUnselectedVirtuals(t *testing.T) { + if conflicts := ParseResolutionConflicts(unselectedVirtualError); len(conflicts) != 0 { + t.Fatalf("conflicts = %+v, want none", conflicts) + } +} + +func TestPreferredProviderPicksCompatibleDefault(t *testing.T) { + choice := VirtualChoice{ + Virtual: "launcher", + Default: "appload", + Providers: []ProviderOption{ + {Name: "oxide", Compatible: true}, + {Name: "appload", Compatible: true}, + }, + } + if provider := choice.PreferredProvider(); provider != "appload" { + t.Fatalf("provider = %q, want appload", provider) + } +} + +func TestPreferredProviderSkipsIncompatibleDefault(t *testing.T) { + choice := VirtualChoice{ + Virtual: "launcher", + Default: "appload", + Providers: []ProviderOption{ + {Name: "appload", Compatible: false, IncompatibleReason: "os"}, + {Name: "oxide", Compatible: true}, + }, + } + if provider := choice.PreferredProvider(); provider != "oxide" { + t.Fatalf("provider = %q, want oxide", provider) + } +} + +func TestPreferredProviderEmptyWhenNoneCompatible(t *testing.T) { + choice := VirtualChoice{ + Virtual: "launcher", + Default: "appload", + Providers: []ProviderOption{ + {Name: "appload", Compatible: false}, + {Name: "oxide", Compatible: false}, + }, + } + if provider := choice.PreferredProvider(); provider != "" { + t.Fatalf("provider = %q, want empty", provider) + } +}