diff --git a/go.mod b/go.mod index 457832c3..e420de78 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/fetcher/jobs.go b/internal/fetcher/jobs.go new file mode 100644 index 00000000..aaea88f7 --- /dev/null +++ b/internal/fetcher/jobs.go @@ -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) +} diff --git a/internal/githubmirror/README.md b/internal/githubmirror/README.md new file mode 100644 index 00000000..c892da10 --- /dev/null +++ b/internal/githubmirror/README.md @@ -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. diff --git a/internal/githubmirror/config.go b/internal/githubmirror/config.go new file mode 100644 index 00000000..bc67cd41 --- /dev/null +++ b/internal/githubmirror/config.go @@ -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 +} diff --git a/internal/githubmirror/config_test.go b/internal/githubmirror/config_test.go new file mode 100644 index 00000000..c2509550 --- /dev/null +++ b/internal/githubmirror/config_test.go @@ -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) + } +} diff --git a/internal/githubmirror/execute.go b/internal/githubmirror/execute.go new file mode 100644 index 00000000..d6c1ac69 --- /dev/null +++ b/internal/githubmirror/execute.go @@ -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...)) +} diff --git a/internal/githubmirror/execute_test.go b/internal/githubmirror/execute_test.go new file mode 100644 index 00000000..22a19b4d --- /dev/null +++ b/internal/githubmirror/execute_test.go @@ -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) + } +} diff --git a/internal/protocol/bt/config.go b/internal/protocol/bt/config.go index e9d2a238..53d422c4 100644 --- a/internal/protocol/bt/config.go +++ b/internal/protocol/bt/config.go @@ -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. diff --git a/internal/protocol/bt/download_test.go b/internal/protocol/bt/download_test.go index 8ae3a9a8..3f4453a9 100644 --- a/internal/protocol/bt/download_test.go +++ b/internal/protocol/bt/download_test.go @@ -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") diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 874c9a62..905f71ac 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -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, diff --git a/internal/protocol/bt/fetcher_test.go b/internal/protocol/bt/fetcher_test.go index 02e18713..8ce18231 100644 --- a/internal/protocol/bt/fetcher_test.go +++ b/internal/protocol/bt/fetcher_test.go @@ -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", }} diff --git a/internal/protocol/bt/trackers.go b/internal/protocol/bt/trackers.go new file mode 100644 index 00000000..1c2ee122 --- /dev/null +++ b/internal/protocol/bt/trackers.go @@ -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) + } +} diff --git a/internal/protocol/bt/trackers_test.go b/internal/protocol/bt/trackers_test.go new file mode 100644 index 00000000..1822cc3a --- /dev/null +++ b/internal/protocol/bt/trackers_test.go @@ -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\nerror\nhttps://tracker.example/announce\n")) + if err != nil || len(got) != 2 { + t.Fatalf("%v %v", got, err) + } + if _, err := parseTrackers([]byte("mirror error")); 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) + } +} diff --git a/pkg/api/routes.go b/pkg/api/routes.go index 2dd8061a..c29988e5 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -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] { diff --git a/pkg/api/service.go b/pkg/api/service.go index 86a91353..57663020 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -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 { diff --git a/pkg/api/service_test.go b/pkg/api/service_test.go index 2562756c..6b1dc942 100644 --- a/pkg/api/service_test.go +++ b/pkg/api/service_test.go @@ -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) + } +} diff --git a/pkg/base/github_mirror.go b/pkg/base/github_mirror.go new file mode 100644 index 00000000..50aebfaa --- /dev/null +++ b/pkg/base/github_mirror.go @@ -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() +} diff --git a/pkg/base/model.go b/pkg/base/model.go index e60bfffe..85eba736 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -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 } diff --git a/pkg/base/model_test.go b/pkg/base/model_test.go index c0cf50f2..5bd6864d 100644 --- a/pkg/base/model_test.go +++ b/pkg/base/model_test.go @@ -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, diff --git a/pkg/download/backend_services_test.go b/pkg/download/backend_services_test.go new file mode 100644 index 00000000..bed24fd0 --- /dev/null +++ b/pkg/download/backend_services_test.go @@ -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, "proxy landing page") + 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): + } +} diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index f9bc2192..54fae7d5 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -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 } diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index db0bd592..e41f2890 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -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) } diff --git a/pkg/download/extension.go b/pkg/download/extension.go index 01585ac8..ea2c8300 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -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) { diff --git a/pkg/download/extension_git_mirror_test.go b/pkg/download/extension_git_mirror_test.go new file mode 100644 index 00000000..78da031e --- /dev/null +++ b/pkg/download/extension_git_mirror_test.go @@ -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") + } +} diff --git a/pkg/download/extension_git_transport.go b/pkg/download/extension_git_transport.go new file mode 100644 index 00000000..7ab3ba7a --- /dev/null +++ b/pkg/download/extension_git_transport.go @@ -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) +} diff --git a/pkg/download/extension_git_transport_test.go b/pkg/download/extension_git_transport_test.go new file mode 100644 index 00000000..bbb84e4c --- /dev/null +++ b/pkg/download/extension_git_transport_test.go @@ -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) + } +} diff --git a/pkg/download/extension_test.go b/pkg/download/extension_test.go index 8644664b..38288cae 100644 --- a/pkg/download/extension_test.go +++ b/pkg/download/extension_test.go @@ -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) { diff --git a/pkg/download/github_mirror.go b/pkg/download/github_mirror.go new file mode 100644 index 00000000..0e86e685 --- /dev/null +++ b/pkg/download/github_mirror.go @@ -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 +} diff --git a/pkg/download/mirror.go b/pkg/download/mirror.go new file mode 100644 index 00000000..ff3f86c6 --- /dev/null +++ b/pkg/download/mirror.go @@ -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) +} diff --git a/pkg/download/protocol_config.go b/pkg/download/protocol_config.go new file mode 100644 index 00000000..18110f3a --- /dev/null +++ b/pkg/download/protocol_config.go @@ -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) +} diff --git a/pkg/download/protocol_jobs.go b/pkg/download/protocol_jobs.go new file mode 100644 index 00000000..d0edb40f --- /dev/null +++ b/pkg/download/protocol_jobs.go @@ -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 := ®isteredJob{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<app")}, + "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), "app") { + 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{"/": "app", "/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) { diff --git a/ui/flutter/docs/backend-transport-architecture.md b/ui/flutter/docs/backend-transport-architecture.md index a00985c1..a67dc745 100644 --- a/ui/flutter/docs/backend-transport-architecture.md +++ b/ui/flutter/docs/backend-transport-architecture.md @@ -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 diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index 2845007a..77a24563 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -208,11 +208,6 @@ Future login(LoginReq loginReq) { return _parse(() => _request('api/web/login', method: 'POST', data: loginReq.toJson()), null); } -Future> proxyRequest(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 forward( }) async { return _request(path, method: method, data: data, queryParameters: queryParameters); } + +Future triggerProtocolJob(String protocol, String name) => _parse( + () => _request( + 'api/v1/protocols/${Uri.encodeComponent(protocol)}/jobs/${Uri.encodeComponent(name)}/trigger', + method: 'POST', + ), + null, +); + +Future> getMirrorCandidates(String provider, String url) => _parse( + () => _request('api/v1/mirrors/${Uri.encodeComponent(provider)}/candidates', method: 'POST', data: {'url': url}), + (json) => List.from(json['urls'] as List), +); diff --git a/ui/flutter/lib/api/gopeed_site_api.dart b/ui/flutter/lib/api/gopeed_site_api.dart index 73c34f8f..d250dc09 100644 --- a/ui/flutter/lib/api/gopeed_site_api.dart +++ b/ui/flutter/lib/api/gopeed_site_api.dart @@ -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 = {}; final _storeRequests = >{}; - Future> getReleases({int perPage = 10}) async { - final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()}); - if (json is! List) { - throw const FormatException('Invalid releases response'); - } - return json; - } - Future 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 _fetchStorePage(Map params) async { - final json = await _getJson('/api/extensions', queryParameters: params); - return StoreExtensionPage.fromJson(json as Map); + final response = await _client.get>( + 'https://gopeed.com/api/extensions', + queryParameters: params, + ); + return StoreExtensionPage.fromJson(response.data!); } Future 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); + final response = await _client.get>(uri.toString()); + return StoreExtension.fromJson(response.data!); + } + + Future getReleases() async { + final response = await _client.get( + 'https://gopeed.com/api/releases', + queryParameters: {'per_page': 10}, + options: Options(followRedirects: false), + ); + return response.data; } Future 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 _getJson(String path, {Map? queryParameters}) { - return _getJsonUri(Uri.https(_host, path, queryParameters)); - } - - Future _getJsonUri(Uri uri) async { - final Response 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('https://gopeed.com/api/extensions/install', data: {'id': id}); } } diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index a1503953..f4813b42 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -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 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 json) => _$TrackerSubscriptionStatusFromJson(json); + + Map toJson() => _$TrackerSubscriptionStatusToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class BtState { + Map> trackerSources; + Map trackerSubscriptionStatuses; + @JsonKey(fromJson: _trackerUpdateTimeFromJson) + DateTime? lastTrackerUpdateTime; + + BtState({this.trackerSources = const {}, this.trackerSubscriptionStatuses = const {}, this.lastTrackerUpdateTime}); + + factory BtState.fromJson(Map? json) => json == null ? BtState() : _$BtStateFromJson(json); + + Map toJson() => _$BtStateToJson(this); +} + +@JsonSerializable(explicitToJson: true) class BtConfig { + BtState state = BtState(); + List trackerSubscribeUrls; + bool autoUpdateTrackers; + List customTrackers; int listenPort; - List 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 downloadCategories; - ExtraConfigBt bt = ExtraConfigBt(); - ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror(); - ExtraConfig({ this.themeMode = '', this.themeColor = 'green', @@ -271,22 +304,6 @@ class ProxyConfig { Map toJson() => _$ProxyConfigToJson(this); } -@JsonSerializable() -class ExtraConfigBt { - List trackerSubscribeUrls = []; - List subscribeTrackers = []; - bool autoUpdateTrackers = true; - DateTime? lastTrackerUpdateTime; - - List customTrackers = []; - - ExtraConfigBt(); - - factory ExtraConfigBt.fromJson(Map json) => _$ExtraConfigBtFromJson(json); - - Map toJson() => _$ExtraConfigBtToJson(this); -} - enum GithubMirrorType { jsdelivr, ghProxy } @JsonSerializable() @@ -304,16 +321,16 @@ class GithubMirror { } @JsonSerializable(explicitToJson: true) -class ExtraConfigGithubMirror { +class GithubMirrorConfig { bool enabled; List mirrors; - ExtraConfigGithubMirror({this.enabled = true, this.mirrors = const []}); + GithubMirrorConfig({this.enabled = true, this.mirrors = const []}); - factory ExtraConfigGithubMirror.fromJson(Map? json) => - json == null ? ExtraConfigGithubMirror() : _$ExtraConfigGithubMirrorFromJson(json); + factory GithubMirrorConfig.fromJson(Map? json) => + json == null ? GithubMirrorConfig() : _$GithubMirrorConfigFromJson(json); - Map toJson() => _$ExtraConfigGithubMirrorToJson(this); + Map toJson() => _$GithubMirrorConfigToJson(this); } @JsonSerializable() diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 63a3a248..d59e43d4 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -15,6 +15,7 @@ DownloaderConfig _$DownloaderConfigFromJson(Map json) => ) ..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map?) ..extra = ExtraConfig.fromJson(json['extra'] as Map?) + ..githubMirror = GithubMirrorConfig.fromJson(json['githubMirror'] as Map?) ..proxy = ProxyConfig.fromJson(json['proxy'] as Map) ..webhook = WebhookConfig.fromJson(json['webhook'] as Map?) ..script = ScriptConfig.fromJson(json['script'] as Map?) @@ -27,6 +28,7 @@ Map _$DownloaderConfigToJson(DownloaderConfig instance) => _$HttpConfigToJson(HttpConfig instance) => json) => TrackerSubscriptionStatus( + success: json['success'] as bool, + trackerCount: (json['trackerCount'] as num).toInt(), + lastFetchTime: DateTime.parse(json['lastFetchTime'] as String), +); + +Map _$TrackerSubscriptionStatusToJson(TrackerSubscriptionStatus instance) => { + 'success': instance.success, + 'trackerCount': instance.trackerCount, + 'lastFetchTime': instance.lastFetchTime.toIso8601String(), +}; + +BtState _$BtStateFromJson(Map json) => BtState( + trackerSources: + (json['trackerSources'] as Map?)?.map( + (k, e) => MapEntry(k, (e as List).map((e) => e as String).toList()), + ) ?? + const {}, + trackerSubscriptionStatuses: + (json['trackerSubscriptionStatuses'] as Map?)?.map( + (k, e) => MapEntry(k, TrackerSubscriptionStatus.fromJson(e as Map)), + ) ?? + const {}, + lastTrackerUpdateTime: _trackerUpdateTimeFromJson(json['lastTrackerUpdateTime'] as String?), +); + +Map _$BtStateToJson(BtState instance) => { + 'trackerSources': instance.trackerSources, + 'trackerSubscriptionStatuses': instance.trackerSubscriptionStatuses.map((k, e) => MapEntry(k, e.toJson())), + 'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(), +}; + BtConfig _$BtConfigFromJson(Map json) => BtConfig( + trackerSubscribeUrls: (json['trackerSubscribeUrls'] as List?)?.map((e) => e as String).toList() ?? const [], + autoUpdateTrackers: json['autoUpdateTrackers'] as bool? ?? true, + customTrackers: (json['customTrackers'] as List?)?.map((e) => e as String).toList() ?? const [], listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, - trackers: (json['trackers'] as List?)?.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?); Map _$BtConfigToJson(BtConfig instance) => { + '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 _$Ed2kConfigToJson(Ed2kConfig instance) => 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?), - bookmarks: (json['bookmarks'] as Map?)?.map((k, e) => MapEntry(k, e as String)) ?? const {}, - createHistory: (json['createHistory'] as List?)?.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?) - ?.map((e) => DownloadCategory.fromJson(e as Map)) - .toList() ?? - const [], - ) - ..bt = ExtraConfigBt.fromJson(json['bt'] as Map) - ..githubMirror = ExtraConfigGithubMirror.fromJson(json['githubMirror'] as Map?); +ExtraConfig _$ExtraConfigFromJson(Map 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?), + bookmarks: (json['bookmarks'] as Map?)?.map((k, e) => MapEntry(k, e as String)) ?? const {}, + createHistory: (json['createHistory'] as List?)?.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?) + ?.map((e) => DownloadCategory.fromJson(e as Map)) + .toList() ?? + const [], +); Map _$ExtraConfigToJson(ExtraConfig instance) => { 'themeMode': instance.themeMode, @@ -170,8 +203,7 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => _$ExtraConfigToJson(ExtraConfig instance) => e.toJson()).toList(), - 'bt': instance.bt.toJson(), - 'githubMirror': instance.githubMirror.toJson(), }; WindowStateConfig _$WindowStateConfigFromJson(Map json) => WindowStateConfig( @@ -249,23 +279,6 @@ Map _$ProxyConfigToJson(ProxyConfig instance) => json) => ExtraConfigBt() - ..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List).map((e) => e as String).toList() - ..subscribeTrackers = (json['subscribeTrackers'] as List).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).map((e) => e as String).toList(); - -Map _$ExtraConfigBtToJson(ExtraConfigBt instance) => { - 'trackerSubscribeUrls': instance.trackerSubscribeUrls, - 'subscribeTrackers': instance.subscribeTrackers, - 'autoUpdateTrackers': instance.autoUpdateTrackers, - 'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(), - 'customTrackers': instance.customTrackers, -}; - GithubMirror _$GithubMirrorFromJson(Map json) => GithubMirror( type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']), url: json['url'] as String, @@ -282,14 +295,14 @@ Map _$GithubMirrorToJson(GithubMirror instance) => json) => ExtraConfigGithubMirror( +GithubMirrorConfig _$GithubMirrorConfigFromJson(Map json) => GithubMirrorConfig( enabled: json['enabled'] as bool? ?? true, mirrors: (json['mirrors'] as List?)?.map((e) => GithubMirror.fromJson(e as Map)).toList() ?? const [], ); -Map _$ExtraConfigGithubMirrorToJson(ExtraConfigGithubMirror instance) => { +Map _$GithubMirrorConfigToJson(GithubMirrorConfig instance) => { 'enabled': instance.enabled, 'mirrors': instance.mirrors.map((e) => e.toJson()).toList(), }; diff --git a/ui/flutter/lib/app/application/app_platform_controller.dart b/ui/flutter/lib/app/application/app_platform_controller.dart index 02977029..5bab043e 100644 --- a/ui/flutter/lib/app/application/app_platform_controller.dart +++ b/ui/flutter/lib/app/application/app_platform_controller.dart @@ -317,12 +317,7 @@ class AppPlatformController extends AsyncNotifier with WindowL } Future 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( diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart index 41ee09a2..829da4ba 100644 --- a/ui/flutter/lib/app/application/app_runtime_controller.dart +++ b/ui/flutter/lib/app/application/app_runtime_controller.dart @@ -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 { ), ); unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive)); - unawaited( - ContinuedProcessing.setEnabled( - config.extra.backgroundContinuedProcessing, - ), - ); + unawaited(ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing)); } - Future _init() async { await AppInitializer.ensureStorageInitialized(); try { @@ -175,7 +169,6 @@ class AppRuntimeController extends AsyncNotifier { ..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 { // 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 { 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 { ), ]; } - 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 _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 _initAnalytics(DownloaderConfig config) async { @@ -344,32 +312,12 @@ class AppRuntimeController extends AsyncNotifier { } } - Future updateTrackers(DownloaderConfig config) async { - final btExtra = config.extra.bt; - final trackers = []; - 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 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 { 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', -]; diff --git a/ui/flutter/lib/core/capabilities/app_capabilities.dart b/ui/flutter/lib/core/capabilities/app_capabilities.dart index be71ca0e..cbe4748a 100644 --- a/ui/flutter/lib/core/capabilities/app_capabilities.dart +++ b/ui/flutter/lib/core/capabilities/app_capabilities.dart @@ -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) diff --git a/ui/flutter/lib/core/capabilities/gopeed_capability.dart b/ui/flutter/lib/core/capabilities/gopeed_capability.dart index d7e497d4..6c3d2453 100644 --- a/ui/flutter/lib/core/capabilities/gopeed_capability.dart +++ b/ui/flutter/lib/core/capabilities/gopeed_capability.dart @@ -13,6 +13,8 @@ import '../../api/model/update_extension_settings.dart'; import 'capability_rpc.dart'; abstract final class GopeedMethods { + static const triggerProtocolJob = RpcMethod, RpcUnit>('gopeed.protocol.job.trigger'); + static const getMirrorCandidates = RpcMethod, List>('gopeed.mirrors.candidates'); static const resolve = RpcMethod('gopeed.resolve'); static const createTask = RpcMethod('gopeed.task.create'); static const createTaskBatch = RpcMethod>('gopeed.task.createBatch'); @@ -43,6 +45,10 @@ class GopeedService { final CapabilityInvoker _invoker; + Future triggerProtocolJob(String protocol, String name) async => + _invoker.invoke(GopeedMethods.triggerProtocolJob, {'protocol': protocol, 'name': name}); + Future> getMirrorCandidates(String provider, String url) => + _invoker.invoke(GopeedMethods.getMirrorCandidates, {'provider': provider, 'url': url}); Future resolve(ResolveTask request) => _invoker.invoke(GopeedMethods.resolve, request); Future createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request); diff --git a/ui/flutter/lib/core/network/app_update_client.dart b/ui/flutter/lib/core/network/app_update_client.dart new file mode 100644 index 00000000..b0f9ece2 --- /dev/null +++ b/ui/flutter/lib/core/network/app_update_client.dart @@ -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 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> _fetchGithubReleases() async { + final cancel = CancelToken(); + try { + final response = await _client + .get(releasesUrl, cancelToken: cancel, options: Options(followRedirects: false)) + .timeout(const Duration(seconds: 30)); + return _decodeReleases(response.data); + } finally { + cancel.cancel('GitHub release request completed'); + } + } + + Future 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 _decodeAssetSha256(dynamic assets) { + if (assets == null) return {}; + if (assets is! List) throw const FormatException('Invalid release assets'); + final result = {}; + 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 assetSha256; +} diff --git a/ui/flutter/lib/core/network/candidate_download.dart b/ui/flutter/lib/core/network/candidate_download.dart new file mode 100644 index 00000000..fbc0cde9 --- /dev/null +++ b/ui/flutter/lib/core/network/candidate_download.dart @@ -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 downloadFromCandidates( + List 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( + 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'; +} diff --git a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart index faad20b7..669d0216 100644 --- a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart +++ b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart @@ -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> proxyRequest(String uri, {dynamic data, Options? options}) { - return _externalDio.request( - uri, - data: data, - options: (options ?? Options()).copyWith(responseType: ResponseType.plain), - ); - } - @override String join(String path) => path; } diff --git a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart index fddb9474..112f2a96 100644 --- a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart +++ b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart @@ -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> proxyRequest(String uri, {dynamic data, Options? options}) { - options ??= Options(); - options.headers ??= {}; - options.headers!['X-Target-Uri'] = uri; - return _dio.request( - '/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; diff --git a/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart b/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart index aa2848ac..2b778519 100644 --- a/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart +++ b/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart @@ -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 request(String path, {String method = 'GET', dynamic data, Map? queryParameters}); - Future> proxyRequest(String uri, {dynamic data, Options? options}); - String join(String path); } diff --git a/ui/flutter/lib/core/network/mirror_client.dart b/ui/flutter/lib/core/network/mirror_client.dart new file mode 100644 index 00000000..8cf1f3ef --- /dev/null +++ b/ui/flutter/lib/core/network/mirror_client.dart @@ -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 getJson(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(); + final tokens = []; + var pending = urls.length; + Future attempt(String candidate, CancelToken token) async { + try { + final response = await _client + .get(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'); + } + } + } +} diff --git a/ui/flutter/lib/features/settings/application/settings_controller.dart b/ui/flutter/lib/features/settings/application/settings_controller.dart index 0af7e1b0..4c77effb 100644 --- a/ui/flutter/lib/features/settings/application/settings_controller.dart +++ b/ui/flutter/lib/features/settings/application/settings_controller.dart @@ -20,29 +20,55 @@ class SettingsState { } class SettingsController extends AsyncNotifier { + Future _saveTail = Future.value(); + int _saveRevision = 0; + @override Future 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 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 save(DownloaderConfig config) async { + Future 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 _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 testWebhook(String url) async { diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index d3ca1301..7b8f1ce5 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -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 { 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 { ], ), ), - _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 { ], 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 { ], ), ), + _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 { 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 { _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 { 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 { config.apiToken = _apiTokenController.text.trim(); } - Future _saveConfig() async { + Future _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 { _mutateConfig((config) => config.extra.backgroundLocationKeepAlive = enabled); await LocationKeepAliveCoordinator.instance.reconcile(enabled: enabled); } - Future - _setBackgroundContinuedProcessing( - bool enabled, - ) async { - if (enabled && - !await ContinuedProcessing.isSupported()) { + + Future _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 _lines(String text) { @@ -1410,13 +1391,13 @@ class _SettingsPageState extends ConsumerState { } List 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 _editGithubMirror([GithubMirror? mirror]) async { final draft = await showGithubMirrorDialog(context, mirror: mirror); if (draft == null || !mounted) return; _mutateConfig((config) { - final mirrors = List.from(config.extra.githubMirror.mirrors); + final mirrors = List.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 { 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 { if (mirror.isBuiltIn) { mirror.isDeleted = true; } else { - config.extra.githubMirror.mirrors = List.from(config.extra.githubMirror.mirrors)..remove(mirror); + config.githubMirror.mirrors = List.from(config.githubMirror.mirrors)..remove(mirror); } }); } @@ -1511,8 +1492,12 @@ class _SettingsPageState extends ConsumerState { 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 selected; - final bool autoUpdate; - final DateTime? lastUpdated; - final ValueChanged> onChanged; - final ValueChanged onAutoUpdateChanged; - final Future 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.from(widget.selected); - checked ? next.remove(url) : next.add(url); - widget.onChanged(next); - } - - void _toggleAll(bool allSelected) { - widget.onChanged(allSelected ? const [] : List.from(allTrackerSubscribeUrls)); - } - - Future _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}); diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart index 35a29293..e658fef1 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart @@ -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 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 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), + ), + ), ], ), ], diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart index 1c2abf5e..ec0fc19b 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_item.dart @@ -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; diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart index d64cf360..00c911c7 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart @@ -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)), ], ), ), diff --git a/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_control.dart b/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_control.dart new file mode 100644 index 00000000..f16a0952 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_control.dart @@ -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 urls; + final Map statuses; + final bool autoUpdate; + final DateTime? lastUpdated; + final ValueChanged> onChanged; + final ValueChanged onAutoUpdateChanged; + final Future Function() onUpdate; + + @override + State createState() => _TrackerSubscriptionsControlState(); +} + +class _TrackerSubscriptionsControlState extends State { + 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 _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 _delete(int index) async { + final url = widget.urls[index]; + if (!await showDeleteSettingsEntryDialog(context, url) || !mounted) return; + widget.onChanged(List.from(widget.urls)..remove(url)); + } + + Future _update() async { + setState(() => _updating = true); + try { + await widget.onUpdate(); + } finally { + if (mounted) setState(() => _updating = false); + } + } +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_schedule.dart b/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_schedule.dart new file mode 100644 index 00000000..3eaf9f6f --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_schedule.dart @@ -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 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)}'; + } +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_table.dart b/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_table.dart new file mode 100644 index 00000000..453c5039 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/tracker_subscriptions_table.dart @@ -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 urls; + final Map statuses; + final ValueChanged onDelete; + + @override + State createState() => _TrackerSubscriptionsTableState(); +} + +class _TrackerSubscriptionsTableState extends State { + 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), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 8ad2315f..f9845c82 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -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": "الإجراءات" } diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 2e20e587..23ca1b05 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 9c6824d1..aa633930 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index d7437017..679d4856 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index ae3c56d1..9b51a6ef 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index e9f3dfb4..1dce38f7 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index b47757d3..7f12ae8a 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -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": "عملیات" } diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index efca9e9f..5683c615 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index a5280139..f5bc8d3a 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index f334f2e3..39607a44 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 7d140ee8..38a4f45f 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 9928f2ce..2cd7d729 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -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": "操作" } diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 9c42a7e5..48bf3ec7 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -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": "작업" } diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index dafd8aa9..b39d206d 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 572f088a..55cec827 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 5a6bf19c..e1d2da3d 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -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": "Действия" } diff --git a/ui/flutter/lib/l10n/app_sk.arb b/ui/flutter/lib/l10n/app_sk.arb index a28e80c3..9a14f4d9 100644 --- a/ui/flutter/lib/l10n/app_sk.arb +++ b/ui/flutter/lib/l10n/app_sk.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index c48ff6a4..cc6f3b2f 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -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": "செயல்கள்" } diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 67253dbd..cfb7120b 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 20fb2caf..2143ba81 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -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": "Дії" } diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 68c04c25..b242ebde 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -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" } diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 19f54a05..920c4fc8 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -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": "操作" } diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index f3b59ca9..f1ed9ec6 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -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": "操作" } diff --git a/ui/flutter/lib/util/github_mirror.dart b/ui/flutter/lib/util/github_mirror.dart deleted file mode 100644 index d5183581..00000000 --- a/ui/flutter/lib/util/github_mirror.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../api/model/downloader_config.dart'; - -enum MirrorType { githubSource, githubRelease } - -Future 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(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 githubMirrorUrls(String rawUrl, MirrorType type, {required ExtraConfigGithubMirror config}) { - if (!config.enabled) return const []; - final urls = []; - 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; -} diff --git a/ui/flutter/lib/util/updater.dart b/ui/flutter/lib/util/updater.dart index 7da5040c..d742a8eb 100644 --- a/ui/flutter/lib/util/updater.dart +++ b/ui/flutter/lib/util/updater.dart @@ -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 installUpdater() async { ); } -Future checkUpdate() async { - List 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) { - throw const FormatException('Invalid GitHub releases response'); - } - releases = releaseData; - } catch (_) { - releases = await GopeedSiteApi.instance.getReleases(perPage: _releasePageSize); - } - return selectUpdateRelease(releases, appVersion); +Future 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 releases, String currentVersionText) { - late final Version currentVersion; - try { - currentVersion = _parseVersion(currentVersionText); - } on FormatException { - return null; - } - - Map? selectedRelease; - Version? selectedVersion; - - for (final item in releases) { - if (item is! Map) continue; - final release = Map.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 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 _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'; -} diff --git a/ui/flutter/test/api/gopeed_site_api_test.dart b/ui/flutter/test/api/gopeed_site_api_test.dart index fdda9510..58071940 100644 --- a/ui/flutter/test/api/gopeed_site_api_test.dart +++ b/ui/flutter/test/api/gopeed_site_api_test.dart @@ -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>(); - 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())); + }); + + test('summary requests go directly to the website and remain shared and cached', () async { + final pending = Completer>(); + final started = Completer(); + 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())); + 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 _response(Map json) => Response( - requestOptions: RequestOptions(path: '/test'), - data: jsonEncode(json), - statusCode: 200, -); - class _Transport implements GopeedTransport { - final urls = []; - Future> Function(String) onProxy = (_) async => _response({'data': []}); - + int calls = 0; @override - Future> proxyRequest(String uri, {dynamic data, Options? options}) { - urls.add(uri); - return onProxy(uri); + Future request( + String path, { + String method = 'GET', + dynamic data, + Map? 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 = []; + int statusCode = 200; + Future> Function(RequestOptions) onRequest = (_) async => {'data': []}; + @override + Future fetch( + RequestOptions options, + Stream>? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + return ResponseBody.fromString( + jsonEncode(await onRequest(options)), + statusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} } diff --git a/ui/flutter/test/api/protocol_config_test.dart b/ui/flutter/test/api/protocol_config_test.dart new file mode 100644 index 00000000..a2899ed6 --- /dev/null +++ b/ui/flutter/test/api/protocol_config_test.dart @@ -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>(codecs.encode(payload)), payload); + expect(codecs.decode?>(null), isNull); + expect(GopeedMethods.triggerProtocolJob.name, 'gopeed.protocol.job.trigger'); + expect(const RpcUnit(), isA()); + }); + + 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>(codecs.encode(result)), result); + expect(GopeedMethods.getMirrorCandidates.name, 'gopeed.mirrors.candidates'); + final request = {'provider': 'another-resource', 'url': 'https://example.com/resource'}; + expect(codecs.decode>(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 request( + String path, { + String method = 'GET', + dynamic data, + Map? 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; +} diff --git a/ui/flutter/test/api/resolve_test.dart b/ui/flutter/test/api/resolve_test.dart index 323a38c6..02d0ff23 100644 --- a/ui/flutter/test/api/resolve_test.dart +++ b/ui/flutter/test/api/resolve_test.dart @@ -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> proxyRequest(String uri, {dynamic data, dio.Options? options}) { - throw UnimplementedError(); - } } diff --git a/ui/flutter/test/core/network/app_update_client_test.dart b/ui/flutter/test/core/network/app_update_client_test.dart new file mode 100644 index 00000000..658bfde0 --- /dev/null +++ b/ui/flutter/test/core/network/app_update_client_test.dart @@ -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())); + 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, + 'error', + {'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())); + }); + + 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 = []; + late Future Function(RequestOptions, Future?) reply; + @override + Future fetch(RequestOptions options, Stream>? requestStream, Future? cancelFuture) { + requests.add(options); + return reply(options, cancelFuture); + } + + @override + void close({bool force = false}) {} +} + +class _RejectTransport implements GopeedTransport { + int calls = 0; + @override + Future request( + String path, { + String method = 'GET', + dynamic data, + Map? queryParameters, + }) async { + calls++; + throw StateError('Update checks must not use the Downloader or mirror API'); + } + + @override + String join(String path) => path; +} diff --git a/ui/flutter/test/core/network/candidate_download_test.dart b/ui/flutter/test/core/network/candidate_download_test.dart new file mode 100644 index 00000000..57d3db7a --- /dev/null +++ b/ui/flutter/test/core/network/candidate_download_test.dart @@ -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 downloads; + late Future 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 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.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.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.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.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('mirror error'); + }; + 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.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()), + ); + 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()), + ); + } + expect(requests, 0); + expect(await File(savePath).exists(), isFalse); + }); +} diff --git a/ui/flutter/test/core/network/mirror_client_test.dart b/ui/flutter/test/core/network/mirror_client_test.dart new file mode 100644 index 00000000..dc20a57d --- /dev/null +++ b/ui/flutter/test/core/network/mirror_client_test.dart @@ -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(); + final mirrorRead = Completer(); + adapter.reply = (request, _) { + if (request.uri.host == 'raw.githubusercontent.com') return official.future; + mirrorRead.complete(); + return Future.value(_json('mirror error')); + }; + 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(); + final canceled = Completer(); + 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(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(); + 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())); + await canceled.future; + }); +} + +List _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 = []; + late Future Function(RequestOptions, Future?) reply; + @override + Future fetch(RequestOptions options, Stream>? requestStream, Future? cancelFuture) { + requests.add(options); + return reply(options, cancelFuture); + } + + @override + void close({bool force = false}) {} +} + +class _CandidatesTransport implements GopeedTransport { + _CandidatesTransport(this.urls); + List urls; + String provider = 'github'; + bool fail = false; + final requested = []; + @override + Future request( + String path, { + String method = 'GET', + dynamic data, + Map? 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; +} diff --git a/ui/flutter/test/features/auth/application/web_auth_controller_test.dart b/ui/flutter/test/features/auth/application/web_auth_controller_test.dart index 3bae4cb3..70d5357f 100644 --- a/ui/flutter/test/features/auth/application/web_auth_controller_test.dart +++ b/ui/flutter/test/features/auth/application/web_auth_controller_test.dart @@ -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> proxyRequest(String uri, {dynamic data, Options? options}) { - throw UnimplementedError(); - } - @override String join(String path) => path; } diff --git a/ui/flutter/test/features/settings/application/settings_controller_test.dart b/ui/flutter/test/features/settings/application/settings_controller_test.dart index d1473f30..8629d330 100644 --- a/ui/flutter/test/features/settings/application/settings_controller_test.dart +++ b/ui/flutter/test/features/settings/application/settings_controller_test.dart @@ -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 = []; + 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(); + final release = Completer(); + final puts = []; + 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 { diff --git a/ui/flutter/test/features/settings/presentation/tracker_subscriptions_control_test.dart b/ui/flutter/test/features/settings/presentation/tracker_subscriptions_control_test.dart new file mode 100644 index 00000000..d758abb7 --- /dev/null +++ b/ui/flutter/test/features/settings/presentation/tracker_subscriptions_control_test.dart @@ -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 = []; + 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); + }); + } +} diff --git a/ui/flutter/test/updater_test.dart b/ui/flutter/test/updater_test.dart index 6ea25fad..7275f244 100644 --- a/ui/flutter/test/updater_test.dart +++ b/ui/flutter/test/updater_test.dart @@ -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 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(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( diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 393b7b0b..123588d9 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -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(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(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(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(); @override - Future updateTrackers(DownloaderConfig config) => _trackerUpdateCompleter.future; + Future updateTrackers() => _trackerUpdateCompleter.future; void completeTrackerUpdate() => _trackerUpdateCompleter.complete(); } class FailingTrackerRuntimeController extends FakeRuntimeController { @override - Future updateTrackers(DownloaderConfig config) async { + Future updateTrackers() async { throw StateError('tracker update failed'); } }