Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions app_packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export interface InstallSimulationResult {
requested: string[]
error?: string
conflicts?: QueueConflictEntry[]
unresolvedVirtuals?: string[]
}

export interface InstallSet {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/tabs/ModsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
))
Expand Down
16 changes: 16 additions & 0 deletions internal/vellum/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions internal/vellum/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
77 changes: 77 additions & 0 deletions internal/vellum/virtual_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading