Merge remote-tracking branch 'origin/main' into wails-v3
Build & publish Arch package / arch-package (push) Successful in 2m32s
CI / check (push) Successful in 3m10s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 1h31m25s

This commit is contained in:
2026-08-17 11:38:49 -04:00
16 changed files with 1149 additions and 29 deletions
+18
View File
@@ -191,6 +191,24 @@ func NewYellowJacketApp(
yjApp.library.SetJobRegistry(yjApp.jobs)
yjApp.explore.SetJobRegistry(yjApp.jobs)
// Whether this connection is one to spend ~0.6 GB of catalog on
// (plan 016 B4). The probe is injected from here because `explore` is
// imported by `cmd/indexbuild`, which must not link Wails: naming
// `application` there is what `TestIndexToolsDoNotImportWails`
// forbids.
//
// `application.Mobile`, not `application.Android`: the latter exists
// only under the `android` build tag, while `Mobile` is the portable
// name whose desktop implementation is a stub returning "" — which
// parses to "unknown" and refuses nothing. Plan 016 named the tagged
// one; this is the same call by the name every build has.
yjApp.explore.SetNetworkPolicy(
func() explore.Network {
return explore.ParseNetworkJSON(application.Mobile.NetworkJSON())
},
yjApp.appConfig.GetAllowMeteredCatalogDownload,
)
// Let the release prefetch skip albums the user already owns in
// full — those open with no catalog call at all, so warming their
// tracklists spends the most expensive request in the app on
+45
View File
@@ -620,6 +620,51 @@ func (c *Config) SetQueueFallback(mode string) error {
return nil
}
// GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
// catalog may be fetched on a metered connection.
func (c *Config) GetAllowMeteredCatalogDownload() bool {
if c.General == nil {
return false
}
return c.General.AllowMeteredCatalogDownload
}
// SetAllowMeteredCatalogDownload saves the metered-download permission.
//
// There is nothing to validate and nothing to restart: the policy is
// read at the moment a download would start, so turning it on takes
// effect on the next attempt rather than needing this launch to be over.
func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.AllowMeteredCatalogDownload = allow
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"AllowMeteredCatalogDownload": allow,
},
)
c.logger.Info(
"metered catalog download permission updated",
"allow", allow,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
+6
View File
@@ -48,6 +48,12 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
type GeneralConfig struct {
DefaultPage DefaultPage `toml:"DefaultPage"`
QueueFallback QueueFallback `toml:"QueueFallback"`
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
// be fetched on a connection the platform calls cellular. It defaults
// to false, which is the whole point: the zero value is the safe one,
// so an existing config with no such key refuses by default rather
// than needing a migration to become careful.
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
+14
View File
@@ -27,6 +27,20 @@ var artifactStageNames = [...]string{
// failure path is non-fatal by design: the caller falls back, and a
// fresh install with no network still gets its own library in Explore.
func (si *SearchIndex) tryCoreArtifact(ctx context.Context) error {
// Before anything is staged: ~0.6 GB is not a download to start on
// someone's cellular allowance without being asked (plan 016 B4).
// This is checked first so no job appears and no status changes --
// declining is a no-op, not a failure the user has to dismiss.
if si.netPolicy.refuses() {
si.logIndexJob(
jobs.LevelInfo,
"Skipping the catalog download on a metered connection. "+
"Enable it in Settings to download anyway.",
)
return ErrMeteredNetwork
}
si.mu.Lock()
si.buildStatus = IndexStatus{
Building: true,
+137
View File
@@ -0,0 +1,137 @@
package explore
import (
"encoding/json"
"errors"
"strings"
"sync"
)
// Whether the catalog artifact may be downloaded on this connection
// (plan 016 B4).
//
// The artifact is ~0.6 GB. On a desktop that is a minute of someone
// else's bandwidth; on a phone it can be a month's allowance, and the
// app had no awareness of the difference at all.
//
// Three decisions shape this file.
//
// **The policy lives here and the platform call does not.** `explore` is
// imported by `cmd/indexbuild`, which is built with `CGO_ENABLED=0` in a
// plain Go container, so naming `application` here would break the one
// job that must not fail (see `TestIndexToolsDoNotImportWails`). What is
// injected is a closure; what is *tested* is the parsing and the
// decision, on every platform.
//
// **An unknown answer is not a metered one.** Only mobile answers this
// question — the desktop stub returns an empty string — so a policy that
// treated silence as "metered" would refuse the download on every
// desktop in the world. Silence means "no reason to refuse".
//
// **Cellular is the signal, and it is the only one available.** Wails
// reports `{"connected":bool,"type":"wifi|cellular|ethernet|none"}` and
// no metered flag, so a metered *wifi* — a phone hotspot, a hotel — is
// invisible to us and will not be refused. That is a known gap rather
// than an oversight: Android knows (`NET_CAPABILITY_NOT_METERED`) and
// the runtime does not pass it on.
// ErrMeteredNetwork is returned instead of downloading the catalog when
// the connection looks metered and the user has not opted in. Every
// failure path in `tryCoreArtifact` is already non-fatal, so this
// behaves like any other reason the artifact is not available yet.
var ErrMeteredNetwork = errors.New(
"explore: catalog download declined on a metered connection",
)
// Network is what the platform can say about the connection.
type Network struct {
// Known is false when nothing answered — every desktop, and any
// mobile build whose bridge is not up yet.
Known bool
// Connected reports a usable connection of any kind.
Connected bool
// Metered reports a connection the user is plausibly paying for by
// the byte. See the note above on what this cannot see.
Metered bool
}
// NetworkProbe answers "what kind of connection is this", or an unknown
// Network when the platform does not say.
type NetworkProbe func() Network
// ParseNetworkJSON reads the runtime's network payload.
//
// Anything unparseable is `Known: false` rather than an error: this
// decides whether to *skip* an optional download, and a malformed
// payload is not a reason to refuse one.
func ParseNetworkJSON(payload string) Network {
var raw struct {
Connected bool `json:"connected"`
Type string `json:"type"`
}
if strings.TrimSpace(payload) == "" {
return Network{}
}
if err := json.Unmarshal([]byte(payload), &raw); err != nil {
return Network{}
}
return Network{
Known: true,
Connected: raw.Connected,
Metered: strings.EqualFold(raw.Type, "cellular"),
}
}
// networkPolicy is the injected half: how to ask, and whether the user
// has said yes anyway.
type networkPolicy struct {
mu sync.RWMutex
probe NetworkProbe
allowMetered func() bool
}
func (p *networkPolicy) set(probe NetworkProbe, allowMetered func() bool) {
p.mu.Lock()
defer p.mu.Unlock()
p.probe = probe
p.allowMetered = allowMetered
}
// refuses reports whether a large optional download should be skipped.
func (p *networkPolicy) refuses() bool {
p.mu.RLock()
probe, allow := p.probe, p.allowMetered
p.mu.RUnlock()
if probe == nil {
return false
}
if allow != nil && allow() {
return false
}
state := probe()
return state.Known && state.Metered
}
// SetNetworkPolicy wires how the catalog download decides whether this
// connection is one to spend 0.6 GB on. Both arguments may be nil, which
// is the desktop's answer: never refuse.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (si *SearchIndex) SetNetworkPolicy(probe NetworkProbe, allowMetered func() bool) {
si.netPolicy.set(probe, allowMetered)
}
// SetNetworkPolicy wires the metered-connection policy into the index.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (e *Service) SetNetworkPolicy(probe NetworkProbe, allowMetered func() bool) {
e.index.SetNetworkPolicy(probe, allowMetered)
}
+158
View File
@@ -0,0 +1,158 @@
package explore
import (
"errors"
"testing"
)
// The catalog is ~0.6 GB and the decision not to fetch it is the only
// part of plan 016 B4 that can be tested anywhere but on a phone: the
// platform call is a one-line closure injected from app.go, and
// everything that decides anything is here.
func TestParseNetworkJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
payload string
want Network
}{{
name: "cellular is metered",
payload: `{"connected":true,"type":"cellular"}`,
want: Network{Known: true, Connected: true, Metered: true},
}, {
name: "wifi is not",
payload: `{"connected":true,"type":"wifi"}`,
want: Network{Known: true, Connected: true},
}, {
name: "ethernet is not",
payload: `{"connected":true,"type":"ethernet"}`,
want: Network{Known: true, Connected: true},
}, {
name: "the case is the platform's business, not ours",
payload: `{"connected":true,"type":"Cellular"}`,
want: Network{Known: true, Connected: true, Metered: true},
}, {
name: "offline is known and unmetered",
payload: `{"connected":false,"type":"none"}`,
want: Network{Known: true},
}, {
// The desktop stub. This is the case that must not read as
// "metered": every desktop in the world answers this way.
name: "an empty payload is unknown",
payload: "",
want: Network{},
}, {
name: "so is a malformed one",
payload: `{"connected":`,
want: Network{},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ParseNetworkJSON(tt.payload); got != tt.want {
t.Errorf("ParseNetworkJSON(%q) = %+v, want %+v", tt.payload, got, tt.want)
}
})
}
}
func TestNetworkPolicyRefuses(t *testing.T) {
t.Parallel()
cellular := func() Network {
return Network{Known: true, Connected: true, Metered: true}
}
wifi := func() Network { return Network{Known: true, Connected: true} }
unknown := func() Network { return Network{} }
yes := func() bool { return true }
no := func() bool { return false }
tests := []struct {
name string
probe NetworkProbe
allowMetered func() bool
want bool
}{{
name: "no probe wired refuses nothing",
probe: nil,
want: false,
}, {
name: "an unknown connection refuses nothing",
probe: unknown,
want: false,
}, {
name: "wifi refuses nothing",
probe: wifi,
want: false,
}, {
name: "cellular refuses by default",
probe: cellular,
want: true,
}, {
name: "cellular with no permission refuses",
probe: cellular,
allowMetered: no,
want: true,
}, {
name: "cellular the user opted into does not",
probe: cellular,
allowMetered: yes,
want: false,
}, {
// The permission is read at decision time rather than captured,
// so turning it on takes effect on the next attempt instead of
// the next launch.
name: "permission is asked, not remembered",
probe: cellular,
allowMetered: yes,
want: false,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var p networkPolicy
p.set(tt.probe, tt.allowMetered)
if got := p.refuses(); got != tt.want {
t.Errorf("refuses() = %v, want %v", got, tt.want)
}
})
}
}
// The gate has to come before anything is staged: a declined download is
// a no-op, not a job in the indicator or a status the user must dismiss.
func TestTryCoreArtifactDeclinesMeteredWithoutStaging(t *testing.T) {
t.Parallel()
si := &SearchIndex{}
si.SetNetworkPolicy(
func() Network { return Network{Known: true, Connected: true, Metered: true} },
nil,
)
err := si.tryCoreArtifact(t.Context())
if !errors.Is(err, ErrMeteredNetwork) {
t.Fatalf("tryCoreArtifact() error = %v, want ErrMeteredNetwork", err)
}
// Nothing announced itself: no build status, no tiers, no job. A
// SearchIndex with no database would panic on any of the work below
// the gate, which is itself part of the assertion.
if si.buildStatus.Building {
t.Error("declining a metered download still reported a build in progress")
}
if len(si.buildStatus.Tiers) != 0 {
t.Errorf("declining staged %d tiers, want none", len(si.buildStatus.Tiers))
}
}
+5
View File
@@ -212,6 +212,11 @@ type SearchIndex struct {
cancel context.CancelFunc
done chan struct{}
// netPolicy decides whether this connection is one to spend ~0.6 GB
// of catalog on. Its own lock: it is written once at startup and read
// from the build goroutine (netpolicy.go).
netPolicy networkPolicy
mu sync.RWMutex
ready bool