refactor: remove proxy API (#1596)

This commit is contained in:
Levi authored and GitHub committed 2026-10-21 22:48:17 +08:00
1 parent 47cafb7575
commit 4214765d39
98 files changed
+5958 -1225

No files matched your search

+1 -1
View File
@@ -66,6 +66,7 @@ require (
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/mod v0.33.0
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
)
@@ -156,7 +157,6 @@ require (
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.34.0
+72
View File
@@ -0,0 +1,72 @@
package fetcher
import (
"context"
"encoding/json"
"errors"
"time"
)
type TriggerSource string
const (
TriggerScheduled TriggerSource = "scheduled"
TriggerManual TriggerSource = "manual"
)
var ErrJobSkipped = errors.New("protocol job skipped")
var ErrConfigChanged = errors.New("protocol configuration changed during execution")
// ConfigState embeds the common backend-owned state section in a protocol config.
// Public config PUTs always retain this section from the latest stored config.
type ConfigState[T any] struct {
State T `json:"state"`
}
// ProtocolHost is scoped to one manager. UpdateState runs against the latest
// config under Downloader's lock; callbacks must not perform network I/O.
type ProtocolHost struct {
Read func(any) error
UpdateState func(func(config, state json.RawMessage) (any, error)) error
Fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)
}
func ReadConfig[T any](host ProtocolHost) (*T, error) {
var cfg T
if err := host.Read(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// UpdateState gives the callback a detached config snapshot and mutable state.
// Only the state is committed; edits to the config snapshot are discarded.
func UpdateState[C, S any](host ProtocolHost, update func(*C, *S) error) error {
return host.UpdateState(func(rawConfig, rawState json.RawMessage) (any, error) {
var cfg C
var state S
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return nil, err
}
if err := json.Unmarshal(rawState, &state); err != nil {
return nil, err
}
if err := update(&cfg, &state); err != nil {
return nil, err
}
return &state, nil
})
}
type CronJob struct {
Name string
Every time.Duration
Timeout time.Duration
Run func(context.Context, TriggerSource) error
}
type CronProvider interface{ CronJobs(ProtocolHost) []CronJob }
// ConfigNormalizer is optional. It owns protocol validation and derived fields.
type ConfigNormalizer interface {
NormalizeConfig(json.RawMessage) (any, error)
}
+73
View File
@@ -0,0 +1,73 @@
# GitHub mirror execution
This package owns GitHub URL rewriting and execution strategies. It depends only
on the standard library and does not depend on Downloader, HTTP clients, Git, or
storage. Downloader persists `Config` at its top-level `githubMirror` field;
`pkg/base` preserves the existing configuration names through aliases.
## Race: resource reads
```go
body, err := githubmirror.Race(ctx, cfg.GithubMirror, rawURL,
githubmirror.Options[[]byte]{
Kind: githubmirror.Resource,
Timeout: 30 * time.Second,
},
func(ctx context.Context, candidate string) ([]byte, error) {
return fetchAndValidate(ctx, client, candidate)
},
)
```
The callback owns HTTP requests, size limits, and content validation. The first
completed valid result wins. Other operations are canceled and drained. There is
no HEAD-probe assumption or client-specific HTTP behavior inside the executor.
## Fallback: repository operations
```go
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
githubmirror.Options[*git.Repository]{
Kind: githubmirror.Repository,
},
func(ctx context.Context, candidate string) (*git.Repository, error) {
// Use a clean temporary checkout for each attempt.
return clone(ctx, candidate)
},
)
```
The official URL is attempted first; mirrors follow their configured order.
jsDelivr is excluded for repositories. Stop on the first success. Perform
persistent installation or other business state changes after success, rather
than inside a potentially repeated attempt.
## Results with resources
For temporary files, set `Options[*Download].Discard` to remove the file. Return
the partial `*Download` alongside errors, so cleanup also covers failed reads or
validation. The executor discards every failure and every race loser exactly
once. The caller owns and must track/release the winning result.
- `Timeout == 0` uses 30 seconds per resource attempt and no overall deadline
for repository attempts; a negative timeout is invalid. Extension Git HTTP
transport limits connections/TLS handshakes to 10 seconds and resets a 60-second
read deadline as data arrives. Slow continuous transfers can finish, while
stalled sources fail and allow ordered fallback. Parent cancellation still applies.
- Attempts must honor the supplied context; draining waits for them to return.
- Attempt contexts are canceled after return, so callbacks must finish reading
HTTP bodies before returning. Returning a still-streaming response is unsupported.
- Discard callbacks must accept zero values, must not panic, and run serially.
- Parent cancellation wins over a result selected while the parent is canceled;
the selected result is then discarded as well.
- Errors preserve their causes through wrapping/joining for `errors.Is`.
- `github.com` and `raw.githubusercontent.com` expand into eligible mirrors.
jsDelivr is only eligible for raw source resources. `api.github.com` is never
mirrored and is rejected by the public mirror candidates API.
- Client application update checks request the official GitHub API directly,
falling back to the independent Gopeed website API on request or validation
failure. Only package downloads obtain URLs from
`POST /api/v1/mirrors/github/candidates`. Candidate generation never performs
HTTP requests.
- Disabled mirrors and other URLs execute only the original URL.
- Config is a caller-owned immutable snapshot for the duration of a call.
+90
View File
@@ -0,0 +1,90 @@
// Package githubmirror rewrites GitHub URLs and executes mirrored operations.
package githubmirror
import (
"fmt"
"net/url"
"strings"
)
type Mirror struct {
Type string `json:"type"`
URL string `json:"url"`
IsBuiltIn bool `json:"isBuiltIn"`
IsDeleted bool `json:"isDeleted"`
}
type Config struct {
Enabled bool `json:"enabled"`
Mirrors []Mirror `json:"mirrors"`
}
func DefaultConfig() *Config {
return &Config{Enabled: true, Mirrors: []Mirror{
{Type: "jsdelivr", URL: "https://fastly.jsdelivr.net/gh", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://fastgit.cc", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://gh-proxy.com", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://ghfast.top", IsBuiltIn: true},
}}
}
func (c *Config) Validate() error {
if len(c.Mirrors) > 32 {
return fmt.Errorf("too many GitHub mirrors")
}
for _, mirror := range c.Mirrors {
u, err := url.Parse(mirror.URL)
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("invalid GitHub mirror URL")
}
if mirror.Type != "jsdelivr" && mirror.Type != "ghProxy" {
return fmt.Errorf("invalid GitHub mirror type: %s", mirror.Type)
}
}
return nil
}
// Kind describes what a candidate endpoint must support.
type Kind uint8
const (
Resource Kind = iota
Repository
)
// Candidates preserves the official URL first. jsDelivr is only eligible for
// raw source resources; Git operations use mirrors supporting Git Smart HTTP.
// GitHub API URLs are never mirrored.
func Candidates(c *Config, raw string, kind Kind) []string {
result := []string{raw}
u, err := url.Parse(raw)
if err != nil || c == nil || !c.Enabled || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") {
return result
}
if u.Host != "github.com" && u.Host != "raw.githubusercontent.com" {
return result
}
seen := map[string]bool{raw: true}
for _, mirror := range c.Mirrors {
if mirror.IsDeleted {
continue
}
var candidate string
switch mirror.Type {
case "ghProxy":
candidate = strings.TrimRight(mirror.URL, "/") + "/" + raw
case "jsdelivr":
if kind == Repository || u.Host != "raw.githubusercontent.com" {
continue
}
parts := strings.SplitN(strings.TrimPrefix(u.EscapedPath(), "/"), "/", 4)
if len(parts) != 4 {
continue
}
candidate = strings.TrimRight(mirror.URL, "/") + "/" + parts[0] + "/" + parts[1] + "@" + parts[2] + "/" + parts[3]
}
if candidate != "" && !seen[candidate] {
result = append(result, candidate)
seen[candidate] = true
}
}
return result
}
+38
View File
@@ -0,0 +1,38 @@
package githubmirror
import (
"reflect"
"testing"
)
func TestMirrorCandidates(t *testing.T) {
cfg := &Config{Enabled: true, Mirrors: []Mirror{
{Type: "jsdelivr", URL: "https://cdn.example/gh/"},
{Type: "ghProxy", URL: "https://mirror.example/"},
{Type: "ghProxy", URL: "https://deleted.example", IsDeleted: true},
}}
source := "https://raw.githubusercontent.com/owner/repo/main/a.txt"
want := []string{source, "https://cdn.example/gh/owner/repo@main/a.txt", "https://mirror.example/" + source}
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
repo := "https://github.com/owner/repo.git"
if got := Candidates(cfg, repo, Repository); !reflect.DeepEqual(got, []string{repo, "https://mirror.example/" + repo}) {
t.Fatalf("git candidates %v", got)
}
for _, raw := range []string{"https://github.com.evil.example/a", "https://example.com/a", "https://user:secret@github.com/a"} {
if got := Candidates(cfg, raw, Resource); !reflect.DeepEqual(got, []string{raw}) {
t.Fatalf("unexpected mirror: %v", got)
}
}
apiURL := "https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10"
for _, kind := range []Kind{Resource, Repository} {
if got := Candidates(cfg, apiURL, kind); !reflect.DeepEqual(got, []string{apiURL}) {
t.Fatalf("GitHub API must never use mirrors: %v", got)
}
}
cfg.Enabled = false
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, []string{source}) {
t.Fatalf("disabled mirror: %v", got)
}
}
+160
View File
@@ -0,0 +1,160 @@
package githubmirror
import (
"context"
"errors"
"fmt"
"time"
)
// Options configures execution, not URL persistence. Discard releases results
// from failed attempts and successful race losers, including partial results
// returned together with an error. The caller owns the winning result.
type Options[T any] struct {
Kind Kind
// Timeout limits each attempt. Zero uses 30 seconds for resources and no
// overall deadline for repositories, whose transport owns idle timeouts.
Timeout time.Duration
Discard func(T)
}
// Attempt must honor ctx and return a completed, validated result. Its context
// is canceled after it returns, so live response streams must not be returned.
// Return any partially acquired resources along with errors so Discard can
// release them. Failed attempts must not mutate persistent business state.
type Attempt[T any] func(context.Context, string) (T, error)
const defaultTimeout = 30 * time.Second
func prepare[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) ([]string, time.Duration, error) {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
if raw == "" {
return nil, 0, errors.New("empty GitHub source URL")
}
if options.Kind != Resource && options.Kind != Repository {
return nil, 0, errors.New("invalid GitHub resource kind")
}
if options.Timeout < 0 {
return nil, 0, errors.New("invalid GitHub attempt timeout")
}
if attempt == nil {
return nil, 0, errors.New("GitHub mirror attempt is required")
}
if config != nil {
if err := config.Validate(); err != nil {
return nil, 0, err
}
}
timeout := options.Timeout
if timeout == 0 && options.Kind == Resource {
timeout = defaultTimeout
}
return Candidates(config, raw, options.Kind), timeout, nil
}
func invoke[T any](ctx context.Context, timeout time.Duration, candidate string, attempt Attempt[T]) (value T, err error) {
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("GitHub mirror attempt panicked: %v", recovered)
}
}()
value, err = attempt(ctx, candidate)
if err == nil {
err = ctx.Err()
}
return
}
func discard[T any](options Options[T], value T) {
if options.Discard != nil {
options.Discard(value)
}
}
// Race executes the official URL and eligible mirrors concurrently. The first
// completed successful result wins; losing attempts are canceled and drained
// before return so no temporary files or other resources outlive the call.
// HTTP status/content validation is the attempt's responsibility.
func Race[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
var zero T
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
if err != nil {
return zero, err
}
raceCtx, cancel := context.WithCancel(ctx)
defer cancel()
type outcome struct {
index int
value T
err error
}
outcomes := make(chan outcome, len(candidates))
for index, candidate := range candidates {
go func(index int, candidate string) {
value, err := invoke(raceCtx, timeout, candidate, attempt)
outcomes <- outcome{index, value, err}
}(index, candidate)
}
var winner T
won := false
failures := make([]error, len(candidates))
for range candidates {
outcome := <-outcomes
if !won && outcome.err == nil && ctx.Err() == nil {
winner = outcome.value
won = true
cancel()
} else {
discard(options, outcome.value)
if outcome.err != nil {
failures[outcome.index] = fmt.Errorf("GitHub source %d: %w", outcome.index+1, outcome.err)
}
if ctx.Err() != nil {
cancel()
}
}
}
if err := ctx.Err(); err != nil {
if won {
discard(options, winner)
}
return zero, err
}
if won {
return winner, nil
}
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
}
// Fallback executes the official URL first, then eligible mirrors in their
// configured order. It stops immediately on success and cleans every failure.
func Fallback[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
var zero T
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
if err != nil {
return zero, err
}
failures := make([]error, 0, len(candidates))
for index, candidate := range candidates {
if err := ctx.Err(); err != nil {
return zero, err
}
value, err := invoke(ctx, timeout, candidate, attempt)
if err == nil && ctx.Err() == nil {
return value, nil
}
discard(options, value)
if parentErr := ctx.Err(); parentErr != nil {
return zero, parentErr
}
failures = append(failures, fmt.Errorf("GitHub source %d: %w", index+1, err))
}
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
}
+214
View File
@@ -0,0 +1,214 @@
package githubmirror
import (
"context"
"errors"
"reflect"
"strings"
"sync"
"testing"
"time"
)
const testSource = "https://github.com/owner/repo.git"
func testConfig() *Config {
return &Config{Enabled: true, Mirrors: []Mirror{
{Type: "ghProxy", URL: "https://first.example"},
{Type: "ghProxy", URL: "https://second.example"},
}}
}
func TestRaceWinsAndDrainsOwnedResults(t *testing.T) {
config := testConfig()
candidates := Candidates(config, testSource, Resource)
var started sync.WaitGroup
started.Add(len(candidates))
discarded := []string{}
winner, err := Race(context.Background(), config, testSource, Options[string]{
Discard: func(value string) { discarded = append(discarded, value) },
}, func(ctx context.Context, address string) (string, error) {
started.Done()
started.Wait()
switch address {
case candidates[0]:
return address, errors.New("official offline")
case candidates[1]:
return address, nil
default:
<-ctx.Done()
return address, ctx.Err()
}
})
if err != nil || winner != candidates[1] {
t.Fatalf("winner=%s err=%v", winner, err)
}
if len(discarded) != 2 || !contains(discarded, candidates[0]) || !contains(discarded, candidates[2]) {
t.Fatalf("discarded %v", discarded)
}
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
return true
}
}
return false
}
func TestRaceCleansSimultaneousSuccessfulLosers(t *testing.T) {
config := testConfig()
var started sync.WaitGroup
started.Add(3)
cleaned := []string{}
winner, err := Race(context.Background(), config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
started.Done()
started.Wait()
return address, nil
})
if err != nil || winner == "" || len(cleaned) != 2 || contains(cleaned, winner) {
t.Fatalf("winner=%s discarded=%v err=%v", winner, cleaned, err)
}
}
func TestRaceParentCancellationCleansWinnerToo(t *testing.T) {
config := testConfig()
config.Mirrors = config.Mirrors[:1]
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleaned := []string{}
winner, err := Race(ctx, config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
return address, nil
}
<-ctx.Done()
cancel()
return address, ctx.Err()
})
if !errors.Is(err, context.Canceled) || winner != "" || len(cleaned) != 2 || !contains(cleaned, testSource) {
t.Fatalf("winner=%s cleanup=%v err=%v", winner, cleaned, err)
}
}
func TestRaceTimeoutAndErrorAggregation(t *testing.T) {
expected := errors.New("offline")
config := testConfig()
config.Mirrors = config.Mirrors[:1]
discarded := 0
_, err := Race(context.Background(), config, testSource, Options[string]{Timeout: 10 * time.Millisecond, Discard: func(string) { discarded++ }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
return address, expected
}
<-ctx.Done()
return address, ctx.Err()
})
if !errors.Is(err, expected) || !errors.Is(err, context.DeadlineExceeded) || discarded != 2 {
t.Fatalf("discarded=%d err=%v", discarded, err)
}
}
func TestFallbackPreservesOrderAndStopsOnSuccess(t *testing.T) {
config := testConfig()
candidates := Candidates(config, testSource, Repository)
tried := []string{}
cleaned := []string{}
winner, err := Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository, Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
tried = append(tried, address)
if address == candidates[1] {
return address, nil
}
return address, errors.New("offline")
})
if err != nil || winner != candidates[1] || !reflect.DeepEqual(tried, candidates[:2]) || !reflect.DeepEqual(cleaned, candidates[:1]) {
t.Fatalf("winner=%s tried=%v discarded=%v err=%v", winner, tried, cleaned, err)
}
tried = nil
_, err = Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository}, func(_ context.Context, address string) (string, error) {
tried = append(tried, address)
return address, nil
})
if err != nil || !reflect.DeepEqual(tried, candidates[:1]) {
t.Fatalf("tried=%v err=%v", tried, err)
}
}
func TestFallbackTimeoutContinuesWithFreshContext(t *testing.T) {
discarded := []string{}
winner, err := Fallback(context.Background(), testConfig(), testSource, Options[string]{Kind: Repository, Timeout: 10 * time.Millisecond, Discard: func(value string) { discarded = append(discarded, value) }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
<-ctx.Done()
return address, ctx.Err()
}
if err := ctx.Err(); err != nil {
return address, err
}
return address, nil
})
if err != nil || !strings.HasPrefix(winner, "https://first.example/") || !reflect.DeepEqual(discarded, []string{testSource}) {
t.Fatalf("winner=%s cleanup=%v err=%v", winner, discarded, err)
}
}
func TestFallbackCancellationStopsBeforeNextMirror(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
cleaned := 0
_, err := Fallback(ctx, testConfig(), testSource, Options[string]{Kind: Repository, Discard: func(string) { cleaned++ }}, func(ctx context.Context, address string) (string, error) {
calls++
cancel()
return address, ctx.Err()
})
if !errors.Is(err, context.Canceled) || calls != 1 || cleaned != 1 {
t.Fatalf("calls=%d discarded=%d err=%v", calls, cleaned, err)
}
}
func TestExecutorsDoNotStartInvalidOrCanceledRequests(t *testing.T) {
canceled, cancel := context.WithCancel(context.Background())
cancel()
for _, test := range []struct {
ctx context.Context
config *Config
options Options[string]
attempt Attempt[string]
}{
{ctx: canceled, options: Options[string]{}, attempt: func(context.Context, string) (string, error) { t.Fatal("called after cancellation"); return "", nil }},
{ctx: context.Background(), options: Options[string]{Kind: 99}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid kind"); return "", nil }},
{ctx: context.Background(), options: Options[string]{Timeout: -time.Second}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid timeout"); return "", nil }},
{ctx: context.Background(), options: Options[string]{}, attempt: nil},
{ctx: context.Background(), config: &Config{Mirrors: []Mirror{{Type: "invalid", URL: "https://mirror.example"}}}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid config"); return "", nil }},
} {
if _, err := Race(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
t.Fatal("invalid race accepted")
}
if _, err := Fallback(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
t.Fatal("invalid fallback accepted")
}
}
}
func TestAttemptPanicBecomesError(t *testing.T) {
attempt := func(context.Context, string) (string, error) { panic("broken operation") }
if _, err := Race(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
t.Fatal(err)
}
if _, err := Fallback(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
t.Fatal(err)
}
}
func TestNonGithubResourcesAndDisabledMirrorsUseOnlyOriginal(t *testing.T) {
for _, test := range []struct {
config *Config
raw string
}{{testConfig(), "https://example.com/resource"}, {&Config{Enabled: false, Mirrors: testConfig().Mirrors}, testSource}} {
calls := 0
got, err := Race(context.Background(), test.config, test.raw, Options[string]{}, func(_ context.Context, address string) (string, error) { calls++; return address, nil })
if err != nil || got != test.raw || calls != 1 {
t.Fatalf("got=%s calls=%d err=%v", got, calls, err)
}
}
}
func TestRepositoryAttemptsHaveNoDefaultDeadline(t *testing.T) {
value, err := Fallback(context.Background(), nil, testSource, Options[string]{Kind: Repository}, func(ctx context.Context, raw string) (string, error) {
if _, ok := ctx.Deadline(); ok {
t.Fatal("repository attempt has a total deadline")
}
return raw, nil
})
if err != nil || value != testSource {
t.Fatalf("value=%s error=%v", value, err)
}
}
+23 -2
View File
@@ -1,8 +1,29 @@
package bt
import (
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
type trackerSubscriptionStatus struct {
Success bool `json:"success"`
TrackerCount int `json:"trackerCount"`
LastFetchTime time.Time `json:"lastFetchTime"`
}
type state struct {
TrackerSubscriptionStatuses map[string]trackerSubscriptionStatus `json:"trackerSubscriptionStatuses"`
TrackerSources map[string][]string `json:"trackerSources"`
LastTrackerUpdateTime time.Time `json:"lastTrackerUpdateTime"`
}
type config struct {
ListenPort int `json:"listenPort"`
Trackers []string `json:"trackers"`
fetcher.ConfigState[state]
TrackerSubscribeURLs []string `json:"trackerSubscribeUrls"`
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
CustomTrackers []string `json:"customTrackers"`
ListenPort int `json:"listenPort"`
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
SeedKeep bool `json:"seedKeep"`
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
+18 -2
View File
@@ -198,7 +198,7 @@ func TestBTDownloadSources(t *testing.T) {
}
func TestBTDirectDownloadWithExtraTrackers(t *testing.T) {
for _, source := range []string{"request", "config"} {
for _, source := range []string{"request", "config", "subscription"} {
t.Run(source, func(t *testing.T) {
fixture := makeBTFixture(t, map[string][]byte{
"a.bin": btPayload(20*1024, "direct-a"),
@@ -217,7 +217,23 @@ func TestBTDirectDownloadWithExtraTrackers(t *testing.T) {
if source == "request" {
req.Extra = &btprotocol.ReqExtra{Trackers: []string{fixture.TrackerURL}}
} else {
f.config.Trackers = []string{fixture.TrackerURL}
backendConfig := new(FetcherManager).DefaultConfig().(*config)
if source == "config" {
backendConfig.CustomTrackers = []string{fixture.TrackerURL}
} else {
backendConfig.TrackerSubscribeURLs = []string{"https://subscription.example/list"}
backendConfig.State.TrackerSources[backendConfig.TrackerSubscribeURLs[0]] = []string{fixture.TrackerURL}
}
f.ctl.GetConfig = func(dst any) {
raw, err := json.Marshal(backendConfig)
if err != nil {
t.Error(err)
return
}
if err := json.Unmarshal(raw, dst); err != nil {
t.Error(err)
}
}
}
// CreateDirect supplies options before Downloader resolves the task.
dir := filepath.Join(t.TempDir(), "direct-download")
+10 -5
View File
@@ -744,6 +744,7 @@ func (f *Fetcher) WaitUpload() (err error) {
}
func (f *Fetcher) addTorrent(req *base.Request) (err error) {
f.ctl.GetConfig(&f.config)
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
return
}
@@ -816,10 +817,8 @@ func (f *Fetcher) addTorrent(req *base.Request) (err error) {
}
}
}
if len(f.config.Trackers) > 0 {
for _, tracker := range f.config.Trackers {
trackers[tracker] = true
}
for _, tracker := range f.config.effectiveTrackers() {
trackers[tracker] = true
}
if len(trackers) > 0 {
for tracker := range trackers {
@@ -947,8 +946,14 @@ func (fm *FetcherManager) AutoRename() bool {
func (fm *FetcherManager) DefaultConfig() any {
return &config{
TrackerSubscribeURLs: append([]string{}, defaultTrackerSubscribeURLs...),
AutoUpdateTrackers: true,
CustomTrackers: []string{},
ConfigState: fetcher.ConfigState[state]{State: state{
TrackerSources: map[string][]string{},
TrackerSubscriptionStatuses: map[string]trackerSubscriptionStatus{},
}},
ListenPort: 0,
Trackers: []string{},
SeedKeep: false,
SeedRatio: 1.0,
SeedTime: 120 * 60,
+1 -1
View File
@@ -227,7 +227,7 @@ func buildConfigFetcher(t testing.TB, proxyConfig *base.DownloaderProxyConfig) f
fetcher := new(FetcherManager).Build()
newController := controller.NewController()
mockCfg := config{
Trackers: []string{
CustomTrackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
}}
+234
View File
@@ -0,0 +1,234 @@
package bt
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"sync"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
var defaultTrackerSubscribeURLs = []string{
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt",
}
func validTracker(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" || u.User != nil {
return false
}
switch u.Scheme {
case "udp", "http", "https", "ws", "wss":
return true
}
return false
}
func uniqueTrackers(groups ...[]string) []string {
result := []string{}
seen := map[string]bool{}
for _, group := range groups {
for _, raw := range group {
value := strings.TrimSpace(raw)
if value != "" && !seen[value] {
seen[value] = true
result = append(result, value)
}
}
}
return result
}
// effectiveTrackers derives the client list without persisting duplicate lists.
func (c *config) effectiveTrackers() []string {
groups := [][]string{c.CustomTrackers}
for _, source := range c.TrackerSubscribeURLs {
groups = append(groups, c.State.TrackerSources[source])
}
return uniqueTrackers(groups...)
}
func (fm *FetcherManager) NormalizeConfig(raw json.RawMessage) (any, error) {
c := fm.DefaultConfig().(*config)
if err := json.Unmarshal(raw, c); err != nil {
return nil, err
}
if c.ListenPort < 0 || c.ListenPort > 65535 || c.SeedRatio < 0 || c.SeedTime < 0 {
return nil, errors.New("invalid BT configuration")
}
if len(c.TrackerSubscribeURLs) > 64 {
return nil, errors.New("too many tracker subscriptions")
}
c.TrackerSubscribeURLs = uniqueTrackers(c.TrackerSubscribeURLs)
for _, source := range c.TrackerSubscribeURLs {
u, err := url.Parse(source)
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil {
return nil, errors.New("invalid tracker subscription URL")
}
}
c.CustomTrackers = uniqueTrackers(c.CustomTrackers)
for _, tracker := range c.CustomTrackers {
if !validTracker(tracker) {
return nil, fmt.Errorf("invalid tracker URL: %s", tracker)
}
}
if c.State.TrackerSources == nil {
c.State.TrackerSources = map[string][]string{}
}
for source := range c.State.TrackerSources {
if !slices.Contains(c.TrackerSubscribeURLs, source) {
delete(c.State.TrackerSources, source)
}
}
if c.State.TrackerSubscriptionStatuses == nil {
c.State.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
}
for source := range c.State.TrackerSubscriptionStatuses {
if !slices.Contains(c.TrackerSubscribeURLs, source) {
delete(c.State.TrackerSubscriptionStatuses, source)
}
}
return c, nil
}
func (fm *FetcherManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
return []fetcher.CronJob{{
Name: "update-trackers",
Every: 24 * time.Hour,
Timeout: 90 * time.Second,
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
return fm.updateTrackers(ctx, host, source)
},
}}
}
func parseTrackers(data []byte) ([]string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 4096), 64*1024)
result := []string{}
for scanner.Scan() {
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\ufeff"))
if validTracker(line) {
result = append(result, line)
}
if len(result) > 20000 {
return nil, errors.New("too many trackers")
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(result) == 0 {
return nil, errors.New("subscription contains no valid trackers")
}
return uniqueTrackers(result), nil
}
func (fm *FetcherManager) updateTrackers(ctx context.Context, host fetcher.ProtocolHost, source fetcher.TriggerSource) error {
snapshot, err := fetcher.ReadConfig[config](host)
if err != nil {
return err
}
if (source == fetcher.TriggerScheduled && !snapshot.AutoUpdateTrackers) || len(snapshot.TrackerSubscribeURLs) == 0 {
return fetcher.ErrJobSkipped
}
fetched := map[string][]string{}
statuses := map[string]trackerSubscriptionStatus{}
var failures []error
var mu sync.Mutex
var wg sync.WaitGroup
slots := make(chan struct{}, 4)
for _, address := range snapshot.TrackerSubscribeURLs {
wg.Add(1)
go func(address string) {
defer wg.Done()
select {
case slots <- struct{}{}:
defer func() { <-slots }()
case <-ctx.Done():
return
}
data, err := host.Fetch(ctx, address, 2<<20, func(body []byte) error { _, err := parseTrackers(body); return err })
var trackers []string
if err == nil {
trackers, err = parseTrackers(data)
}
mu.Lock()
defer mu.Unlock()
statuses[address] = trackerSubscriptionStatus{Success: err == nil, TrackerCount: len(trackers), LastFetchTime: time.Now().UTC()}
if err != nil {
failures = append(failures, fmt.Errorf("subscription %s: %w", address, err))
} else {
fetched[address] = trackers
}
}(address)
}
wg.Wait()
if err := ctx.Err(); err != nil {
failures = append(failures, err)
}
if len(statuses) == 0 {
return errors.Join(failures...)
}
err = fetcher.UpdateState[config, state](host, func(current *config, state *state) error {
if !slices.Equal(current.TrackerSubscribeURLs, snapshot.TrackerSubscribeURLs) {
return fetcher.ErrConfigChanged
}
if state.TrackerSources == nil {
state.TrackerSources = map[string][]string{}
}
if state.TrackerSubscriptionStatuses == nil {
state.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
}
for address, status := range statuses {
state.TrackerSubscriptionStatuses[address] = status
}
for address, trackers := range fetched {
state.TrackerSources[address] = trackers
}
if len(failures) == 0 {
state.LastTrackerUpdateTime = time.Now().UTC()
}
return nil
})
if err != nil {
return err
}
current, err := fetcher.ReadConfig[config](host)
if err != nil {
return err
}
fm.applyTrackers(current.effectiveTrackers())
return errors.Join(failures...)
}
func (fm *FetcherManager) applyTrackers(trackers []string) {
lock.Lock()
defer lock.Unlock()
if client == nil {
return
}
announce := make([][]string, 0, len(trackers))
for _, tracker := range trackers {
announce = append(announce, []string{tracker})
}
for _, t := range client.Torrents() {
info := t.Info()
if info == nil || (info.Private != nil && *info.Private) {
continue
}
t.AddTrackers(announce)
}
}
+217
View File
@@ -0,0 +1,217 @@
package bt
import (
"context"
"encoding/json"
"errors"
"reflect"
"sync"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
func trackerTestHost(t *testing.T, c *config, fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)) (fetcher.ProtocolHost, *sync.Mutex) {
t.Helper()
mu := &sync.Mutex{}
return fetcher.ProtocolHost{
Read: func(dst any) error {
mu.Lock()
defer mu.Unlock()
raw, err := json.Marshal(c)
if err != nil {
return err
}
return json.Unmarshal(raw, dst)
},
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
mu.Lock()
defer mu.Unlock()
raw, _ := json.Marshal(c)
currentState, _ := json.Marshal(c.State)
next, err := change(raw, currentState)
if err != nil {
return err
}
raw, err = json.Marshal(next)
if err != nil {
return err
}
return json.Unmarshal(raw, &c.State)
},
Fetch: fetch,
}, mu
}
func TestTrackerRefreshPartialSuccessUsesLatestConfig(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.TrackerSubscribeURLs = []string{"https://a.example/list", "https://b.example/list"}
cfg.CustomTrackers = []string{"udp://custom.example:80/announce"}
cfg.State.TrackerSources = map[string][]string{cfg.TrackerSubscribeURLs[0]: {"udp://old.example:80/announce"}, cfg.TrackerSubscribeURLs[1]: {"udp://cached.example:80/announce"}}
host, mu := trackerTestHost(t, cfg, nil)
host.Fetch = func(ctx context.Context, address string, _ int64, validate func([]byte) error) ([]byte, error) {
if address == "https://b.example/list" {
return nil, errors.New("offline")
}
// Simulate a frontend update while the network request is in flight.
mu.Lock()
cfg.CustomTrackers = []string{"udp://new-custom.example:80/announce"}
mu.Unlock()
data := []byte("udp://fresh.example:80/announce\n# ignored\nudp://fresh.example:80/announce\n")
return data, validate(data)
}
err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual)
if err == nil {
t.Fatal("partial failure must be reported")
}
want := []string{"udp://new-custom.example:80/announce", "udp://fresh.example:80/announce", "udp://cached.example:80/announce"}
if !reflect.DeepEqual(cfg.effectiveTrackers(), want) {
t.Fatalf("got %v", cfg.effectiveTrackers())
}
success := cfg.State.TrackerSubscriptionStatuses["https://a.example/list"]
failure := cfg.State.TrackerSubscriptionStatuses["https://b.example/list"]
if !success.Success || success.TrackerCount != 1 || success.LastFetchTime.IsZero() || failure.Success || failure.TrackerCount != 0 || failure.LastFetchTime.IsZero() {
t.Fatalf("unexpected subscription statuses: %+v", cfg.State.TrackerSubscriptionStatuses)
}
if !cfg.State.LastTrackerUpdateTime.IsZero() {
t.Fatal("partial refresh marked fully successful")
}
}
func TestTrackerRefreshDiscardsChangedSubscriptions(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
host, mu := trackerTestHost(t, cfg, nil)
host.Fetch = func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
mu.Lock()
cfg.TrackerSubscribeURLs = []string{}
mu.Unlock()
return []byte("udp://fresh.example:80/announce"), nil
}
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); !errors.Is(err, fetcher.ErrConfigChanged) {
t.Fatal(err)
}
if len(cfg.State.TrackerSources) != 0 {
t.Fatal("outdated source was committed")
}
}
func TestTrackerRefreshManualWorksWhenAutomaticDisabled(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.AutoUpdateTrackers = false
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
calls := 0
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
calls++
return []byte("udp://fresh.example:80/announce"), nil
})
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerScheduled); !errors.Is(err, fetcher.ErrJobSkipped) {
t.Fatal(err)
}
if calls != 0 {
t.Fatal("scheduled job fetched while disabled")
}
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err != nil {
t.Fatal(err)
}
if calls != 1 || cfg.State.LastTrackerUpdateTime.IsZero() {
t.Fatal("manual refresh did not succeed")
}
}
func TestTrackerConfigPreservesExplicitSettings(t *testing.T) {
value, err := (&FetcherManager{}).NormalizeConfig(json.RawMessage(`{
"listenPort":6881, "trackerSubscribeUrls":[], "autoUpdateTrackers":false,
"customTrackers":["udp://custom.example:80/announce"]
}`))
if err != nil {
t.Fatal(err)
}
cfg := value.(*config)
if cfg.AutoUpdateTrackers || len(cfg.TrackerSubscribeURLs) != 0 || cfg.ListenPort != 6881 {
t.Fatalf("%+v", cfg)
}
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.CustomTrackers) {
t.Fatalf("derived trackers: %v", cfg.effectiveTrackers())
}
}
func TestParseTrackerList(t *testing.T) {
got, err := parseTrackers([]byte("\ufeffudp://tracker.example:80/announce\r\n\n<html>error</html>\nhttps://tracker.example/announce\n"))
if err != nil || len(got) != 2 {
t.Fatalf("%v %v", got, err)
}
if _, err := parseTrackers([]byte("<html>mirror error</html>")); err == nil {
t.Fatal("invalid mirror body accepted")
}
}
func TestTrackerDefaultsRegisterDailyJob(t *testing.T) {
jobs := (&FetcherManager{}).CronJobs(fetcher.ProtocolHost{})
if len(jobs) != 1 || jobs[0].Name != "update-trackers" || jobs[0].Every != 24*time.Hour {
t.Fatal(jobs)
}
}
func TestTrackerRefreshAllFailuresPersistStatusesAndKeepCache(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{address}
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now().Add(-time.Hour)}
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
return nil, errors.New("offline")
})
before := time.Now()
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err == nil {
t.Fatal("failure was not reported")
}
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.Before(before) {
t.Fatalf("failed fetch status not stored: %+v", status)
}
if !reflect.DeepEqual(cfg.State.TrackerSources[address], []string{"udp://cached.example:80/announce"}) {
t.Fatal("last good cache lost")
}
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.State.TrackerSources[address]) {
t.Fatal("cached trackers not retained")
}
}
func TestTrackerConfigPrunesRemovedSubscriptions(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{}
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now()}
raw, _ := json.Marshal(cfg)
value, err := fm.NormalizeConfig(raw)
if err != nil {
t.Fatal(err)
}
next := value.(*config)
if len(next.State.TrackerSources) != 0 || len(next.State.TrackerSubscriptionStatuses) != 0 || len(next.effectiveTrackers()) != 0 {
t.Fatal("removed subscription still owns cached results")
}
}
func TestTrackerRefreshTimeoutPersistsCompletedAttemptStatus(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{address}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
cancel()
return nil, context.DeadlineExceeded
})
if err := fm.updateTrackers(ctx, host, fetcher.TriggerManual); !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.IsZero() {
t.Fatalf("timeout status lost: %+v", status)
}
}
+6 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"fmt"
"net/url"
pathpkg "path"
"strings"
)
@@ -75,7 +76,11 @@ func (r RouteSpec) Match(rawPath string) (map[string]string, bool) {
params := make(map[string]string)
for index, part := range r.parts {
if part.param != "" {
params[part.param] = segments[index]
value, err := url.PathUnescape(segments[index])
if err != nil {
return nil, false
}
params[part.param] = value
continue
}
if part.literal != segments[index] {
+34
View File
@@ -2,7 +2,9 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -62,6 +64,8 @@ func NewService(downloader *download.Downloader) (*Service, error) {
newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", service.getTaskStatus),
newRoute(http.MethodGet, "/api/v1/tasks", service.getTasks),
newRoute(http.MethodGet, "/api/v1/tasks/{id}/stats", service.getStats),
newRoute(http.MethodPost, "/api/v1/protocols/{protocol}/jobs/{name}/trigger", service.triggerProtocolJob),
newRoute(http.MethodPost, "/api/v1/mirrors/{provider}/candidates", service.mirrorCandidates),
newRoute(http.MethodGet, "/api/v1/config", service.getConfig),
newRoute(http.MethodPut, "/api/v1/config", service.putConfig),
newRoute(http.MethodPost, "/api/v1/extensions", service.installExtension),
@@ -294,6 +298,36 @@ func (s *Service) getStats(ctx *Context) *Response {
return newOKResponse(stats)
}
func (s *Service) triggerProtocolJob(ctx *Context) *Response {
if err := s.Downloader.TriggerProtocolJob(context.Background(), ctx.PathParam("protocol"), ctx.PathParam("name")); err != nil {
status := http.StatusBadRequest
if errors.Is(err, download.ErrUnSupportedProtocol) || errors.Is(err, download.ErrProtocolJobNotFound) {
status = http.StatusNotFound
}
return newStatusResponse(status, restmodel.NewErrorResult(err.Error()))
}
return newNilResponse()
}
func (s *Service) mirrorCandidates(ctx *Context) *Response {
var request restmodel.MirrorCandidatesReq
if response := decodeRequest(ctx.Body, &request); response != nil {
return response
}
urls, err := s.Downloader.GetMirrorCandidates(ctx.PathParam("provider"), request.URL)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, download.ErrMirrorProviderNotFound):
status = http.StatusNotFound
case errors.Is(err, download.ErrInvalidMirrorURL):
status = http.StatusBadRequest
}
return newStatusResponse(status, restmodel.NewErrorResult(err.Error()))
}
return newOKResponse(&restmodel.MirrorCandidatesResp{URLs: urls})
}
func (s *Service) getConfig(_ *Context) *Response {
config, err := s.Downloader.GetConfig()
if err != nil {
+187
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -8,7 +9,10 @@ import (
"path/filepath"
"reflect"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
restmodel "github.com/GopeedLab/gopeed/pkg/rest/model"
@@ -80,3 +84,186 @@ func serviceTestRequest[T any](t *testing.T, service *Service, method, path stri
}
return result.Data
}
func TestFullConfigDispatch(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, []byte(`{"protocolConfig":{"http":{"connections":3,"useServerCtime":false}}}`), nil)
if response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
status, body := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
var result struct {
Data struct {
ProtocolConfig map[string]struct {
Connections int `json:"connections"`
} `json:"protocolConfig"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatal(err)
}
if status != http.StatusOK || result.Data.ProtocolConfig["http"].Connections != 3 {
t.Fatalf("%d %s", status, body)
}
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/http/jobs/missing/trigger", nil, nil, nil); response.StatusCode != http.StatusNotFound {
t.Fatalf("%+v", response)
}
}
func TestNativePathParametersAreDecodedOnce(t *testing.T) {
route := newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", func(c *Context) *Response { return newOKResponse(c.PathParam("id")) })
for _, test := range []struct{ path, want string }{
{"/api/v1/tasks/%E5%BC%80%E5%8F%91%E8%80%85%40name/status", "开发者@name"},
{"/api/v1/tasks/literal%252Fname/status", "literal%2Fname"},
} {
response := (&Service{routes: []RouteSpec{route}}).Dispatch(http.MethodGet, test.path, nil, nil, nil)
raw, _ := json.Marshal(response.Body)
var body struct {
Data string `json:"data"`
}
_ = json.Unmarshal(raw, &body)
if body.Data != test.want {
t.Fatalf("%s => %q", test.path, body.Data)
}
}
}
func TestMirrorCandidates(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
cfg, _ := d.GetConfig()
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
{Type: "jsdelivr", URL: "https://cdn.example/gh"},
{Type: "ghProxy", URL: "https://proxy.example"},
{Type: "ghProxy", URL: "https://proxy.example"},
}}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
for _, official := range []string{
"https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip",
"https://raw.githubusercontent.com/owner/repo/main/trackers.txt",
} {
request, _ := json.Marshal(map[string]string{"url": official})
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil)
var body struct {
Data struct {
URLs []string `json:"urls"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
want := 2
if official == "https://raw.githubusercontent.com/owner/repo/main/trackers.txt" {
want = 3
}
urls := body.Data.URLs
if status != http.StatusOK || len(urls) != want || urls[0] != official || urls[len(urls)-1] != "https://proxy.example/"+official {
t.Fatalf("%d %s", status, raw)
}
}
for _, invalid := range []string{"https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10", "", "https://example.com/x", "http://github.com/x", "https://github.com.evil.test/x", "https://user@github.com/x", "https://github.com:443/x", "https://github.com:/x", "https://github.com/x#fragment", "https://github.com/x#", "/relative", "https://github.com%2fevil.test/x"} {
request, _ := json.Marshal(map[string]string{"url": invalid})
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil); response.StatusCode != http.StatusBadRequest {
t.Fatalf("accepted %s: %+v", invalid, response)
}
}
cfg.GithubMirror.Enabled = false
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil)
var body struct {
Data struct {
URLs []string `json:"urls"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil || status != http.StatusOK || len(body.Data.URLs) != 1 || body.Data.URLs[0] != "https://github.com/owner/repo" {
t.Fatalf("%d %s", status, raw)
}
for _, provider := range []string{"unknown", "GitHub"} {
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/"+provider+"/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil); response.StatusCode != http.StatusNotFound {
t.Fatalf("unknown provider accepted: %+v", response)
}
}
}
type apiJobState struct {
Runs int `json:"runs"`
}
type apiJobConfig struct {
fetcher.ConfigState[apiJobState]
Writable int `json:"writable"`
}
type apiStateJobManager struct{ httpProtocol.FetcherManager }
func (*apiStateJobManager) Name() string { return "state-api" }
func (*apiStateJobManager) DefaultConfig() any { return &apiJobConfig{Writable: 7} }
func (*apiStateJobManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
return []fetcher.CronJob{{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error {
if source == fetcher.TriggerScheduled {
return fetcher.ErrJobSkipped
}
return fetcher.UpdateState[apiJobConfig, apiJobState](host, func(cfg *apiJobConfig, state *apiJobState) error { cfg.Writable = 999; state.Runs++; return nil })
}}}
}
func TestFullConfigAndJobTriggerShareProtectedProtocolState(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&apiStateJobManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
put := []byte(`{"protocolConfig":{"state-api":{"writable":9,"state":"ignore forged state"}}}`)
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/state-api/jobs/refresh/trigger", nil, nil, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
status, raw := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
var response struct {
Data struct {
ProtocolConfig map[string]apiJobConfig `json:"protocolConfig"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatal(err)
}
cfg := response.Data.ProtocolConfig["state-api"]
if status != http.StatusOK || cfg.Writable != 9 || cfg.State.Runs != 1 {
t.Fatalf("%d %s", status, raw)
}
// Replaying an old full PUT must not undo the manual job's state change.
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
_, raw = service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatal(err)
}
if response.Data.ProtocolConfig["state-api"].State.Runs != 1 {
t.Fatalf("job state overwritten: %s", raw)
}
}
+11
View File
@@ -0,0 +1,11 @@
package base
import "github.com/GopeedLab/gopeed/internal/githubmirror"
// Preserve downloader configuration type names and the existing JSON schema.
type GithubMirror = githubmirror.Mirror
type GithubMirrorConfig = githubmirror.Config
func DefaultGithubMirrorConfig() *GithubMirrorConfig {
return githubmirror.DefaultConfig()
}
+7
View File
@@ -181,6 +181,7 @@ type DownloaderStoreConfig struct {
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
GithubMirror *GithubMirrorConfig `json:"githubMirror"`
Extra map[string]any `json:"extra"`
Proxy *DownloaderProxyConfig `json:"proxy"`
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
@@ -199,6 +200,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.ProtocolConfig == nil {
cfg.ProtocolConfig = make(map[string]any)
}
if cfg.GithubMirror == nil {
cfg.GithubMirror = DefaultGithubMirrorConfig()
}
if cfg.Proxy == nil {
cfg.Proxy = &DownloaderProxyConfig{}
}
@@ -239,6 +243,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
if cfg.Extra == nil {
cfg.Extra = beforeCfg.Extra
}
if cfg.GithubMirror == nil {
cfg.GithubMirror = beforeCfg.GithubMirror
}
if cfg.Proxy == nil {
cfg.Proxy = beforeCfg.Proxy
}
+22 -1
View File
@@ -15,6 +15,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
"Init",
&DownloaderStoreConfig{},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
@@ -36,6 +37,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
MaxRunning: 10,
},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 10,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
@@ -59,7 +61,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
&DownloaderStoreConfig{
MaxRunning: 5,
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{
"key": "value",
},
@@ -84,6 +87,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{
@@ -110,6 +114,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
@@ -134,6 +139,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
@@ -149,6 +155,20 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
},
{
name: "Init explicitly disabled mirrors with empty list",
fields: &DownloaderStoreConfig{GithubMirror: &GithubMirrorConfig{Enabled: false, Mirrors: []GithubMirror{}}},
want: &DownloaderStoreConfig{
MaxRunning: 5,
ProtocolConfig: map[string]any{},
GithubMirror: &GithubMirrorConfig{Enabled: false, Mirrors: []GithubMirror{}},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
Script: &ScriptConfig{},
AutoTorrent: &AutoTorrentConfig{},
Archive: &ArchiveConfig{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -157,6 +177,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
DownloadDir: tt.fields.DownloadDir,
MaxRunning: tt.fields.MaxRunning,
ProtocolConfig: tt.fields.ProtocolConfig,
GithubMirror: tt.fields.GithubMirror,
Extra: tt.fields.Extra,
Proxy: tt.fields.Proxy,
Webhook: tt.fields.Webhook,
+245
View File
@@ -0,0 +1,245 @@
package download
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
)
type testJobManager struct {
httpProtocol.FetcherManager
job fetcher.CronJob
}
func (m *testJobManager) Name() string { return "test" }
func (m *testJobManager) CronJobs(fetcher.ProtocolHost) []fetcher.CronJob {
return []fetcher.CronJob{m.job}
}
func testBackendDownloader(t *testing.T, managers ...fetcher.FetcherManager) *Downloader {
t.Helper()
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: managers})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := d.Close(); err != nil {
t.Error(err)
}
})
return d
}
func TestProtocolJobCoalescesAndCancelsOnClose(t *testing.T) {
entered := make(chan struct{})
var calls atomic.Int32
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Minute, Run: func(ctx context.Context, _ fetcher.TriggerSource) error {
if calls.Add(1) == 1 {
close(entered)
}
<-ctx.Done()
return ctx.Err()
}}}
d := testBackendDownloader(t, manager)
result := make(chan error, 2)
go func() { result <- d.TriggerProtocolJob(context.Background(), "test", "refresh") }()
<-entered
waiterCtx, cancel := context.WithCancel(context.Background())
// A canceled waiter joins the existing work without canceling it.
cancel()
if err := d.TriggerProtocolJob(waiterCtx, "test", "refresh"); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("executed %d times", calls.Load())
}
if err := d.Close(); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if err := d.TriggerProtocolJob(context.Background(), "test", "missing"); !errors.Is(err, ErrProtocolJobNotFound) {
t.Fatal(err)
}
}
func TestProtocolJobPersistsSuccess(t *testing.T) {
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Minute, Run: func(context.Context, fetcher.TriggerSource) error { return nil }}}
d := testBackendDownloader(t, manager)
if err := d.TriggerProtocolJob(context.Background(), "test", "refresh"); err != nil {
t.Fatal(err)
}
var state protocolJobState
exists, err := d.storage.Get(bucketProtocolJobs, "test/refresh", &state)
if err != nil || !exists || state.LastSuccess.IsZero() {
t.Fatalf("state %+v: %v", state, err)
}
}
func TestRaceResourceValidatesResponsesAndCancelsLosers(t *testing.T) {
loserCanceled := make(chan struct{})
loserStarted := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodConnect:
http.Error(w, "official unavailable", http.StatusBadGateway)
case strings.HasPrefix(r.URL.Path, "/bad/"):
io.WriteString(w, "<html>proxy landing page</html>")
case strings.HasPrefix(r.URL.Path, "/good/"):
<-loserStarted
io.WriteString(w, `{"ok":true}`)
case strings.HasPrefix(r.URL.Path, "/slow/"):
close(loserStarted)
<-r.Context().Done()
close(loserCanceled)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
d := testBackendDownloader(t, &httpProtocol.FetcherManager{})
cfg, _ := d.GetConfig()
cfg.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: strings.TrimPrefix(server.URL, "http://")}
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
{Type: "ghProxy", URL: server.URL + "/bad"},
{Type: "ghProxy", URL: server.URL + "/slow"},
{Type: "ghProxy", URL: server.URL + "/good"},
}}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
body, err := d.fetchResource(context.Background(), "https://raw.githubusercontent.com/owner/repo/main/test.json", 1024, false, func(body []byte) error {
if !json.Valid(body) {
return errors.New("invalid JSON")
}
return nil
})
if err != nil || string(body) != `{"ok":true}` {
t.Fatalf("%s %v", body, err)
}
select {
case <-loserCanceled:
case <-time.After(time.Second):
t.Fatal("losing request was not canceled")
}
}
func TestProtocolConfigInitializesCurrentSchema(t *testing.T) {
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
GithubMirror: &base.GithubMirrorConfig{Enabled: false, Mirrors: []base.GithubMirror{}},
ProtocolConfig: map[string]any{"http": map[string]any{"connections": 3}},
}
if err := d.storage.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
defer d.storage.Close()
if err := d.initProtocolConfigs(); err != nil {
t.Fatal(err)
}
if d.cfg.GithubMirror.Enabled || len(d.cfg.GithubMirror.Mirrors) != 0 {
t.Fatal("explicit disabled/empty mirrors lost")
}
value, err := d.readProtocolConfig("http")
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(value)
var cfg struct {
Connections int `json:"connections"`
}
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.Connections != 3 {
t.Fatalf("current protocol config: %s %v", raw, err)
}
if err := d.initProtocolConfigs(); err != nil {
t.Fatal(err)
}
}
func TestProtocolConfigDoesNotImportOldExtraSettings(t *testing.T) {
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&btProtocol.FetcherManager{}}})
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
Extra: map[string]any{
"githubMirror": "obsolete invalid value",
"bt": map[string]any{"customTrackers": []string{"udp://old.example:80/announce"}, "autoUpdateTrackers": true},
},
ProtocolConfig: map[string]any{"bt": map[string]any{"autoUpdateTrackers": false, "listenPort": 6881}},
}
if err := d.storage.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
defer d.storage.Close()
if err := d.initProtocolConfigs(); err != nil {
t.Fatal(err)
}
if !d.cfg.GithubMirror.Enabled || len(d.cfg.GithubMirror.Mirrors) != 4 {
t.Fatal("missing root mirror config must use new defaults")
}
value, err := d.readProtocolConfig("bt")
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(value)
var cfg struct {
ListenPort int `json:"listenPort"`
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
CustomTrackers []string `json:"customTrackers"`
}
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ListenPort != 6881 || cfg.AutoUpdateTrackers || len(cfg.CustomTrackers) != 0 {
t.Fatalf("old extra settings affected protocol config: %s %v", raw, err)
}
}
func TestProtocolJobsRunAutomaticallyAndRestoreSuccess(t *testing.T) {
dir := t.TempDir()
executed := make(chan fetcher.TriggerSource, 4)
manager := func() *testJobManager {
return &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error { executed <- source; return nil }}}
}
makeDownloader := func() *Downloader {
d := NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(dir), StorageDir: dir, TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{manager()}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
return d
}
d := makeDownloader()
select {
case source := <-executed:
if source != fetcher.TriggerScheduled {
t.Fatal(source)
}
case <-time.After(3 * time.Second):
t.Fatal("overdue job was not run")
}
entry := d.protocolJobs.jobs["test/refresh"]
entry.mu.Lock()
run := entry.active
entry.mu.Unlock()
if run != nil {
<-run.done
}
if err := d.Close(); err != nil {
t.Fatal(err)
}
d = makeDownloader()
defer d.Close()
state := d.protocolJobs.jobs["test/refresh"].state
if state.LastSuccess.IsZero() {
t.Fatal("successful execution not restored")
}
select {
case <-executed:
t.Fatal("recently successful job ran again on restart")
case <-time.After(30 * time.Millisecond):
}
}
+85 -16
View File
@@ -2,6 +2,7 @@ package download
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -42,6 +43,7 @@ const (
bucketTaskStats = "task_stats"
// protocol-level shared client state bucket
bucketProtocolState = "protocol_state"
bucketProtocolJobs = "protocol_jobs"
// downloader config bucket
bucketConfig = "config"
// downloader extension bucket
@@ -99,6 +101,9 @@ type Progress struct {
}
type Downloader struct {
backgroundCtx context.Context
cancelBackground context.CancelFunc
protocolJobs *protocolScheduler
tempFiles *tempfiles.Scope
webviewProfilesLock sync.Mutex
webviewProfiles map[string]*extensionWebViewProfile
@@ -125,8 +130,10 @@ type Downloader struct {
// Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it
claimedExtractions sync.Map
extensions []*Extension
blob *internalblob.Registry
extensions []*Extension
extensionGitLock sync.Mutex
extensionGitWG sync.WaitGroup
blob *internalblob.Registry
}
func NewDownloader(cfg *DownloaderConfig) *Downloader {
@@ -135,11 +142,14 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
}
cfg.Init()
backgroundCtx, cancelBackground := context.WithCancel(context.Background())
d := &Downloader{
cfg: cfg,
fetcherCache: make(map[string]fetcher.Fetcher),
waitTasks: make([]*Task, 0),
storage: cfg.Storage,
backgroundCtx: backgroundCtx,
cancelBackground: cancelBackground,
cfg: cfg,
fetcherCache: make(map[string]fetcher.Fetcher),
waitTasks: make([]*Task, 0),
storage: cfg.Storage,
lock: &sync.Mutex{},
configLock: &sync.RWMutex{},
@@ -165,7 +175,7 @@ func (d *Downloader) Setup() error {
d.blob = internalblob.NewRegistry("")
// setup storage
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketProtocolJobs, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
return err
}
// load config from storage
@@ -181,14 +191,13 @@ func (d *Downloader) Setup() error {
FirstLoad: true,
}
}
// init default config
d.cfg.DownloaderStoreConfig.Init()
// init protocol config, if not exist, use default config
// Initialize the current backend configuration schema and defaults.
if err := d.initProtocolConfigs(); err != nil {
return err
}
// Attach optional protocol state stores after configuration initialization.
for _, fm := range d.cfg.FetchManagers {
protocol := fm.Name()
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
}
if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {
sfm.SetStateStore(&protocolStateStore{
storage: d.storage,
@@ -197,6 +206,10 @@ func (d *Downloader) Setup() error {
}
}
if err := d.setupProtocolJobs(); err != nil {
return err
}
// load tasks from storage
var tasks []*Task
if err = d.storage.List(bucketTask, &tasks); err != nil {
@@ -365,6 +378,7 @@ func (d *Downloader) Setup() error {
time.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval))
}
}()
d.protocolJobs.start()
return nil
}
@@ -1181,7 +1195,14 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) {
}
func (d *Downloader) Close() error {
d.closed.Store(true)
if d.closed.Swap(true) {
return nil
}
d.cancelBackground()
d.waitForExtensionGit()
if d.protocolJobs != nil {
d.protocolJobs.close()
}
d.webviewProxyLock.Lock()
if d.webviewProxy != nil {
_ = d.webviewProxy.Close()
@@ -1336,7 +1357,7 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
func (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) {
d.configLock.RLock()
defer d.configLock.RUnlock()
return d.cfg.DownloaderStoreConfig, nil
return util.DeepClone(d.cfg.DownloaderStoreConfig), nil
}
func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
@@ -1344,12 +1365,60 @@ func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
return errors.New("config is nil")
}
next := util.DeepClone(v)
changedProtocols := []string{}
d.configLock.Lock()
defer d.configLock.Unlock()
if d.closed.Load() {
d.configLock.Unlock()
return errors.New("downloader is closed")
}
if next.GithubMirror == nil {
next.GithubMirror = util.DeepClone(d.cfg.GithubMirror)
}
if err := next.GithubMirror.Validate(); err != nil {
d.configLock.Unlock()
return err
}
if next.ProtocolConfig == nil {
next.ProtocolConfig = map[string]any{}
}
for name := range next.ProtocolConfig {
if _, err := d.protocolManager(name); err != nil {
d.configLock.Unlock()
return err
}
}
for _, fm := range d.cfg.FetchManagers {
name := fm.Name()
value, exists := next.ProtocolConfig[name]
if !exists {
value = d.cfg.ProtocolConfig[name]
}
value, err := preserveProtocolState(value, d.cfg.ProtocolConfig[name])
if err == nil {
value, err = normalizeProtocol(fm, value)
}
if err != nil {
d.configLock.Unlock()
return err
}
next.ProtocolConfig[name] = value
oldJSON, _ := json.Marshal(d.cfg.ProtocolConfig[name])
newJSON, _ := json.Marshal(value)
if !bytes.Equal(oldJSON, newJSON) {
changedProtocols = append(changedProtocols, name)
}
}
if err := d.storage.Put(bucketConfig, "config", next); err != nil {
d.configLock.Unlock()
return err
}
d.cfg.DownloaderStoreConfig = next
d.configLock.Unlock()
if d.protocolJobs != nil {
for _, name := range changedProtocols {
d.protocolJobs.changed(name)
}
}
return nil
}
+21 -3
View File
@@ -1241,7 +1241,7 @@ func TestDownloader_Protocol_Config(t *testing.T) {
"trackerSubscribeUrls": []string{
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
},
"trackers": []string{
"customTrackers": []string{
"udp://tracker.coppersurfer.tk:6969/announce",
"udp://tracker.leechers-paradise.org:6969/announce",
},
@@ -1261,8 +1261,20 @@ func TestDownloader_Protocol_Config(t *testing.T) {
t.Fatal(err)
}
if !test.JsonEqual(storeCfg, newStoreCfg) {
t.Errorf("GetConfig() got = %v, want %v", test.ToJson(storeCfg), test.ToJson(newStoreCfg))
if newStoreCfg.DownloadDir != storeCfg.DownloadDir || newStoreCfg.Extra["theme"] != "dark" {
t.Fatalf("downloader config was not saved: %+v", newStoreCfg)
}
var savedHTTP struct {
Connections int `json:"connections"`
}
if err := util.MapToStruct(newStoreCfg.ProtocolConfig["http"], &savedHTTP); err != nil || savedHTTP.Connections != 4 {
t.Fatalf("HTTP config: %+v %v", savedHTTP, err)
}
var savedBT struct {
CustomTrackers []string `json:"customTrackers"`
}
if err := util.MapToStruct(newStoreCfg.ProtocolConfig["bt"], &savedBT); err != nil || len(savedBT.CustomTrackers) != 2 {
t.Fatalf("BT derived trackers: %+v %v", savedBT, err)
}
}
@@ -2002,6 +2014,9 @@ func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) {
t.Fatal(err)
}
cfg.Extra = map[string]any{"autoStartTasks": true}
if err := downloader.PutConfig(cfg); err != nil {
t.Fatal(err)
}
if err := downloader.ContinueOnStartup(); err != nil {
t.Fatal(err)
}
@@ -2010,6 +2025,9 @@ func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) {
}
cfg.AutoStartTasks = true
if err := downloader.PutConfig(cfg); err != nil {
t.Fatal(err)
}
if err := downloader.ContinueOnStartup(); err != nil {
t.Fatal(err)
}
+73 -36
View File
@@ -1,17 +1,19 @@
package download
import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/githubmirror"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download/engine"
@@ -196,50 +198,85 @@ func (d *Downloader) getExtension(identity string) *Extension {
return nil
}
func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath string, devMode bool) (*Extension, error)) (*Extension, error) {
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
url = "https://" + url
}
// waitForExtensionGit waits for clones and their install/check handlers before
// shutdown releases storage and temporary files. Close cancels backgroundCtx first.
func (d *Downloader) waitForExtensionGit() {
d.extensionGitLock.Lock()
d.extensionGitLock.Unlock()
d.extensionGitWG.Wait()
}
// resolve project path
parentPath, projectPath := path.Split(url)
// resolve project name and sub path
pathArr := strings.SplitN(projectPath, "#", 2)
projectPath = strings.TrimSuffix(pathArr[0], gitSuffix)
subPath := ""
if len(pathArr) > 1 {
subPath = pathArr[1]
func (d *Downloader) fetchExtensionByGit(rawURL string, handler func(string, bool) (*Extension, error)) (*Extension, error) {
// Pair admission with shutdown so no operation is added after its wait starts.
d.extensionGitLock.Lock()
if d.closed.Load() {
d.extensionGitLock.Unlock()
return nil, fmt.Errorf("downloader is closed")
}
// Use unique suffix to avoid concurrent conflicts when multiple git operations
// target the same extension (e.g., install and update check happening simultaneously)
tempExtDir, err := os.MkdirTemp(d.cfg.TempDir, "gopeed-extension-")
d.extensionGitWG.Add(1)
d.extensionGitLock.Unlock()
defer d.extensionGitWG.Done()
ctx := d.backgroundCtx
if !strings.HasPrefix(rawURL, "https://") && !strings.HasPrefix(rawURL, "http://") {
rawURL = "https://" + rawURL
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("invalid extension repository URL")
}
subPath := filepath.FromSlash(parsed.Fragment)
if filepath.IsAbs(subPath) || subPath == ".." || strings.HasPrefix(filepath.Clean(subPath), ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("invalid extension directory")
}
parsed.Fragment = ""
parsed.Path = strings.TrimSuffix(strings.TrimRight(parsed.Path, "/"), gitSuffix) + gitSuffix
officialURL := parsed.String()
cfg, err := d.GetConfig()
if err != nil {
return nil, err
}
if err := d.tempFiles.Track(tempExtDir); err != nil {
return nil, err
}
defer d.tempFiles.Remove(tempExtDir)
proxyOptions := transport.ProxyOptions{}
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
if proxyUrl != nil {
proxyOptions.URL = proxyUrl.Scheme + "://" + proxyUrl.Host
proxyOptions.Username = proxyUrl.User.Username()
proxyOptions.Password, _ = proxyUrl.User.Password()
if proxyURL := cfg.Proxy.ToUrl(); proxyURL != nil {
proxyOptions.URL = proxyURL.Scheme + "://" + proxyURL.Host
if proxyURL.User != nil {
proxyOptions.Username = proxyURL.User.Username()
proxyOptions.Password, _ = proxyURL.User.Password()
}
}
// clone project to extension temp dir
gitUrl := parentPath + projectPath + gitSuffix
if _, err := git.PlainClone(tempExtDir, false, &git.CloneOptions{
URL: gitUrl,
Depth: 1,
ProxyOptions: proxyOptions,
}); err != nil {
tempDir, err := os.MkdirTemp(d.cfg.TempDir, "gopeed-extension-")
if err != nil {
return nil, err
}
return handler(filepath.Join(tempExtDir, subPath), false)
if err = d.tempFiles.Track(tempDir); err != nil {
_ = os.RemoveAll(tempDir)
return nil, err
}
defer d.tempFiles.Remove(tempDir)
repoDir := filepath.Join(tempDir, "repo")
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
githubmirror.Options[*git.Repository]{Kind: githubmirror.Repository},
func(ctx context.Context, candidate string) (*git.Repository, error) {
if err := os.RemoveAll(repoDir); err != nil {
return nil, err
}
return git.PlainCloneContext(ctx, repoDir, false, &git.CloneOptions{URL: candidate, Depth: 1, ProxyOptions: proxyOptions})
},
)
if err != nil {
return nil, err
}
// Preserve the canonical source instead of persisting a selected mirror.
repoConfig, err := repo.Config()
if err != nil {
return nil, err
}
if remote := repoConfig.Remotes["origin"]; remote != nil {
remote.URLs = []string{officialURL}
}
if err = repo.SetConfig(repoConfig); err != nil {
return nil, err
}
return handler(filepath.Join(repoDir, subPath), false)
}
func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {
+210
View File
@@ -0,0 +1,210 @@
package download
import (
"encoding/json"
"io"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
)
// Exercise real Git clone/install/check/update without depending on GitHub or
// public mirror availability. The proxy deliberately rejects the official URL.
// github.com/gopeed-test/repo is a placeholder: the successful mirror serves
// the temporary local repository through git http-backend instead.
func newExtensionGitFixture(t *testing.T) (*Downloader, func(string), *atomic.Int32, *atomic.Int32) {
t.Helper()
git, err := exec.LookPath("git")
if err != nil {
t.Skip("Git executable required for HTTP Git fixture")
}
root := t.TempDir()
bare := filepath.Join(root, "repo.git")
work := t.TempDir()
command := func(args ...string) {
t.Helper()
cmd := exec.Command(git, args...)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, output)
}
}
command("-c", "init.defaultBranch=main", "init", "--bare", bare)
command("clone", bare, work)
command("-C", work, "config", "user.name", "Gopeed Test")
command("-C", work, "config", "user.email", "test@example.com")
publish := func(version string) {
t.Helper()
directory := filepath.Join(work, "nested", "extension")
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
manifest := map[string]any{"name": "mirror-test", "author": "test", "title": "Mirror test", "version": version, "repository": map[string]string{"url": "https://github.com/gopeed-test/repo", "directory": "nested/extension"}}
body, _ := json.Marshal(manifest)
if err := os.WriteFile(filepath.Join(directory, "manifest.json"), body, 0600); err != nil {
t.Fatal(err)
}
command("-C", work, "add", ".")
command("-C", work, "commit", "-m", version)
command("-C", work, "push", "origin", "HEAD:main")
}
publish("1.0.0")
var official, badMirror atomic.Int32
backend := &cgi.Handler{Path: git, Args: []string{"http-backend"}, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/https://github.com/gopeed-test")
r.RequestURI = r.URL.RequestURI()
backend.ServeHTTP(w, r)
}))
t.Cleanup(mirror.Close)
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { badMirror.Add(1); http.Error(w, "mirror offline", 502) }))
t.Cleanup(failed.Close)
direct := &http.Transport{}
t.Cleanup(direct.CloseIdleConnections)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect || r.URL.Hostname() == "github.com" {
official.Add(1)
http.Error(w, "official unavailable", 502)
return
}
request := r.Clone(r.Context())
request.RequestURI = ""
response, err := direct.RoundTrip(request)
if err != nil {
http.Error(w, err.Error(), 502)
return
}
defer response.Body.Close()
for key, values := range response.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, response.Body)
}))
t.Cleanup(proxy.Close)
d := testBackendDownloader(t, &httpProtocol.FetcherManager{})
config, _ := d.GetConfig()
config.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: strings.TrimPrefix(proxy.URL, "http://")}
config.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{{Type: "ghProxy", URL: failed.URL}, {Type: "ghProxy", URL: mirror.URL}}}
if err := d.PutConfig(config); err != nil {
t.Fatal(err)
}
return d, publish, &official, &badMirror
}
func testInstallExtensionGitURL(t *testing.T, raw string) {
d, _, official, badMirror := newExtensionGitFixture(t)
extension, err := d.InstallExtensionByGit(raw)
if err != nil {
t.Fatal(err)
}
if extension.Version != "1.0.0" || extension.Identity != "test@mirror-test" {
t.Fatalf("%+v", extension)
}
if official.Load() == 0 || badMirror.Load() == 0 {
t.Fatal("ordered fallback was not exercised")
}
if _, err := os.Stat(filepath.Join(d.ExtensionPath(extension), "manifest.json")); err != nil {
t.Fatal(err)
}
}
func TestExtensionGitMirrorInstallAndUpdate(t *testing.T) {
d, publish, official, badMirror := newExtensionGitFixture(t)
extension, err := d.InstallExtensionByGit("https://github.com/gopeed-test/repo#nested/extension")
if err != nil {
t.Fatal(err)
}
publish("1.1.0")
version, err := d.UpgradeCheckExtension(extension.Identity)
if err != nil || version != "1.1.0" {
t.Fatalf("%s %v", version, err)
}
if err = d.UpgradeExtension(extension.Identity); err != nil {
t.Fatal(err)
}
updated, err := d.GetExtension(extension.Identity)
if err != nil || updated.Version != "1.1.0" {
t.Fatalf("%+v %v", updated, err)
}
if updated.Repository.Url != "https://github.com/gopeed-test/repo" {
t.Fatal("mirror URL replaced canonical repository")
}
if official.Load() != 3 || badMirror.Load() != 3 {
t.Fatalf("attempts official=%d mirror=%d", official.Load(), badMirror.Load())
}
}
func TestExtensionGitShutdownWaitsForHandler(t *testing.T) {
d, _, _, _ := newExtensionGitFixture(t)
started := make(chan string, 1)
release := make(chan struct{})
t.Cleanup(func() {
select {
case <-release:
default:
close(release)
}
})
finished := make(chan error, 1)
go func() {
_, err := d.fetchExtensionByGit("https://github.com/gopeed-test/repo#nested/extension", func(path string, _ bool) (*Extension, error) {
started <- path
<-release
return d.parseExtensionByPath(path)
})
finished <- err
}()
var path string
select {
case path = <-started:
case err := <-finished:
t.Fatalf("Git operation finished before handler: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("Git handler did not start")
}
closed := make(chan error, 1)
go func() { closed <- d.Close() }()
select {
case <-d.backgroundCtx.Done():
case <-time.After(5 * time.Second):
t.Fatal("shutdown did not cancel Git context")
}
select {
case err := <-closed:
t.Fatalf("shutdown returned before handler finished: %v", err)
case <-time.After(100 * time.Millisecond):
}
if _, err := os.Stat(filepath.Join(path, "manifest.json")); err != nil {
t.Fatalf("shutdown removed files still used by handler: %v", err)
}
if _, err := d.InstallExtensionByGit("https://github.com/gopeed-test/repo"); err == nil || !strings.Contains(err.Error(), "downloader is closed") {
t.Fatalf("expected new Git operations to be rejected during shutdown: %v", err)
}
close(release)
select {
case err := <-finished:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("Git handler did not finish")
}
select {
case err := <-closed:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("shutdown did not finish after Git handler")
}
}
+62
View File
@@ -0,0 +1,62 @@
package download
import (
"context"
"net"
"net/http"
"time"
gitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
)
func init() {
// go-git exposes a process-wide protocol registry. Register once during
// initialization, rather than changing it during concurrent clones. Proxy
// settings still come from each clone's ProxyOptions.
transport := githttp.NewClient(newExtensionGitHTTPClient(10*time.Second, 60*time.Second))
gitclient.InstallProtocol("http", transport)
gitclient.InstallProtocol("https", transport)
}
func newExtensionGitHTTPClient(connectTimeout, idleTimeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: connectTimeout, KeepAlive: 30 * time.Second}
// A socket read deadline must apply to one transfer, rather than a shared
// HTTP/2 connection carrying traffic from other requests.
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return nil, err
}
return &gitIdleConn{Conn: conn, timeout: idleTimeout}, nil
},
TLSHandshakeTimeout: connectTimeout,
IdleConnTimeout: idleTimeout,
Protocols: protocols,
}}
}
type gitIdleConn struct {
net.Conn
timeout time.Duration
}
func (c *gitIdleConn) Read(p []byte) (int, error) {
if err := c.Conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
return 0, err
}
return c.Conn.Read(p)
}
// Refresh an already-pending read when a pooled HTTP/1 connection starts the
// next request; time spent idle in the pool must not consume its read allowance.
func (c *gitIdleConn) Write(p []byte) (int, error) {
if err := c.Conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
return 0, err
}
return c.Conn.Write(p)
}
@@ -0,0 +1,189 @@
package download
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"net/url"
"reflect"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/githubmirror"
)
func TestExtensionGitHTTPTransportAllowsSlowStreaming(t *testing.T) {
const chunks = 24
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for range chunks {
if _, err := io.WriteString(w, "x"); err != nil {
return
}
w.(http.Flusher).Flush()
select {
case <-time.After(50 * time.Millisecond):
case <-r.Context().Done():
return
}
}
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, time.Second)
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
// A regularly streaming response lasts longer than the idle allowance.
body, err := io.ReadAll(response.Body)
if err != nil || len(body) != chunks {
t.Fatalf("received %d bytes, error=%v", len(body), err)
}
}
func TestExtensionGitHTTPTransportTimesOutStalledReads(t *testing.T) {
for _, bodyStarted := range []bool{false, true} {
name := "headers"
if bodyStarted {
name = "body"
}
t.Run(name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bodyStarted {
_, _ = io.WriteString(w, "partial")
w.(http.Flusher).Flush()
}
<-r.Context().Done()
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, 100*time.Millisecond)
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
response, err := client.Do(request)
if err == nil {
defer response.Body.Close()
_, err = io.ReadAll(response.Body)
}
var timeout net.Error
if !errors.As(err, &timeout) || !timeout.Timeout() || ctx.Err() != nil {
t.Fatalf("expected idle timeout before parent deadline: error=%v context=%v", err, ctx.Err())
}
})
}
}
func TestExtensionGitIdleTimeoutFallsBackThroughProxy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Hostname() == "github.com" {
_, _ = io.WriteString(w, "partial")
w.(http.Flusher).Flush()
<-r.Context().Done()
return
}
_, _ = io.WriteString(w, "complete")
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, 100*time.Millisecond)
defer client.CloseIdleConnections()
proxy, _ := url.Parse(server.URL)
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
config := &githubmirror.Config{Enabled: true, Mirrors: []githubmirror.Mirror{{Type: "ghProxy", URL: server.URL}}}
source := "http://github.com/test/repo.git"
var tried []string
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := githubmirror.Fallback(ctx, config, source, githubmirror.Options[string]{Kind: githubmirror.Repository}, func(ctx context.Context, candidate string) (string, error) {
tried = append(tried, candidate)
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
return string(body), err
})
if err != nil || result != "complete" || !reflect.DeepEqual(tried, []string{source, server.URL + "/" + source}) {
t.Fatalf("result=%q tried=%v error=%v", result, tried, err)
}
}
func TestExtensionGitHTTPTransportCancelsStalledBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, time.Minute)
defer client.CloseIdleConnections()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
cancel()
done := make(chan error, 1)
go func() { _, err := io.ReadAll(response.Body); done <- err }()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("error=%v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("parent cancellation did not interrupt the body")
}
}
func TestExtensionGitHTTPTransportRefreshesReusedConnection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/slow" {
select {
case <-time.After(500 * time.Millisecond):
case <-r.Context().Done():
return
}
}
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, time.Second)
defer client.CloseIdleConnections()
response, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
if err != nil {
t.Fatal(err)
}
time.Sleep(700 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
reused := false
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused }})
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/slow", nil)
response, err = client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil || string(body) != "ok" || !reused {
t.Fatalf("reused=%v body=%q error=%v", reused, body, err)
}
}
+3 -42
View File
@@ -1070,54 +1070,15 @@ func TestDownloader_Extension_BlobRecoverOnError(t *testing.T) {
}
func TestDownloader_InstallExtensionByGit(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
})
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
}
})
testInstallExtensionGitURL(t, "https://github.com/gopeed-test/repo#nested/extension")
}
func TestDownloader_InstallExtensionByGitSimple(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByGit("github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
})
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
}
})
testInstallExtensionGitURL(t, "github.com/gopeed-test/repo#nested/extension")
}
func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples.git#github-release-sample"); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
})
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
}
})
testInstallExtensionGitURL(t, "https://github.com/gopeed-test/repo.git#nested/extension")
}
func TestDownloader_UpgradeExtension(t *testing.T) {
+151
View File
@@ -0,0 +1,151 @@
package download
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/GopeedLab/gopeed/internal/githubmirror"
"github.com/GopeedLab/gopeed/internal/httpclient"
)
func (d *Downloader) resourceClient(publicOnly bool) (*http.Client, error) {
cfg, err := d.GetConfig()
if err != nil {
return nil, err
}
options := httpclient.Options{
Client: httpclient.ClientOptions{Timeout: 30 * time.Second},
Transport: httpclient.TransportOptions{Proxy: cfg.Proxy.ToHandler(), TLSHandshakeTimeout: 10 * time.Second},
}
if publicOnly {
// A configured upstream proxy is trusted, but target URLs are still checked.
options.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
return validatePublicURL(req.Context(), req.URL.String())
}
if cfg.Proxy == nil || !cfg.Proxy.Enable {
options.Transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := publicIPs(ctx, host)
if err != nil {
return nil, err
}
var last error
for _, ip := range ips {
conn, err := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
last = err
}
return nil, last
}
}
}
return httpclient.NewClient(options)
}
func publicIPs(ctx context.Context, host string) ([]net.IPAddr, error) {
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, errors.New("target has no addresses")
}
for _, ip := range ips {
if !ip.IP.IsGlobalUnicast() || ip.IP.IsPrivate() || ip.IP.IsLoopback() || ip.IP.IsLinkLocalUnicast() || ip.IP.IsUnspecified() {
return nil, errors.New("private or local resource target is not allowed")
}
}
return ips, nil
}
func validatePublicURL(ctx context.Context, raw string) error {
u, err := url.Parse(raw)
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil {
return errors.New("invalid resource URL")
}
_, err = publicIPs(ctx, u.Hostname())
return err
}
func (d *Downloader) fetchPublicResource(ctx context.Context, raw string, limit int64, validate func([]byte) error) ([]byte, error) {
return d.fetchResource(ctx, raw, limit, true, validate)
}
// fetchResource races complete, validated responses. A successful HEAD alone
// cannot win, and failed candidates never discard a usable official response.
func (d *Downloader) fetchResource(ctx context.Context, raw string, limit int64, publicOnly bool, validate func([]byte) error) ([]byte, error) {
cfg, err := d.GetConfig()
if err != nil {
return nil, err
}
client, err := d.resourceClient(publicOnly)
if err != nil {
return nil, err
}
defer client.CloseIdleConnections()
return githubmirror.Race(ctx, cfg.GithubMirror, raw,
githubmirror.Options[[]byte]{Kind: githubmirror.Resource, Timeout: 30 * time.Second},
func(ctx context.Context, candidate string) ([]byte, error) {
if publicOnly {
if err := validatePublicURL(ctx, candidate); err != nil {
return nil, err
}
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("resource returned HTTP %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(body)) > limit {
return nil, errors.New("resource exceeds size limit")
}
if len(body) == 0 {
return nil, errors.New("empty resource")
}
if validate != nil {
if err := validate(body); err != nil {
return nil, err
}
}
return body, nil
},
)
}
// githubResourceCandidates accepts official GitHub HTTPS resource URLs only.
func githubResourceCandidates(config *githubmirror.Config, raw string) ([]string, error) {
u, err := url.Parse(raw)
if err != nil || u == nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || u.Fragment != "" || strings.Contains(raw, "#") {
return nil, ErrInvalidMirrorURL
}
u.Host = strings.ToLower(u.Host)
switch u.Host {
case "github.com", "raw.githubusercontent.com":
default:
return nil, ErrInvalidMirrorURL
}
return githubmirror.Candidates(config, u.String(), githubmirror.Resource), nil
}
+35
View File
@@ -0,0 +1,35 @@
package download
import (
"errors"
"github.com/GopeedLab/gopeed/pkg/base"
)
var (
ErrMirrorProviderNotFound = errors.New("mirror provider not found")
ErrInvalidMirrorURL = errors.New("invalid mirror resource URL")
)
// Each provider owns URL validation and candidate generation using its own
// configuration. Providers only generate URLs; they must not fetch resources.
type mirrorCandidateProvider func(config *base.DownloaderStoreConfig, raw string) ([]string, error)
// Add providers here without changing the public API or its client transport.
var mirrorCandidateProviders = map[string]mirrorCandidateProvider{
"github": func(config *base.DownloaderStoreConfig, raw string) ([]string, error) {
return githubResourceCandidates(config.GithubMirror, raw)
},
}
func (d *Downloader) GetMirrorCandidates(provider, raw string) ([]string, error) {
candidates, ok := mirrorCandidateProviders[provider]
if !ok {
return nil, ErrMirrorProviderNotFound
}
config, err := d.GetConfig()
if err != nil {
return nil, err
}
return candidates(config, raw)
}
+172
View File
@@ -0,0 +1,172 @@
package download
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/util"
)
func (d *Downloader) protocolManager(name string) (fetcher.FetcherManager, error) {
for _, fm := range d.cfg.FetchManagers {
if fm.Name() == name {
return fm, nil
}
}
return nil, fmt.Errorf("%w: %s", ErrUnSupportedProtocol, name)
}
func jsonObject(v any) (map[string]json.RawMessage, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
var object map[string]json.RawMessage
if err = json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if object == nil {
object = map[string]json.RawMessage{}
}
return object, nil
}
func normalizeProtocol(fm fetcher.FetcherManager, v any) (any, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
target := fm.DefaultConfig()
if target == nil {
return nil, errors.New("protocol does not expose configuration")
}
// Managers may return either typed pointers or value/map defaults.
valueDefault := reflect.TypeOf(target).Kind() != reflect.Pointer
if valueDefault {
pointer := reflect.New(reflect.TypeOf(target))
defaults, err := json.Marshal(target)
if err != nil {
return nil, err
}
if err = json.Unmarshal(defaults, pointer.Interface()); err != nil {
return nil, err
}
target = pointer.Interface()
}
if err = json.Unmarshal(raw, target); err != nil {
return nil, err
}
if normalizer, ok := fm.(fetcher.ConfigNormalizer); ok {
raw, err = json.Marshal(target)
if err != nil {
return nil, err
}
return normalizer.NormalizeConfig(raw)
}
if valueDefault {
return reflect.ValueOf(target).Elem().Interface(), nil
}
return target, nil
}
// preserveProtocolState ignores all client-supplied state, including null or
// malformed state values, before decoding/validating the writable config.
func preserveProtocolState(next, previous any) (any, error) {
object, err := jsonObject(next)
if err != nil {
return nil, err
}
before, err := jsonObject(previous)
if err != nil {
return nil, err
}
// Match encoding/json field folding as well as the canonical spelling.
for key := range object {
if strings.EqualFold(key, "state") {
delete(object, key)
}
}
if state, ok := before["state"]; ok {
object["state"] = state
}
return object, nil
}
func (d *Downloader) readProtocolConfig(name string) (any, error) {
if _, err := d.protocolManager(name); err != nil {
return nil, err
}
d.configLock.RLock()
defer d.configLock.RUnlock()
value := d.cfg.ProtocolConfig[name]
return *util.DeepClone(&value), nil
}
// updateProtocolState is the only internal write path for backend-owned state.
// It never publishes callback edits outside the reserved state section.
func (d *Downloader) updateProtocolState(name string, change func(config, state json.RawMessage) (any, error)) error {
fm, err := d.protocolManager(name)
if err != nil {
return err
}
d.configLock.Lock()
defer d.configLock.Unlock()
if d.closed.Load() {
return errors.New("downloader is closed")
}
next := util.DeepClone(d.cfg.DownloaderStoreConfig)
object, err := jsonObject(next.ProtocolConfig[name])
if err != nil {
return err
}
state, ok := object["state"]
if !ok {
return fmt.Errorf("protocol %s does not expose persistent state", name)
}
raw, err := json.Marshal(object)
if err != nil {
return err
}
value, err := change(raw, state)
if err != nil {
return err
}
// Keep the transaction's original config isolated from callback mutations.
object["state"], err = json.Marshal(value)
if err != nil {
return err
}
normalized, err := normalizeProtocol(fm, object)
if err != nil {
return err
}
next.ProtocolConfig[name] = normalized
if err = d.storage.Put(bucketConfig, "config", next); err != nil {
return err
}
d.cfg.DownloaderStoreConfig.ProtocolConfig = next.ProtocolConfig
return nil
}
// initProtocolConfigs loads the current schema and initializes missing defaults.
// This release intentionally does not import settings from older config layouts.
func (d *Downloader) initProtocolConfigs() error {
cfg := d.cfg.DownloaderStoreConfig
cfg.Init()
if err := cfg.GithubMirror.Validate(); err != nil {
return err
}
for _, fm := range d.cfg.FetchManagers {
value, ok := cfg.ProtocolConfig[fm.Name()]
if !ok {
value = map[string]any{}
}
normalized, err := normalizeProtocol(fm, value)
if err != nil {
return fmt.Errorf("%s config: %w", fm.Name(), err)
}
cfg.ProtocolConfig[fm.Name()] = normalized
}
return d.storage.Put(bucketConfig, "config", cfg)
}
+250
View File
@@ -0,0 +1,250 @@
package download
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"sync"
"sync/atomic"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/util"
)
var ErrProtocolJobNotFound = errors.New("protocol job not found")
var jobNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
type protocolJobState struct {
LastSuccess time.Time `json:"lastSuccess"`
Failures int `json:"failures"`
}
type protocolJobRun struct {
done chan struct{}
err error
source fetcher.TriggerSource
}
type registeredJob struct {
protocol string
spec fetcher.CronJob
mu sync.Mutex
state protocolJobState
active *protocolJobRun
next time.Time
wake chan struct{}
force atomic.Bool
}
type protocolScheduler struct {
d *Downloader
jobs map[string]*registeredJob
wg sync.WaitGroup
// gate prevents adding work while shutdown waits for it.
gate sync.Mutex
stopped bool
}
func (d *Downloader) setupProtocolJobs() error {
scheduler := &protocolScheduler{d: d, jobs: map[string]*registeredJob{}}
for _, fm := range d.cfg.FetchManagers {
provider, ok := fm.(fetcher.CronProvider)
if !ok {
continue
}
name := fm.Name()
host := fetcher.ProtocolHost{
Read: func(dst any) error {
value, err := d.readProtocolConfig(name)
if err != nil {
return err
}
return util.MapToStruct(value, dst)
},
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
return d.updateProtocolState(name, change)
},
Fetch: func(ctx context.Context, raw string, limit int64, validate func([]byte) error) ([]byte, error) {
return d.fetchPublicResource(ctx, raw, limit, validate)
},
}
for _, spec := range provider.CronJobs(host) {
if !jobNamePattern.MatchString(spec.Name) || spec.Every <= 0 || spec.Timeout <= 0 || spec.Run == nil {
return fmt.Errorf("invalid protocol job: %s/%s", name, spec.Name)
}
key := name + "/" + spec.Name
if _, ok := scheduler.jobs[key]; ok {
return fmt.Errorf("duplicate protocol job: %s", key)
}
entry := &registeredJob{protocol: name, spec: spec, wake: make(chan struct{}, 1)}
if _, err := d.storage.Get(bucketProtocolJobs, key, &entry.state); err != nil {
return err
}
scheduler.jobs[key] = entry
}
}
d.protocolJobs = scheduler
return nil
}
func (s *protocolScheduler) start() {
for key, entry := range s.jobs {
s.wg.Add(1)
go s.loop(key, entry)
}
}
func (s *protocolScheduler) signal(entry *registeredJob) {
select {
case entry.wake <- struct{}{}:
default:
}
}
func (s *protocolScheduler) changed(protocol string) {
for _, entry := range s.jobs {
if protocol == "" || entry.protocol == protocol {
entry.force.Store(true)
s.signal(entry)
}
}
}
func (s *protocolScheduler) loop(key string, entry *registeredJob) {
defer s.wg.Done()
entry.mu.Lock()
if entry.next.IsZero() {
entry.next = entry.state.LastSuccess.Add(entry.spec.Every)
if entry.next.Before(time.Now()) {
entry.next = time.Now().Add(time.Second)
}
}
next := entry.next
entry.mu.Unlock()
timer := time.NewTimer(time.Until(next))
defer timer.Stop()
for {
select {
case <-s.d.backgroundCtx.Done():
return
case <-entry.wake:
entry.mu.Lock()
next = entry.next
entry.mu.Unlock()
if entry.force.Swap(false) {
next = time.Now()
}
case <-timer.C:
_ = s.trigger(s.d.backgroundCtx, key, fetcher.TriggerScheduled)
entry.mu.Lock()
next = entry.next
entry.mu.Unlock()
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(max(time.Until(next), time.Millisecond))
}
}
func (s *protocolScheduler) trigger(ctx context.Context, key string, source fetcher.TriggerSource) error {
entry, ok := s.jobs[key]
if !ok {
return fmt.Errorf("%w: %s", ErrProtocolJobNotFound, key)
}
for {
if err := ctx.Err(); err != nil {
return err
}
s.gate.Lock()
if s.stopped || s.d.closed.Load() {
s.gate.Unlock()
return errors.New("downloader is closed")
}
entry.mu.Lock()
run := entry.active
if run == nil {
run = &protocolJobRun{done: make(chan struct{}), source: source}
entry.active = run
s.wg.Add(1)
go s.execute(key, entry, run, source)
}
entry.mu.Unlock()
s.gate.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-run.done:
// A manual request must still run when the scheduled attempt it
// joined was skipped (for example, automatic refresh is disabled).
if source == fetcher.TriggerManual && run.source == fetcher.TriggerScheduled && errors.Is(run.err, fetcher.ErrJobSkipped) {
continue
}
return run.err
}
}
}
func (s *protocolScheduler) execute(key string, entry *registeredJob, run *protocolJobRun, source fetcher.TriggerSource) {
defer s.wg.Done()
ctx, cancel := context.WithTimeout(s.d.backgroundCtx, entry.spec.Timeout)
defer cancel()
err := func() (err error) {
defer func() {
if v := recover(); v != nil {
err = fmt.Errorf("protocol job panic: %v", v)
}
}()
return entry.spec.Run(ctx, source)
}()
if err == nil {
err = ctx.Err()
}
entry.mu.Lock()
switch {
case err == nil:
entry.state.LastSuccess = time.Now().UTC()
entry.state.Failures = 0
case errors.Is(err, fetcher.ErrJobSkipped), errors.Is(err, fetcher.ErrConfigChanged), errors.Is(err, context.Canceled):
default:
entry.state.Failures++
}
if storeErr := s.d.storage.Put(bucketProtocolJobs, key, entry.state); storeErr != nil {
err = errors.Join(err, storeErr)
}
switch {
case err == nil:
entry.next = entry.state.LastSuccess.Add(entry.spec.Every)
case errors.Is(err, fetcher.ErrJobSkipped):
entry.next = time.Now().Add(entry.spec.Every)
case errors.Is(err, fetcher.ErrConfigChanged):
entry.next = time.Now().Add(time.Second)
default:
retry := time.Minute * time.Duration(1<<min(max(entry.state.Failures-1, 0), 6))
entry.next = time.Now().Add(retry)
}
run.err = err
entry.active = nil
close(run.done)
entry.mu.Unlock()
if err != nil && !errors.Is(err, fetcher.ErrJobSkipped) && !errors.Is(err, context.Canceled) {
s.d.Logger.Warn().Err(err).Str("job", key).Str("source", string(source)).Msg("protocol job failed")
}
if source == fetcher.TriggerManual {
s.signal(entry)
}
}
func (s *protocolScheduler) close() {
s.gate.Lock()
s.stopped = true
s.gate.Unlock()
s.wg.Wait()
}
func (d *Downloader) TriggerProtocolJob(ctx context.Context, protocol, name string) error {
if d.protocolJobs == nil {
return ErrProtocolJobNotFound
}
err := d.protocolJobs.trigger(ctx, protocol+"/"+name, fetcher.TriggerManual)
if errors.Is(err, fetcher.ErrJobSkipped) {
return nil
}
return err
}
+89
View File
@@ -0,0 +1,89 @@
package download
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
type jobWaitContext struct {
context.Context
waiting chan struct{}
once sync.Once
}
func (c *jobWaitContext) Done() <-chan struct{} {
c.once.Do(func() { close(c.waiting) })
return c.Context.Done()
}
func TestManualProtocolJobRunsAfterJoinedScheduledSkip(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
var manualCalls atomic.Int32
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second,
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
if source == fetcher.TriggerManual {
manualCalls.Add(1)
return nil
}
close(entered)
select {
case <-release:
return fetcher.ErrJobSkipped
case <-ctx.Done():
return ctx.Err()
}
},
}}
d := testBackendDownloader(t, manager)
scheduled := make(chan error, 1)
go func() {
scheduled <- d.protocolJobs.trigger(context.Background(), "test/refresh", fetcher.TriggerScheduled)
}()
<-entered
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
waiter := &jobWaitContext{Context: ctx, waiting: make(chan struct{})}
manual := make(chan error, 1)
go func() { manual <- d.TriggerProtocolJob(waiter, "test", "refresh") }()
<-waiter.waiting
close(release)
if err := <-scheduled; !errors.Is(err, fetcher.ErrJobSkipped) {
t.Fatal(err)
}
if err := <-manual; err != nil {
t.Fatal(err)
}
if manualCalls.Load() != 1 {
t.Fatal("manual refresh was swallowed by skipped automatic work")
}
}
func TestManualProtocolJobFailureKeepsScheduledBackoff(t *testing.T) {
var calls atomic.Int32
failure := errors.New("offline")
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second,
Run: func(context.Context, fetcher.TriggerSource) error { calls.Add(1); return failure },
}}
d := testBackendDownloader(t, manager)
if err := d.TriggerProtocolJob(context.Background(), "test", "refresh"); !errors.Is(err, failure) {
t.Fatal(err)
}
entry := d.protocolJobs.jobs["test/refresh"]
entry.mu.Lock()
next := entry.next
entry.mu.Unlock()
if time.Until(next) < 59*time.Second {
t.Fatal("manual failure did not schedule retry backoff")
}
time.Sleep(50 * time.Millisecond)
if calls.Load() != 1 {
t.Fatalf("manual failure immediately retried %d times", calls.Load())
}
}
+340
View File
@@ -0,0 +1,340 @@
package download
import (
"encoding/json"
"errors"
"reflect"
"sync"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
)
// State tests initialize config without starting network clients or cron loops.
func protocolConfigDownloader(t *testing.T, storage Storage, managers ...fetcher.FetcherManager) *Downloader {
t.Helper()
if storage == nil {
storage = NewMemStorage()
}
d := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir(), FetchManagers: managers})
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{}
if err := storage.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.cancelBackground(); _ = storage.Close() })
if err := d.initProtocolConfigs(); err != nil {
t.Fatal(err)
}
return d
}
func protocolObject(t *testing.T, d *Downloader, name string) map[string]any {
t.Helper()
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(cfg.ProtocolConfig[name])
if err != nil {
t.Fatal(err)
}
var result map[string]any
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatal(err)
}
return result
}
func configureBT(t *testing.T, d *Downloader, urls ...string) {
t.Helper()
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["bt"] = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": urls, "customTrackers": []string{"udp://custom.example:80/announce"}}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
}
func seedBTState(t *testing.T, d *Downloader) {
t.Helper()
if err := d.updateProtocolState("bt", func(_, _ json.RawMessage) (any, error) {
return map[string]any{
"trackerSources": map[string][]string{"https://example.com/list": {"udp://cached.example:80/announce"}},
"trackerSubscriptionStatuses": map[string]any{"https://example.com/list": map[string]any{"success": true, "trackerCount": 1, "lastFetchTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC)}},
"lastTrackerUpdateTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC),
}, nil
}); err != nil {
t.Fatal(err)
}
}
func TestFullConfigValidatesProtocolsAndPreservesExplicitValues(t *testing.T) {
d := protocolConfigDownloader(t, nil, &httpProtocol.FetcherManager{}, &btProtocol.FetcherManager{})
configureBT(t, d)
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["http"] = map[string]any{"connections": 0, "useServerCtime": false}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
got := protocolObject(t, d, "http")
if got["connections"] != float64(0) || got["useServerCtime"] != false {
t.Fatal(got)
}
for _, invalid := range []any{map[string]any{"connections": "bad"}, map[string]any{"useServerCtime": "bad"}} {
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["http"] = invalid
if err := d.PutConfig(cfg); err == nil {
t.Fatalf("invalid protocol config accepted: %v", invalid)
}
}
cfg, _ = d.GetConfig()
cfg.DownloadDir = "must-not-publish"
cfg.ProtocolConfig["bt"] = map[string]any{"listenPort": -1}
if err := d.PutConfig(cfg); err == nil {
t.Fatal("invalid BT config accepted")
}
saved, _ := d.GetConfig()
if saved.DownloadDir == "must-not-publish" {
t.Fatal("invalid full PUT partially published")
}
cfg, _ = d.GetConfig()
cfg.ProtocolConfig["unknown"] = map[string]any{"state": map[string]any{"injected": true}}
if err := d.PutConfig(cfg); err == nil {
t.Fatal("unregistered protocol accepted")
}
// Returned configs are detached snapshots.
snapshot, _ := d.GetConfig()
snapshot.ProtocolConfig["http"] = map[string]any{"connections": 999}
if !reflect.DeepEqual(protocolObject(t, d, "http"), got) {
t.Fatal("snapshot changed live config")
}
}
func TestProtocolConfigIgnoresUnknownFields(t *testing.T) {
for _, mode := range []string{"stored", "put"} {
t.Run(mode, func(t *testing.T) {
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["bt"] = map[string]any{
"listenPort": 6881,
"autoUpdateTrackers": false,
"trackers": []string{"udp://obsolete.example:80/announce"},
"state": map[string]any{"obsolete": true},
}
if mode == "stored" {
if err := d.storage.Put(bucketConfig, "config", cfg); err != nil {
t.Fatal(err)
}
var restored base.DownloaderStoreConfig
if exists, err := d.storage.Get(bucketConfig, "config", &restored); err != nil || !exists {
t.Fatalf("load stored config: %v %v", exists, err)
}
d.cfg.DownloaderStoreConfig = &restored
if err := d.initProtocolConfigs(); err != nil {
t.Fatalf("load config containing removed fields: %v", err)
}
} else if err := d.PutConfig(cfg); err != nil {
t.Fatalf("save config containing unknown fields: %v", err)
}
actual := protocolObject(t, d, "bt")
if actual["listenPort"] != float64(6881) || actual["autoUpdateTrackers"] != false {
t.Fatalf("known fields changed: %v", actual)
}
if _, exists := actual["trackers"]; exists {
t.Fatal("removed field retained")
}
if len(actual["customTrackers"].([]any)) != 0 {
t.Fatal("removed trackers migrated into custom trackers")
}
if _, exists := actual["state"].(map[string]any)["obsolete"]; exists {
t.Fatal("unknown state field retained")
}
})
}
}
func TestBTStateSurvivesOmittedNullForgedAndStaleFullConfig(t *testing.T) {
for _, mode := range []string{"omitted", "null", "forged", "wrong-type", "stale", "omitted-protocol", "omitted-timestamp", "case-variant", "unicode-variant"} {
t.Run(mode, func(t *testing.T) {
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
configureBT(t, d, "https://example.com/list")
stale, _ := d.GetConfig()
seedBTState(t, d)
expected := protocolObject(t, d, "bt")["state"]
cfg, _ := d.GetConfig()
bt := protocolObject(t, d, "bt")
switch mode {
case "omitted":
delete(bt, "state")
case "null":
bt["state"] = nil
case "forged":
bt["state"] = map[string]any{"lastTrackerUpdateTime": "2000-01-01T00:00:00Z", "trackerSources": map[string]any{}, "unknown": true}
case "wrong-type":
bt["state"] = "ignore this before decoding"
case "stale":
cfg = stale
bt = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": []string{"https://example.com/list"}, "customTrackers": []string{"udp://custom.example:80/announce"}, "state": map[string]any{}}
case "omitted-protocol":
delete(cfg.ProtocolConfig, "bt")
case "case-variant":
bt["STATE"] = "ignore this alias"
case "unicode-variant":
bt["ſtate"] = "ignore Unicode case folding"
case "omitted-timestamp":
bt["state"] = map[string]any{"trackerSources": map[string]any{}}
}
if mode != "omitted-protocol" {
bt["listenPort"] = 6881
cfg.ProtocolConfig["bt"] = bt
}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
actual := protocolObject(t, d, "bt")
if !reflect.DeepEqual(actual["state"], expected) {
t.Fatalf("backend state replaced: %v", actual)
}
if _, exists := actual["trackers"]; exists {
t.Fatal("derived tracker list persisted")
}
if mode != "omitted-protocol" && actual["listenPort"] != float64(6881) {
t.Fatal("writable config did not change")
}
})
}
}
func TestBTConfigRemovalPrunesOnlyRemovedState(t *testing.T) {
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
configureBT(t, d, "https://example.com/list")
seedBTState(t, d)
configureBT(t, d, "https://new.example/list")
state := protocolObject(t, d, "bt")["state"].(map[string]any)
if len(state["trackerSources"].(map[string]any)) != 0 || len(state["trackerSubscriptionStatuses"].(map[string]any)) != 0 {
t.Fatal("removed subscription retained state")
}
}
type counterState struct {
Count int `json:"count"`
Other int `json:"other"`
}
type counterConfig struct {
fetcher.ConfigState[counterState]
Writable int `json:"writable"`
}
type counterManager struct{ httpProtocol.FetcherManager }
func (*counterManager) Name() string { return "counter" }
func (*counterManager) DefaultConfig() any { return &counterConfig{Writable: 7} }
func counterHost(d *Downloader) fetcher.ProtocolHost {
return fetcher.ProtocolHost{UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
return d.updateProtocolState("counter", change)
}}
}
func TestProtocolUpdateStateCommitsOnlyStateAndUsesLatestConfig(t *testing.T) {
d := protocolConfigDownloader(t, nil, &counterManager{})
host := counterHost(d)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := fetcher.UpdateState[counterConfig, counterState](host, func(cfg *counterConfig, state *counterState) error { cfg.Writable = 999; state.Count++; return nil }); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
actual := protocolObject(t, d, "counter")
if actual["writable"] != float64(7) || actual["state"].(map[string]any)["count"] != float64(20) {
t.Fatal(actual)
}
// Rejecting a callback must not publish its partial mutation.
err := fetcher.UpdateState[counterConfig, counterState](host, func(_ *counterConfig, state *counterState) error { state.Count = 1000; return errors.New("reject") })
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), actual) {
t.Fatal("failed callback published state")
}
if err := d.updateProtocolState("counter", func(_, _ json.RawMessage) (any, error) { return map[string]any{"count": "bad"}, nil }); err == nil {
t.Fatal("invalid state accepted")
}
}
func TestProtocolStateAndFullPutDoNotLoseConcurrentUpdates(t *testing.T) {
d := protocolConfigDownloader(t, nil, &counterManager{})
snapshot, _ := d.GetConfig()
snapshot.ProtocolConfig["counter"] = map[string]any{"writable": 21}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(2)
go func() {
defer wg.Done()
if err := d.PutConfig(snapshot); err != nil {
t.Error(err)
}
}()
go func() {
defer wg.Done()
if err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(cfg *counterConfig, state *counterState) error { state.Count++; return nil }); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
actual := protocolObject(t, d, "counter")
if actual["writable"] != float64(21) || actual["state"].(map[string]any)["count"] != float64(20) {
t.Fatal(actual)
}
}
func TestBTSubscriptionStatusesSurviveStorageRestart(t *testing.T) {
dir := t.TempDir()
storage := NewBoltStorage(dir)
d := protocolConfigDownloader(t, storage, &btProtocol.FetcherManager{})
configureBT(t, d, "https://example.com/list")
seedBTState(t, d)
expected := protocolObject(t, d, "bt")["state"]
if err := storage.Close(); err != nil {
t.Fatal(err)
}
reopened := NewBoltStorage(dir)
if err := reopened.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
defer reopened.Close()
var persisted base.DownloaderStoreConfig
if exists, err := reopened.Get(bucketConfig, "config", &persisted); err != nil || !exists {
t.Fatalf("reload persisted config: %v %v", exists, err)
}
raw, _ := json.Marshal(persisted.ProtocolConfig["bt"])
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(cfg["state"], expected) {
t.Fatalf("state lost after reopening storage: %+v", cfg)
}
}
type rejectedConfigStorage struct{ Storage }
func (*rejectedConfigStorage) Put(string, string, any) error {
return errors.New("storage unavailable")
}
func TestProtocolStateAndPutStorageFailureDoNotPublish(t *testing.T) {
d := protocolConfigDownloader(t, nil, &counterManager{})
before := protocolObject(t, d, "counter")
d.storage = &rejectedConfigStorage{Storage: d.storage}
err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(_ *counterConfig, state *counterState) error { state.Count = 99; return nil })
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
t.Fatal("failed state persistence published a change")
}
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["counter"] = map[string]any{"writable": 99}
if err := d.PutConfig(cfg); err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
t.Fatal("failed config persistence published a change")
}
}
+12 -9
View File
@@ -191,13 +191,16 @@ func ensureScriptExecutable(t *testing.T, scriptPath string) {
}
func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
defaultDownloader.cfg.DownloadDir = ".test_download"
defer func() {
defaultDownloader.Clear()
os.RemoveAll(defaultDownloader.cfg.StorageDir)
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
}()
fn(defaultDownloader)
t.Helper()
// Each test owns its runtime; a closed Downloader cannot be reused.
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := d.Close(); err != nil {
t.Error(err)
}
})
fn(d)
}
+12 -10
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
@@ -858,13 +857,16 @@ func TestWebhook_TriggerOnPause(t *testing.T) {
}
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
defaultDownloader.cfg.DownloadDir = ".test_download"
defer func() {
defaultDownloader.Clear()
os.RemoveAll(defaultDownloader.cfg.StorageDir)
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
}()
fn(defaultDownloader)
t.Helper()
// Each test owns its runtime; a closed Downloader cannot be reused.
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := d.Close(); err != nil {
t.Error(err)
}
})
fn(d)
}
-39
View File
@@ -1,9 +1,7 @@
package rest
import (
"io"
"net/http"
"net/url"
"runtime"
"github.com/GopeedLab/gopeed/pkg/base"
@@ -337,43 +335,6 @@ func UpdateExtension(w http.ResponseWriter, r *http.Request) {
WriteJson(w, model.NewNilResult())
}
func DoProxy(w http.ResponseWriter, r *http.Request) {
target := r.Header.Get("X-Target-Uri")
if target == "" {
writeError(w, "param invalid: X-Target-Uri")
return
}
targetUrl, err := url.Parse(target)
if err != nil {
writeError(w, err.Error())
return
}
r.RequestURI = ""
r.URL = targetUrl
r.Host = targetUrl.Host
r.Header.Del("Cookie")
r.Header.Del("X-Api-Token")
r.Header.Del("X-Target-Uri")
resp, err := http.DefaultClient.Do(r)
if err != nil {
writeError(w, err.Error())
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Set(k, v)
}
}
w.WriteHeader(resp.StatusCode)
buf, err := io.ReadAll(resp.Body)
if err != nil {
writeError(w, err.Error())
return
}
w.Write(buf)
}
func GetStats(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
+9
View File
@@ -0,0 +1,9 @@
package model
type MirrorCandidatesReq struct {
URL string `json:"url"`
}
type MirrorCandidatesResp struct {
URLs []string `json:"urls"`
}
+31 -28
View File
@@ -216,7 +216,6 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
WriteStatusJson(w, response.StatusCode, response.Body)
})
}
r.Path("/api/web/proxy").HandlerFunc(DoProxy)
if startCfg.MCPEnable {
r.Path("/mcp").Handler(mcpserver.NewHandler(Downloader))
} else {
@@ -231,35 +230,39 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
if enableWebAuth {
webSessions = newWebSessionStore()
}
if startCfg.WebEnable {
if enableWebAuth {
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var loginReq model.WebAuth
if ReadJson(r, w, &loginReq) {
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
sessionID, expiresAt, err := webSessions.create()
if err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
http.SetCookie(w, &http.Cookie{
Name: webAuthCookieName,
Value: sessionID,
Path: "/",
MaxAge: int(webAuthSessionTTL.Seconds()),
Expires: expiresAt,
HttpOnly: true,
Secure: requestUsesHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
WriteJson(w, model.NewNilResult())
if enableWebAuth {
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var loginReq model.WebAuth
if ReadJson(r, w, &loginReq) {
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
sessionID, expiresAt, err := webSessions.create()
if err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
http.SetCookie(w, &http.Cookie{
Name: webAuthCookieName,
Value: sessionID,
Path: "/",
MaxAge: int(webAuthSessionTTL.Seconds()),
Expires: expiresAt,
HttpOnly: true,
Secure: requestUsesHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
WriteJson(w, model.NewNilResult())
return
}
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
})
}
}
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
})
}
// Reserve the API namespace before the web file handler, including unknown
// endpoints and unsupported methods on otherwise known API paths.
r.Path("/api").Handler(http.NotFoundHandler())
r.PathPrefix("/api/").Handler(http.NotFoundHandler())
if startCfg.WebEnable {
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
@@ -325,7 +328,7 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
})
server := &http.Server{Handler: handlers.CORS(
handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}),
handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
handlers.AllowedOrigins([]string{"*"}),
handlers.ExposedHeaders([]string{"Mcp-Session-Id"}),
+44 -24
View File
@@ -2,7 +2,6 @@ package rest
import (
"bytes"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
@@ -15,6 +14,7 @@ import (
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/GopeedLab/gopeed/internal/test"
@@ -669,29 +669,49 @@ func TestWebFsEnhance(t *testing.T) {
})
}
func TestDoProxy(t *testing.T) {
doTest(func() {
code, respBody := doHttpRequest0(http.MethodGet, "/api/web/proxy", map[string]string{
"X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md",
}, nil)
if code != http.StatusOK {
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusOK)
}
want := "4ee193b676f1ebb2ad810e016350d52a"
got := fmt.Sprintf("%x", md5.Sum(respBody))
if got != want {
t.Errorf("DoProxy() got = %v, want %v", got, want)
}
})
doTest(func() {
code, _ := doHttpRequest0(http.MethodGet, "/api/web/proxy", map[string]string{
"X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/NOT_FOUND",
}, nil)
if code != http.StatusNotFound {
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusNotFound)
}
})
func TestUnknownAPIPathsReturn404(t *testing.T) {
for _, webEnabled := range []bool{false, true} {
t.Run(fmt.Sprintf("web=%t", webEnabled), func(t *testing.T) {
doTest0(func(cfg *model.StartConfig) {
cfg.WebEnable = webEnabled
cfg.WebFS = fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<html>app</html>")},
"api/static.txt": &fstest.MapFile{Data: []byte("must not serve API paths as files")},
"apiary": &fstest.MapFile{Data: []byte("namespace boundary")},
}
}, func() {
paths := []string{"/api", "/api/", "/api/missing", "/api/v1/missing", "/api/web/missing", "/api/web/login", "/api/static.txt"}
for _, path := range paths {
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodHead} {
code, body := doHttpRequest0(method, path, nil, nil)
if code != http.StatusNotFound {
t.Fatalf("%s %s status %d, want 404: %s", method, path, code, body)
}
if strings.Contains(string(body), "<html>app</html>") {
t.Fatalf("%s %s fell through to web files", method, path)
}
}
}
// Known API routes still run, while unsupported methods use the API fallback.
code, _ := doHttpRequest0(http.MethodGet, "/api/v1/info", nil, nil)
if code != http.StatusOK {
t.Fatalf("known API status %d, want 200", code)
}
code, _ = doHttpRequest0(http.MethodPost, "/api/v1/info", nil, nil)
if code != http.StatusNotFound {
t.Fatalf("unsupported API method status %d, want 404", code)
}
if webEnabled {
for path, want := range map[string]string{"/": "<html>app</html>", "/apiary": "namespace boundary"} {
code, body := doHttpRequest0(http.MethodGet, path, map[string]string{"Accept-Encoding": "identity"}, nil)
if code != http.StatusOK || !strings.Contains(string(body), want) {
t.Fatalf("web file %s status %d body %s", path, code, body)
}
}
}
})
})
}
}
func TestTestWebhook(t *testing.T) {
@@ -68,12 +68,11 @@ The following endpoints are web HTTP infrastructure and are not part of the FFI/
- `/fs/tasks/*`
- `/fs/extensions/*`
- `/api/web/proxy`
- Web static file hosting at `/`
`/fs/tasks` returns files from the Go host machine to a browser over HTTP. Native clients can access local file paths directly and do not need an equivalent API.
`/api/web/proxy` is infrastructure used by the web transport to work around browser CORS restrictions. It is not part of the published core `/api/v1/**` API. Native clients can request the target URL directly with Dio and do not need the Go proxy.
All unmatched `/api` and `/api/**` requests use the common 404 handler before the web file handler.
## 4. Backend Maintenance Model
@@ -182,7 +181,7 @@ The REST adapter is limited to transport responsibilities:
- Convert `http.Request` into a Dispatcher request.
- Write a Dispatcher response back to HTTP.
- Handle API tokens, web login, CORS, compression, and panic recovery.
- Register web-only file, proxy, and static resource routes.
- Register web-only file and static resource routes.
The REST adapter no longer calls the global `rest.Downloader` directly.
@@ -198,7 +197,6 @@ Core API routes
Web-only routes
├── /api/web/login
├── /api/web/proxy
├── /fs/tasks/*
├── /fs/extensions/*
└── /* static Web UI
@@ -416,12 +414,7 @@ The following patterns are prohibited:
### 7.4 Non-business HTTP capabilities
`proxyRequest` is not a Gopeed Core API and belongs in a separate `ExternalHttpClient`:
```text
Native → request the target directly with Dio
Web → /api/web/proxy
```
Tracker subscriptions run in Go. Application update checks and extension store requests run directly in the client. Go supplies mirror candidate URLs through `/api/v1/mirrors/{provider}/candidates`; clients probe and download update packages locally with Dio to retain real download progress. There is no arbitrary external HTTP forwarding API.
Task file access does not belong in `GopeedClient`:
@@ -594,7 +587,7 @@ Flutter converts all of them into `GopeedException`. Components do not handle Di
- Windows, macOS, Linux: startup, InvokeAsync, events, shutdown, and repeated startup.
- Android, iOS: gomobile InvokeAsync callbacks, reverse MethodChannel events, and background/foreground transitions.
- Web: REST, login, proxy, and task-file downloads.
- Web: REST, login, and task-file downloads.
- Native: enabling and disabling REST does not interrupt download tasks.
## 12. Implementation Phases
+13 -5
View File
@@ -208,11 +208,6 @@ Future<void> login(LoginReq loginReq) {
return _parse<void>(() => _request('api/web/login', method: 'POST', data: loginReq.toJson()), null);
}
Future<Response<String>> proxyRequest<T>(String uri, {dynamic data, Options? options}) async {
initDefault();
return _transport!.proxyRequest(uri, data: data, options: options);
}
String join(String path) {
initDefault();
return _transport!.join(path);
@@ -226,3 +221,16 @@ Future<dynamic> forward(
}) async {
return _request(path, method: method, data: data, queryParameters: queryParameters);
}
Future<void> triggerProtocolJob(String protocol, String name) => _parse<void>(
() => _request(
'api/v1/protocols/${Uri.encodeComponent(protocol)}/jobs/${Uri.encodeComponent(name)}/trigger',
method: 'POST',
),
null,
);
Future<List<String>> getMirrorCandidates(String provider, String url) => _parse(
() => _request('api/v1/mirrors/${Uri.encodeComponent(provider)}/candidates', method: 'POST', data: {'url': url}),
(json) => List<String>.from(json['urls'] as List),
);
+30 -45
View File
@@ -1,27 +1,18 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'api.dart';
import 'model/store_extension.dart';
class GopeedSiteApi {
GopeedSiteApi._();
GopeedSiteApi({Dio? client})
: _client =
client ??
Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10)));
static final instance = GopeedSiteApi._();
static const _host = 'gopeed.com';
static final instance = GopeedSiteApi();
final Dio _client;
final _storePages = <String, ({StoreExtensionPage page, DateTime expires})>{};
final _storeRequests = <String, Future<StoreExtensionPage>>{};
Future<List<dynamic>> getReleases({int perPage = 10}) async {
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
if (json is! List<dynamic>) {
throw const FormatException('Invalid releases response');
}
return json;
}
Future<StoreExtensionPage> getExtensions({
int page = 1,
int limit = 20,
@@ -31,14 +22,14 @@ class GopeedSiteApi {
bool forceRefresh = false,
}) async {
final params = {
'view': 'summary',
'page': page.toString(),
'limit': limit.clamp(1, 100).toString(),
'sort': sort.name,
'order': order.name,
'view': 'summary',
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
};
final key = Uri.https(_host, '/api/extensions', params).toString();
final key = Uri(queryParameters: params).toString();
final now = DateTime.now();
_storePages.removeWhere((_, entry) => !now.isBefore(entry.expires));
final cached = _storePages[key];
@@ -58,40 +49,34 @@ class GopeedSiteApi {
}
Future<StoreExtensionPage> _fetchStorePage(Map<String, String> params) async {
final json = await _getJson('/api/extensions', queryParameters: params);
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
final response = await _client.get<Map<String, dynamic>>(
'https://gopeed.com/api/extensions',
queryParameters: params,
);
return StoreExtensionPage.fromJson(response.data!);
}
Future<StoreExtension> getExtension(String id, {String? version}) async {
final json = await _getJsonUri(
Uri(
scheme: 'https',
host: _host,
pathSegments: ['api', 'extensions', id],
queryParameters: {if (version != null && version.isNotEmpty) 'version': version},
),
final uri = Uri(
scheme: 'https',
host: 'gopeed.com',
pathSegments: ['api', 'extensions', id],
queryParameters: {'version': ?version},
);
return StoreExtension.fromJson(json as Map<String, dynamic>);
final response = await _client.get<Map<String, dynamic>>(uri.toString());
return StoreExtension.fromJson(response.data!);
}
Future<dynamic> getReleases() async {
final response = await _client.get<dynamic>(
'https://gopeed.com/api/releases',
queryParameters: {'per_page': 10},
options: Options(followRedirects: false),
);
return response.data;
}
Future<void> reportExtensionInstall(String id) async {
final uri = Uri.https(_host, '/api/extensions/install');
await proxyRequest(
uri.toString(),
data: {'id': id},
options: Options(method: 'POST', contentType: Headers.jsonContentType),
);
}
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
return _getJsonUri(Uri.https(_host, path, queryParameters));
}
Future<dynamic> _getJsonUri(Uri uri) async {
final Response<String> response = await proxyRequest(uri.toString());
if (response.data == null || response.data!.isEmpty) {
throw Exception('Empty response from $uri');
}
return jsonDecode(response.data!);
await _client.post<void>('https://gopeed.com/api/extensions/install', data: {'id': id});
}
}
+43 -26
View File
@@ -8,6 +8,7 @@ class DownloaderConfig {
int maxRunning;
ProtocolConfig protocolConfig = ProtocolConfig();
ExtraConfig extra = ExtraConfig();
GithubMirrorConfig githubMirror = GithubMirrorConfig();
ProxyConfig proxy = ProxyConfig();
WebhookConfig webhook = WebhookConfig();
ScriptConfig script = ScriptConfig();
@@ -98,10 +99,43 @@ class HttpConfig {
Map<String, dynamic> toJson() => _$HttpConfigToJson(this);
}
DateTime? _trackerUpdateTimeFromJson(String? value) =>
value == null || value.startsWith('0001-') ? null : DateTime.parse(value);
@JsonSerializable()
class TrackerSubscriptionStatus {
final bool success;
final int trackerCount;
final DateTime lastFetchTime;
TrackerSubscriptionStatus({required this.success, required this.trackerCount, required this.lastFetchTime});
factory TrackerSubscriptionStatus.fromJson(Map<String, dynamic> json) => _$TrackerSubscriptionStatusFromJson(json);
Map<String, dynamic> toJson() => _$TrackerSubscriptionStatusToJson(this);
}
@JsonSerializable(explicitToJson: true)
class BtState {
Map<String, List<String>> trackerSources;
Map<String, TrackerSubscriptionStatus> trackerSubscriptionStatuses;
@JsonKey(fromJson: _trackerUpdateTimeFromJson)
DateTime? lastTrackerUpdateTime;
BtState({this.trackerSources = const {}, this.trackerSubscriptionStatuses = const {}, this.lastTrackerUpdateTime});
factory BtState.fromJson(Map<String, dynamic>? json) => json == null ? BtState() : _$BtStateFromJson(json);
Map<String, dynamic> toJson() => _$BtStateToJson(this);
}
@JsonSerializable(explicitToJson: true)
class BtConfig {
BtState state = BtState();
List<String> trackerSubscribeUrls;
bool autoUpdateTrackers;
List<String> customTrackers;
int listenPort;
List<String> trackers;
bool seedKeep;
double seedRatio;
int seedTime;
@@ -109,8 +143,10 @@ class BtConfig {
bool disablePex;
BtConfig({
this.trackerSubscribeUrls = const [],
this.autoUpdateTrackers = true,
this.customTrackers = const [],
this.listenPort = 0,
this.trackers = const [],
this.seedKeep = false,
this.seedRatio = 0,
this.seedTime = 0,
@@ -158,9 +194,6 @@ class ExtraConfig {
String analyticsClientId;
List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt();
ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();
ExtraConfig({
this.themeMode = '',
this.themeColor = 'green',
@@ -271,22 +304,6 @@ class ProxyConfig {
Map<String, dynamic> toJson() => _$ProxyConfigToJson(this);
}
@JsonSerializable()
class ExtraConfigBt {
List<String> trackerSubscribeUrls = [];
List<String> subscribeTrackers = [];
bool autoUpdateTrackers = true;
DateTime? lastTrackerUpdateTime;
List<String> customTrackers = [];
ExtraConfigBt();
factory ExtraConfigBt.fromJson(Map<String, dynamic> json) => _$ExtraConfigBtFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
}
enum GithubMirrorType { jsdelivr, ghProxy }
@JsonSerializable()
@@ -304,16 +321,16 @@ class GithubMirror {
}
@JsonSerializable(explicitToJson: true)
class ExtraConfigGithubMirror {
class GithubMirrorConfig {
bool enabled;
List<GithubMirror> mirrors;
ExtraConfigGithubMirror({this.enabled = true, this.mirrors = const []});
GithubMirrorConfig({this.enabled = true, this.mirrors = const []});
factory ExtraConfigGithubMirror.fromJson(Map<String, dynamic>? json) =>
json == null ? ExtraConfigGithubMirror() : _$ExtraConfigGithubMirrorFromJson(json);
factory GithubMirrorConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? GithubMirrorConfig() : _$GithubMirrorConfigFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigGithubMirrorToJson(this);
Map<String, dynamic> toJson() => _$GithubMirrorConfigToJson(this);
}
@JsonSerializable()
@@ -15,6 +15,7 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
)
..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map<String, dynamic>?)
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
..githubMirror = GithubMirrorConfig.fromJson(json['githubMirror'] as Map<String, dynamic>?)
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
@@ -27,6 +28,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
'maxRunning': instance.maxRunning,
'protocolConfig': instance.protocolConfig.toJson(),
'extra': instance.extra.toJson(),
'githubMirror': instance.githubMirror.toJson(),
'proxy': instance.proxy.toJson(),
'webhook': instance.webhook.toJson(),
'script': instance.script.toJson(),
@@ -92,19 +94,56 @@ Map<String, dynamic> _$HttpConfigToJson(HttpConfig instance) => <String, dynamic
'useServerCtime': instance.useServerCtime,
};
TrackerSubscriptionStatus _$TrackerSubscriptionStatusFromJson(Map<String, dynamic> json) => TrackerSubscriptionStatus(
success: json['success'] as bool,
trackerCount: (json['trackerCount'] as num).toInt(),
lastFetchTime: DateTime.parse(json['lastFetchTime'] as String),
);
Map<String, dynamic> _$TrackerSubscriptionStatusToJson(TrackerSubscriptionStatus instance) => <String, dynamic>{
'success': instance.success,
'trackerCount': instance.trackerCount,
'lastFetchTime': instance.lastFetchTime.toIso8601String(),
};
BtState _$BtStateFromJson(Map<String, dynamic> json) => BtState(
trackerSources:
(json['trackerSources'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as List<dynamic>).map((e) => e as String).toList()),
) ??
const {},
trackerSubscriptionStatuses:
(json['trackerSubscriptionStatuses'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, TrackerSubscriptionStatus.fromJson(e as Map<String, dynamic>)),
) ??
const {},
lastTrackerUpdateTime: _trackerUpdateTimeFromJson(json['lastTrackerUpdateTime'] as String?),
);
Map<String, dynamic> _$BtStateToJson(BtState instance) => <String, dynamic>{
'trackerSources': instance.trackerSources,
'trackerSubscriptionStatuses': instance.trackerSubscriptionStatuses.map((k, e) => MapEntry(k, e.toJson())),
'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(),
};
BtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig(
trackerSubscribeUrls: (json['trackerSubscribeUrls'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
autoUpdateTrackers: json['autoUpdateTrackers'] as bool? ?? true,
customTrackers: (json['customTrackers'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,
trackers: (json['trackers'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
seedKeep: json['seedKeep'] as bool? ?? false,
seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0,
seedTime: (json['seedTime'] as num?)?.toInt() ?? 0,
disableDht: json['disableDht'] as bool? ?? false,
disablePex: json['disablePex'] as bool? ?? false,
);
)..state = BtState.fromJson(json['state'] as Map<String, dynamic>?);
Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
'state': instance.state.toJson(),
'trackerSubscribeUrls': instance.trackerSubscribeUrls,
'autoUpdateTrackers': instance.autoUpdateTrackers,
'customTrackers': instance.customTrackers,
'listenPort': instance.listenPort,
'trackers': instance.trackers,
'seedKeep': instance.seedKeep,
'seedRatio': instance.seedRatio,
'seedTime': instance.seedTime,
@@ -128,37 +167,31 @@ Map<String, dynamic> _$Ed2kConfigToJson(Ed2kConfig instance) => <String, dynamic
'nodesDat': instance.nodesDat,
};
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) =>
ExtraConfig(
themeMode: json['themeMode'] as String? ?? '',
themeColor: json['themeColor'] as String? ?? 'green',
locale: json['locale'] as String? ?? '',
lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
desktopNotification: json['desktopNotification'] as bool? ?? true,
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false,
backgroundContinuedProcessing:
json['backgroundContinuedProcessing']
as bool? ??
false,
windowState: json['windowState'] == null
? null
: WindowStateConfig.fromJson(json['windowState'] as Map<String, dynamic>?),
bookmarks: (json['bookmarks'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)) ?? const {},
createHistory: (json['createHistory'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
analyticsClientId: json['analyticsClientId'] as String? ?? '',
downloadCategories:
(json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
)
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)
..githubMirror = ExtraConfigGithubMirror.fromJson(json['githubMirror'] as Map<String, dynamic>?);
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
themeMode: json['themeMode'] as String? ?? '',
themeColor: json['themeColor'] as String? ?? 'green',
locale: json['locale'] as String? ?? '',
lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
desktopNotification: json['desktopNotification'] as bool? ?? true,
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false,
backgroundContinuedProcessing: json['backgroundContinuedProcessing'] as bool? ?? false,
windowState: json['windowState'] == null
? null
: WindowStateConfig.fromJson(json['windowState'] as Map<String, dynamic>?),
bookmarks: (json['bookmarks'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)) ?? const {},
createHistory: (json['createHistory'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
analyticsClientId: json['analyticsClientId'] as String? ?? '',
downloadCategories:
(json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynamic>{
'themeMode': instance.themeMode,
@@ -170,8 +203,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
'desktopNotification': instance.desktopNotification,
'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive,
'backgroundContinuedProcessing':
instance.backgroundContinuedProcessing,
'backgroundContinuedProcessing': instance.backgroundContinuedProcessing,
'windowState': instance.windowState.toJson(),
'bookmarks': instance.bookmarks,
'createHistory': instance.createHistory,
@@ -179,8 +211,6 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
'analyticsEnabled': instance.analyticsEnabled,
'analyticsClientId': instance.analyticsClientId,
'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
'bt': instance.bt.toJson(),
'githubMirror': instance.githubMirror.toJson(),
};
WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
@@ -249,23 +279,6 @@ Map<String, dynamic> _$ProxyConfigToJson(ProxyConfig instance) => <String, dynam
'pwd': instance.pwd,
};
ExtraConfigBt _$ExtraConfigBtFromJson(Map<String, dynamic> json) => ExtraConfigBt()
..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List<dynamic>).map((e) => e as String).toList()
..subscribeTrackers = (json['subscribeTrackers'] as List<dynamic>).map((e) => e as String).toList()
..autoUpdateTrackers = json['autoUpdateTrackers'] as bool
..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null
? null
: DateTime.parse(json['lastTrackerUpdateTime'] as String)
..customTrackers = (json['customTrackers'] as List<dynamic>).map((e) => e as String).toList();
Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) => <String, dynamic>{
'trackerSubscribeUrls': instance.trackerSubscribeUrls,
'subscribeTrackers': instance.subscribeTrackers,
'autoUpdateTrackers': instance.autoUpdateTrackers,
'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(),
'customTrackers': instance.customTrackers,
};
GithubMirror _$GithubMirrorFromJson(Map<String, dynamic> json) => GithubMirror(
type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']),
url: json['url'] as String,
@@ -282,14 +295,14 @@ Map<String, dynamic> _$GithubMirrorToJson(GithubMirror instance) => <String, dyn
const _$GithubMirrorTypeEnumMap = {GithubMirrorType.jsdelivr: 'jsdelivr', GithubMirrorType.ghProxy: 'ghProxy'};
ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(Map<String, dynamic> json) => ExtraConfigGithubMirror(
GithubMirrorConfig _$GithubMirrorConfigFromJson(Map<String, dynamic> json) => GithubMirrorConfig(
enabled: json['enabled'] as bool? ?? true,
mirrors:
(json['mirrors'] as List<dynamic>?)?.map((e) => GithubMirror.fromJson(e as Map<String, dynamic>)).toList() ??
const [],
);
Map<String, dynamic> _$ExtraConfigGithubMirrorToJson(ExtraConfigGithubMirror instance) => <String, dynamic>{
Map<String, dynamic> _$GithubMirrorConfigToJson(GithubMirrorConfig instance) => <String, dynamic>{
'enabled': instance.enabled,
'mirrors': instance.mirrors.map((e) => e.toJson()).toList(),
};
@@ -317,12 +317,7 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
}
Future<void> startUpdate(VersionInfo versionInfo, UpdateProgressCallback onProgress) async {
final runtime = ref.read(appRuntimeControllerProvider).value;
await updateApp(
versionInfo,
githubMirror: runtime?.downloaderConfig.extra.githubMirror ?? ExtraConfigGithubMirror(enabled: false),
onProgress: onProgress,
);
await updateApp(versionInfo, service: ref.read(gopeedServiceProvider), onProgress: onProgress);
}
AppPlatformState _snapshot() => AppPlatformState(
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as path;
@@ -106,14 +105,9 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
),
);
unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive));
unawaited(
ContinuedProcessing.setEnabled(
config.extra.backgroundContinuedProcessing,
),
);
unawaited(ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing));
}
Future<AppRuntimeState> _init() async {
await AppInitializer.ensureStorageInitialized();
try {
@@ -175,7 +169,6 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
..address = config.api.address
..apiToken = config.api.token;
}
unawaited(_initTrackerUpdate(config));
unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale)));
await _initAnalytics(config);
@@ -190,9 +183,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
// Make sure the native Continued Processing manager has the
// persisted setting before app initialization completes. This
// avoids the first download racing ahead of setEnabled(true).
await ContinuedProcessing.setEnabled(
config.extra.backgroundContinuedProcessing,
);
await ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing);
LocationKeepAliveCoordinator.instance.start(
() =>
@@ -271,9 +262,6 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
if (extra.themeColor.isEmpty) {
extra.themeColor = 'green';
}
if (extra.bt.trackerSubscribeUrls.isEmpty) {
extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
}
if (config.proxy.scheme.isEmpty) {
config.proxy.scheme = 'http';
}
@@ -308,26 +296,6 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
),
];
}
if (extra.githubMirror.mirrors.isEmpty) {
extra.githubMirror.mirrors = [
GithubMirror(type: GithubMirrorType.jsdelivr, url: 'https://fastly.jsdelivr.net/gh', isBuiltIn: true),
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://fastgit.cc', isBuiltIn: true),
];
}
}
Future<void> _initTrackerUpdate(DownloaderConfig config) async {
final btExtra = config.extra.bt;
final lastUpdateTime = btExtra.lastTrackerUpdateTime;
if (!btExtra.autoUpdateTrackers ||
(lastUpdateTime != null && !lastUpdateTime.isBefore(DateTime.now().subtract(const Duration(days: 1))))) {
return;
}
try {
await updateTrackers(config);
} catch (error, stackTrace) {
logger.w('tracker update failed', error, stackTrace);
}
}
Future<void> _initAnalytics(DownloaderConfig config) async {
@@ -344,32 +312,12 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
}
}
Future<void> updateTrackers(DownloaderConfig config) async {
final btExtra = config.extra.bt;
final trackers = <String>[];
for (final url in btExtra.trackerSubscribeUrls) {
final resp = await api.proxyRequest(url);
final data = resp.data;
if (resp.statusCode == 200 && data != null && data.isNotEmpty) {
trackers.addAll(const LineSplitter().convert(data).where((line) => line.trim().isNotEmpty));
}
}
btExtra.subscribeTrackers = trackers;
btExtra.lastTrackerUpdateTime = DateTime.now();
config.protocolConfig.bt.trackers = {...btExtra.subscribeTrackers, ...btExtra.customTrackers}.toList();
await ref.read(gopeedServiceProvider).putConfig(config);
final current = state.value;
if (current != null) {
state = AsyncValue.data(
AppRuntimeState(
startConfig: current.startConfig,
apiServerState: current.apiServerState,
downloaderConfig: config,
localBackendStarted: current.localBackendStarted,
startupError: current.startupError,
),
);
Future<void> updateTrackers() async {
final service = ref.read(gopeedServiceProvider);
try {
await service.triggerProtocolJob('bt', 'update-trackers');
} finally {
replaceDownloaderConfig(await service.getConfig());
}
}
@@ -509,17 +457,3 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
return operation;
}
}
const allTrackerSubscribeUrls = [
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt',
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt',
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt',
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt',
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt',
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt',
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt',
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt',
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt',
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt',
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt',
];
@@ -82,6 +82,14 @@ RpcCodecRegistry createAppCapabilityCodecs() {
void _bindGopeed(CapabilityRegistry registry) {
registry
..bind(GopeedMethods.triggerProtocolJob, (p) async {
await api.triggerProtocolJob(p['protocol'] as String, p['name'] as String);
return const RpcUnit();
})
..bind(
GopeedMethods.getMirrorCandidates,
(p) => api.getMirrorCandidates(p['provider'] as String, p['url'] as String),
)
..bind(GopeedMethods.resolve, api.resolve)
..bind(GopeedMethods.createTask, api.createTask)
..bind(GopeedMethods.createTaskBatch, api.createTaskBatch)
@@ -13,6 +13,8 @@ import '../../api/model/update_extension_settings.dart';
import 'capability_rpc.dart';
abstract final class GopeedMethods {
static const triggerProtocolJob = RpcMethod<Map<String, dynamic>, RpcUnit>('gopeed.protocol.job.trigger');
static const getMirrorCandidates = RpcMethod<Map<String, dynamic>, List<String>>('gopeed.mirrors.candidates');
static const resolve = RpcMethod<ResolveTask, ResolveResult>('gopeed.resolve');
static const createTask = RpcMethod<CreateTask, String>('gopeed.task.create');
static const createTaskBatch = RpcMethod<CreateTaskBatch, List<String>>('gopeed.task.createBatch');
@@ -43,6 +45,10 @@ class GopeedService {
final CapabilityInvoker _invoker;
Future<void> triggerProtocolJob(String protocol, String name) async =>
_invoker.invoke(GopeedMethods.triggerProtocolJob, {'protocol': protocol, 'name': name});
Future<List<String>> getMirrorCandidates(String provider, String url) =>
_invoker.invoke(GopeedMethods.getMirrorCandidates, {'provider': provider, 'url': url});
Future<ResolveResult> resolve(ResolveTask request) => _invoker.invoke(GopeedMethods.resolve, request);
Future<String> createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request);
@@ -0,0 +1,147 @@
import 'package:dio/dio.dart';
import 'package:pub_semver/pub_semver.dart';
import '../../api/gopeed_site_api.dart';
class VersionInfo {
const VersionInfo({
required this.version,
required this.changeLog,
required this.releaseUrl,
this.assetSha256 = const {},
});
final String version;
final String changeLog;
final String releaseUrl;
// Populated only from official release metadata, never from a download mirror.
final Map<String, String> assetSha256;
}
/// Query the official GitHub API directly, then the independent website API
/// only if the GitHub request or its response validation fails.
class AppUpdateClient {
AppUpdateClient({Dio? client, GopeedSiteApi? site})
: _client =
client ??
Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30))),
_site = site ?? GopeedSiteApi.instance;
static const releasesUrl = 'https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10';
final Dio _client;
final GopeedSiteApi _site;
Future<List<_Release>> _fetchGithubReleases() async {
final cancel = CancelToken();
try {
final response = await _client
.get<dynamic>(releasesUrl, cancelToken: cancel, options: Options(followRedirects: false))
.timeout(const Duration(seconds: 30));
return _decodeReleases(response.data);
} finally {
cancel.cancel('GitHub release request completed');
}
}
Future<VersionInfo?> check(String currentVersion) async {
final current = _parseVersion(currentVersion);
if (current == null) throw FormatException('Invalid current application version', currentVersion);
List<_Release> releases;
try {
releases = await _fetchGithubReleases();
} catch (_) {
releases = _decodeReleases(await _site.getReleases());
}
_Release? selected;
for (final release in releases) {
final version = release.version;
if (release.draft || version.compareTo(current) <= 0) continue;
final preview = release.prerelease || version.isPreRelease;
if (!current.isPreRelease && preview) continue;
if (current.isPreRelease &&
preview &&
(version.major != current.major || version.minor != current.minor || version.patch != current.patch)) {
continue;
}
if (selected == null || version.compareTo(selected.version) > 0) selected = release;
}
if (selected == null) return null;
return VersionInfo(
version: selected.tag.replaceFirst(RegExp(r'^v'), ''),
changeLog: selected.body,
assetSha256: Map.unmodifiable(selected.assetSha256),
releaseUrl: selected.url.isEmpty
? 'https://github.com/GopeedLab/gopeed/releases/tag/${selected.tag}'
: selected.url,
);
}
}
// Build metadata does not affect SemVer precedence.
Version? _parseVersion(String raw) {
try {
final version = Version.parse(raw.replaceFirst(RegExp(r'^v'), ''));
return Version(version.major, version.minor, version.patch, pre: version.preRelease.join('.'));
} on FormatException {
return null;
}
}
List<_Release> _decodeReleases(dynamic data) {
if (data is! List) throw const FormatException('Invalid release list');
final releases = <_Release>[];
for (final entry in data) {
if (entry is! Map || entry['tag_name'] is! String) continue;
final tag = entry['tag_name'] as String;
final version = _parseVersion(tag);
if (version == null) continue;
releases.add(
_Release(
tag: tag,
version: version,
draft: entry['draft'] == true,
prerelease: entry['prerelease'] == true,
body: entry['body'] as String? ?? '',
url: entry['html_url'] as String? ?? '',
assetSha256: _decodeAssetSha256(entry['assets']),
),
);
}
if (data.isNotEmpty && releases.isEmpty) throw const FormatException('Response contains no valid releases');
return releases;
}
Map<String, String> _decodeAssetSha256(dynamic assets) {
if (assets == null) return {};
if (assets is! List) throw const FormatException('Invalid release assets');
final result = <String, String>{};
for (final asset in assets) {
if (asset is! Map || asset['name'] is! String || asset['digest'] is! String) continue;
final digest = asset['digest'] as String;
final match = RegExp(r'^sha256:([a-fA-F0-9]{64})$').firstMatch(digest);
if (match == null || match.end != digest.length) continue;
final name = asset['name'] as String;
if (result.containsKey(name)) throw const FormatException('Duplicate release asset');
result[name] = match.group(1)!.toLowerCase();
}
return result;
}
class _Release {
const _Release({
required this.tag,
required this.version,
required this.draft,
required this.prerelease,
required this.body,
required this.url,
required this.assetSha256,
});
final String tag;
final Version version;
final bool draft;
final bool prerelease;
final String body;
final String url;
final Map<String, String> assetSha256;
}
@@ -0,0 +1,92 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
class DownloadChecksumException implements Exception {
const DownloadChecksumException();
@override
String toString() => 'Update package checksum verification failed';
}
/// Probes backend-provided URLs concurrently, then downloads the fastest usable
/// candidate. Other candidates remain available if HEAD is unsupported or the
/// actual download fails. This layer has no GitHub URL or mirror-config rules.
Future<void> downloadFromCandidates(
List<String> candidates,
String savePath, {
required ProgressCallback onReceiveProgress,
required String expectedSha256,
Dio? client,
Duration probeTimeout = const Duration(seconds: 3),
}) async {
// Reject missing or malformed trusted metadata before any network request.
if (expectedSha256.length != 64 || !RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(expectedSha256)) {
throw const DownloadChecksumException();
}
final urls = candidates.toSet().toList(growable: false);
if (urls.isEmpty) throw ArgumentError.value(candidates, 'candidates', 'No download candidates');
final dio = client ?? Dio(BaseOptions(connectTimeout: const Duration(seconds: 10)));
try {
final probes = await Future.wait(
urls.map((url) async {
final watch = Stopwatch()..start();
final cancel = CancelToken();
try {
final response = await dio
.head<void>(
url,
cancelToken: cancel,
options: Options(receiveTimeout: probeTimeout, validateStatus: (status) => status == 200),
)
.timeout(probeTimeout);
if (_isPackageResponse(response.headers)) return (url: url, latency: watch.elapsed);
} catch (_) {
// HEAD failure does not prove that a normal GET will fail.
} finally {
cancel.cancel();
}
return (url: url, latency: null);
}),
);
final available = probes.where((probe) => probe.latency != null).toList()
..sort((a, b) => a.latency!.compareTo(b.latency!));
final ordered = [
...available.map((probe) => probe.url),
...probes.where((p) => p.latency == null).map((p) => p.url),
];
Object? lastError;
StackTrace? lastStack;
for (final url in ordered) {
try {
onReceiveProgress(0, 0);
final response = await dio.download(
url,
savePath,
onReceiveProgress: onReceiveProgress,
deleteOnError: true,
options: Options(receiveTimeout: const Duration(seconds: 30), validateStatus: (status) => status == 200),
);
if (!_isPackageResponse(response.headers) || await File(savePath).length() == 0) {
throw StateError('Invalid download response from $url');
}
final actual = await sha256.bind(File(savePath).openRead()).first;
if (actual.toString() != expectedSha256.toLowerCase()) throw const DownloadChecksumException();
return;
} catch (error, stack) {
lastError = error;
lastStack = stack;
final file = File(savePath);
if (await file.exists()) await file.delete();
}
}
Error.throwWithStackTrace(lastError!, lastStack!);
} finally {
if (client == null) dio.close(force: true);
}
}
bool _isPackageResponse(Headers headers) {
final contentType = headers.value(Headers.contentTypeHeader)?.split(';').first.trim().toLowerCase();
return contentType != 'text/html' && contentType != 'application/xhtml+xml' && contentType != 'application/json';
}
@@ -1,7 +1,5 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../../util/util.dart';
import '../../../ffi/libgopeed_worker.dart';
import '../../../libgopeed_boot.dart';
@@ -10,17 +8,6 @@ import '../gopeed_transport.dart';
GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport();
class NativeGopeedTransport implements GopeedTransport {
NativeGopeedTransport()
: _externalDio = Dio(
BaseOptions(
contentType: Headers.jsonContentType,
sendTimeout: const Duration(seconds: 5),
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 120),
),
);
final Dio _externalDio;
final LibgopeedWorker? _ffiWorker = Util.isDesktop() ? LibgopeedWorker() : null;
@override
@@ -45,15 +32,6 @@ class NativeGopeedTransport implements GopeedTransport {
return jsonDecode(payload);
}
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
return _externalDio.request<String>(
uri,
data: data,
options: (options ?? Options()).copyWith(responseType: ResponseType.plain),
);
}
@override
String join(String path) => path;
}
@@ -8,8 +8,7 @@ class RestGopeedTransport implements GopeedTransport {
RestGopeedTransport(GopeedTransportConfig config)
: _dio = Dio(
BaseOptions(
// Keep release requests relative so deployments under a reverse
// proxy path continue to work, matching the legacy Web client.
// Keep requests relative for deployments under a reverse proxy path.
baseUrl: webTransportBaseUrl(config.address, Uri.base),
contentType: Headers.jsonContentType,
sendTimeout: const Duration(seconds: 5),
@@ -53,18 +52,6 @@ class RestGopeedTransport implements GopeedTransport {
return response.data;
}
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
options ??= Options();
options.headers ??= {};
options.headers!['X-Target-Uri'] = uri;
return _dio.request<String>(
'/api/web/proxy?t=${DateTime.now().millisecondsSinceEpoch}',
data: data,
options: options.copyWith(responseType: ResponseType.plain),
);
}
@override
String join(String path) {
final baseUrl = _dio.options.baseUrl;
@@ -1,4 +1,3 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'entry/gopeed_transport_stub.dart'
@@ -60,8 +59,6 @@ String normalizeWebApiBaseUrl(String address, Uri pageUri) {
abstract interface class GopeedTransport {
Future<dynamic> request(String path, {String method = 'GET', dynamic data, Map<String, dynamic>? queryParameters});
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options});
String join(String path);
}
@@ -0,0 +1,62 @@
import 'dart:async';
import 'package:dio/dio.dart';
import '../capabilities/gopeed_capability.dart';
/// Mirror requests use backend-generated candidates, without forwarding the
/// Downloader's authentication headers to resource providers or their mirrors.
class MirrorClient {
MirrorClient({
required GopeedService service,
required this.provider,
Dio? client,
this.timeout = const Duration(seconds: 30),
}) : _service = service,
_client = client ?? Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: timeout));
final String provider;
final GopeedService _service;
final Dio _client;
final Duration timeout;
/// The first complete response accepted by [decode] wins. HTTP 200 alone is
/// insufficient: a mirror's HTML error page must not hide a usable response.
Future<T> getJson<T>(String url, {required T Function(dynamic) decode}) async {
final urls = (await _service.getMirrorCandidates(provider, url)).toSet();
if (urls.isEmpty) throw StateError('No mirror candidates');
final result = Completer<T>();
final tokens = <CancelToken>[];
var pending = urls.length;
Future<void> attempt(String candidate, CancelToken token) async {
try {
final response = await _client
.get<dynamic>(candidate, cancelToken: token)
.timeout(
timeout,
onTimeout: () {
token.cancel('Mirror candidate timed out');
throw TimeoutException('Mirror candidate timed out', timeout);
},
);
final value = decode(response.data);
if (!result.isCompleted) result.complete(value);
} catch (error, stack) {
if (--pending == 0 && !result.isCompleted) result.completeError(error, stack);
}
}
for (final candidate in urls) {
final token = CancelToken();
tokens.add(token);
unawaited(attempt(candidate, token));
}
try {
return await result.future;
} finally {
for (final token in tokens) {
token.cancel('Mirror candidate race completed');
}
}
}
}
@@ -20,29 +20,55 @@ class SettingsState {
}
class SettingsController extends AsyncNotifier<SettingsState> {
Future<void> _saveTail = Future<void>.value();
int _saveRevision = 0;
@override
Future<SettingsState> build() async {
await ref.read(appRuntimeControllerProvider.future);
return SettingsState(config: await ref.read(gopeedServiceProvider).getConfig());
final config = await ref.read(gopeedServiceProvider).getConfig();
return SettingsState(config: config);
}
Future<void> reload({bool showLoading = true}) async {
if (showLoading) state = const AsyncValue.loading();
state = AsyncValue.data(SettingsState(config: await ref.read(gopeedServiceProvider).getConfig()));
final config = await ref.read(gopeedServiceProvider).getConfig();
state = AsyncValue.data(SettingsState(config: config));
}
Future<void> save(DownloaderConfig config) async {
Future<void> save(DownloaderConfig config) {
final snapshot = DownloaderConfig.fromJson(config.toJson());
final revision = ++_saveRevision;
state = AsyncValue.data(SettingsState(config: config, saving: true));
final latest = await ref.read(gopeedServiceProvider).getConfig();
config.extra
..windowState = latest.extra.windowState
..bookmarks = latest.extra.bookmarks
..createHistory = latest.extra.createHistory
..runAsMenubarApp = latest.extra.runAsMenubarApp
..analyticsClientId = latest.extra.analyticsClientId;
await ref.read(gopeedServiceProvider).putConfig(config);
ref.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(config);
state = AsyncValue.data(SettingsState(config: config));
final keepAlive = ref.keepAlive();
final result = _saveTail.then((_) => _save(snapshot, revision)).whenComplete(keepAlive.close);
// A failed save must not prevent subsequent edits or retries from running.
_saveTail = result.then((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
Future<void> _save(DownloaderConfig config, int revision) async {
try {
final service = ref.read(gopeedServiceProvider);
final latest = await service.getConfig();
config.extra
..windowState = latest.extra.windowState
..bookmarks = latest.extra.bookmarks
..createHistory = latest.extra.createHistory
..runAsMenubarApp = latest.extra.runAsMenubarApp
..analyticsClientId = latest.extra.analyticsClientId;
// The backend preserves protocol-owned results and validates the whole PUT.
await service.putConfig(config);
final saved = await service.getConfig();
if (ref.mounted) {
ref.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(saved);
if (revision == _saveRevision) state = AsyncValue.data(SettingsState(config: saved));
}
} finally {
if (ref.mounted && revision == _saveRevision && state.value?.saving == true) {
state = AsyncValue.data(state.requireValue.copyWith(saving: false));
}
}
}
Future<void> testWebhook(String url) async {
@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show Divider, Icons, Scrollbar, ScrollbarOrientation;
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -43,6 +43,7 @@ import '../widgets/settings_content_frame.dart';
import '../widgets/settings_item.dart';
import '../widgets/settings_list_editor.dart';
import '../widgets/theme_settings_control.dart';
import '../widgets/tracker_subscriptions_control.dart';
enum _SettingsSection { basic, downloads, advanced }
@@ -385,14 +386,10 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
if (Util.isIOS())
SettingsItem(
title: context.l10n.backgroundContinuedProcessing,
subtitle:
context.l10n.backgroundContinuedProcessingDescription,
subtitle: context.l10n.backgroundContinuedProcessingDescription,
child: shad.Switch(
value:
config.extra.backgroundContinuedProcessing,
onChanged: (value) => unawaited(
_setBackgroundContinuedProcessing(value),
),
value: config.extra.backgroundContinuedProcessing,
onChanged: (value) => unawaited(_setBackgroundContinuedProcessing(value)),
),
),
],
@@ -576,32 +573,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
],
),
),
_SettingsBlock(
key: const ValueKey('settings-hls-block'),
title: 'HLS',
child: _SettingsGroup(
children: [
SettingsItem(
title: context.l10n.connections,
child: _NumberSettingControl(
fieldKey: const ValueKey('hls-connections-input'),
controller: _hlsConnectionsController,
min: 1,
max: 256,
),
),
SettingsItem(
title: context.l10n.maxRetries,
child: _NumberSettingControl(
fieldKey: const ValueKey('hls-max-retries-input'),
controller: _hlsMaxRetriesController,
min: 0,
max: 30,
),
),
],
),
),
_SettingsBlock(
title: 'BitTorrent',
child: _SettingsGroup(
@@ -669,12 +640,15 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
],
SettingsItem(
title: context.l10n.subscribeTracker,
child: _TrackerSubscriptionsControl(
selected: config.extra.bt.trackerSubscribeUrls,
autoUpdate: config.extra.bt.autoUpdateTrackers,
lastUpdated: config.extra.bt.lastTrackerUpdateTime,
onChanged: (urls) => _mutateConfig((next) => next.extra.bt.trackerSubscribeUrls = urls),
onAutoUpdateChanged: (value) => _mutateConfig((next) => next.extra.bt.autoUpdateTrackers = value),
stacked: true,
child: TrackerSubscriptionsControl(
urls: config.protocolConfig.bt.trackerSubscribeUrls,
statuses: config.protocolConfig.bt.state.trackerSubscriptionStatuses,
autoUpdate: config.protocolConfig.bt.autoUpdateTrackers,
lastUpdated: config.protocolConfig.bt.state.lastTrackerUpdateTime,
onChanged: (urls) => _mutateConfig((next) => next.protocolConfig.bt.trackerSubscribeUrls = urls),
onAutoUpdateChanged: (value) =>
_mutateConfig((next) => next.protocolConfig.bt.autoUpdateTrackers = value),
onUpdate: _updateTrackers,
),
),
@@ -686,6 +660,32 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
],
),
),
_SettingsBlock(
key: const ValueKey('settings-hls-block'),
title: 'HLS',
child: _SettingsGroup(
children: [
SettingsItem(
title: context.l10n.connections,
child: _NumberSettingControl(
fieldKey: const ValueKey('hls-connections-input'),
controller: _hlsConnectionsController,
min: 1,
max: 256,
),
),
SettingsItem(
title: context.l10n.maxRetries,
child: _NumberSettingControl(
fieldKey: const ValueKey('hls-max-retries-input'),
controller: _hlsMaxRetriesController,
min: 0,
max: 30,
),
),
],
),
),
_SettingsBlock(
title: 'ED2K',
child: _SettingsGroup(
@@ -890,15 +890,15 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
title: context.l10n.githubMirrorEnable,
subtitle: context.l10n.githubMirrorDesc,
child: shad.Switch(
value: config.extra.githubMirror.enabled,
onChanged: (value) => _mutateConfig((next) => next.extra.githubMirror.enabled = value),
value: config.githubMirror.enabled,
onChanged: (value) => _mutateConfig((next) => next.githubMirror.enabled = value),
),
),
SettingsItem(
title: context.l10n.githubMirror,
child: SettingsListEditor(
entries: [
for (final entry in config.extra.githubMirror.mirrors.indexed)
for (final entry in config.githubMirror.mirrors.indexed)
if (!entry.$2.isDeleted)
SettingsListEntry(
id: 'github-mirror-${entry.$1}',
@@ -1014,7 +1014,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_proxyPortController.text = proxyAddress.port;
_proxyUserController.text = _config!.proxy.usr;
_proxyPasswordController.text = _config!.proxy.pwd;
_customTrackersController.text = _config!.extra.bt.customTrackers.join('\n');
_customTrackersController.text = _config!.protocolConfig.bt.customTrackers.join('\n');
_syncingControllers = false;
}
@@ -1143,11 +1143,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
config.proxy.host = _joinHostPort(_proxyHostController.text, _proxyPortController.text);
config.proxy.usr = _proxyUserController.text.trim();
config.proxy.pwd = _proxyPasswordController.text;
config.extra.bt.customTrackers = _lines(_customTrackersController.text);
config.protocolConfig.bt.trackers = {
...config.extra.bt.subscribeTrackers,
...config.extra.bt.customTrackers,
}.toList();
config.protocolConfig.bt.customTrackers = _lines(_customTrackersController.text);
}
void _applyStartControllers(StartConfig config) {
@@ -1156,18 +1152,20 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
config.apiToken = _apiTokenController.text.trim();
}
Future<void> _saveConfig() async {
Future<bool> _saveConfig() async {
final config = _config;
if (config == null) {
return;
return false;
}
_applyTextControllers(config);
final snapshot = DownloaderConfig.fromJson(config.toJson());
_loadedSignature = _signature(snapshot);
try {
await ref.read(settingsControllerProvider.notifier).save(snapshot);
return true;
} catch (error) {
if (mounted) _toast(error.toString());
return false;
}
}
@@ -1311,42 +1309,25 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
_mutateConfig((config) => config.extra.backgroundLocationKeepAlive = enabled);
await LocationKeepAliveCoordinator.instance.reconcile(enabled: enabled);
}
Future<void>
_setBackgroundContinuedProcessing(
bool enabled,
) async {
if (enabled &&
!await ContinuedProcessing.isSupported()) {
Future<void> _setBackgroundContinuedProcessing(bool enabled) async {
if (enabled && !await ContinuedProcessing.isSupported()) {
if (mounted) {
_toast(
context.l10n
.backgroundContinuedProcessingUnsupported,
);
_toast(context.l10n.backgroundContinuedProcessingUnsupported);
}
return;
}
final applied =
await ContinuedProcessing.setEnabled(
enabled,
);
final applied = await ContinuedProcessing.setEnabled(enabled);
if (enabled && !applied) {
if (mounted) {
_toast(
context.l10n
.backgroundContinuedProcessingUnsupported,
);
_toast(context.l10n.backgroundContinuedProcessingUnsupported);
}
return;
}
_mutateConfig(
(config) =>
config.extra
.backgroundContinuedProcessing =
enabled,
);
_mutateConfig((config) => config.extra.backgroundContinuedProcessing = enabled);
}
List<String> _lines(String text) {
@@ -1410,13 +1391,13 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
}
List<GithubMirror> get _visibleGithubMirrors =>
_config?.extra.githubMirror.mirrors.where((mirror) => !mirror.isDeleted).toList(growable: false) ?? const [];
_config?.githubMirror.mirrors.where((mirror) => !mirror.isDeleted).toList(growable: false) ?? const [];
Future<void> _editGithubMirror([GithubMirror? mirror]) async {
final draft = await showGithubMirrorDialog(context, mirror: mirror);
if (draft == null || !mounted) return;
_mutateConfig((config) {
final mirrors = List<GithubMirror>.from(config.extra.githubMirror.mirrors);
final mirrors = List<GithubMirror>.from(config.githubMirror.mirrors);
if (mirror == null) {
mirrors.add(GithubMirror(type: draft.type, url: draft.url));
} else {
@@ -1424,7 +1405,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
if (index < 0) return;
mirrors[index] = GithubMirror(type: draft.type, url: draft.url, isBuiltIn: mirror.isBuiltIn, isDeleted: false);
}
config.extra.githubMirror.mirrors = mirrors;
config.githubMirror.mirrors = mirrors;
});
}
@@ -1443,7 +1424,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
if (mirror.isBuiltIn) {
mirror.isDeleted = true;
} else {
config.extra.githubMirror.mirrors = List<GithubMirror>.from(config.extra.githubMirror.mirrors)..remove(mirror);
config.githubMirror.mirrors = List<GithubMirror>.from(config.githubMirror.mirrors)..remove(mirror);
}
});
}
@@ -1511,8 +1492,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
final config = _config;
if (config == null) return;
await _runAction(() async {
await ref.read(appRuntimeControllerProvider.notifier).updateTrackers(DownloaderConfig.fromJson(config.toJson()));
if (mounted) await ref.read(settingsControllerProvider.notifier).reload(showLoading: false);
if (!await _saveConfig()) return;
try {
await ref.read(appRuntimeControllerProvider.notifier).updateTrackers();
} finally {
if (mounted) await ref.read(settingsControllerProvider.notifier).reload(showLoading: false);
}
});
}
@@ -2153,233 +2138,6 @@ class _BrowserExtensionLinks extends StatelessWidget {
}
}
class _TrackerSubscriptionsControl extends StatefulWidget {
const _TrackerSubscriptionsControl({
required this.selected,
required this.autoUpdate,
required this.lastUpdated,
required this.onChanged,
required this.onAutoUpdateChanged,
required this.onUpdate,
});
final List<String> selected;
final bool autoUpdate;
final DateTime? lastUpdated;
final ValueChanged<List<String>> onChanged;
final ValueChanged<bool> onAutoUpdateChanged;
final Future<void> Function() onUpdate;
@override
State<_TrackerSubscriptionsControl> createState() => _TrackerSubscriptionsControlState();
}
class _TrackerSubscriptionsControlState extends State<_TrackerSubscriptionsControl> {
final _verticalScrollController = ScrollController();
final _horizontalScrollController = ScrollController();
bool _updating = false;
@override
void dispose() {
_verticalScrollController.dispose();
_horizontalScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
final palette = AppPalette.of(context);
final selectedCount = allTrackerSubscribeUrls.where(widget.selected.contains).length;
final allSelected = selectedCount == allTrackerSubscribeUrls.length;
final selectAllState = allSelected
? shad.CheckboxState.checked
: selectedCount == 0
? shad.CheckboxState.unchecked
: shad.CheckboxState.indeterminate;
return SizedBox(
width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 210,
decoration: BoxDecoration(
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
child: Column(
children: [
GestureDetector(
key: const ValueKey('tracker-select-all'),
behavior: HitTestBehavior.opaque,
onTap: () => _toggleAll(allSelected),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
shad.Checkbox(state: selectAllState, onChanged: (_) => _toggleAll(allSelected)),
const SizedBox(width: AppDesignTokens.checkboxLabelGap),
Expanded(
child: Text(
context.l10n.selectAll,
style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w600),
),
),
Text(
'$selectedCount/${allTrackerSubscribeUrls.length}',
style: TextStyle(color: palette.textMuted, fontSize: 11),
),
],
),
),
),
Divider(height: 1, color: palette.border),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final labelStyle = TextStyle(color: palette.textSecondary, fontSize: 11);
final contentHeight = (constraints.maxHeight - AppDesignTokens.space12).clamp(
0.0,
double.infinity,
);
return Scrollbar(
key: const ValueKey('tracker-horizontal-scrollbar'),
controller: _horizontalScrollController,
thumbVisibility: true,
scrollbarOrientation: ScrollbarOrientation.bottom,
notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(bottom: AppDesignTokens.space12),
child: SingleChildScrollView(
key: const ValueKey('tracker-horizontal-scroll-view'),
controller: _horizontalScrollController,
scrollDirection: Axis.horizontal,
child: SizedBox(
height: contentHeight,
child: Scrollbar(
key: const ValueKey('tracker-vertical-scrollbar'),
controller: _verticalScrollController,
thumbVisibility: true,
scrollbarOrientation: ScrollbarOrientation.right,
notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical,
child: SingleChildScrollView(
key: const ValueKey('tracker-vertical-scroll-view'),
controller: _verticalScrollController,
child: IntrinsicWidth(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, url) in allTrackerSubscribeUrls.indexed) ...[
Builder(
builder: (context) {
final checked = widget.selected.contains(url);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _toggle(url, checked),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
shad.Checkbox(
state: checked
? shad.CheckboxState.checked
: shad.CheckboxState.unchecked,
onChanged: (_) => _toggle(url, checked),
),
const SizedBox(width: AppDesignTokens.checkboxLabelGap),
Text(url, maxLines: 1, style: labelStyle),
],
),
),
);
},
),
if (index != allTrackerSubscribeUrls.length - 1)
Divider(height: 1, color: palette.border),
],
],
),
),
),
),
),
),
),
),
),
);
},
),
),
],
),
),
const SizedBox(height: 10),
Row(
children: [
AppLoadingButton(
key: const ValueKey('tracker-update-button'),
onPressed: _update,
loading: _updating,
icon: const Icon(Icons.refresh, size: 17),
child: Text(context.l10n.newVersionUpdate),
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.updateEveryDay,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: TextStyle(color: palette.textSecondary, fontSize: 12),
),
),
const SizedBox(width: 8),
shad.Switch(value: widget.autoUpdate, onChanged: widget.onAutoUpdateChanged),
],
),
const SizedBox(height: 6),
Text(
widget.lastUpdated == null
? context.l10n.neverUpdated
: context.l10n.lastUpdated(_formatDateTime(widget.lastUpdated!)),
style: TextStyle(color: palette.textMuted, fontSize: 11),
),
],
),
);
}
void _toggle(String url, bool checked) {
final next = List<String>.from(widget.selected);
checked ? next.remove(url) : next.add(url);
widget.onChanged(next);
}
void _toggleAll(bool allSelected) {
widget.onChanged(allSelected ? const [] : List<String>.from(allTrackerSubscribeUrls));
}
Future<void> _update() async {
setState(() => _updating = true);
try {
await widget.onUpdate();
} finally {
if (mounted) setState(() => _updating = false);
}
}
String _formatDateTime(DateTime value) {
String two(int number) => number.toString().padLeft(2, '0');
return '${value.year}-${two(value.month)}-${two(value.day)} ${two(value.hour)}:${two(value.minute)}:${two(value.second)}';
}
}
class _UpdateActionControl extends ConsumerWidget {
const _UpdateActionControl({required this.platform});
@@ -8,6 +8,7 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_loading_button.dart';
import '../../../../util/package_info.dart';
import '../../../../util/updater.dart';
import '../../../../core/network/candidate_download.dart';
import '../../../../util/util.dart';
import '../../../../l10n/l10n.dart';
import 'app_update_notes_viewport.dart';
@@ -57,7 +58,9 @@ Future<void> showAppUpdateDialog(
if (!dialogContext.mounted) return;
setDialogState(() {
updating = false;
errorMessage = dialogContext.l10n.updateFailed;
errorMessage = error is DownloadChecksumException
? dialogContext.l10n.updateVerificationFailed
: dialogContext.l10n.updateFailed;
});
}
}
@@ -137,7 +140,13 @@ Future<void> showAppUpdateDialog(
children: [
Icon(Icons.error_outline, size: 15, color: palette.error),
const SizedBox(width: 6),
Text(errorMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
Flexible(
child: Text(
errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: palette.error, fontSize: 12),
),
),
],
),
],
@@ -12,18 +12,20 @@ class SettingsItem extends StatelessWidget {
this.subtitle,
this.subtitleAction,
this.titleTrailing,
this.stacked = false,
});
final String title;
final String? subtitle;
final Widget? subtitleAction;
final Widget? titleTrailing;
final bool stacked;
final Widget child;
@override
Widget build(BuildContext context) {
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
if (desktop) {
if (desktop && !stacked) {
return LayoutBuilder(
builder: (context, constraints) {
const leadingSpace = 2.0;
@@ -105,7 +105,7 @@ class SettingsListEditor extends StatelessWidget {
children: [
const Icon(Icons.add, size: 17),
const SizedBox(width: AppDesignTokens.space8),
Text(addLabel ?? context.l10n.add),
Flexible(child: Text(addLabel ?? context.l10n.add)),
],
),
),
@@ -0,0 +1,106 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/widgets/app_loading_button.dart';
import 'settings_list_editor.dart';
import 'tracker_subscriptions_schedule.dart';
import 'tracker_subscriptions_table.dart';
class TrackerSubscriptionsControl extends StatefulWidget {
const TrackerSubscriptionsControl({
super.key,
required this.urls,
required this.statuses,
required this.autoUpdate,
required this.lastUpdated,
required this.onChanged,
required this.onAutoUpdateChanged,
required this.onUpdate,
});
final List<String> urls;
final Map<String, TrackerSubscriptionStatus> statuses;
final bool autoUpdate;
final DateTime? lastUpdated;
final ValueChanged<List<String>> onChanged;
final ValueChanged<bool> onAutoUpdateChanged;
final Future<void> Function() onUpdate;
@override
State<TrackerSubscriptionsControl> createState() => _TrackerSubscriptionsControlState();
}
class _TrackerSubscriptionsControlState extends State<TrackerSubscriptionsControl> {
bool _updating = false;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TrackerSubscriptionsSchedule(
lastUpdated: widget.lastUpdated,
autoUpdate: widget.autoUpdate,
onAutoUpdateChanged: widget.onAutoUpdateChanged,
),
const SizedBox(height: AppDesignTokens.space12),
TrackerSubscriptionsTable(urls: widget.urls, statuses: widget.statuses, onDelete: _delete),
const SizedBox(height: AppDesignTokens.space12),
Wrap(
key: const ValueKey('tracker-subscriptions-actions'),
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: AppDesignTokens.space8,
runSpacing: AppDesignTokens.space8,
children: [
AppLoadingButton(
key: const ValueKey('tracker-update-button'),
onPressed: _update,
loading: _updating,
icon: const Icon(Icons.refresh, size: 17),
child: Text(context.l10n.newVersionUpdate),
),
SecondaryButton(
key: const ValueKey('add-tracker-subscription'),
onPressed: _add,
leading: const Icon(Icons.add, size: 17),
child: Text(context.l10n.add),
),
],
),
],
),
);
}
Future<void> _add() async {
final value = await showTextSettingDialog(
context,
title: context.l10n.addTrackerSubscription,
fieldLabel: context.l10n.trackerSubscriptionUrl,
requireHttpUrl: true,
);
if (!mounted || value == null) return;
final next = [...widget.urls, value];
widget.onChanged(next.toSet().toList());
}
Future<void> _delete(int index) async {
final url = widget.urls[index];
if (!await showDeleteSettingsEntryDialog(context, url) || !mounted) return;
widget.onChanged(List<String>.from(widget.urls)..remove(url));
}
Future<void> _update() async {
setState(() => _updating = true);
try {
await widget.onUpdate();
} finally {
if (mounted) setState(() => _updating = false);
}
}
}
@@ -0,0 +1,56 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
class TrackerSubscriptionsSchedule extends StatelessWidget {
const TrackerSubscriptionsSchedule({
super.key,
required this.lastUpdated,
required this.autoUpdate,
required this.onAutoUpdateChanged,
});
final DateTime? lastUpdated;
final bool autoUpdate;
final ValueChanged<bool> onAutoUpdateChanged;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Column(
key: const ValueKey('tracker-subscriptions-schedule'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
key: const ValueKey('tracker-auto-update-control'),
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(context.l10n.updateEveryDay, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
),
const SizedBox(width: AppDesignTokens.space8),
Switch(value: autoUpdate, onChanged: onAutoUpdateChanged),
],
),
if (lastUpdated case final time?) ...[
const SizedBox(height: AppDesignTokens.space4),
Text(
key: const ValueKey('tracker-last-updated'),
context.l10n.lastUpdated(_formatDateTime(time)),
textAlign: TextAlign.end,
style: TextStyle(color: palette.textMuted, fontSize: 11),
),
],
],
);
}
String _formatDateTime(DateTime time) {
final value = time.toLocal();
String two(int number) => number.toString().padLeft(2, '0');
return '${value.year}-${two(value.month)}-${two(value.day)} ${two(value.hour)}:${two(value.minute)}:${two(value.second)}';
}
}
@@ -0,0 +1,258 @@
import 'dart:math' as math;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_tooltip.dart';
class TrackerSubscriptionsTable extends StatefulWidget {
const TrackerSubscriptionsTable({super.key, required this.urls, required this.statuses, required this.onDelete});
final List<String> urls;
final Map<String, TrackerSubscriptionStatus> statuses;
final ValueChanged<int> onDelete;
@override
State<TrackerSubscriptionsTable> createState() => _TrackerSubscriptionsTableState();
}
class _TrackerSubscriptionsTableState extends State<TrackerSubscriptionsTable> {
static const _headerHeight = 40.0;
static const _rowHeight = 48.0;
static const _statusWidth = 110.0;
static const _countWidth = 120.0;
static const _actionsWidth = 80.0;
final _horizontalController = ScrollController();
final _verticalController = ScrollController();
@override
void dispose() {
_horizontalController.dispose();
_verticalController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final height = _headerHeight + _rowHeight * widget.urls.length.clamp(1, 5) + 12;
return Container(
key: const ValueKey('tracker-subscriptions-table'),
height: height,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
child: LayoutBuilder(
builder: (context, constraints) {
final contentWidth = math.max(640.0, constraints.maxWidth);
final hiddenWidth = contentWidth - constraints.maxWidth;
return Scrollbar(
key: const ValueKey('tracker-subscriptions-horizontal-scrollbar'),
controller: _horizontalController,
interactive: true,
thickness: 7,
scrollbarOrientation: ScrollbarOrientation.bottom,
notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SingleChildScrollView(
controller: _horizontalController,
scrollDirection: Axis.horizontal,
physics: const ClampingScrollPhysics(),
child: SizedBox(
width: contentWidth,
child: Column(
children: [
Container(
height: _headerHeight,
decoration: BoxDecoration(
color: palette.sideBg,
border: Border(bottom: BorderSide(color: palette.border)),
),
child: Stack(
children: [
Positioned.fill(
child: Row(
children: [
Expanded(child: _header(context.l10n.trackerSubscriptionUrl)),
SizedBox(
width: _statusWidth,
child: _header(context.l10n.trackerSubscriptionUpdateStatus),
),
SizedBox(
width: _countWidth,
child: _header(context.l10n.trackerSubscriptionTrackerCount),
),
const SizedBox(width: _actionsWidth),
],
),
),
_pinnedActions(
hiddenWidth,
color: palette.sideBg,
child: SizedBox(
key: const ValueKey('tracker-subscriptions-actions-header'),
child: _header(context.l10n.actions),
),
),
],
),
),
Expanded(
child: widget.urls.isEmpty
? Center(
child: Text('—', style: TextStyle(color: palette.textMuted)),
)
: Scrollbar(
controller: _verticalController,
thumbVisibility: true,
notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical,
child: ListView.builder(
key: const ValueKey('tracker-subscriptions-rows'),
controller: _verticalController,
padding: EdgeInsets.zero,
itemExtent: _rowHeight,
itemCount: widget.urls.length,
itemBuilder: (context, index) => _row(index, hiddenWidth),
),
),
),
],
),
),
),
),
);
},
),
);
}
Widget _header(String label) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: AppPalette.of(context).textSecondary, fontSize: 11, fontWeight: FontWeight.w700),
),
);
}
// Keep actions inside the same vertical list while compensating for the
// horizontal viewport offset, so each button stays aligned with its source.
Widget _pinnedActions(double hiddenWidth, {required Color color, required Widget child}) {
final palette = AppPalette.of(context);
return AnimatedBuilder(
animation: _horizontalController,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
border: Border(bottom: BorderSide(color: palette.border)),
),
child: Center(child: child),
),
builder: (context, child) => PositionedDirectional(
top: 0,
bottom: 0,
end:
hiddenWidth - (_horizontalController.hasClients ? _horizontalController.offset.clamp(0.0, hiddenWidth) : 0),
width: _actionsWidth,
child: child!,
),
);
}
Widget _row(int index, double hiddenWidth) {
final palette = AppPalette.of(context);
final url = widget.urls[index];
final status = widget.statuses[url];
final result = status == null
? context.l10n.trackerSubscriptionNotUpdated
: status.success
? context.l10n.trackerSubscriptionFetchSuccess
: context.l10n.trackerSubscriptionFetchFailed;
return DecoratedBox(
key: ValueKey('tracker-subscription-$url'),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: palette.border)),
),
child: Stack(
children: [
Positioned.fill(
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: AppTooltip(
message: url,
child: Text(
url,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 12),
),
),
),
),
SizedBox(
width: _statusWidth,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
result,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: status == null
? palette.textMuted
: status.success
? palette.success
: palette.error,
fontSize: 11,
),
),
),
),
SizedBox(
width: _countWidth,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
status?.trackerCount.toString() ?? '—',
style: TextStyle(color: palette.textSecondary, fontSize: 11),
),
),
),
const SizedBox(width: _actionsWidth),
],
),
),
_pinnedActions(
hiddenWidth,
color: palette.cardBg,
child: AppTooltip(
message: context.l10n.delete,
child: SizedBox.square(
dimension: 44,
child: GhostButton(
key: ValueKey('delete-settings-list-$url'),
density: ButtonDensity.icon,
onPressed: () => widget.onDelete(index),
child: const Icon(Icons.delete_outline, size: 17),
),
),
),
),
],
),
);
}
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "الفئة",
"deleteCategoryTitle": "تحذف الفئة؟",
"updateFailed": "لقد فشل التحديث، حاول مرة أخرى لاحقاً",
"updateVerificationFailed": "فشل التحقق من التحديث. تم إيقاف التثبيت.",
"newVersionAvailable": "ويمكن الحصول على نسخة جديدة",
"releaseNotes": "ما الجديد",
"noReleaseNotes": "لا توجد مذكرات إطلاق",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "التبديل إلى إعدادات المصادقة",
"mcpRequiresTcp": "تتوفر إرشادات ربط الوكيل عندما يكون بروتوكول API هو TCP.",
"emptyTaskCreateHint": "اضغط على {action} لبدء التنزيل",
"emptyStateRetryHint": "اضغط على {action} لإعادة المحاولة"
"emptyStateRetryHint": "اضغط على {action} لإعادة المحاولة",
"addTrackerSubscription": "إضافة اشتراك متتبعات",
"trackerSubscriptionUrl": "رابط الاشتراك",
"trackerSubscriptionFetchSuccess": "نجح",
"trackerSubscriptionFetchFailed": "فشل",
"trackerSubscriptionUpdateStatus": "حالة التحديث",
"trackerSubscriptionTrackerCount": "عدد المتتبعات",
"trackerSubscriptionNotUpdated": "لم يُحدّث بعد",
"actions": "الإجراءات"
}
+10 -1
View File
@@ -452,6 +452,7 @@
"editCategory": "Kateqoriyanı Redaktə Et",
"deleteCategoryTitle": "Kateqoriya silinsin?",
"updateFailed": "Yeniləmə uğursuz oldu. Zəhmət olmasa sonra yenidən cəhd edin",
"updateVerificationFailed": "Yeniləmənin yoxlanması uğursuz oldu. Quraşdırma dayandırıldı.",
"newVersionAvailable": "Yeni versiya mövcuddur",
"releaseNotes": "Yeniliklər",
"noReleaseNotes": "Buraxılış qeydləri mövcud deyil",
@@ -640,5 +641,13 @@
"webApiConfigReadOnlyHelp": "Veb versiyası API parametrlərinin onlayn dəyişdirilməsini dəstəkləmir. Server başlanğıc seçimlərini və ya konfiqurasiya faylını yeniləyin, sonra dəyişikliklərin qüvvəyə minməsi üçün xidməti yenidən başladın.",
"mcpRequiresTcp": "Agent əlaqə təlimatları API protokolu TCP olduqda mövcuddur.",
"emptyTaskCreateHint": "Endirməyə başlamaq üçün {action} düyməsinə klikləyin",
"emptyStateRetryHint": "Yenidən cəhd etmək üçün {action} düyməsinə klikləyin"
"emptyStateRetryHint": "Yenidən cəhd etmək üçün {action} düyməsinə klikləyin",
"addTrackerSubscription": "Tracker abunəliyi əlavə et",
"trackerSubscriptionUrl": "Abunəlik URL-i",
"trackerSubscriptionFetchSuccess": "Uğurlu",
"trackerSubscriptionFetchFailed": "Uğursuz",
"trackerSubscriptionUpdateStatus": "Yeniləmə statusu",
"trackerSubscriptionTrackerCount": "Tracker sayı",
"trackerSubscriptionNotUpdated": "Hələ yenilənməyib",
"actions": "Əməliyyatlar"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Edita categoria",
"deleteCategoryTitle": "Esborro la categoria?",
"updateFailed": "Ha fallat l' actualització. Torneu- ho a provar més tard",
"updateVerificationFailed": "La verificació de l’actualització ha fallat. S’ha aturat la instal·lació.",
"newVersionAvailable": "Hi ha disponible una nova versió",
"releaseNotes": "Quèts nous",
"noReleaseNotes": "No hi ha notes disponibles",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Canvia a la configuració autenticada",
"mcpRequiresTcp": "Les instruccions de connexió d’agents estan disponibles quan el protocol de l’API és TCP.",
"emptyTaskCreateHint": "Fes clic a {action} per començar a baixar",
"emptyStateRetryHint": "Fes clic a {action} per tornar-ho a provar"
"emptyStateRetryHint": "Fes clic a {action} per tornar-ho a provar",
"addTrackerSubscription": "Afegeix una subscripció de trackers",
"trackerSubscriptionUrl": "URL de subscripció",
"trackerSubscriptionFetchSuccess": "Correcte",
"trackerSubscriptionFetchFailed": "Fallida",
"trackerSubscriptionUpdateStatus": "Estat de l’actualització",
"trackerSubscriptionTrackerCount": "Nombre de trackers",
"trackerSubscriptionNotUpdated": "Sense actualitzar",
"actions": "Accions"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Kategorie bearbeiten",
"deleteCategoryTitle": "Kategorie löschen?",
"updateFailed": "Aktualisierung fehlgeschlagen. Bitte versuchen Sie es später noch einmal",
"updateVerificationFailed": "Die Überprüfung des Updates ist fehlgeschlagen. Die Installation wurde gestoppt.",
"newVersionAvailable": "Eine neue Version ist verfügbar",
"releaseNotes": "Was ist neu?",
"noReleaseNotes": "Keine Versionshinweise verfügbar",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Zur authentifizierten Konfiguration wechseln",
"mcpRequiresTcp": "Anweisungen zur Agent-Verbindung sind verfügbar, wenn das API-Protokoll TCP ist.",
"emptyTaskCreateHint": "Klicke auf {action}, um den Download zu starten",
"emptyStateRetryHint": "Klicke auf {action}, um es erneut zu versuchen"
"emptyStateRetryHint": "Klicke auf {action}, um es erneut zu versuchen",
"addTrackerSubscription": "Tracker-Abonnement hinzufügen",
"trackerSubscriptionUrl": "Abonnement-URL",
"trackerSubscriptionFetchSuccess": "Erfolgreich",
"trackerSubscriptionFetchFailed": "Fehlgeschlagen",
"trackerSubscriptionUpdateStatus": "Aktualisierungsstatus",
"trackerSubscriptionTrackerCount": "Tracker-Anzahl",
"trackerSubscriptionNotUpdated": "Noch nicht aktualisiert",
"actions": "Aktionen"
}
+10 -1
View File
@@ -462,6 +462,7 @@
"editCategory": "Edit Category",
"deleteCategoryTitle": "Delete category?",
"updateFailed": "Update failed. Please try again later",
"updateVerificationFailed": "Update verification failed. Installation stopped.",
"newVersionAvailable": "A new version is available",
"releaseNotes": "What’s new",
"noReleaseNotes": "No release notes available",
@@ -666,5 +667,13 @@
"type": "String"
}
}
}
},
"addTrackerSubscription": "Add tracker subscription",
"trackerSubscriptionUrl": "Subscription URL",
"trackerSubscriptionFetchSuccess": "Succeeded",
"trackerSubscriptionFetchFailed": "Failed",
"trackerSubscriptionUpdateStatus": "Update status",
"trackerSubscriptionTrackerCount": "Tracker count",
"trackerSubscriptionNotUpdated": "Not updated",
"actions": "Actions"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Editar la categoría",
"deleteCategoryTitle": "¿Supir la categoría?",
"updateFailed": "Actualizar falló. Por favor, intente de nuevo más tarde",
"updateVerificationFailed": "Falló la verificación de la actualización. Se detuvo la instalación.",
"newVersionAvailable": "Una nueva versión está disponible",
"releaseNotes": "Qué es nuevo",
"noReleaseNotes": "No hay notas de liberación disponibles",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Cambiar a la configuración autenticada",
"mcpRequiresTcp": "Las instrucciones de conexión de agentes están disponibles cuando el protocolo de la API es TCP.",
"emptyTaskCreateHint": "Haz clic en {action} para empezar a descargar",
"emptyStateRetryHint": "Haz clic en {action} para reintentar"
"emptyStateRetryHint": "Haz clic en {action} para reintentar",
"addTrackerSubscription": "Añadir suscripción de trackers",
"trackerSubscriptionUrl": "URL de suscripción",
"trackerSubscriptionFetchSuccess": "Correcto",
"trackerSubscriptionFetchFailed": "Fallido",
"trackerSubscriptionUpdateStatus": "Estado de actualización",
"trackerSubscriptionTrackerCount": "Número de trackers",
"trackerSubscriptionNotUpdated": "Sin actualizar",
"actions": "Acciones"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "ویرایش دسته‌بندی",
"deleteCategoryTitle": "دسته بندی حذفی؟",
"updateFailed": "به روز رسانی شکست خورد لطفا دوباره امتحان کنید",
"updateVerificationFailed": "تأیید به‌روزرسانی ناموفق بود. نصب متوقف شد.",
"newVersionAvailable": "نسخه جدید در دسترس است",
"releaseNotes": "چه چیز جدیدی است",
"noReleaseNotes": "هیچ یادداشت آزادی در دسترس نیست",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "تغییر به پیکربندی احراز هویت",
"mcpRequiresTcp": "راهنمای اتصال عامل زمانی در دسترس است که پروتکل API روی TCP باشد.",
"emptyTaskCreateHint": "برای شروع دانلود روی {action} کلیک کنید",
"emptyStateRetryHint": "برای تلاش مجدد روی {action} کلیک کنید"
"emptyStateRetryHint": "برای تلاش مجدد روی {action} کلیک کنید",
"addTrackerSubscription": "افزودن اشتراک ردیاب",
"trackerSubscriptionUrl": "نشانی اشتراک",
"trackerSubscriptionFetchSuccess": "موفق",
"trackerSubscriptionFetchFailed": "ناموفق",
"trackerSubscriptionUpdateStatus": "وضعیت به‌روزرسانی",
"trackerSubscriptionTrackerCount": "تعداد ردیاب‌ها",
"trackerSubscriptionNotUpdated": "هنوز به‌روزرسانی نشده",
"actions": "عملیات"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Modifier la catégorie",
"deleteCategoryTitle": "Supprimer la catégorie?",
"updateFailed": "La mise à jour a échoué. Veuillez réessayer plus tard",
"updateVerificationFailed": "La vérification de la mise à jour a échoué. L’installation a été arrêtée.",
"newVersionAvailable": "Une nouvelle version est disponible",
"releaseNotes": "Quoi de neuf",
"noReleaseNotes": "Aucune note de sortie disponible",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Passer à la configuration authentifiée",
"mcpRequiresTcp": "Les instructions de connexion des agents sont disponibles lorsque le protocole de l’API est TCP.",
"emptyTaskCreateHint": "Cliquez sur {action} pour commencer le téléchargement",
"emptyStateRetryHint": "Cliquez sur {action} pour réessayer"
"emptyStateRetryHint": "Cliquez sur {action} pour réessayer",
"addTrackerSubscription": "Ajouter un abonnement de trackers",
"trackerSubscriptionUrl": "URL d’abonnement",
"trackerSubscriptionFetchSuccess": "Réussite",
"trackerSubscriptionFetchFailed": "Échec",
"trackerSubscriptionUpdateStatus": "État de mise à jour",
"trackerSubscriptionTrackerCount": "Nombre de trackers",
"trackerSubscriptionNotUpdated": "Pas encore mis à jour",
"actions": "Actions"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Kategória szerkesztése",
"deleteCategoryTitle": "Kategória törlése?",
"updateFailed": "A frissítés nem sikerült. Kérjük, próbálja újra később",
"updateVerificationFailed": "A frissítés ellenőrzése sikertelen. A telepítés leállt.",
"newVersionAvailable": "Új verzió érhető el",
"releaseNotes": "Újdonságok",
"noReleaseNotes": "Nem állnak rendelkezésre kiadási megjegyzések",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Váltás hitelesített konfigurációra",
"mcpRequiresTcp": "Az ügynökcsatlakoztatási útmutató akkor érhető el, ha az API protokollja TCP.",
"emptyTaskCreateHint": "Kattints a {action} gombra a letöltés indításához",
"emptyStateRetryHint": "Kattints a {action} gombra az újrapróbálkozáshoz"
"emptyStateRetryHint": "Kattints a {action} gombra az újrapróbálkozáshoz",
"addTrackerSubscription": "Tracker-feliratkozás hozzáadása",
"trackerSubscriptionUrl": "Feliratkozási URL",
"trackerSubscriptionFetchSuccess": "Sikeres",
"trackerSubscriptionFetchFailed": "Sikertelen",
"trackerSubscriptionUpdateStatus": "Frissítés állapota",
"trackerSubscriptionTrackerCount": "Trackerek száma",
"trackerSubscriptionNotUpdated": "Még nem frissült",
"actions": "Műveletek"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Sunting Kategori",
"deleteCategoryTitle": "Hapus kategori?",
"updateFailed": "Pemutakhiran gagal. Silakan coba lagi nanti",
"updateVerificationFailed": "Verifikasi pembaruan gagal. Instalasi dihentikan.",
"newVersionAvailable": "Versi baru tersedia",
"releaseNotes": "Apa yang baru",
"noReleaseNotes": "Tidak ada catatan rilis yang tersedia",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Beralih ke konfigurasi terautentikasi",
"mcpRequiresTcp": "Petunjuk koneksi agen tersedia saat protokol API menggunakan TCP.",
"emptyTaskCreateHint": "Klik {action} untuk mulai mengunduh",
"emptyStateRetryHint": "Klik {action} untuk mencoba lagi"
"emptyStateRetryHint": "Klik {action} untuk mencoba lagi",
"addTrackerSubscription": "Tambah langganan tracker",
"trackerSubscriptionUrl": "URL langganan",
"trackerSubscriptionFetchSuccess": "Berhasil",
"trackerSubscriptionFetchFailed": "Gagal",
"trackerSubscriptionUpdateStatus": "Status pembaruan",
"trackerSubscriptionTrackerCount": "Jumlah tracker",
"trackerSubscriptionNotUpdated": "Belum diperbarui",
"actions": "Tindakan"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Modifica della categoria",
"deleteCategoryTitle": "Elimina categoria?",
"updateFailed": "Aggiornamento fallito. Si prega di riprovare più tardi",
"updateVerificationFailed": "Verifica dell’aggiornamento non riuscita. Installazione interrotta.",
"newVersionAvailable": "Una nuova versione è disponibile",
"releaseNotes": "Cosa c’è di nuovo",
"noReleaseNotes": "Nessun note di rilascio disponibili",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Passa alla configurazione autenticata",
"mcpRequiresTcp": "Le istruzioni per connettere gli agenti sono disponibili quando il protocollo API è TCP.",
"emptyTaskCreateHint": "Fai clic su {action} per iniziare il download",
"emptyStateRetryHint": "Fai clic su {action} per riprovare"
"emptyStateRetryHint": "Fai clic su {action} per riprovare",
"addTrackerSubscription": "Aggiungi abbonamento tracker",
"trackerSubscriptionUrl": "URL abbonamento",
"trackerSubscriptionFetchSuccess": "Riuscito",
"trackerSubscriptionFetchFailed": "Non riuscito",
"trackerSubscriptionUpdateStatus": "Stato aggiornamento",
"trackerSubscriptionTrackerCount": "Numero di tracker",
"trackerSubscriptionNotUpdated": "Non ancora aggiornato",
"actions": "Azioni"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "編集カテゴリ",
"deleteCategoryTitle": "カテゴリを削除?",
"updateFailed": "更新が失敗しました。 後でもう一度お試しください",
"updateVerificationFailed": "更新パッケージの検証に失敗しました。インストールを中止しました。",
"newVersionAvailable": "新規バージョンが利用可能",
"releaseNotes": "新着情報",
"noReleaseNotes": "リリースノートは使用できません",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "認証付き設定に切り替える",
"mcpRequiresTcp": "API プロトコルが TCP の場合にエージェント接続手順を利用できます。",
"emptyTaskCreateHint": "{action}をクリックしてダウンロードを開始",
"emptyStateRetryHint": "{action}をクリックして再試行"
"emptyStateRetryHint": "{action}をクリックして再試行",
"addTrackerSubscription": "Tracker 購読を追加",
"trackerSubscriptionUrl": "購読 URL",
"trackerSubscriptionFetchSuccess": "成功",
"trackerSubscriptionFetchFailed": "失敗",
"trackerSubscriptionUpdateStatus": "更新状況",
"trackerSubscriptionTrackerCount": "Tracker 数",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "관련 기사",
"deleteCategoryTitle": "범주 삭제?",
"updateFailed": "업데이트 실패. 나중에 다시 시도하십시오",
"updateVerificationFailed": "업데이트 검증에 실패했습니다. 설치를 중단했습니다.",
"newVersionAvailable": "새 버전은 사용할 수 있습니다",
"releaseNotes": "새로운 소식",
"noReleaseNotes": "출시 노트",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "인증 구성으로 전환",
"mcpRequiresTcp": "API 프로토콜이 TCP일 때 에이전트 연결 안내를 사용할 수 있습니다.",
"emptyTaskCreateHint": "{action}을 눌러 다운로드 시작",
"emptyStateRetryHint": "{action}을 눌러 다시 시도"
"emptyStateRetryHint": "{action}을 눌러 다시 시도",
"addTrackerSubscription": "트래커 구독 추가",
"trackerSubscriptionUrl": "구독 URL",
"trackerSubscriptionFetchSuccess": "성공",
"trackerSubscriptionFetchFailed": "실패",
"trackerSubscriptionUpdateStatus": "업데이트 상태",
"trackerSubscriptionTrackerCount": "트래커 수",
"trackerSubscriptionNotUpdated": "아직 업데이트되지 않음",
"actions": "작업"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Edytuj kategorię",
"deleteCategoryTitle": "Skasować kategorię?",
"updateFailed": "Uaktualnienie nie powiodło się. Proszę spróbować ponownie później",
"updateVerificationFailed": "Weryfikacja aktualizacji nie powiodła się. Instalacja została zatrzymana.",
"newVersionAvailable": "Nowa wersja jest dostępna",
"releaseNotes": "Co nowego?",
"noReleaseNotes": "Brak informacji o wydaniu",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Przełącz na konfigurację z uwierzytelnianiem",
"mcpRequiresTcp": "Instrukcje łączenia agentów są dostępne, gdy protokół API to TCP.",
"emptyTaskCreateHint": "Kliknij {action}, aby rozpocząć pobieranie",
"emptyStateRetryHint": "Kliknij {action}, aby spróbować ponownie"
"emptyStateRetryHint": "Kliknij {action}, aby spróbować ponownie",
"addTrackerSubscription": "Dodaj subskrypcję trackerów",
"trackerSubscriptionUrl": "URL subskrypcji",
"trackerSubscriptionFetchSuccess": "Sukces",
"trackerSubscriptionFetchFailed": "Niepowodzenie",
"trackerSubscriptionUpdateStatus": "Stan aktualizacji",
"trackerSubscriptionTrackerCount": "Liczba trackerów",
"trackerSubscriptionNotUpdated": "Jeszcze nie zaktualizowano",
"actions": "Działania"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Editar a Categoria",
"deleteCategoryTitle": "Apagar a categoria?",
"updateFailed": "A actualização falhou. Por favor tente novamente mais tarde",
"updateVerificationFailed": "A verificação da atualização falhou. A instalação foi interrompida.",
"newVersionAvailable": "Uma nova versão está disponível",
"releaseNotes": "O que há de novo",
"noReleaseNotes": "Nenhuma nota de lançamento disponível",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Mudar para a configuração autenticada",
"mcpRequiresTcp": "As instruções de conexão de agentes estão disponíveis quando o protocolo da API é TCP.",
"emptyTaskCreateHint": "Clique em {action} para começar a baixar",
"emptyStateRetryHint": "Clique em {action} para tentar novamente"
"emptyStateRetryHint": "Clique em {action} para tentar novamente",
"addTrackerSubscription": "Adicionar subscrição de trackers",
"trackerSubscriptionUrl": "URL da subscrição",
"trackerSubscriptionFetchSuccess": "Sucesso",
"trackerSubscriptionFetchFailed": "Falha",
"trackerSubscriptionUpdateStatus": "Estado da atualização",
"trackerSubscriptionTrackerCount": "Número de trackers",
"trackerSubscriptionNotUpdated": "Ainda não atualizado",
"actions": "Ações"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Категория редактирования",
"deleteCategoryTitle": "Удалить категорию?",
"updateFailed": "Обновление не удалось. Пожалуйста, попробуйте еще раз позже",
"updateVerificationFailed": "Проверка обновления не удалась. Установка остановлена.",
"newVersionAvailable": "Доступна новая версия",
"releaseNotes": "Что нового",
"noReleaseNotes": "Нет доступных записей о выпуске",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Переключиться на конфигурацию с аутентификацией",
"mcpRequiresTcp": "Инструкции по подключению агентов доступны, когда протокол API — TCP.",
"emptyTaskCreateHint": "Нажмите {action}, чтобы начать загрузку",
"emptyStateRetryHint": "Нажмите {action}, чтобы повторить попытку"
"emptyStateRetryHint": "Нажмите {action}, чтобы повторить попытку",
"addTrackerSubscription": "Добавить подписку на трекеры",
"trackerSubscriptionUrl": "URL подписки",
"trackerSubscriptionFetchSuccess": "Успешно",
"trackerSubscriptionFetchFailed": "Ошибка",
"trackerSubscriptionUpdateStatus": "Статус обновления",
"trackerSubscriptionTrackerCount": "Число трекеров",
"trackerSubscriptionNotUpdated": "Ещё не обновлено",
"actions": "Действия"
}
+10 -1
View File
@@ -461,6 +461,7 @@
"editCategory": "Upraviť kategóriu",
"deleteCategoryTitle": "Odstrániť kategóriu?",
"updateFailed": "Aktualizácia zlyhala. Skúste to znova neskôr",
"updateVerificationFailed": "Overenie aktualizácie zlyhalo. Inštalácia bola zastavená.",
"newVersionAvailable": "Je dostupná nová verzia",
"releaseNotes": "Čo je nové",
"noReleaseNotes": "Nie sú dostupné žiadne poznámky k vydaniu",
@@ -657,5 +658,13 @@
}
}
},
"emptyStateRetryHint": "Kliknutím na {action} skúsite znova"
"emptyStateRetryHint": "Kliknutím na {action} skúsite znova",
"addTrackerSubscription": "Pridať odber trackerov",
"trackerSubscriptionUrl": "URL odberu",
"trackerSubscriptionFetchSuccess": "Úspešné",
"trackerSubscriptionFetchFailed": "Neúspešné",
"trackerSubscriptionUpdateStatus": "Stav aktualizácie",
"trackerSubscriptionTrackerCount": "Počet trackerov",
"trackerSubscriptionNotUpdated": "Zatiaľ neaktualizované",
"actions": "Akcie"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "வகையைத் திருத்துக",
"deleteCategoryTitle": "வகையை நீக்கவா?",
"updateFailed": "புதுப்பித்தல் தோல்வியுற்றது. பின்னர் மீண்டும் முயற்சிக்கவும்",
"updateVerificationFailed": "புதுப்பிப்பைச் சரிபார்க்க முடியவில்லை. நிறுவல் நிறுத்தப்பட்டது.",
"newVersionAvailable": "ஒரு புதிய பதிப்பு கிடைக்கிறது",
"releaseNotes": "புதியது என்ன:",
"noReleaseNotes": "வெளியீட்டு குறிப்புகள் எதுவும் கிடைக்கவில்லை",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "அங்கீகார உள்ளமைவுக்கு மாறு",
"mcpRequiresTcp": "API நெறிமுறை TCP ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்.",
"emptyTaskCreateHint": "பதிவிறக்கத்தைத் தொடங்க {action} ஐ அழுத்தவும்",
"emptyStateRetryHint": "மீண்டும் முயற்சிக்க {action} ஐ அழுத்தவும்"
"emptyStateRetryHint": "மீண்டும் முயற்சிக்க {action} ஐ அழுத்தவும்",
"addTrackerSubscription": "டிராக்கர் சந்தாவைச் சேர்",
"trackerSubscriptionUrl": "சந்தா URL",
"trackerSubscriptionFetchSuccess": "வெற்றி",
"trackerSubscriptionFetchFailed": "தோல்வி",
"trackerSubscriptionUpdateStatus": "புதுப்பிப்பு நிலை",
"trackerSubscriptionTrackerCount": "டிராக்கர் எண்ணிக்கை",
"trackerSubscriptionNotUpdated": "இன்னும் புதுப்பிக்கப்படவில்லை",
"actions": "செயல்கள்"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Edit Kategori",
"deleteCategoryTitle": "Delete kategorisi?",
"updateFailed": "Güncelleme başarısız oldu. Lütfen daha sonra tekrar deneyin",
"updateVerificationFailed": "Güncelleme doğrulanamadı. Kurulum durduruldu.",
"newVersionAvailable": "Yeni bir sürüm mevcut",
"releaseNotes": "Yeni Nedir",
"noReleaseNotes": "Mevcut sürüm notları mevcut değildir",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Kimlik doğrulamalı yapılandırmaya geç",
"mcpRequiresTcp": "Ajan bağlantı yönergeleri API protokolü TCP olduğunda kullanılabilir.",
"emptyTaskCreateHint": "İndirmeye başlamak için {action} düğmesine tıklayın",
"emptyStateRetryHint": "Yeniden denemek için {action} düğmesine tıklayın"
"emptyStateRetryHint": "Yeniden denemek için {action} düğmesine tıklayın",
"addTrackerSubscription": "Tracker aboneliği ekle",
"trackerSubscriptionUrl": "Abonelik URL’si",
"trackerSubscriptionFetchSuccess": "Başarılı",
"trackerSubscriptionFetchFailed": "Başarısız",
"trackerSubscriptionUpdateStatus": "Güncelleme durumu",
"trackerSubscriptionTrackerCount": "Tracker sayısı",
"trackerSubscriptionNotUpdated": "Henüz güncellenmedi",
"actions": "İşlemler"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Редагувати Категорія",
"deleteCategoryTitle": "Категорія Видалити?",
"updateFailed": "Поверніть ще раз",
"updateVerificationFailed": "Не вдалося перевірити оновлення. Встановлення зупинено.",
"newVersionAvailable": "Нова версія доступна",
"releaseNotes": "Що нового",
"noReleaseNotes": "Немає вихідних нот",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Перейти до конфігурації з автентифікацією",
"mcpRequiresTcp": "Інструкції з підключення агентів доступні, коли протокол API — TCP.",
"emptyTaskCreateHint": "Натисніть {action}, щоб почати завантаження",
"emptyStateRetryHint": "Натисніть {action}, щоб спробувати ще раз"
"emptyStateRetryHint": "Натисніть {action}, щоб спробувати ще раз",
"addTrackerSubscription": "Додати підписку на трекери",
"trackerSubscriptionUrl": "URL підписки",
"trackerSubscriptionFetchSuccess": "Успішно",
"trackerSubscriptionFetchFailed": "Помилка",
"trackerSubscriptionUpdateStatus": "Стан оновлення",
"trackerSubscriptionTrackerCount": "Кількість трекерів",
"trackerSubscriptionNotUpdated": "Ще не оновлено",
"actions": "Дії"
}
+10 -1
View File
@@ -438,6 +438,7 @@
"editCategory": "Sửa phân loại",
"deleteCategoryTitle": "Xoá phân loại",
"updateFailed": "Lỗi cập nhật. Xin hãy thử lại sau",
"updateVerificationFailed": "Xác minh bản cập nhật thất bại. Đã dừng cài đặt.",
"newVersionAvailable": "Name",
"releaseNotes": "Có gì mới không?",
"noReleaseNotes": "Không có ghi chú giải thoát",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "Chuyển sang cấu hình có xác thực",
"mcpRequiresTcp": "Hướng dẫn kết nối tác nhân khả dụng khi giao thức API là TCP.",
"emptyTaskCreateHint": "Nhấn {action} để bắt đầu tải xuống",
"emptyStateRetryHint": "Nhấn {action} để thử lại"
"emptyStateRetryHint": "Nhấn {action} để thử lại",
"addTrackerSubscription": "Thêm đăng ký tracker",
"trackerSubscriptionUrl": "URL đăng ký",
"trackerSubscriptionFetchSuccess": "Thành công",
"trackerSubscriptionFetchFailed": "Thất bại",
"trackerSubscriptionUpdateStatus": "Trạng thái cập nhật",
"trackerSubscriptionTrackerCount": "Số tracker",
"trackerSubscriptionNotUpdated": "Chưa cập nhật",
"actions": "Thao tác"
}
+11 -2
View File
@@ -426,7 +426,7 @@
"logDirectoryUnavailable": "日志目录尚未初始化",
"unableOpenExternalLink": "无法打开外部链接",
"neverUpdated": "尚未更新",
"lastUpdated": "上次更新:{time}",
"lastUpdated": "上次更新时间:{time}",
"unableLoadSettings": "无法加载设置",
"fieldRequired": "{field}不能为空",
"addGithubMirror": "添加 GitHub 镜像",
@@ -438,6 +438,7 @@
"editCategory": "编辑分类",
"deleteCategoryTitle": "删除分类?",
"updateFailed": "更新失败,请稍后重试",
"updateVerificationFailed": "更新包校验失败,已停止安装",
"newVersionAvailable": "发现新版本",
"releaseNotes": "更新内容",
"noReleaseNotes": "暂无更新说明",
@@ -456,5 +457,13 @@
"webApiConfigReadOnlyHelp": "Web 版本不支持在线修改 API 配置。请修改服务端启动参数或配置文件,并重启服务后使配置生效。",
"mcpRequiresTcp": "API 通讯协议为 TCP 时才能使用 Agent 接入指引。",
"emptyTaskCreateHint": "点击{action}开始下载",
"emptyStateRetryHint": "点击{action}重新加载"
"emptyStateRetryHint": "点击{action}重新加载",
"addTrackerSubscription": "添加 Tracker 订阅",
"trackerSubscriptionUrl": "订阅地址",
"trackerSubscriptionFetchSuccess": "成功",
"trackerSubscriptionFetchFailed": "失败",
"trackerSubscriptionUpdateStatus": "更新状态",
"trackerSubscriptionTrackerCount": "Tracker 数量",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
}
+11 -2
View File
@@ -426,7 +426,7 @@
"logDirectoryUnavailable": "日誌目錄尚未初始化",
"unableOpenExternalLink": "無法開啟外部連結",
"neverUpdated": "尚未更新",
"lastUpdated": "上次更新:{time}",
"lastUpdated": "上次更新時間:{time}",
"unableLoadSettings": "無法載入設定",
"fieldRequired": "{field}不能為空",
"addGithubMirror": "新增 GitHub 映象",
@@ -438,6 +438,7 @@
"editCategory": "編輯分類",
"deleteCategoryTitle": "刪除分類?",
"updateFailed": "更新失敗,請稍後重試",
"updateVerificationFailed": "更新套件驗證失敗,已停止安裝",
"newVersionAvailable": "發現新版本",
"releaseNotes": "更新內容",
"noReleaseNotes": "暫無更新說明",
@@ -456,5 +457,13 @@
"useMcpApiTokenTemplate": "切換為認證設定",
"mcpRequiresTcp": "API 通訊協定為 TCP 時才能使用 Agent 接入指引。",
"emptyTaskCreateHint": "點擊{action}開始下載",
"emptyStateRetryHint": "點擊{action}重新載入"
"emptyStateRetryHint": "點擊{action}重新載入",
"addTrackerSubscription": "新增 Tracker 訂閱",
"trackerSubscriptionUrl": "訂閱網址",
"trackerSubscriptionFetchSuccess": "成功",
"trackerSubscriptionFetchFailed": "失敗",
"trackerSubscriptionUpdateStatus": "更新狀態",
"trackerSubscriptionTrackerCount": "Tracker 數量",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
}
-50
View File
@@ -1,50 +0,0 @@
import 'package:dio/dio.dart';
import '../api/model/downloader_config.dart';
enum MirrorType { githubSource, githubRelease }
Future<String> githubAutoMirror(String rawUrl, MirrorType type, {required ExtraConfigGithubMirror config}) async {
final mirrorUrls = githubMirrorUrls(rawUrl, type, config: config);
if (mirrorUrls.isEmpty) return rawUrl;
final results = await Future.wait(
mirrorUrls.map((url) async {
final client = Dio(
BaseOptions(connectTimeout: const Duration(seconds: 3), sendTimeout: const Duration(seconds: 3)),
);
final startedAt = DateTime.now();
try {
final response = await client.head<void>(url);
if (response.statusCode == 200) return (url, DateTime.now().difference(startedAt));
} catch (_) {
// A failed mirror probe must never prevent the original download.
} finally {
client.close();
}
return (url, null);
}),
);
final available = results.where((result) => result.$2 != null).toList()
..sort((left, right) => left.$2!.compareTo(right.$2!));
return available.isEmpty ? rawUrl : available.first.$1;
}
List<String> githubMirrorUrls(String rawUrl, MirrorType type, {required ExtraConfigGithubMirror config}) {
if (!config.enabled) return const [];
final urls = <String>[];
for (final mirror in config.mirrors.where((item) => !item.isDeleted)) {
switch (mirror.type) {
case GithubMirrorType.jsdelivr:
if (type != MirrorType.githubSource) continue;
final match = RegExp(r'.*raw\.githubusercontent\.com(/[^/]+)(/[^/]+)/(?:master|main)(/.*)').firstMatch(rawUrl);
if (match != null) {
urls.add('${mirror.url}${match.group(1)}${match.group(2)}${match.group(3)}');
}
case GithubMirrorType.ghProxy:
urls.add('${mirror.url}/$rawUrl');
}
}
return urls;
}
+34 -141
View File
@@ -1,22 +1,20 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:install_plugin/install_plugin.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:url_launcher/url_launcher.dart';
import '../api/api.dart' as api;
import '../api/gopeed_site_api.dart';
import '../api/model/downloader_config.dart';
import '../core/capabilities/gopeed_capability.dart';
import '../core/network/candidate_download.dart';
import '../core/network/app_update_client.dart';
import 'arch/arch.dart';
import 'github_mirror.dart';
import 'log_util.dart';
import 'package_info.dart';
import 'util.dart';
export '../core/network/app_update_client.dart' show VersionInfo;
enum UpdateChannel {
windowsInstaller,
windowsPortable,
@@ -46,17 +44,6 @@ UpdateChannel? get updateChannel {
String get _updaterBinaryName => 'updater${Util.isWindows() ? '.exe' : ''}';
const _releasePageSize = 10;
const _githubReleasesUrl = 'https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=$_releasePageSize';
class VersionInfo {
const VersionInfo({required this.version, required this.changeLog, required this.releaseUrl});
final String version;
final String changeLog;
final String releaseUrl;
}
/// Installs the small native updater bundled with desktop releases.
///
/// Development builds do not necessarily contain this asset. Callers should
@@ -71,91 +58,27 @@ Future<void> installUpdater() async {
);
}
Future<VersionInfo?> checkUpdate() async {
List<dynamic> releases;
try {
final releaseDataStr = (await api.proxyRequest(_githubReleasesUrl)).data;
if (releaseDataStr == null || releaseDataStr.isEmpty) {
throw const FormatException('Empty GitHub releases response');
}
final releaseData = jsonDecode(releaseDataStr);
if (releaseData is! List<dynamic>) {
throw const FormatException('Invalid GitHub releases response');
}
releases = releaseData;
} catch (_) {
releases = await GopeedSiteApi.instance.getReleases(perPage: _releasePageSize);
}
return selectUpdateRelease(releases, appVersion);
Future<VersionInfo?> checkUpdate() => AppUpdateClient().check(appVersion);
/// Package selection belongs to the client, including when the Downloader is remote.
String appUpdateAssetName(String version, UpdateChannel? channel, String architecture) {
final arch = {'ia32': '386', 'x64': 'amd64'}[architecture] ?? architecture;
final prefix = 'Gopeed-v$version';
return switch (channel) {
UpdateChannel.windowsInstaller => '$prefix-windows-$arch.zip',
UpdateChannel.windowsPortable => '$prefix-windows-$arch-portable.zip',
UpdateChannel.macosDmg => '$prefix-macos-$arch.dmg',
UpdateChannel.linuxDeb => '$prefix-linux-$arch.deb',
UpdateChannel.androidApk => switch (architecture) {
'arm' => '$prefix-android-armeabi-v7a.apk',
'arm64' => '$prefix-android-arm64-v8a.apk',
'x64' => '$prefix-android-x86_64.apk',
_ => '$prefix-android.apk',
},
_ => '',
};
}
bool isNewerVersion(String latest, String current) {
try {
return _parseVersion(latest).compareTo(_parseVersion(current)) > 0;
} on FormatException {
return false;
}
}
VersionInfo? selectUpdateRelease(List<dynamic> releases, String currentVersionText) {
late final Version currentVersion;
try {
currentVersion = _parseVersion(currentVersionText);
} on FormatException {
return null;
}
Map<String, dynamic>? selectedRelease;
Version? selectedVersion;
for (final item in releases) {
if (item is! Map) continue;
final release = Map<String, dynamic>.from(item);
if (release['draft'] == true) continue;
final tagName = release['tag_name'];
if (tagName is! String || tagName.isEmpty) continue;
late final Version candidateVersion;
try {
candidateVersion = _parseVersion(tagName);
} on FormatException {
continue;
}
if (candidateVersion.compareTo(currentVersion) <= 0) continue;
final candidateIsPrerelease = release['prerelease'] == true || candidateVersion.isPreRelease;
if (!currentVersion.isPreRelease) {
if (candidateIsPrerelease) continue;
} else if (candidateIsPrerelease && !_hasSameReleaseCore(candidateVersion, currentVersion)) {
// Preview builds graduate through their own beta/rc line before returning
// to stable; they do not jump into the next version's preview line.
continue;
}
if (selectedVersion == null || candidateVersion.compareTo(selectedVersion) > 0) {
selectedRelease = release;
selectedVersion = candidateVersion;
}
}
if (selectedRelease == null || selectedVersion == null) return null;
final tagName = selectedRelease['tag_name'] as String;
return VersionInfo(
version: _versionText(tagName),
changeLog: (selectedRelease['body'] ?? '').toString(),
releaseUrl: (selectedRelease['html_url'] ?? 'https://github.com/GopeedLab/gopeed/releases/tag/$tagName').toString(),
);
}
Version _parseVersion(String version) => Version.parse(_versionText(version));
String _versionText(String version) => version.startsWith('v') ? version.substring(1) : version;
bool _hasSameReleaseCore(Version first, Version second) =>
first.major == second.major && first.minor == second.minor && first.patch == second.patch;
/// Extracts the matching section from Gopeed's bilingual GitHub release notes.
String localizedReleaseNotes(String fullChangeLog, String languageCode) {
final isChinese = languageCode.toLowerCase().startsWith('zh');
@@ -175,23 +98,23 @@ typedef UpdateProgressCallback = void Function(int received, int total);
/// Store-distributed and unsupported channels safely open the release page.
Future<void> updateApp(
VersionInfo versionInfo, {
required ExtraConfigGithubMirror githubMirror,
required GopeedService service,
required UpdateProgressCallback onProgress,
}) async {
final channel = updateChannel;
final assetName = updateAssetName(versionInfo.version, channel: channel);
final assetName = appUpdateAssetName(versionInfo.version, channel, getArch().name);
var assetPath = '';
if (assetName.isNotEmpty) {
final rawUrl = 'https://github.com/GopeedLab/gopeed/releases/download/v${versionInfo.version}/$assetName';
final expectedSha256 = versionInfo.assetSha256[assetName];
if (expectedSha256 == null) throw const DownloadChecksumException();
onProgress(0, 0);
assetPath = path.join((await getTemporaryDirectory()).path, assetName);
final downloadUrl = await githubAutoMirror(rawUrl, MirrorType.githubRelease, config: githubMirror);
final client = Dio();
try {
await client.download(downloadUrl, assetPath, onReceiveProgress: onProgress);
} finally {
client.close();
}
final urls = await service.getMirrorCandidates(
'github',
'https://github.com/GopeedLab/gopeed/releases/download/v${versionInfo.version}/$assetName',
);
await downloadFromCandidates(urls, assetPath, expectedSha256: expectedSha256, onReceiveProgress: onProgress);
}
switch (channel) {
@@ -233,33 +156,3 @@ Future<void> _openRelease(String releaseUrl) async {
final opened = await launchUrl(Uri.parse(releaseUrl), mode: LaunchMode.externalApplication);
if (!opened) throw StateError('Unable to open the release page.');
}
String updateAssetName(String version, {UpdateChannel? channel, Architecture? architecture}) {
final targetChannel = channel ?? updateChannel;
final arch = architecture ?? getArch();
String commonArchName() => switch (arch) {
Architecture.ia32 => '386',
Architecture.x64 => 'amd64',
_ => arch.name,
};
return switch (targetChannel) {
UpdateChannel.windowsInstaller => 'Gopeed-v$version-windows-${commonArchName()}.zip',
UpdateChannel.windowsPortable => 'Gopeed-v$version-windows-${commonArchName()}-portable.zip',
UpdateChannel.macosDmg => 'Gopeed-v$version-macos-${commonArchName()}.dmg',
UpdateChannel.linuxDeb => 'Gopeed-v$version-linux-${commonArchName()}.deb',
UpdateChannel.androidApk => _androidAssetName(version, arch),
_ => '',
};
}
String _androidAssetName(String version, Architecture arch) {
final archName = switch (arch) {
Architecture.arm => 'armeabi-v7a',
Architecture.arm64 => 'arm64-v8a',
Architecture.x64 => 'x86_64',
_ => null,
};
return 'Gopeed-v$version-android${archName == null ? '' : '-$archName'}.apk';
}
+105 -40
View File
@@ -8,71 +8,136 @@ import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
test('summary requests are shared and cached; explicit refresh requests fresh data', () async {
final pending = Completer<Response<String>>();
final transport = _Transport()..onProxy = (_) => pending.future;
late Dio client;
late _SiteAdapter adapter;
late GopeedSiteApi site;
late _Transport transport;
setUp(() {
client = Dio();
adapter = _SiteAdapter();
client.httpClientAdapter = adapter;
site = GopeedSiteApi(client: client);
transport = _Transport();
api.setTransportForTesting(transport);
final site = GopeedSiteApi.instance;
});
tearDown(() {
client.close(force: true);
expect(transport.calls, 0);
for (final request in adapter.requests) {
expect(request.uri.origin, 'https://gopeed.com');
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
}
});
test('install counts are reported directly with JSON and failures remain observable', () async {
await site.reportExtensionInstall('开发者@name');
final request = adapter.requests.single;
expect(request.uri.toString(), 'https://gopeed.com/api/extensions/install');
expect(request.method, 'POST');
expect(request.contentType, Headers.jsonContentType);
expect(request.data, {'id': '开发者@name'});
adapter.statusCode = 503;
await expectLater(site.reportExtensionInstall('test'), throwsA(isA<DioException>()));
});
test('summary requests go directly to the website and remain shared and cached', () async {
final pending = Completer<Map<String, dynamic>>();
final started = Completer<void>();
adapter.onRequest = (_) {
if (!started.isCompleted) started.complete();
return pending.future;
};
final first = site.getExtensions(query: 'cache-test', forceRefresh: true);
final second = site.getExtensions(query: 'cache-test');
expect(transport.urls.length, 1);
expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary');
pending.complete(
_response({
'data': [
{'id': 'test'},
],
}),
);
await started.future;
expect(adapter.requests.length, 1);
final request = adapter.requests.single;
expect(request.method, 'GET');
expect(request.uri.path, '/api/extensions');
expect(request.uri.queryParameters, {
'view': 'summary',
'page': '1',
'limit': '20',
'sort': 'stars',
'order': 'desc',
'q': 'cache-test',
});
pending.complete({
'data': [
{'id': 'test'},
],
});
expect((await first).data.single.hasDetails, isFalse);
await second;
await site.getExtensions(query: 'cache-test');
expect(transport.urls.length, 1);
expect(adapter.requests.length, 1);
await site.getExtensions(query: 'cache-test', forceRefresh: true);
expect(transport.urls.length, 2);
expect(adapter.requests.length, 2);
});
test('failed lists are not cached', () async {
final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline'));
api.setTransportForTesting(transport);
final site = GopeedSiteApi.instance;
await expectLater(site.getExtensions(query: 'error-test'), throwsStateError);
transport.onProxy = (_) async => _response({
adapter.statusCode = 503;
await expectLater(site.getExtensions(query: 'error-test'), throwsA(isA<DioException>()));
adapter.statusCode = 200;
adapter.onRequest = (_) async => {
'data': [
{'id': 'recovered'},
],
});
};
expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered');
expect(transport.urls.length, 2);
expect(adapter.requests.length, 2);
});
test('detail IDs are encoded once and a null README is still loaded detail', () async {
final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null});
api.setTransportForTesting(transport);
final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3');
final uri = Uri.parse(transport.urls.single);
test('detail IDs are encoded once and version remains a query parameter', () async {
adapter.onRequest = (_) async => {'id': '开发者@name', 'readme': null};
final result = await site.getExtension('开发者@name', version: '1.2.3');
final uri = adapter.requests.single.uri;
expect(uri.pathSegments, ['api', 'extensions', '开发者@name']);
expect(uri.queryParameters['version'], '1.2.3');
expect(result.hasDetails, isTrue);
await site.getExtension('literal%2Fname');
expect(adapter.requests.last.uri.pathSegments.last, 'literal%2Fname');
});
}
Response<String> _response(Map<String, dynamic> json) => Response(
requestOptions: RequestOptions(path: '/test'),
data: jsonEncode(json),
statusCode: 200,
);
class _Transport implements GopeedTransport {
final urls = <String>[];
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
int calls = 0;
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
urls.add(uri);
return onProxy(uri);
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
calls++;
throw StateError('Store requests must not use the Downloader transport');
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
String join(String path) => path;
}
class _SiteAdapter implements HttpClientAdapter {
final requests = <RequestOptions>[];
int statusCode = 200;
Future<Map<String, dynamic>> Function(RequestOptions) onRequest = (_) async => {'data': []};
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
) async {
requests.add(options);
return ResponseBody.fromString(
jsonEncode(await onRequest(options)),
statusCode,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}
@@ -0,0 +1,98 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
test('protocol jobs use the common protocol and job name API', () async {
final transport = _Transport();
api.setTransportForTesting(transport);
await LocalAppCapabilities.instance.capabilities.gopeed.triggerProtocolJob('bt', 'update-trackers');
expect(transport.path, 'api/v1/protocols/bt/jobs/update-trackers/trigger');
expect(transport.method, 'POST');
});
test('mirror settings and tracker settings serialize at their backend-owned locations', () {
final config = DownloaderConfig();
config.githubMirror = GithubMirrorConfig(enabled: false, mirrors: []);
config.protocolConfig.bt.customTrackers = ['udp://example.com:80/announce'];
config.protocolConfig.bt.state.trackerSubscriptionStatuses = {
'https://example.com/list': TrackerSubscriptionStatus(
success: true,
trackerCount: 12,
lastFetchTime: DateTime.utc(2026, 10, 19),
),
};
final json = config.toJson();
expect(json['githubMirror'], {'enabled': false, 'mirrors': []});
expect(json['extra'], isNot(contains('githubMirror')));
expect(json['extra'], isNot(contains('bt')));
expect((json['protocolConfig'] as Map)['bt']['customTrackers'], ['udp://example.com:80/announce']);
final restored = DownloaderConfig.fromJson(
json,
).protocolConfig.bt.state.trackerSubscriptionStatuses['https://example.com/list']!;
expect(restored.success, isTrue);
expect(restored.trackerCount, 12);
expect(restored.lastFetchTime, DateTime.utc(2026, 10, 19));
expect(BtState.fromJson({'lastTrackerUpdateTime': '0001-01-01T00:00:00Z'}).lastTrackerUpdateTime, isNull);
});
test('protocol operations survive the shared capability codec', () {
final codecs = createAppCapabilityCodecs();
final payload = {'protocol': 'bt', 'name': 'update-trackers'};
expect(codecs.decode<Map<String, dynamic>>(codecs.encode(payload)), payload);
expect(codecs.decode<Map<String, dynamic>?>(null), isNull);
expect(GopeedMethods.triggerProtocolJob.name, 'gopeed.protocol.job.trigger');
expect(const RpcUnit(), isA<RpcUnit>());
});
test('mirror candidates use provider dispatch and the shared capability codec', () async {
final transport = _Transport();
api.setTransportForTesting(transport);
const url = 'https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip';
final result = await LocalAppCapabilities.instance.capabilities.gopeed.getMirrorCandidates('github', url);
expect(transport.path, 'api/v1/mirrors/github/candidates');
expect(transport.method, 'POST');
expect(transport.data, {'url': url});
expect(result, ['https://example.com/package.dmg']);
final codecs = createAppCapabilityCodecs();
expect(codecs.decode<List<String>>(codecs.encode(result)), result);
expect(GopeedMethods.getMirrorCandidates.name, 'gopeed.mirrors.candidates');
final request = {'provider': 'another-resource', 'url': 'https://example.com/resource'};
expect(codecs.decode<Map<String, dynamic>>(codecs.encode(request)), request);
await LocalAppCapabilities.instance.capabilities.gopeed.getMirrorCandidates('another-resource', request['url']!);
expect(transport.path, 'api/v1/mirrors/another-resource/candidates');
expect(transport.data, {'url': request['url']});
});
}
class _Transport implements GopeedTransport {
String? path;
String? method;
dynamic data;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
this.path = path;
this.method = method;
this.data = data;
return {
'code': 0,
'data': path.endsWith('/trigger')
? null
: {
'urls': ['https://example.com/package.dmg'],
},
};
}
@override
String join(String path) => path;
}
-6
View File
@@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:dio/dio.dart' as dio;
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/create_task.dart';
@@ -77,9 +76,4 @@ class _RecordingTransport implements GopeedTransport {
@override
String join(String path) => path;
@override
Future<dio.Response<String>> proxyRequest(String uri, {dynamic data, dio.Options? options}) {
throw UnimplementedError();
}
}
@@ -0,0 +1,232 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/core/network/app_update_client.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
late Dio dio;
late _Adapter adapter;
late AppUpdateClient updates;
late _RejectTransport transport;
setUp(() {
transport = _RejectTransport();
api.setTransportForTesting(transport);
dio = Dio();
adapter = _Adapter();
dio.httpClientAdapter = adapter;
updates = AppUpdateClient(
client: dio,
site: GopeedSiteApi(client: dio),
);
});
tearDown(() {
dio.close(force: true);
expect(transport.calls, 0);
for (final request in adapter.requests) {
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
}
});
test('update metadata comes directly from the official GitHub API without mirror lookup', () async {
adapter.reply = (_, _) async => _json([
{'tag_name': 'v2.0.0', 'body': 'notes'},
]);
final info = await updates.check('1.0.0');
expect(info!.version, '2.0.0');
expect(info.changeLog, 'notes');
expect(info.releaseUrl, 'https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0');
expect(adapter.requests.single.uri.toString(), AppUpdateClient.releasesUrl);
});
test('release asset hashes are bound to the selected release and exact file name', () async {
final good = 'aB' * 32;
adapter.reply = (_, _) async => _json([
{
'tag_name': 'v1.9.9',
'assets': [
{'name': 'old.zip', 'digest': 'sha256:${'c' * 64}'},
],
},
{
'tag_name': 'v2.0.0',
'assets': [
{'name': 'package.zip', 'digest': 'sha256:$good'},
{'name': 'wrong-algorithm.zip', 'digest': 'md5:${'a' * 32}'},
{'name': 'missing.zip', 'digest': null},
{'name': 'bad-hex.zip', 'digest': 'sha256:${'g' * 64}'},
{'name': 'short.zip', 'digest': 'sha256:1234'},
],
},
]);
final info = (await updates.check('1.0.0'))!;
expect(info.assetSha256, {'package.zip': good.toLowerCase()});
expect(() => info.assetSha256['package.zip'] = 'd' * 64, throwsUnsupportedError);
expect(adapter.requests.single.followRedirects, isFalse);
});
test('the official website fallback preserves asset digests', () async {
final checksum = 'e' * 64;
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
? _json({'message': 'rate limited'}, status: 403)
: _json([
{
'tag_name': 'v2.0.0',
'assets': [
{'name': 'package.dmg', 'digest': 'sha256:$checksum'},
],
},
]);
final info = (await updates.check('1.0.0'))!;
expect(info.assetSha256, {'package.dmg': checksum});
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
});
test('API redirects fail instead of obtaining metadata from another host', () async {
adapter.reply = (_, _) async => ResponseBody.fromString(
'',
302,
headers: {
'location': ['https://mirror.example/releases'],
},
);
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
});
test('website fallback runs once after the official GitHub request fails', () async {
adapter.reply = (request, _) async => switch (request.uri.host) {
'api.github.com' => _json({'message': 'rate limited'}, status: 403),
_ => _json([
{'tag_name': 'v2.0.0'},
]),
};
expect((await updates.check('1.0.0'))!.version, '2.0.0');
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
final website = adapter.requests.where((r) => r.uri.host == 'gopeed.com').single;
expect(website.method, 'GET');
expect(website.uri.toString(), 'https://gopeed.com/api/releases?per_page=10');
});
test('invalid GitHub metadata falls back to valid website metadata', () async {
for (final invalid in [
null,
'<html>error</html>',
{'message': 'error'},
[
{'tag_name': 'invalid'},
],
]) {
adapter.requests.clear();
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
? _json(invalid)
: _json([
{'tag_name': 'v2.0.0'},
]);
expect((await updates.check('1.0.0'))!.version, '2.0.0');
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
}
});
test('valid empty and no-update lists do not fall back', () async {
for (final releases in [
[],
[
{'tag_name': 'v1.0.0'},
],
]) {
adapter.requests.clear();
adapter.reply = (_, _) async => _json(releases);
expect(await updates.check('1.0.0'), isNull);
expect(adapter.requests.single.uri.host, 'api.github.com');
}
});
test('null, objects, invalid releases and failed website responses propagate', () async {
for (final invalid in [
null,
{'message': 'error'},
[
{'tag_name': 'not a version'},
],
]) {
adapter.requests.clear();
adapter.reply = (_, _) async => _json(invalid);
await expectLater(updates.check('1.0.0'), throwsFormatException);
expect(adapter.requests.map((r) => r.uri.host), ['api.github.com', 'gopeed.com']);
}
adapter.reply = (_, _) async => _json({}, status: 503);
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
});
test('SemVer selection preserves stable and same-series preview rules', () async {
var releases = [
{'tag_name': 'v2.1.0-beta.1', 'prerelease': true},
{'tag_name': 'v2.0.0-beta.2', 'prerelease': true},
{'tag_name': 'v2.0.0-rc.1', 'prerelease': true},
{'tag_name': 'v1.9.9'},
{'tag_name': 'v2.0.0', 'draft': true},
];
adapter.reply = (_, _) async => _json(releases);
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-rc.1');
expect((await updates.check('1.9.8'))!.version, '1.9.9');
releases = [
{'tag_name': 'v2.0.0-beta.10'},
{'tag_name': 'v2.0.0-beta.2'},
];
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-beta.10');
releases = [
{'tag_name': 'v2.0.0'},
{'tag_name': 'v2.0.1'},
];
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.1');
releases = [
{'tag_name': 'v2.0.0+build.2'},
];
expect(await updates.check('2.0.0+build.1'), isNull);
await expectLater(updates.check('invalid'), throwsFormatException);
});
}
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
jsonEncode(body),
status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
class _Adapter implements HttpClientAdapter {
final requests = <RequestOptions>[];
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
requests.add(options);
return reply(options, cancelFuture);
}
@override
void close({bool force = false}) {}
}
class _RejectTransport implements GopeedTransport {
int calls = 0;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
calls++;
throw StateError('Update checks must not use the Downloader or mirror API');
}
@override
String join(String path) => path;
}
@@ -0,0 +1,196 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:crypto/crypto.dart';
import 'package:gopeed/core/network/candidate_download.dart';
void main() {
final expectedSha256 = sha256.convert([for (var i = 1; i <= 3; i++) ...List.filled(32768, i)]).toString();
late HttpServer server;
var requests = 0;
late Directory directory;
late String savePath;
late List<String> downloads;
late Future<void> Function(HttpRequest request) handle;
setUp(() async {
directory = await Directory.systemTemp.createTemp('gopeed-update-test-');
savePath = '${directory.path}/package.zip';
downloads = [];
requests = 0;
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) async {
requests++;
if (request.method == 'GET') downloads.add(request.uri.path);
await handle(request);
await request.response.close();
});
});
tearDown(() async {
await server.close(force: true);
await directory.delete(recursive: true);
});
String url(String path) => 'http://127.0.0.1:${server.port}$path';
Future<void> package(HttpRequest request) async {
const chunkSize = 32768;
request.response.headers.contentType = ContentType.binary;
request.response.contentLength = chunkSize * 3;
if (request.method == 'HEAD') return;
for (var i = 0; i < 3; i++) {
request.response.add(List.filled(chunkSize, i + 1));
await request.response.flush();
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
test('downloads the fastest successful probe and reports intermediate byte progress', () async {
handle = (request) async {
if (request.uri.path == '/slow' && request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 120));
}
await package(request);
};
final progress = <(int, int)>[];
await downloadFromCandidates(
[url('/slow'), url('/fast'), url('/fast')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (received, total) => progress.add((received, total)),
);
expect(downloads, ['/fast']);
expect(await File(savePath).length(), 98304);
expect(progress.any((p) => p.$1 > 0 && p.$1 < p.$2), isTrue);
expect(progress.last, (98304, 98304));
});
test('falls back when a successful HEAD is followed by a failed GET', () async {
handle = (request) async {
if (request.uri.path == '/broken') {
request.response.headers.contentType = ContentType.binary;
if (request.method == 'GET') {
request.response.statusCode = HttpStatus.serviceUnavailable;
request.response.write('unavailable');
}
return;
}
if (request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 60));
}
await package(request);
};
await downloadFromCandidates(
[url('/broken'), url('/working')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/broken', '/working']);
final bytes = await File(savePath).readAsBytes();
expect(bytes.length, 98304);
expect(bytes.take(32768), everyElement(1));
});
test('tries GET even when HEAD is unsupported', () async {
handle = (request) async {
if (request.method == 'HEAD') {
request.response.statusCode = HttpStatus.methodNotAllowed;
return;
}
await package(request);
};
await downloadFromCandidates(
[url('/no-head')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/no-head']);
expect(await File(savePath).length(), 98304);
});
test('bounds probe time and still allows a normal download', () async {
handle = (request) async {
if (request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 100));
return;
}
await package(request);
};
await downloadFromCandidates(
[url('/head-timeout')],
savePath,
expectedSha256: expectedSha256,
probeTimeout: const Duration(milliseconds: 20),
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/head-timeout']);
expect(await File(savePath).length(), 98304);
});
test('rejects mirror landing pages and removes an unsuccessful download', () async {
handle = (request) async {
request.response.headers.contentType = ContentType.html;
if (request.method == 'GET') request.response.write('<html>mirror error</html>');
};
await expectLater(
downloadFromCandidates([url('/landing')], savePath, expectedSha256: expectedSha256, onReceiveProgress: (_, _) {}),
throwsStateError,
);
expect(await File(savePath).exists(), isFalse);
});
test('same-sized modified mirror bytes fail the official hash and fall back', () async {
handle = (request) async {
if (request.uri.path == '/tampered') {
request.response.headers.contentType = ContentType.binary;
// A mirror-provided hash must never replace the expected official hash.
request.response.headers.set('x-checksum-sha256', sha256.convert(List.filled(98304, 9)).toString());
request.response.contentLength = 98304;
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
return;
}
if (request.method == 'HEAD') await Future<void>.delayed(const Duration(milliseconds: 60));
await package(request);
};
await downloadFromCandidates(
[url('/tampered'), url('/official')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/tampered', '/official']);
expect((await sha256.bind(File(savePath).openRead()).first).toString(), expectedSha256);
});
test('all checksum failures abort and delete the untrusted package', () async {
handle = (request) async {
request.response.headers.contentType = ContentType.binary;
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
};
await expectLater(
downloadFromCandidates(
[url('/bad-one'), url('/bad-two')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
),
throwsA(isA<DownloadChecksumException>()),
);
expect(downloads.toSet(), {'/bad-one', '/bad-two'});
expect(await File(savePath).exists(), isFalse);
});
test('missing or malformed official checksums cannot start a probe or download', () async {
handle = package;
for (final checksum in ['', 'sha256:$expectedSha256', '0' * 63, 'g' * 64, '$expectedSha256\n']) {
await expectLater(
downloadFromCandidates([url('/official')], savePath, expectedSha256: checksum, onReceiveProgress: (_, _) {}),
throwsA(isA<DownloadChecksumException>()),
);
}
expect(requests, 0);
expect(await File(savePath).exists(), isFalse);
});
}
@@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/network/mirror_client.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
late _CandidatesTransport transport;
late Dio dio;
late _Adapter adapter;
late MirrorClient client;
const resourceUrl = 'https://raw.githubusercontent.com/owner/repo/main/manifest.json';
const mirror = 'https://mirror.example/https://raw.githubusercontent.com/owner/repo/main/manifest.json';
setUp(() {
transport = _CandidatesTransport([resourceUrl, mirror]);
api.setTransportForTesting(transport);
dio = Dio();
adapter = _Adapter();
dio.httpClientAdapter = adapter;
client = MirrorClient(service: LocalAppCapabilities.instance.capabilities.gopeed, provider: 'github', client: dio);
});
tearDown(() {
dio.close(force: true);
for (final request in adapter.requests) {
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
}
});
test('an invalid fast mirror cannot hide a slower valid release list', () async {
final official = Completer<ResponseBody>();
final mirrorRead = Completer<void>();
adapter.reply = (request, _) {
if (request.uri.host == 'raw.githubusercontent.com') return official.future;
mirrorRead.complete();
return Future.value(_json('<html>mirror error</html>'));
};
final result = client.getJson(resourceUrl, decode: _decodeList);
await mirrorRead.future;
official.complete(
_json([
{'tag_name': 'v2.0.0', 'body': 'notes'},
]),
);
final info = await result;
expect(info.single['tag_name'], 'v2.0.0');
expect(info.single['body'], 'notes');
expect(adapter.requests.any((r) => r.uri.host == 'gopeed.com'), isFalse);
expect(transport.requested, [resourceUrl]);
});
test('a complete validated winner cancels a stalled candidate', () async {
final officialStarted = Completer<void>();
final canceled = Completer<void>();
adapter.reply = (request, cancellation) async {
if (request.uri.host == 'raw.githubusercontent.com') {
officialStarted.complete();
await cancellation;
canceled.complete();
throw StateError('canceled');
}
await officialStarted.future;
return _json([
{'tag_name': 'v2.0.0'},
]);
};
expect((await client.getJson(resourceUrl, decode: _decodeList)).single['tag_name'], 'v2.0.0');
await canceled.future;
expect(adapter.requests.length, 2);
});
test('the mirror client supports other providers and non-GitHub resources', () async {
const resource = 'https://resources.example/manifest.json';
transport.provider = 'resources';
transport.urls = [resource, 'https://cdn.example/manifest.json'];
adapter.reply = (request, _) async =>
request.uri.host == 'resources.example' ? _json({}, status: 503) : _json({'revision': 42});
final client = MirrorClient(
service: LocalAppCapabilities.instance.capabilities.gopeed,
provider: 'resources',
client: dio,
);
final revision = await client.getJson<int>(resource, decode: (data) => (data as Map)['revision'] as int);
expect(revision, 42);
expect(transport.requested, [resource]);
expect(adapter.requests.map((request) => request.uri.host).toSet(), {'resources.example', 'cdn.example'});
});
test('metadata attempts have a total timeout even if the transport stalls', () async {
final canceled = Completer<void>();
adapter.reply = (_, cancellation) async {
await cancellation;
canceled.complete();
throw StateError('canceled');
};
transport.urls = [resourceUrl];
final github = MirrorClient(
service: LocalAppCapabilities.instance.capabilities.gopeed,
provider: 'github',
client: dio,
timeout: const Duration(milliseconds: 30),
);
await expectLater(github.getJson(resourceUrl, decode: (data) => data), throwsA(isA<TimeoutException>()));
await canceled.future;
});
}
List<dynamic> _decodeList(dynamic data) {
if (data is! List) throw const FormatException('Invalid manifest');
return data;
}
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
jsonEncode(body),
status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
class _Adapter implements HttpClientAdapter {
final requests = <RequestOptions>[];
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
requests.add(options);
return reply(options, cancelFuture);
}
@override
void close({bool force = false}) {}
}
class _CandidatesTransport implements GopeedTransport {
_CandidatesTransport(this.urls);
List<String> urls;
String provider = 'github';
bool fail = false;
final requested = <String>[];
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
expect(path, 'api/v1/mirrors/$provider/candidates');
expect(method, 'POST');
requested.add((data as Map)['url'] as String);
if (fail) throw StateError('Downloader unavailable');
return {
'code': 0,
'data': {'urls': urls},
};
}
@override
String join(String path) => path;
}
@@ -1,4 +1,3 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
@@ -31,11 +30,6 @@ class _LoginTransport implements GopeedTransport {
return {'code': 0};
}
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
throw UnimplementedError();
}
@override
String join(String path) => path;
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
@@ -67,6 +69,106 @@ void main() {
expect(saved?.extra.createHistory, ['https://example.com/existing.zip']);
expect(saved?.extra.analyticsClientId, 'existing-client');
});
test('protocol edits use a single full PUT and reload backend-owned tracker results', () async {
final backend = DownloaderConfig();
final puts = <DownloaderConfig>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
..bind(GopeedMethods.putConfig, (config) {
puts.add(config);
// Simulate the backend retaining its read-only results on a full PUT.
backend.protocolConfig.bt.listenPort = config.protocolConfig.bt.listenPort;
return const RpcUnit();
});
final container = _settingsContainer(registry);
addTearDown(container.dispose);
final initial = await container.read(settingsControllerProvider.future);
final draft = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
backend.protocolConfig.bt.state.trackerSources = {
'https://example.com/list': ['udp://fresh.example:80/announce'],
};
backend.protocolConfig.bt.state.lastTrackerUpdateTime = DateTime.utc(2026, 10, 19);
await container.read(settingsControllerProvider.notifier).save(draft);
expect(puts, hasLength(1));
expect(puts.single.protocolConfig.bt.listenPort, 6881);
final saved = container.read(settingsControllerProvider).requireValue;
expect(saved.config.protocolConfig.bt.state.trackerSources['https://example.com/list'], [
'udp://fresh.example:80/announce',
]);
expect(saved.saving, isFalse);
});
test('rapid full config saves run in order and keep the latest user edits', () async {
final backend = DownloaderConfig();
final entered = Completer<void>();
final release = Completer<void>();
final puts = <DownloaderConfig>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
..bind(GopeedMethods.putConfig, (config) async {
puts.add(DownloaderConfig.fromJson(config.toJson()));
if (puts.length == 1) {
entered.complete();
await release.future;
}
backend.protocolConfig = config.protocolConfig;
return const RpcUnit();
});
final container = _settingsContainer(registry);
addTearDown(container.dispose);
final initial = await container.read(settingsControllerProvider.future);
final controller = container.read(settingsControllerProvider.notifier);
final first = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
final savingFirst = controller.save(first);
await entered.future;
final second = DownloaderConfig.fromJson(first.toJson())
..protocolConfig.bt.customTrackers = ['udp://custom.example:80/announce'];
final savingSecond = controller.save(second);
expect(puts.length, 1);
release.complete();
await Future.wait([savingFirst, savingSecond]);
expect(puts, hasLength(2));
expect(puts[0].protocolConfig.bt.listenPort, 6881);
expect(puts[1].protocolConfig.bt.listenPort, 6881);
expect(puts[1].protocolConfig.bt.customTrackers, ['udp://custom.example:80/announce']);
expect(container.read(settingsControllerProvider).requireValue.config.protocolConfig.bt.customTrackers, [
'udp://custom.example:80/announce',
]);
});
test('a failed save clears saving state and the same change can be retried', () async {
final backend = DownloaderConfig();
var attempts = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
..bind(GopeedMethods.putConfig, (config) {
if (++attempts == 1) throw StateError('offline');
backend.protocolConfig = config.protocolConfig;
return const RpcUnit();
});
final container = _settingsContainer(registry);
addTearDown(container.dispose);
final initial = await container.read(settingsControllerProvider.future);
final controller = container.read(settingsControllerProvider.notifier);
final draft = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
await expectLater(controller.save(draft), throwsStateError);
expect(container.read(settingsControllerProvider).requireValue.saving, isFalse);
await controller.save(draft);
expect(attempts, 2);
expect(backend.protocolConfig.bt.listenPort, 6881);
});
}
ProviderContainer _settingsContainer(CapabilityRegistry registry) {
final container = ProviderContainer(
overrides: [
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new),
],
);
container.listen(settingsControllerProvider, (_, _) {});
return container;
}
class _TestRuntimeController extends AppRuntimeController {
@@ -0,0 +1,223 @@
import 'package:flutter/gestures.dart' show PointerDeviceKind;
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_control.dart';
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_schedule.dart';
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_table.dart';
import 'package:gopeed/l10n/l10n.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
void main() {
for (final width in [360.0, 768.0, 1024.0]) {
testWidgets('subscription results fit at width $width', (tester) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final time = DateTime.utc(2026, 10, 19);
await tester.pumpWidget(
ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AppComponentThemes(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: TrackerSubscriptionsControl(
urls: const [
'https://success.example/very/long/path/to/my/custom/list.txt',
'https://failed.example/list',
'https://new.example/list',
],
statuses: {
'https://success.example/very/long/path/to/my/custom/list.txt': TrackerSubscriptionStatus(
success: true,
trackerCount: 7,
lastFetchTime: time,
),
'https://failed.example/list': TrackerSubscriptionStatus(
success: false,
trackerCount: 0,
lastFetchTime: time,
),
},
autoUpdate: true,
lastUpdated: time,
onChanged: (_) {},
onAutoUpdateChanged: (_) {},
onUpdate: () async {},
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Update status'), findsOneWidget);
expect(find.text('Tracker count'), findsOneWidget);
expect(find.text('Actions'), findsOneWidget);
for (final entry in const [
('https://success.example/very/long/path/to/my/custom/list.txt', 'Succeeded', '7'),
('https://failed.example/list', 'Failed', '0'),
('https://new.example/list', 'Not updated', '—'),
]) {
final row = find.byKey(ValueKey('tracker-subscription-${entry.$1}'));
expect(find.descendant(of: row, matching: find.text(entry.$2)), findsOneWidget);
expect(find.descendant(of: row, matching: find.text(entry.$3)), findsOneWidget);
}
final table = find.byKey(const ValueKey('tracker-subscriptions-table'));
expect(find.descendant(of: table, matching: find.textContaining('2026-')), findsNothing);
expect(find.textContaining('Last updated: '), findsOneWidget);
final updateRect = tester.getRect(find.byKey(const ValueKey('tracker-update-button')));
final addRect = tester.getRect(find.byKey(const ValueKey('add-tracker-subscription')));
final tableRect = tester.getRect(table);
final autoUpdateRect = tester.getRect(find.byKey(const ValueKey('tracker-auto-update-control')));
final timeRect = tester.getRect(find.byKey(const ValueKey('tracker-last-updated')));
expect(addRect.right, closeTo(tableRect.right, 1));
expect(updateRect.right, lessThan(addRect.left));
expect(addRect.center.dy, closeTo(updateRect.center.dy, 1));
expect(updateRect.top, greaterThan(tableRect.bottom));
expect(autoUpdateRect.bottom, lessThan(tableRect.top));
expect(autoUpdateRect.right, closeTo(tableRect.right, 1));
expect(timeRect.top, greaterThan(autoUpdateRect.bottom));
expect(timeRect.bottom, lessThan(tableRect.top));
expect(timeRect.right, closeTo(tableRect.right, 1));
expect(find.byKey(const ValueKey('add-tracker-subscription')), findsOneWidget);
if (width < 672) {
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
await tester.dragFrom(tester.getTopLeft(table) + const Offset(100, 20), const Offset(-600, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Subscription URL')).dx, lessThan(headerPosition.dx));
}
expect(tester.takeException(), isNull);
});
}
for (final locale in const [Locale('en'), Locale('zh')]) {
for (final width in [320.0, 768.0, 1024.0]) {
testWidgets('schedule hides missing update time at width $width in $locale', (tester) async {
tester.view.physicalSize = Size(width, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
bool? autoUpdate;
await tester.pumpWidget(
ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Localizations.override(
context: context,
locale: locale,
delegates: const [AppLocalizations.delegate],
child: AppComponentThemes(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: TrackerSubscriptionsSchedule(
lastUpdated: null,
autoUpdate: false,
onAutoUpdateChanged: (value) => autoUpdate = value,
),
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('tracker-last-updated')), findsNothing);
expect(find.text('Never updated'), findsNothing);
expect(find.text('尚未更新'), findsNothing);
final scheduleRect = tester.getRect(find.byKey(const ValueKey('tracker-subscriptions-schedule')));
final autoRect = tester.getRect(find.byKey(const ValueKey('tracker-auto-update-control')));
expect(find.byKey(const ValueKey('tracker-update-button')), findsNothing);
expect(autoRect.right, closeTo(scheduleRect.right, 1));
await tester.tap(find.byType(Switch));
expect(autoUpdate, true);
expect(tester.takeException(), isNull);
});
}
}
for (final width in [360.0, 768.0, 1024.0]) {
testWidgets('long subscription table scrolls within its viewport at width $width', (tester) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final urls = List.generate(50, (index) => 'https://tracker-$index.example/list.txt');
final deletions = <int>[];
await tester.pumpWidget(
ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AppComponentThemes(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: TrackerSubscriptionsTable(urls: urls, statuses: const {}, onDelete: deletions.add),
),
),
),
),
);
await tester.pumpAndSettle();
final table = find.byKey(const ValueKey('tracker-subscriptions-table'));
final rows = find.byKey(const ValueKey('tracker-subscriptions-rows'));
expect(tester.getSize(table).height, lessThan(300));
expect(tester.getSize(table).width, width - 32);
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
expect(find.byKey(ValueKey('tracker-subscription-${urls.last}')), findsNothing);
await tester.dragFrom(tester.getTopLeft(rows) + const Offset(40, 80), const Offset(0, -3000));
await tester.pumpAndSettle();
expect(find.byKey(ValueKey('tracker-subscription-${urls.last}')), findsOneWidget);
expect(tester.getTopLeft(find.text('Subscription URL')), headerPosition);
final delete = find.byKey(ValueKey('delete-settings-list-${urls.last}'));
final deleteRect = tester.getRect(delete);
final actionsHeader = tester.getTopLeft(find.byKey(const ValueKey('tracker-subscriptions-actions-header')));
expect(deleteRect.right, lessThanOrEqualTo(tester.getRect(table).right));
expect(deleteRect.left, greaterThan(tester.getRect(table).left));
expect(delete.hitTestable(), findsOneWidget);
// Data columns scroll, while the delete column remains visible and fixed.
if (width < 672) {
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
await tester.dragFrom(tester.getTopLeft(table) + const Offset(100, 20), const Offset(-30, 0));
await tester.pumpAndSettle();
await tester.dragFrom(
tester.getBottomLeft(table) + const Offset(40, -5),
const Offset(600, 0),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Subscription URL')).dx, lessThan(headerPosition.dx));
}
expect(tester.getRect(delete), deleteRect);
expect(tester.getTopLeft(find.byKey(const ValueKey('tracker-subscriptions-actions-header'))), actionsHeader);
expect(find.byIcon(Icons.edit_outlined), findsNothing);
if (width < 672) {
final rightInset = tester.getRect(table).right - deleteRect.right;
tester.view.physicalSize = const Size(768, 900);
await tester.pumpAndSettle();
expect(delete.hitTestable(), findsOneWidget);
expect(tester.getRect(table).right - tester.getRect(delete).right, closeTo(rightInset, 1));
}
await tester.tap(delete);
expect(deletions, [49]);
expect(tester.takeException(), isNull);
});
}
}
+69 -116
View File
@@ -1,14 +1,12 @@
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart';
import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/util/arch/arch.dart';
import 'package:gopeed/util/github_mirror.dart';
import 'package:gopeed/util/package_info.dart' as app_package;
import 'package:gopeed/util/updater.dart';
import 'package:gopeed/core/network/candidate_download.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
@@ -24,101 +22,29 @@ void main() {
);
});
group('version comparison', () {
test('uses semantic version precedence', () {
expect(isNewerVersion('1.10.0', '1.9.9'), isTrue);
expect(isNewerVersion('2.0.0-beta.2', '2.0.0-beta.1'), isTrue);
expect(isNewerVersion('2.0.0-rc.1', '2.0.0-beta.2'), isTrue);
expect(isNewerVersion('2.0.0', '2.0.0-rc.1'), isTrue);
expect(isNewerVersion('1.2.3', '1.2.3'), isFalse);
expect(isNewerVersion('1.2.2', '1.2.3'), isFalse);
expect(isNewerVersion('invalid', '1.2.3'), isFalse);
});
});
group('release selection', () {
Map<String, dynamic> release(String version, {bool prerelease = false, bool draft = false}) => {
'tag_name': 'v$version',
'prerelease': prerelease,
'draft': draft,
'body': '$version notes',
'html_url': 'https://example.com/$version',
};
test('stable builds ignore preview releases', () {
final selected = selectUpdateRelease([release('2.1.0-beta.1', prerelease: true), release('2.0.1')], '2.0.0');
expect(selected?.version, '2.0.1');
});
test('preview builds advance within the same preview line', () {
final selected = selectUpdateRelease([
release('2.0.0-beta.2', prerelease: true),
release('1.9.9'),
release('2.1.0-beta.1', prerelease: true),
], '2.0.0-beta.1');
expect(selected?.version, '2.0.0-beta.2');
});
test('four-part platform beta selects the next GitHub prerelease and matching asset', () {
final currentVersion = app_package.normalizeAppVersion('2.0.0.1');
final selected = selectUpdateRelease([release('2.0.0-beta.2', prerelease: true)], currentVersion);
expect(selected?.version, '2.0.0-beta.2');
expect(
updateAssetName(selected!.version, channel: UpdateChannel.macosDmg, architecture: Architecture.arm64),
'Gopeed-v2.0.0-beta.2-macos-arm64.dmg',
);
});
test('preview builds graduate to the final stable release', () {
final selected = selectUpdateRelease([
release('2.0.0-beta.2', prerelease: true),
release('2.0.0'),
], '2.0.0-beta.1');
expect(selected?.version, '2.0.0');
});
test('selects the greatest eligible version and ignores invalid releases', () {
final selected = selectUpdateRelease([
release('2.0.0-beta.3', prerelease: true),
release('2.0.0-beta.4', prerelease: true, draft: true),
{'tag_name': 'not-a-version'},
release('2.0.0-beta.2', prerelease: true),
], '2.0.0-beta.1');
expect(selected?.version, '2.0.0-beta.3');
});
});
group('release assets', () {
test('uses the release naming convention for desktop packages', () {
expect(
updateAssetName('1.8.0', channel: UpdateChannel.windowsPortable, architecture: Architecture.x64),
'Gopeed-v1.8.0-windows-amd64-portable.zip',
);
expect(
updateAssetName('1.8.0', channel: UpdateChannel.macosDmg, architecture: Architecture.arm64),
'Gopeed-v1.8.0-macos-arm64.dmg',
);
expect(
updateAssetName('1.8.0', channel: UpdateChannel.linuxDeb, architecture: Architecture.ia32),
'Gopeed-v1.8.0-linux-386.deb',
);
});
test('maps Android ABI names', () {
expect(
updateAssetName('1.8.0', channel: UpdateChannel.androidApk, architecture: Architecture.arm),
'Gopeed-v1.8.0-android-armeabi-v7a.apk',
);
expect(
updateAssetName('1.8.0', channel: UpdateChannel.androidApk, architecture: Architecture.arm64),
'Gopeed-v1.8.0-android-arm64-v8a.apk',
);
});
test('package names use the client channel and architecture', () {
expect(appUpdateAssetName('2.0.0', UpdateChannel.windowsInstaller, 'ia32'), 'Gopeed-v2.0.0-windows-386.zip');
expect(
appUpdateAssetName('2.0.0-beta.3', UpdateChannel.windowsPortable, 'x64'),
'Gopeed-v2.0.0-beta.3-windows-amd64-portable.zip',
);
expect(appUpdateAssetName('2.0.0', UpdateChannel.macosDmg, 'arm64'), 'Gopeed-v2.0.0-macos-arm64.dmg');
expect(appUpdateAssetName('2.0.0', UpdateChannel.linuxDeb, 'x64'), 'Gopeed-v2.0.0-linux-amd64.deb');
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'arm'), 'Gopeed-v2.0.0-android-armeabi-v7a.apk');
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'arm64'), 'Gopeed-v2.0.0-android-arm64-v8a.apk');
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'x64'), 'Gopeed-v2.0.0-android-x86_64.apk');
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'riscv64'), 'Gopeed-v2.0.0-android.apk');
for (final channel in [
null,
UpdateChannel.linuxFlathub,
UpdateChannel.linuxSnap,
UpdateChannel.linuxAppImage,
UpdateChannel.linuxRpm,
UpdateChannel.iosIpa,
UpdateChannel.docker,
]) {
expect(appUpdateAssetName('2.0.0', channel, 'arm64'), isEmpty);
}
});
test('selects the matching bilingual release notes', () {
@@ -127,23 +53,6 @@ void main() {
expect(localizedReleaseNotes(notes, 'zh-CN'), '# 更新日志\n\n- 中文更新');
});
test('builds enabled GitHub proxy URLs and ignores deleted mirrors', () {
final config = ExtraConfigGithubMirror(
mirrors: [
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://mirror.example'),
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://deleted.example', isDeleted: true),
],
);
expect(
githubMirrorUrls(
'https://github.com/GopeedLab/gopeed/releases/download/v1.8.0/app.zip',
MirrorType.githubRelease,
config: config,
),
['https://mirror.example/https://github.com/GopeedLab/gopeed/releases/download/v1.8.0/app.zip'],
);
});
testWidgets('update dialog shows beta versions without tag prefixes', (tester) async {
tester.view.physicalSize = const Size(390, 760);
tester.view.devicePixelRatio = 1;
@@ -194,7 +103,7 @@ void main() {
expect(find.text('Faster downloads'), findsOneWidget);
expect(find.text('下载更快'), findsNothing);
expect(find.text('Later'), findsOneWidget);
expect(find.text('Update Now'), findsOneWidget);
expect(find.text('Update'), findsOneWidget);
expect(find.text("What's New"), findsNothing);
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
expect(versionText.style?.decoration, TextDecoration.none);
@@ -207,6 +116,50 @@ void main() {
expect(tester.takeException(), isNull);
});
for (final width in [390.0, 1024.0]) {
testWidgets('checksum failures prevent success and show a localized error at $width', (tester) async {
tester.view.physicalSize = Size(width, 760);
tester.view.devicePixelRatio = 1;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
var attempts = 0;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.SecondaryButton(
onPressed: () => showAppUpdateDialog(
context,
versionInfo: const VersionInfo(
version: '2.0.0',
changeLog: '',
releaseUrl: 'https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0',
),
onUpdate: (_, progress) async {
attempts++;
progress(100, 100);
throw const DownloadChecksumException();
},
),
child: const Text('Open update'),
),
),
),
);
await tester.tap(find.text('Open update'));
await tester.pumpAndSettle();
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(attempts, 1);
expect(find.text('Update verification failed. Installation stopped.'), findsOneWidget);
expect(find.text('Update failed. Please try again later'), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async {
await tester.pumpWidget(
shad.ShadcnApp(
+29 -33
View File
@@ -2274,10 +2274,8 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('tracker subscriptions support selecting and clearing all entries', (WidgetTester tester) async {
testWidgets('tracker subscriptions allow adding and deleting a custom URL', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
tester.platformDispatcher.textScaleFactorTestValue = 1.2;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final settingsController = FakeSettingsController();
await tester.pumpWidget(
ProviderScope(
@@ -2292,35 +2290,31 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final selectAll = find.byKey(const ValueKey('tracker-select-all'));
expect(selectAll, findsOneWidget);
expect(find.descendant(of: selectAll, matching: find.text('Select All')), findsOneWidget);
await tester.ensureVisible(selectAll);
final add = find.byKey(const ValueKey('add-tracker-subscription'));
await tester.ensureVisible(add);
await tester.pumpAndSettle();
final horizontalScrollbar = tester.widget<Scrollbar>(find.byKey(const ValueKey('tracker-horizontal-scrollbar')));
expect(horizontalScrollbar.thumbVisibility, isTrue);
expect(horizontalScrollbar.controller?.hasClients, isTrue);
expect(horizontalScrollbar.controller!.position.maxScrollExtent, greaterThan(0));
final verticalScrollbar = tester.widget<Scrollbar>(find.byKey(const ValueKey('tracker-vertical-scrollbar')));
expect(verticalScrollbar.controller?.hasClients, isTrue);
expect(verticalScrollbar.controller!.position.maxScrollExtent, greaterThan(0));
final scrollbarRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scrollbar')));
final scrollViewRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scroll-view')));
expect(scrollbarRect.bottom - scrollViewRect.bottom, AppDesignTokens.space12);
expect(find.text(allTrackerSubscribeUrls.first), findsOneWidget);
horizontalScrollbar.controller!.jumpTo(horizontalScrollbar.controller!.position.maxScrollExtent);
await tester.pump();
expect(horizontalScrollbar.controller!.offset, greaterThan(0));
await tester.tap(selectAll);
await tester.tap(add);
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, allTrackerSubscribeUrls);
await tester.tap(selectAll);
final dialog = find.byType(shad.AlertDialog);
await tester.enterText(
find.descendant(of: dialog, matching: find.byType(AppTextField)),
'https://my.example/trackers.txt',
);
await tester.tap(find.descendant(of: dialog, matching: find.text('Save')));
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, isEmpty);
expect(settingsController.state.requireValue.config.protocolConfig.bt.trackerSubscribeUrls, [
'https://my.example/trackers.txt',
]);
expect(find.text('https://my.example/trackers.txt'), findsOneWidget);
expect(find.byKey(const ValueKey('edit-settings-list-https://my.example/trackers.txt')), findsNothing);
final delete = find.byKey(const ValueKey('delete-settings-list-https://my.example/trackers.txt'));
await tester.ensureVisible(delete);
await tester.pumpAndSettle();
await tester.tap(delete);
await tester.pumpAndSettle();
await tester.tap(find.descendant(of: find.byType(shad.AlertDialog), matching: find.text('Delete')));
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.protocolConfig.bt.trackerSubscribeUrls, isEmpty);
expect(tester.takeException(), isNull);
});
@@ -2399,7 +2393,9 @@ void main() {
expect(find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget);
});
testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async {
testWidgets('failed tracker update reloads subscription statuses without resetting the settings page', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1024, 900));
final runtimeController = FailingTrackerRuntimeController();
final settingsController = StaticSettingsController();
@@ -2428,7 +2424,7 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(settingsController.reloadCalls, 0);
expect(settingsController.reloadCalls, 1);
expect(tester.state<ScrollableState>(sectionScrollable).position.pixels, closeTo(offsetBeforeUpdate, 0.01));
expect(find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator)), findsNothing);
expect(find.byKey(const ValueKey('app-toast-content')), findsOneWidget);
@@ -5967,14 +5963,14 @@ class PendingTrackerRuntimeController extends FakeRuntimeController {
final _trackerUpdateCompleter = Completer<void>();
@override
Future<void> updateTrackers(DownloaderConfig config) => _trackerUpdateCompleter.future;
Future<void> updateTrackers() => _trackerUpdateCompleter.future;
void completeTrackerUpdate() => _trackerUpdateCompleter.complete();
}
class FailingTrackerRuntimeController extends FakeRuntimeController {
@override
Future<void> updateTrackers(DownloaderConfig config) async {
Future<void> updateTrackers() async {
throw StateError('tracker update failed');
}
}