mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 20:03:46 +00:00
Merge latest main into 2.0.0-beta.2
This commit is contained in:
commit
212c735b66
34 files changed
+1590
-142
No files matched your search
@@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex
|
||||
|
||||
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
|
||||
|
||||
- [Download Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [Download Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
|
||||
|
||||
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
|
||||
|
||||
- [Gopeed 2.0.0 Beta 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [Gopeed 2.0.0 Beta 3 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu
|
||||
|
||||
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
|
||||
|
||||
- [Tải Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [Tải Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
||||
|
||||
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
|
||||
|
||||
- [下载 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [下载 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
||||
|
||||
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
|
||||
|
||||
- [下載 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [下載 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -83,10 +83,15 @@ func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
|
||||
{Length: 4, Path: []string{"keep.txt"}},
|
||||
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
||||
}}
|
||||
store := storage.NewFileOpts(storage.NewFileClientOpts{
|
||||
storageOpts := storage.NewFileClientOpts{
|
||||
ClientBaseDir: dir,
|
||||
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
||||
})
|
||||
}
|
||||
// VerifyDataContext can return before storage finishes marking the piece
|
||||
// complete. Disable automatic part-file promotion so it cannot rename
|
||||
// the .part fixtures that this test creates to exercise cleanup.
|
||||
storageOpts.UsePartFiles.Set(false)
|
||||
store := storage.NewFileOpts(storageOpts)
|
||||
defer store.Close()
|
||||
config := torrent.TestingConfig(t)
|
||||
config.DisableTCP = true
|
||||
|
||||
@@ -150,7 +150,7 @@ type slowStartController struct {
|
||||
totalLaunched int
|
||||
batchPending int // Connections in current batch waiting for HTTP response
|
||||
batchReady int // Connections in current batch that succeeded
|
||||
nextBatchSize int // Next batch size: 1, 2, 4, 8...
|
||||
nextBatchSize int // Next batch size: 1, 2, 4, 16, 256... (capped)
|
||||
expansionCh chan struct{} // Signal to trigger next expansion
|
||||
paused bool // Pause expansion (e.g., on 429)
|
||||
}
|
||||
@@ -229,7 +229,15 @@ func (s *slowStartController) commitBatch(count int) {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.totalLaunched += count
|
||||
s.nextBatchSize = s.nextBatchSize * 2 // Exponential growth: 1, 2, 4, 8...
|
||||
if s.nextBatchSize == 1 {
|
||||
// Bootstrap growth: squaring one would never increase the batch size.
|
||||
s.nextBatchSize = min(2, s.maxConnections)
|
||||
} else if s.nextBatchSize > s.maxConnections/s.nextBatchSize {
|
||||
// Cap before multiplying to avoid integer overflow.
|
||||
s.nextBatchSize = s.maxConnections
|
||||
} else {
|
||||
s.nextBatchSize *= s.nextBatchSize
|
||||
}
|
||||
s.batchPending = count
|
||||
s.batchReady = 0
|
||||
}
|
||||
@@ -409,12 +417,26 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
res.Range = true
|
||||
}
|
||||
|
||||
// Get content length from Content-Length header
|
||||
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
|
||||
if contentLength != "" {
|
||||
parse, err := strconv.ParseInt(contentLength, 10, 64)
|
||||
if err == nil {
|
||||
res.Size = parse
|
||||
// A partial response describes one segment, not the whole resource.
|
||||
reuseResolveBody := true
|
||||
if resp.StatusCode == base.HttpCodePartialContent {
|
||||
start, _, total, err := parseRangeResponse(resp)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
f.setState(stateError)
|
||||
return err
|
||||
}
|
||||
res.Size = total
|
||||
res.Range = true
|
||||
// Prefetch is a contiguous prefix copied at offset zero. A segment
|
||||
// starting elsewhere must be fetched again through the Range path.
|
||||
reuseResolveBody = start == 0
|
||||
} else {
|
||||
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
|
||||
if contentLength != "" {
|
||||
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil {
|
||||
res.Size = size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +481,12 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
// IMPORTANT: Keep the response body open for downloading in Start phase
|
||||
// This is crucial for one-time URLs that can only be accessed once
|
||||
f.resolveRespLock.Lock()
|
||||
f.resolveResp = resp
|
||||
if reuseResolveBody {
|
||||
f.resolveResp = resp
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
f.prefetchDone.Store(true)
|
||||
}
|
||||
f.resolveRespLock.Unlock()
|
||||
|
||||
f.setState(stateResolved)
|
||||
@@ -471,7 +498,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
|
||||
// Start async prefetch in background (only for range-supported resources)
|
||||
// For non-range resources, the response will be used directly in Start
|
||||
if res.Range {
|
||||
if res.Range && reuseResolveBody {
|
||||
f.prefetchStopCh = make(chan struct{})
|
||||
f.resolveFallback.Store(true)
|
||||
go f.asyncPrefetch()
|
||||
@@ -1536,27 +1563,41 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
|
||||
// parseRangeResponse validates a single byte range with a known complete length.
|
||||
// Unknown totals are rejected: a segment length cannot stand in for resource size.
|
||||
func parseRangeResponse(resp *http.Response) (int64, int64, int64, error) {
|
||||
contentRange := resp.Header.Get(base.HttpHeaderContentRange)
|
||||
fields := strings.Fields(contentRange)
|
||||
if len(fields) != 2 || !strings.EqualFold(fields[0], base.HttpHeaderBytes) {
|
||||
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
}
|
||||
|
||||
rangeAndTotal := strings.Split(fields[1], "/")
|
||||
if len(rangeAndTotal) != 2 {
|
||||
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
}
|
||||
bounds := strings.Split(rangeAndTotal[0], "-")
|
||||
if len(bounds) != 2 {
|
||||
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
}
|
||||
|
||||
start, startErr := strconv.ParseInt(bounds[0], 10, 64)
|
||||
end, endErr := strconv.ParseInt(bounds[1], 10, 64)
|
||||
total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64)
|
||||
if startErr != nil || endErr != nil || totalErr != nil || start < 0 || end < start || total <= end {
|
||||
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
}
|
||||
expectedLength := end - start + 1
|
||||
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
|
||||
return 0, 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
|
||||
}
|
||||
return start, end, total, nil
|
||||
}
|
||||
|
||||
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
|
||||
start, end, total, err := parseRangeResponse(resp)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if start != requestedStart || (requestedEnd != nil && end != *requestedEnd) || (requestedEnd == nil && end != total-1) {
|
||||
return 0, 0, fmt.Errorf("%w: Content-Range %d-%d does not match requested range", errInvalidRangeResponse, start, end)
|
||||
@@ -1565,11 +1606,7 @@ func validateRangeResponse(resp *http.Response, requestedStart int64, requestedE
|
||||
return 0, 0, fmt.Errorf("%w: Content-Range total %d does not match resource size %d", errInvalidRangeResponse, total, totalSize)
|
||||
}
|
||||
|
||||
expectedLength := end - start + 1
|
||||
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
|
||||
return 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
|
||||
}
|
||||
return expectedLength, total, nil
|
||||
return end - start + 1, total, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
func TestResolveResponseSize(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
length, contentRange string
|
||||
size int64
|
||||
invalid bool
|
||||
}{
|
||||
{"full", 200, "801571015", "", 801571015, false},
|
||||
{"partial_prefix", 206, "472321339", "bytes 0-472321338/801571015", 801571015, false},
|
||||
{"partial_nonzero", 206, "100000", "bytes 100000-199999/801571015", 801571015, false},
|
||||
{"second_report", 206, "335134409", "bytes 0-335134408/552140234", 552140234, false},
|
||||
{"length_mismatch", 206, "10", "bytes 0-19/100", 0, true},
|
||||
{"unknown_total", 206, "472321339", "bytes 0-472321338/*", 0, true},
|
||||
{"missing_range", 206, "10", "", 0, true},
|
||||
{"malformed_range", 206, "10", "bytes invalid", 0, true},
|
||||
{"invalid_bounds", 206, "10", "bytes 20-10/100", 0, true},
|
||||
{"invalid_total", 206, "10", "bytes 0-9/9", 0, true},
|
||||
{"overflow", 206, "10", "bytes 0-9/9223372036854775808", 0, true},
|
||||
{"chunked_partial", 206, "", "bytes 0-9/100", 100, false},
|
||||
{"unknown_full", 200, "", "", 0, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if tc.length != "" {
|
||||
w.Header().Set("Content-Length", tc.length)
|
||||
}
|
||||
if tc.contentRange != "" {
|
||||
w.Header().Set("Content-Range", tc.contentRange)
|
||||
}
|
||||
w.WriteHeader(tc.status)
|
||||
w.(http.Flusher).Flush()
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
err := f.Resolve(&base.Request{URL: server.URL + "/file"}, &base.Options{Path: t.TempDir()})
|
||||
if tc.invalid {
|
||||
if !errors.Is(err, errInvalidRangeResponse) {
|
||||
t.Fatalf("Resolve error = %v, want invalid range", err)
|
||||
}
|
||||
if f.meta.Res != nil || f.resolveResp != nil || f.getState() != stateError {
|
||||
t.Fatal("invalid response was retained or published")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.meta.Res.Size != tc.size || f.meta.Res.Files[0].Size != tc.size {
|
||||
t.Fatalf("resource/file size = %d/%d, want %d", f.meta.Res.Size, f.meta.Res.Files[0].Size, tc.size)
|
||||
}
|
||||
if tc.status == 206 && !f.meta.Res.Range {
|
||||
t.Fatal("partial response must enable Range downloads")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPartialResolve(t *testing.T) {
|
||||
payload := make([]byte, 256*1024)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i*31 + i/251)
|
||||
}
|
||||
for _, start := range []int{0, 100000} {
|
||||
for _, connections := range []int{1, 4} {
|
||||
t.Run(fmt.Sprintf("start_%d_connections_%d", start, connections), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
begin, end := start, start+9999
|
||||
if h := r.Header.Get("Range"); h != "" {
|
||||
if _, err := fmt.Sscanf(h, "bytes=%d-%d", &begin, &end); err != nil {
|
||||
t.Errorf("invalid request range %q: %v", h, err)
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprint(end-begin+1))
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", begin, end, len(payload)))
|
||||
w.WriteHeader(206)
|
||||
w.Write(payload[begin : end+1])
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/file"}, &base.Options{Path: t.TempDir(), Name: "file", Extra: &fhttp.OptsExtra{Connections: connections}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(f.meta.SingleFilepath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("download content mismatch: got %d bytes, want %d", len(got), len(payload))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,10 +61,13 @@ Go proxy unit tests separately cover successful TLS forwarding and authenticatio
|
||||
These cases run in the existing `test.yml` WebView jobs; existing mobile build jobs
|
||||
compile the Android/iOS bridge. No separate Flutter test application is required.
|
||||
|
||||
## Extension events and host version
|
||||
## Extension events and host environment
|
||||
|
||||
`gopeed.info.hostVersion` is the host's build version (`dev` for development
|
||||
`gopeed.host.env.version` is the host's build version (`dev` for development
|
||||
builds). `gopeed.info.version` continues to identify the extension's own version.
|
||||
`gopeed.host.env.os` and `gopeed.host.env.arch` match the REST info endpoint's `os` and
|
||||
`arch`: the host's Go `runtime.GOOS` and `runtime.GOARCH` values (for example,
|
||||
`windows` / `amd64` or `darwin` / `arm64`).
|
||||
See [extension-api.d.ts](extension-api.d.ts) for the event payloads.
|
||||
|
||||
```js
|
||||
|
||||
Vendored
+13
-1
@@ -5,7 +5,19 @@ export interface ExtensionInfo {
|
||||
author: string;
|
||||
title: string;
|
||||
version: string;
|
||||
hostVersion: string;
|
||||
}
|
||||
|
||||
/** Available as gopeed.host. */
|
||||
export interface Host {
|
||||
env: HostEnv;
|
||||
}
|
||||
|
||||
export interface HostEnv {
|
||||
/** Host build version; "dev" for development builds. */
|
||||
version: string;
|
||||
/** Host OS and architecture, matching the REST info endpoint (Go GOOS/GOARCH). */
|
||||
os: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
/** Functions are serialized; they cannot capture extension-side variables. */
|
||||
|
||||
@@ -25,24 +25,6 @@ type Request struct {
|
||||
SkipVerifyCert bool `json:"skipVerifyCert"`
|
||||
}
|
||||
|
||||
// SetLabels replaces all labels on the request.
|
||||
func (r *Request) SetLabels(labels map[string]string) {
|
||||
r.Labels = labels
|
||||
}
|
||||
|
||||
// PutLabel sets a label on the request.
|
||||
func (r *Request) PutLabel(key, value string) {
|
||||
if r.Labels == nil {
|
||||
r.Labels = make(map[string]string)
|
||||
}
|
||||
r.Labels[key] = value
|
||||
}
|
||||
|
||||
// DelLabel deletes a label from the request.
|
||||
func (r *Request) DelLabel(key string) {
|
||||
delete(r.Labels, key)
|
||||
}
|
||||
|
||||
func (r *Request) Validate() error {
|
||||
if r.URL == "" {
|
||||
return fmt.Errorf("invalid request url")
|
||||
|
||||
@@ -45,6 +45,32 @@ type generationTestManager struct {
|
||||
|
||||
func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
|
||||
taskType := reflect.TypeOf((*Task)(nil))
|
||||
for _, typ := range []reflect.Type{taskType, reflect.TypeOf((*ExtensionTask)(nil)), reflect.TypeOf((*OnErrorExtensionTask)(nil))} {
|
||||
for _, name := range []string{"SetUrl", "SetExtra", "PatchExtra", "PatchHeaders", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} {
|
||||
if _, ok := typ.MethodByName(name); ok {
|
||||
t.Fatalf("%v unexpectedly exposes %s", typ, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
requestType := reflect.TypeOf((*ExtensionTaskRequest)(nil))
|
||||
for _, name := range []string{"SetUrl", "SetLabels", "PutLabel", "DelLabel", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} {
|
||||
if _, ok := requestType.MethodByName(name); !ok {
|
||||
t.Fatalf("request wrapper does not expose %s", name)
|
||||
}
|
||||
}
|
||||
for _, typ := range []reflect.Type{reflect.TypeOf(ExtensionTask{}), reflect.TypeOf(OnErrorExtensionTask{})} {
|
||||
if _, ok := typ.FieldByName("Req"); ok {
|
||||
t.Fatalf("%v exposes the removed task.req entry", typ)
|
||||
}
|
||||
field, ok := typ.FieldByName("Meta")
|
||||
if !ok || field.Type != reflect.TypeOf((*ExtensionTaskMeta)(nil)) {
|
||||
t.Fatalf("%v has no metadata wrapper", typ)
|
||||
}
|
||||
}
|
||||
field, ok := reflect.TypeOf(ExtensionTaskMeta{}).FieldByName("Req")
|
||||
if !ok || field.Type != requestType {
|
||||
t.Fatal("meta.req has no request wrapper")
|
||||
}
|
||||
if _, ok := taskType.MethodByName("Continue"); ok {
|
||||
t.Fatal("regular task unexpectedly exposes Continue")
|
||||
}
|
||||
@@ -53,17 +79,11 @@ func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
|
||||
}
|
||||
|
||||
extensionTaskType := reflect.TypeOf((*ExtensionTask)(nil))
|
||||
if _, ok := extensionTaskType.MethodByName("SetUrl"); !ok {
|
||||
t.Fatal("extension task does not expose SetUrl")
|
||||
}
|
||||
if _, ok := extensionTaskType.MethodByName("Continue"); ok {
|
||||
t.Fatal("non-error extension task unexpectedly exposes Continue")
|
||||
}
|
||||
|
||||
errorTaskType := reflect.TypeOf((*OnErrorExtensionTask)(nil))
|
||||
if _, ok := errorTaskType.MethodByName("SetUrl"); !ok {
|
||||
t.Fatal("onError task does not expose SetUrl")
|
||||
}
|
||||
if _, ok := errorTaskType.MethodByName("Continue"); !ok {
|
||||
t.Fatal("onError task does not expose Continue")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
@@ -735,6 +736,14 @@ type OnDoneContext struct {
|
||||
// only some fields can be modified, such as request info.
|
||||
type ExtensionTask struct {
|
||||
*Task
|
||||
Meta *ExtensionTaskMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// ExtensionTaskMeta preserves metadata fields while exposing request operations
|
||||
// at the existing meta.req path.
|
||||
type ExtensionTaskMeta struct {
|
||||
*fetcher.FetcherMeta
|
||||
Req *ExtensionTaskRequest `json:"req"`
|
||||
}
|
||||
|
||||
// OnErrorExtensionTask adds error-recovery controls to ExtensionTask.
|
||||
@@ -752,7 +761,14 @@ func cloneExtensionTask(task *Task) *Task {
|
||||
}
|
||||
|
||||
func newExtensionTask(task *Task) *ExtensionTask {
|
||||
return &ExtensionTask{Task: cloneExtensionTask(task)}
|
||||
view := cloneExtensionTask(task)
|
||||
return &ExtensionTask{
|
||||
Task: view,
|
||||
Meta: &ExtensionTaskMeta{
|
||||
FetcherMeta: view.Meta,
|
||||
Req: &ExtensionTaskRequest{Request: view.Meta.Req, task: view},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newOnStartExtensionTask(task *Task) *ExtensionTask {
|
||||
@@ -770,11 +786,6 @@ func newOnDoneExtensionTask(task *Task) *Task {
|
||||
return cloneExtensionTask(task)
|
||||
}
|
||||
|
||||
// SetUrl replaces the task request URL.
|
||||
func (t *ExtensionTask) SetUrl(url string) {
|
||||
t.Meta.Req.URL = url
|
||||
}
|
||||
|
||||
func (t *OnErrorExtensionTask) Continue() error {
|
||||
return t.download.Continue(&TaskFilter{
|
||||
IDs: []string{t.ID},
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestExtensionProtocolMethods(t *testing.T) {
|
||||
for _, extra := range []any{nil, map[string]any{"header": map[string]any{"Keep": "yes"}}, &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}} {
|
||||
vm := goja.New()
|
||||
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
req := &base.Request{Extra: extra}
|
||||
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
|
||||
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := vm.RunString(`task.meta.req.setMethod('POST'); task.meta.req.setBody('payload'); task.meta.req.putHeader('Authorization','Bearer token'); task.meta.req.putHeader('Remove','remove'); task.meta.req.delHeader('REMOVE');`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := req.Extra.(*httpProtocol.ReqExtra)
|
||||
if got.Method != "POST" || got.Body != "payload" || got.Header["Authorization"] != "Bearer token" {
|
||||
t.Fatalf("mutation not applied: %#v", got)
|
||||
}
|
||||
if _, ok := got.Header["Remove"]; ok {
|
||||
t.Fatal("header deletion failed")
|
||||
}
|
||||
if extra != nil && got.Header["Keep"] != "yes" {
|
||||
t.Fatal("existing header lost")
|
||||
}
|
||||
}
|
||||
vm := goja.New()
|
||||
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
req := &base.Request{Extra: &btProtocol.ReqExtra{Trackers: []string{"old"}}}
|
||||
task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
|
||||
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := vm.RunString(`task.meta.req.setTrackers(['new','another']);`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := req.Extra.(*btProtocol.ReqExtra).Trackers
|
||||
if len(got) != 2 || got[0] != "new" || got[1] != "another" {
|
||||
t.Fatalf("trackers = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionProtocolMethodsRejectInvalidMutation(t *testing.T) {
|
||||
vm := goja.New()
|
||||
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
original := &httpProtocol.ReqExtra{Method: "POST", Header: map[string]string{"authorization": "old", "Keep": "yes"}}
|
||||
req := &base.Request{Extra: original}
|
||||
task := newOnStartExtensionTask(&Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}})
|
||||
if err := vm.Set("task", task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, script := range []string{`task.meta.req.setHeaders({Keep:null})`, `task.meta.req.setHeaders({Keep:123})`, `task.meta.req.setHeaders({a:'1',A:'2'})`} {
|
||||
if _, err := vm.RunString(script); err == nil {
|
||||
t.Fatalf("accepted invalid mutation: %s", script)
|
||||
}
|
||||
if req.Extra != original || original.Header["Keep"] != "yes" {
|
||||
t.Fatal("invalid operation changed original extra")
|
||||
}
|
||||
}
|
||||
if _, err := vm.RunString(`task.meta.req.putHeader('Authorization','new');`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := req.Extra.(*httpProtocol.ReqExtra)
|
||||
if len(got.Header) != 2 || got.Header["Authorization"] != "new" || original.Header["authorization"] != "old" {
|
||||
t.Fatalf("header update = %#v", got.Header)
|
||||
}
|
||||
if _, err := vm.RunString(`task.meta.req.setHeaders({Only:'one'});`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = req.Extra.(*httpProtocol.ReqExtra)
|
||||
if got.Method != "POST" || len(got.Header) != 1 {
|
||||
t.Fatalf("setHeaders = %#v", got)
|
||||
}
|
||||
req.Extra = map[string]any{"header": 123}
|
||||
if _, err := vm.RunString(`task.meta.req.setBody('new')`); err == nil {
|
||||
t.Fatal("invalid protocol extra accepted")
|
||||
}
|
||||
if req.Extra.(map[string]any)["header"] != 123 {
|
||||
t.Fatal("conversion failure mutated extra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionProtocolMethodsOtherProtocolsAreNoOp(t *testing.T) {
|
||||
for _, protocol := range []string{"http", "bt", "hls"} {
|
||||
for _, onError := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/onError=%v", protocol, onError), func(t *testing.T) {
|
||||
vm := goja.New()
|
||||
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
// An invalid extra proves that unrelated methods skip conversion entirely.
|
||||
original := map[string]any{"unchanged": true, "header": 123}
|
||||
req := &base.Request{Extra: original}
|
||||
task := &Task{Protocol: protocol, Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
|
||||
var wrapper any = newOnStartExtensionTask(task)
|
||||
if onError {
|
||||
wrapper = newOnErrorExtensionTask(nil, task)
|
||||
}
|
||||
if err := vm.Set("task", wrapper); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if protocol != "http" {
|
||||
if _, err := vm.RunString(`task.meta.req.setMethod('POST');task.meta.req.setBody('body');task.meta.req.setHeaders({a:null});task.meta.req.putHeader('A','value');task.meta.req.delHeader('A');`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if protocol != "bt" {
|
||||
if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(req.Extra, original) {
|
||||
t.Fatalf("extra changed: %#v", req.Extra)
|
||||
}
|
||||
original["same-object"] = true
|
||||
if req.Extra.(map[string]any)["same-object"] != true {
|
||||
t.Fatal("no-op replaced extra")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionOnErrorRequestWritesToOriginalTask(t *testing.T) {
|
||||
vm := goja.New()
|
||||
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
req := &base.Request{URL: "https://example.com", Extra: &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}}
|
||||
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
|
||||
if err := vm.Set("task", newOnErrorExtensionTask(nil, task)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := vm.RunString(`
|
||||
if (typeof task.continue !== 'function') throw new Error('missing continue');
|
||||
if (typeof task.putHeader !== 'undefined') throw new Error('task still exposes protocol methods');
|
||||
if (typeof task.req !== 'undefined') throw new Error('legacy task.req');
|
||||
const request = task.meta.req;
|
||||
if (request.url !== 'https://example.com' || request.extra.header.Keep !== 'yes') throw new Error('request data missing');
|
||||
if (!task.meta.opts) throw new Error('metadata fields missing');
|
||||
request.setHeaders({Keep:'new'});
|
||||
request.putHeader('Authorization','Bearer new');
|
||||
request.setMethod('POST');
|
||||
if (typeof task.setUrl !== 'undefined' || typeof task.extra !== 'undefined') throw new Error('legacy task API');
|
||||
request.setUrl('https://example.com/new');
|
||||
request.setLabels({keep:'yes',remove:'yes'});
|
||||
request.putLabel('new','value');
|
||||
request.delLabel('remove');
|
||||
if (request.url !== 'https://example.com/new' || request.labels.new !== 'value' || request.extra.method !== 'POST' || request.extra.header.Authorization !== 'Bearer new') throw new Error('request data did not refresh');
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(req.Labels, map[string]string{"keep": "yes", "new": "value"}) {
|
||||
t.Fatalf("labels = %#v", req.Labels)
|
||||
}
|
||||
got := req.Extra.(*httpProtocol.ReqExtra)
|
||||
if got.Method != "POST" || got.Header["Keep"] != "new" || got.Header["Authorization"] != "Bearer new" || req.URL != "https://example.com/new" {
|
||||
t.Fatalf("original request not updated: %#v, %#v", req, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionBTRequestInitializesNilExtra(t *testing.T) {
|
||||
vm := goja.New()
|
||||
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
req := &base.Request{}
|
||||
task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
|
||||
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := req.Extra.(*btProtocol.ReqExtra)
|
||||
if !ok || !reflect.DeepEqual(got.Trackers, []string{"udp://example.com"}) {
|
||||
t.Fatalf("extra = %#v", req.Extra)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
func TestExtensionExtraMutationHTTP(t *testing.T) {
|
||||
for _, resolved := range []bool{false, true} {
|
||||
for _, invalid := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("resolved=%v/invalid=%v", resolved, invalid), func(t *testing.T) {
|
||||
var updatedRequests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-New") == "new" {
|
||||
updatedRequests.Add(1)
|
||||
if r.Header.Get("X-Old") != "" {
|
||||
t.Error("extra mutation retained the old header")
|
||||
}
|
||||
}
|
||||
http.ServeContent(w, r, "test.txt", time.Time{}, strings.NewReader("extra mutation payload"))
|
||||
}))
|
||||
defer server.Close()
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
extDir := t.TempDir()
|
||||
manifest := `{"name":"http-methods","title":"HTTP methods test","version":"0.0.1","scripts":[{"event":"onStart","match":{"labels":["test"]},"entry":"index.js"}]}`
|
||||
mutation := `await ctx.task.meta.req.setMethod('GET');
|
||||
await ctx.task.meta.req.setBody('');
|
||||
await ctx.task.meta.req.delHeader('x-old');
|
||||
await ctx.task.meta.req.putHeader('X-New', 'new');`
|
||||
if invalid {
|
||||
mutation = `try { await ctx.task.meta.req.setHeaders({'X-New':123}); }
|
||||
catch (err) { await ctx.task.meta.req.putLabel('rejected','true'); } ` + mutation
|
||||
}
|
||||
script := `gopeed.events.onStart(async ctx => { ` + mutation + ` });`
|
||||
for name, content := range map[string]string{"manifest.json": manifest, "index.js": script} {
|
||||
if err := os.WriteFile(filepath.Join(extDir, name), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := d.InstallExtensionByFolder(extDir, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
d.Listener(func(event *Event) {
|
||||
if event.Key == EventKeyFinally {
|
||||
done <- event.Err
|
||||
}
|
||||
})
|
||||
req := &base.Request{URL: server.URL + "/test.txt", Labels: map[string]string{"test": "true"}, Extra: &httpProtocol.ReqExtra{Header: map[string]string{"X-Old": "old"}}}
|
||||
opts := &base.Options{Path: t.TempDir()}
|
||||
var id string
|
||||
var err error
|
||||
if resolved {
|
||||
rr, resolveErr := d.Resolve(req, opts)
|
||||
if resolveErr != nil {
|
||||
t.Fatal(resolveErr)
|
||||
}
|
||||
id, err = d.Create(rr.ID)
|
||||
} else {
|
||||
id, err = d.CreateDirect(req, opts)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("download timed out")
|
||||
}
|
||||
if invalid && d.GetTask(id).Meta.Req.Labels["rejected"] != "true" {
|
||||
t.Fatal("invalid header was not rejected")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := d.GetTask(id).Meta.Req.Extra.(*httpProtocol.ReqExtra)
|
||||
if !ok || got.Header["X-New"] != "new" || len(got.Header) != 1 {
|
||||
t.Fatalf("unexpected protocol extra: %#v", d.GetTask(id).Meta.Req.Extra)
|
||||
}
|
||||
if !resolved && updatedRequests.Load() == 0 {
|
||||
t.Fatal("download did not use the extension headers")
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(opts.Path, "test.txt"))
|
||||
if err != nil || string(data) != "extra mutation payload" {
|
||||
t.Fatalf("download content = %q, error = %v", data, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
type InstanceHost struct {
|
||||
Env HostEnv `json:"env"`
|
||||
}
|
||||
|
||||
type HostEnv struct {
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
}
|
||||
|
||||
func newInstanceHost() *InstanceHost {
|
||||
return &InstanceHost{Env: HostEnv{
|
||||
Version: base.Version,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
}}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
wv "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -23,17 +24,21 @@ type eventTestOpener struct{ page wv.Page }
|
||||
|
||||
func (o eventTestOpener) Open(wv.OpenOptions) (wv.Page, error) { return o.page, nil }
|
||||
|
||||
func TestExtensionInfoHostVersion(t *testing.T) {
|
||||
func TestExtensionHostEnv(t *testing.T) {
|
||||
e := engine.NewEngine(nil)
|
||||
defer e.Close()
|
||||
info := NewExtensionInfo(&Extension{Name: "sample", Author: "test", Version: "1.2.3"})
|
||||
if err := injectGopeed(e.Runtime, &Instance{Info: info}, e.Post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := e.RunString(`gopeed.info.hostVersion + "/" + gopeed.info.version`)
|
||||
if err != nil || result != base.Version+"/1.2.3" {
|
||||
result, err := e.RunString(`gopeed.host.env.version + "/" + gopeed.info.version + "/" + gopeed.host.env.os + "/" + gopeed.host.env.arch`)
|
||||
if err != nil || result != base.Version+"/1.2.3/"+runtime.GOOS+"/"+runtime.GOARCH {
|
||||
t.Fatalf("%v %v", result, err)
|
||||
}
|
||||
result, err = e.RunString(`["hostVersion", "os", "arch"].some(key => key in gopeed.info)`)
|
||||
if err != nil || result != false {
|
||||
t.Fatalf("host metadata retained in extension info: %v %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionWebViewEvents(t *testing.T) {
|
||||
|
||||
@@ -16,6 +16,9 @@ func injectGopeed(vm *goja.Runtime, gopeed *Instance, post func(func(*goja.Runti
|
||||
if err := gopeedObject.Set("events", newJSEventsRuntime(vm, gopeed.Events)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("host", newInstanceHost()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("info", gopeed.Info); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ExtensionTaskRequest exposes request operations without exposing the backing task.
|
||||
// It always reads the current request so a retained handle sees subsequent updates.
|
||||
type ExtensionTaskRequest struct {
|
||||
*base.Request
|
||||
task *Task
|
||||
}
|
||||
|
||||
// SetUrl replaces the task request URL.
|
||||
func (r *ExtensionTaskRequest) SetUrl(url string) { r.task.Meta.Req.URL = url }
|
||||
|
||||
// SetLabels replaces all labels on the request.
|
||||
func (r *ExtensionTaskRequest) SetLabels(labels map[string]string) {
|
||||
r.Labels = labels
|
||||
}
|
||||
|
||||
// PutLabel sets a label on the request.
|
||||
func (r *ExtensionTaskRequest) PutLabel(key, value string) {
|
||||
if r.Labels == nil {
|
||||
r.Labels = make(map[string]string)
|
||||
}
|
||||
r.Labels[key] = value
|
||||
}
|
||||
|
||||
// DelLabel deletes a label from the request.
|
||||
func (r *ExtensionTaskRequest) DelLabel(key string) {
|
||||
delete(r.Labels, key)
|
||||
}
|
||||
|
||||
// mutateTaskExtra converts into an independent protocol value before mutation.
|
||||
// Other protocols are a no-op. Failed conversion or mutation must not change the live request.
|
||||
func mutateTaskExtra[E any](task *Task, protocol string, mutate func(*E) error) error {
|
||||
if task.Protocol != protocol {
|
||||
return nil
|
||||
}
|
||||
if task.Meta == nil || task.Meta.Req == nil {
|
||||
return fmt.Errorf("task request is missing")
|
||||
}
|
||||
var extra E
|
||||
if err := util.MapToStruct(task.Meta.Req.Extra, &extra); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mutate(&extra); err != nil {
|
||||
return err
|
||||
}
|
||||
task.Meta.Req.Extra = &extra
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ExtensionTaskRequest) SetMethod(method string) error {
|
||||
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Method = method; return nil })
|
||||
}
|
||||
|
||||
func (t *ExtensionTaskRequest) SetBody(body string) error {
|
||||
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Body = body; return nil })
|
||||
}
|
||||
|
||||
// decodeHeaders omits undefined properties and rejects non-string values.
|
||||
func decodeHeaders(value *goja.Object) (map[string]string, error) {
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("headers must be an object")
|
||||
}
|
||||
data, err := value.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var input map[string]*string
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input == nil {
|
||||
return nil, fmt.Errorf("headers must be an object")
|
||||
}
|
||||
headers := make(map[string]string, len(input))
|
||||
seen := make(map[string]bool, len(input))
|
||||
for name, value := range input {
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("header %q must be a string", name)
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
if seen[lower] {
|
||||
return nil, fmt.Errorf("duplicate header name %q", name)
|
||||
}
|
||||
seen[lower] = true
|
||||
headers[http.CanonicalHeaderKey(name)] = *value
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
func (t *ExtensionTaskRequest) SetHeaders(headers *goja.Object) error {
|
||||
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error {
|
||||
values, err := decodeHeaders(headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
extra.Header = values
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (t *ExtensionTaskRequest) PutHeader(name, value string) error {
|
||||
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error {
|
||||
if extra.Header == nil {
|
||||
extra.Header = make(map[string]string)
|
||||
}
|
||||
deleteHeader(extra.Header, name)
|
||||
extra.Header[http.CanonicalHeaderKey(name)] = value
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (t *ExtensionTaskRequest) DelHeader(name string) error {
|
||||
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { deleteHeader(extra.Header, name); return nil })
|
||||
}
|
||||
|
||||
func deleteHeader(headers map[string]string, name string) {
|
||||
for key := range headers {
|
||||
if strings.EqualFold(key, name) {
|
||||
delete(headers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExtensionTaskRequest) SetTrackers(trackers []string) error {
|
||||
return mutateTaskExtra(t.task, "bt", func(extra *btProtocol.ReqExtra) error { extra.Trackers = append([]string{}, trackers...); return nil })
|
||||
}
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
@@ -38,13 +40,13 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
||||
t.Fatal("resolve error")
|
||||
}
|
||||
if req.Labels["replaced"] != "true" || req.Labels["modified"] != "true" {
|
||||
t.Fatalf("request label methods did not update labels: %#v", req.Labels)
|
||||
t.Fatalf("declarative labels did not update: %#v", req.Labels)
|
||||
}
|
||||
if _, ok := req.Labels["original"]; ok {
|
||||
t.Fatalf("setLabels did not replace existing labels: %#v", req.Labels)
|
||||
t.Fatalf("label assignment did not replace existing labels: %#v", req.Labels)
|
||||
}
|
||||
if _, ok := req.Labels["removed"]; ok {
|
||||
t.Fatalf("delLabel did not remove label: %#v", req.Labels)
|
||||
t.Fatalf("declarative label deletion failed: %#v", req.Labels)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1188,6 +1190,37 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDownloader_Extension_OnStartExtraMutation(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
if _, err := d.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, extra := range []any{nil, &httpProtocol.ReqExtra{
|
||||
Method: "POST", Body: "old body", Header: map[string]string{"Old": "value"},
|
||||
}} {
|
||||
req := &base.Request{URL: "https://github.com/test", Extra: extra}
|
||||
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
|
||||
d.triggerOnStart(task)
|
||||
if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := req.Extra.(*httpProtocol.ReqExtra)
|
||||
if !ok || got.Header["X-Gopeed-Test"] != "on-start" {
|
||||
t.Fatalf("setHeaders did not update the original request: %#v", req.Extra)
|
||||
}
|
||||
if len(got.Header) != 1 {
|
||||
t.Fatalf("headers not replaced: %#v", got)
|
||||
}
|
||||
if original, ok := extra.(*httpProtocol.ReqExtra); ok && (got.Method != original.Method || got.Body != original.Body) {
|
||||
t.Fatalf("setHeaders changed unrelated fields: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_Extension_OnStart(t *testing.T) {
|
||||
downloadAndCheck := func(req *base.Request) {
|
||||
setupDownloader(func(downloader *Downloader) {
|
||||
|
||||
+6
-3
@@ -1,7 +1,10 @@
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
await ctx.req.setLabels({ replaced: "true", removed: "true" });
|
||||
await ctx.req.putLabel("modified", "true");
|
||||
await ctx.req.delLabel("removed");
|
||||
for (const method of ['setUrl', 'setLabels', 'putLabel', 'delLabel', 'setMethod', 'putHeader', 'setTrackers']) {
|
||||
if (typeof ctx.req[method] !== 'undefined') throw new Error(`onResolve exposes ${method}`);
|
||||
}
|
||||
ctx.req.labels = { replaced: "true", removed: "true" };
|
||||
ctx.req.labels.modified = "true";
|
||||
delete ctx.req.labels.removed;
|
||||
ctx.res = {
|
||||
name: "test",
|
||||
files: Array(2).fill(true).map((_, i) => ({
|
||||
|
||||
@@ -132,7 +132,7 @@ gopeed.events.onError(async function (ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.task.setUrl(await createPayloadURL(source, false));
|
||||
await ctx.task.meta.req.setUrl(await createPayloadURL(source, false));
|
||||
await req.putLabel('started', 'true');
|
||||
await req.putLabel('rebuilt', 'true');
|
||||
await ctx.task.continue();
|
||||
|
||||
+1
-1
@@ -41,6 +41,6 @@ gopeed.events.onError(async function (ctx) {
|
||||
}
|
||||
|
||||
await req.putLabel("recovered", "true");
|
||||
await ctx.task.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" })));
|
||||
await ctx.task.meta.req.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" })));
|
||||
await ctx.task.continue();
|
||||
});
|
||||
+1
-1
@@ -46,7 +46,7 @@ gopeed.events.onError(async function (ctx) {
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.task.setUrl(await createRangeBlobUrl(req.rawUrl));
|
||||
await ctx.task.meta.req.setUrl(await createRangeBlobUrl(req.rawUrl));
|
||||
await req.putLabel('started', 'true');
|
||||
await req.putLabel('rebuilt', 'true');
|
||||
await ctx.task.continue();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
gopeed.events.onError(async function (ctx) {
|
||||
gopeed.logger.info("url", ctx.task.meta.req.url);
|
||||
gopeed.logger.info("error", ctx.error);
|
||||
await ctx.task.setUrl("https://github.com");
|
||||
await ctx.task.meta.req.setUrl("https://github.com");
|
||||
await ctx.task.continue();
|
||||
});
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
gopeed.events.onStart(async function (ctx) {
|
||||
gopeed.logger.info("url", ctx.task.meta.req.url);
|
||||
await ctx.task.setUrl("https://github.com");
|
||||
await ctx.task.meta.req.setUrl("https://github.com");
|
||||
await ctx.task.meta.req.setHeaders({ "X-Gopeed-Test": "on-start" });
|
||||
await ctx.task.meta.req.putLabel('modified', 'true');
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */; };
|
||||
B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002 /* GopeedTaskEvent.swift */; };
|
||||
6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; };
|
||||
6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
@@ -102,6 +103,7 @@
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = "<group>"; };
|
||||
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = "<group>"; };
|
||||
B30000000000000000000002 /* GopeedTaskEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedTaskEvent.swift; sourceTree = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -286,6 +288,7 @@
|
||||
09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */,
|
||||
EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */,
|
||||
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */,
|
||||
B30000000000000000000002 /* GopeedTaskEvent.swift */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -607,6 +610,7 @@
|
||||
1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */,
|
||||
032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */,
|
||||
5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */,
|
||||
B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
|
||||
// MARK: - BGCPT state
|
||||
|
||||
private var registeredIdentifiers = Set<String>()
|
||||
private var pendingTaskIDs = Set<String>()
|
||||
private var foregroundStartTokens: [String: UUID] = [:]
|
||||
private struct ExpiringTaskState {
|
||||
let identifier: String
|
||||
let generation: UUID
|
||||
@@ -167,16 +167,11 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
// Pre-mark task.start so the Objective-C forwarder can
|
||||
// immediately suppress the custom ActivityKit Live Activity.
|
||||
if
|
||||
let data = payload.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(
|
||||
with: data
|
||||
) as? [String: Any],
|
||||
let type = json["type"] as? String,
|
||||
type == "task.start",
|
||||
let taskID = json["taskId"] as? String
|
||||
let event = GopeedTaskEvent.decode(payload),
|
||||
event.type == .start
|
||||
{
|
||||
setTaskOwnership(
|
||||
taskID: taskID,
|
||||
taskID: event.taskID,
|
||||
handled: true
|
||||
)
|
||||
}
|
||||
@@ -198,33 +193,19 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let data = payload.data(
|
||||
using: .utf8
|
||||
),
|
||||
let json =
|
||||
try? JSONSerialization
|
||||
.jsonObject(
|
||||
with: data
|
||||
) as? [String: Any],
|
||||
let type =
|
||||
json["type"] as? String,
|
||||
let taskID =
|
||||
json["taskId"] as? String
|
||||
else {
|
||||
guard let event = GopeedTaskEvent.decode(payload) else {
|
||||
print(
|
||||
"ContinuedProcessing: invalid event"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let name =
|
||||
json["name"] as? String
|
||||
?? "Download"
|
||||
let taskID = event.taskID
|
||||
let name = event.name
|
||||
|
||||
switch type {
|
||||
switch event.type {
|
||||
|
||||
case "task.start":
|
||||
case .start:
|
||||
progressRecoveryTokens.removeValue(
|
||||
forKey: taskID
|
||||
)
|
||||
@@ -234,7 +215,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
name: name
|
||||
)
|
||||
|
||||
case "task.progress":
|
||||
case .progress:
|
||||
if let generation =
|
||||
taskGenerations[taskID] {
|
||||
updateProgress(
|
||||
@@ -249,7 +230,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
case "task.pause":
|
||||
case .pause:
|
||||
if let expiring =
|
||||
expiringTasks[taskID] {
|
||||
finishExpirationCleanup(
|
||||
@@ -265,7 +246,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
case "task.done":
|
||||
case .done:
|
||||
finishTask(
|
||||
taskID: taskID,
|
||||
success: true,
|
||||
@@ -273,7 +254,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
"Download complete"
|
||||
)
|
||||
|
||||
case "task.error":
|
||||
case .error:
|
||||
finishTask(
|
||||
taskID: taskID,
|
||||
success: false,
|
||||
@@ -281,7 +262,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
"Download failed"
|
||||
)
|
||||
|
||||
case "task.delete":
|
||||
case .delete:
|
||||
finishTask(
|
||||
taskID: taskID,
|
||||
success: true,
|
||||
@@ -289,12 +270,11 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
"Download removed"
|
||||
)
|
||||
|
||||
default:
|
||||
case .other:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Recover active task after setting changes
|
||||
|
||||
private func recoverTaskFromProgress(
|
||||
@@ -307,6 +287,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
!pendingTaskIDs.contains(taskID),
|
||||
activeTasks[taskID] == nil,
|
||||
expiringTasks[taskID] == nil,
|
||||
foregroundStartTokens[taskID] == nil,
|
||||
progressRecoveryTokens[taskID] == nil
|
||||
else {
|
||||
return
|
||||
@@ -395,7 +376,8 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
|
||||
guard
|
||||
!pendingTaskIDs.contains(taskID),
|
||||
activeTasks[taskID] == nil
|
||||
activeTasks[taskID] == nil,
|
||||
foregroundStartTokens[taskID] == nil
|
||||
else {
|
||||
setTaskOwnership(
|
||||
taskID: taskID,
|
||||
@@ -404,20 +386,57 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
// BGCPT should originate from a foreground user action.
|
||||
// This main-thread check happens only once per task start.
|
||||
let appIsActive: Bool
|
||||
// BGCPT must originate from a foreground user action.
|
||||
// Never synchronously hop from workerQueue to the main queue:
|
||||
// doing so can deadlock if the main thread is waiting on work
|
||||
// that eventually needs workerQueue.
|
||||
let foregroundToken = UUID()
|
||||
foregroundStartTokens[taskID] =
|
||||
foregroundToken
|
||||
|
||||
if Thread.isMainThread {
|
||||
appIsActive =
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
let appIsActive =
|
||||
UIApplication.shared
|
||||
.applicationState == .active
|
||||
} else {
|
||||
appIsActive =
|
||||
DispatchQueue.main.sync {
|
||||
UIApplication.shared
|
||||
.applicationState == .active
|
||||
}
|
||||
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.workerQueue.async {
|
||||
self.continueBeginTaskAfterForegroundCheck(
|
||||
taskID: taskID,
|
||||
name: name,
|
||||
foregroundToken: foregroundToken,
|
||||
appIsActive: appIsActive
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func continueBeginTaskAfterForegroundCheck(
|
||||
taskID: String,
|
||||
name: String,
|
||||
foregroundToken: UUID,
|
||||
appIsActive: Bool
|
||||
) {
|
||||
guard
|
||||
foregroundStartTokens[taskID]
|
||||
== foregroundToken
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
foregroundStartTokens.removeValue(
|
||||
forKey: taskID
|
||||
)
|
||||
|
||||
guard currentEnabledSnapshot() else {
|
||||
setTaskOwnership(
|
||||
taskID: taskID,
|
||||
handled: false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard appIsActive else {
|
||||
@@ -434,6 +453,20 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check after the asynchronous main-thread hop. A terminal
|
||||
// event, disable, or another valid start may have changed the
|
||||
// lifecycle while the foreground state was being read.
|
||||
guard
|
||||
!pendingTaskIDs.contains(taskID),
|
||||
activeTasks[taskID] == nil
|
||||
else {
|
||||
setTaskOwnership(
|
||||
taskID: taskID,
|
||||
handled: true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let generation = UUID()
|
||||
taskGenerations[taskID] = generation
|
||||
|
||||
@@ -498,8 +531,6 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
registeredIdentifiers.insert(identifier)
|
||||
|
||||
let request =
|
||||
BGContinuedProcessingTaskRequest(
|
||||
identifier: identifier,
|
||||
@@ -558,7 +589,6 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - System launched task
|
||||
|
||||
private func activateTask(
|
||||
@@ -901,6 +931,10 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
success: Bool,
|
||||
finalSubtitle: String
|
||||
) {
|
||||
foregroundStartTokens.removeValue(
|
||||
forKey: taskID
|
||||
)
|
||||
|
||||
progressRecoveryTokens.removeValue(
|
||||
forKey: taskID
|
||||
)
|
||||
@@ -1115,6 +1149,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
Set(
|
||||
handledTaskIDsSnapshot()
|
||||
+ Array(taskIdentifiers.keys)
|
||||
+ Array(foregroundStartTokens.keys)
|
||||
+ Array(pendingTaskIDs)
|
||||
+ Array(activeTasks.keys)
|
||||
+ Array(expiringTasks.keys)
|
||||
@@ -1140,6 +1175,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
}
|
||||
|
||||
activeTasks.removeAll()
|
||||
foregroundStartTokens.removeAll()
|
||||
pendingTaskIDs.removeAll()
|
||||
expiringTasks.removeAll()
|
||||
taskGenerations.removeAll()
|
||||
|
||||
@@ -248,26 +248,22 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let data = payload.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data)
|
||||
as? [String: Any],
|
||||
let type = json["type"] as? String,
|
||||
let taskID = json["taskId"] as? String
|
||||
else {
|
||||
guard let event = GopeedTaskEvent.decode(payload) else {
|
||||
print("LiveActivity: invalid task event payload")
|
||||
return
|
||||
}
|
||||
|
||||
let taskID = event.taskID
|
||||
|
||||
guard !isTaskSuppressed(taskID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let name = json["name"] as? String ?? "Download"
|
||||
let name = event.name
|
||||
|
||||
switch type {
|
||||
switch event.type {
|
||||
|
||||
case "task.start":
|
||||
case .start:
|
||||
let generation =
|
||||
startGeneration(
|
||||
taskID: taskID
|
||||
@@ -282,7 +278,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
case "task.progress":
|
||||
case .progress:
|
||||
guard
|
||||
let generation =
|
||||
generationForProgress(
|
||||
@@ -298,7 +294,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
generation: generation
|
||||
)
|
||||
|
||||
case "task.pause":
|
||||
case .pause:
|
||||
if let generation =
|
||||
invalidateGeneration(
|
||||
taskID: taskID
|
||||
@@ -312,7 +308,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case "task.done":
|
||||
case .done:
|
||||
if let generation =
|
||||
invalidateGeneration(
|
||||
taskID: taskID
|
||||
@@ -325,9 +321,9 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case "task.error":
|
||||
case .error:
|
||||
let error =
|
||||
json["error"] as? String
|
||||
event.error
|
||||
?? "Download failed"
|
||||
|
||||
if let generation =
|
||||
@@ -343,7 +339,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case "task.delete":
|
||||
case .delete:
|
||||
if let generation =
|
||||
invalidateGeneration(
|
||||
taskID: taskID
|
||||
@@ -357,7 +353,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
case .other:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
enum GopeedTaskEventType: Equatable {
|
||||
case start
|
||||
case progress
|
||||
case pause
|
||||
case done
|
||||
case error
|
||||
case delete
|
||||
case other(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
switch rawValue {
|
||||
case "task.start":
|
||||
self = .start
|
||||
case "task.progress":
|
||||
self = .progress
|
||||
case "task.pause":
|
||||
self = .pause
|
||||
case "task.done":
|
||||
self = .done
|
||||
case "task.error":
|
||||
self = .error
|
||||
case "task.delete":
|
||||
self = .delete
|
||||
default:
|
||||
self = .other(rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GopeedTaskEvent {
|
||||
let type: GopeedTaskEventType
|
||||
let taskID: String
|
||||
let name: String
|
||||
let error: String?
|
||||
|
||||
private struct Payload: Decodable {
|
||||
let type: String
|
||||
let taskId: String
|
||||
let name: String?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
static func decode(
|
||||
_ payload: String
|
||||
) -> GopeedTaskEvent? {
|
||||
guard
|
||||
let data = payload.data(using: .utf8),
|
||||
let decoded =
|
||||
try? JSONDecoder().decode(
|
||||
Payload.self,
|
||||
from: data
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return GopeedTaskEvent(
|
||||
type:
|
||||
GopeedTaskEventType(
|
||||
rawValue: decoded.type
|
||||
),
|
||||
taskID: decoded.taskId,
|
||||
name: decoded.name ?? "Download",
|
||||
error: decoded.error
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2.0.0-beta.2+2
|
||||
version: 2.0.0-beta.3+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
|
||||
Reference in new issue
Block a user