Merge remote-tracking branch 'origin/main' into wails-v3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user