Compare commits

...
13 Commits
48 changed files with 1650 additions and 719 deletions

No files matched your search

+2
View File
@@ -11,6 +11,8 @@ categories:
- 'fix' - 'fix'
- 'bugfix' - 'bugfix'
- 'bug' - 'bug'
- title: '🧪 Tests'
label: 'test'
- title: '🧰 Maintenance' - title: '🧰 Maintenance'
label: 'chore' label: 'chore'
- title: '📖 Document' - title: '📖 Document'
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter cd ui/flutter
flutter build windows flutter build windows
$mingw = "C:\ProgramData\Chocolatey\lib\mingw\tools\install\mingw64\bin" $mingw = "C:\Program Files\Git\mingw64\bin"
$system = "C:\Windows\System32" $system = "C:\Windows\System32"
$release = "build\windows\runner\Release\" $release = "build\windows\runner\Release\"
cp $mingw\libstdc++-6.dll $release cp $mingw\libstdc++-6.dll $release
+2 -1
View File
@@ -13,7 +13,8 @@
## Introduction ## Introduction
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://docs.gopeed.com/dev-api.html) or installation and development of [extensions](https://docs.gopeed.com/dev-extension.html).
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Develop Docs](https://docs.gopeed.com) Visit ✈ [Official Website](https://gopeed.com) | 📖 [Develop Docs](https://docs.gopeed.com)
+1 -1
View File
@@ -13,7 +13,7 @@
## はじめに ## はじめに
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。 Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://docs.gopeed.com/dev-api.html)との連動や[拡張機能](https://docs.gopeed.com/dev-extension.html)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com) 見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com)
+1 -1
View File
@@ -13,7 +13,7 @@
## 介绍 ## 介绍
Gopeed(全称 Go Speed),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。 Gopeed(全称 Go Speed),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://docs.gopeed.com/zh/dev-api.html)或者安装和开发[扩展](https://docs.gopeed.com/zh/dev-extension.html)来实现更多的功能。
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [开发文档](https://docs.gopeed.com/zh/) 访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [开发文档](https://docs.gopeed.com/zh/)
+51 -21
View File
@@ -18,6 +18,7 @@ import (
"os" "os"
"path" "path"
"strconv" "strconv"
"time"
) )
type RequestError struct { type RequestError struct {
@@ -37,6 +38,8 @@ type chunk struct {
Begin int64 Begin int64
End int64 End int64
Downloaded int64 Downloaded int64
retryTimes int
} }
func newChunk(begin int64, end int64) *chunk { func newChunk(begin int64, end int64) *chunk {
@@ -255,24 +258,43 @@ func (f *Fetcher) fetch() {
func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) { func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
chunk := f.chunks[index] chunk := f.chunks[index]
chunk.retryTimes = 0
httpReq, err := buildRequest(ctx, f.meta.Req) httpReq, err := buildRequest(ctx, f.meta.Req)
if err != nil { if err != nil {
return err return err
} }
var ( var (
client = buildClient() client = buildClient()
buf = make([]byte, 8192) buf = make([]byte, 8192)
maxRetries = 3
) )
// 重试5次 // retry until all remain chunks failed
for i := 0; i < 5; i++ { for true {
// if chunk is completed, return // if chunk is completed, return
if f.meta.Res.Range && chunk.Downloaded >= chunk.End-chunk.Begin+1 { if f.meta.Res.Range && chunk.Downloaded >= chunk.End-chunk.Begin+1 {
return return
} }
if chunk.retryTimes >= maxRetries {
if !f.meta.Res.Range {
return
}
// check if all failed
allFailed := true
for _, c := range f.chunks {
if chunk.Downloaded < chunk.End-chunk.Begin+1 && c.retryTimes < maxRetries {
allFailed = false
break
}
}
if allFailed {
return
}
}
var ( var (
resp *http.Response resp *http.Response
retry bool
) )
if f.meta.Res.Range { if f.meta.Res.Range {
httpReq.Header.Set(base.HttpHeaderRange, httpReq.Header.Set(base.HttpHeaderRange,
@@ -292,35 +314,45 @@ func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
return nil return nil
}() }()
if err != nil { if err != nil {
// retry request // If canceled, do not retry
if errors.Is(err, context.Canceled) {
return
}
// retry request after 1 second
chunk.retryTimes = chunk.retryTimes + 1
time.Sleep(time.Second)
continue continue
} }
// 请求成功就重置错误次数,连续失败5次才终止 // Http request success, reset retry times
i = 0 chunk.retryTimes = 0
retry, err = func() (bool, error) { err = func() error {
defer resp.Body.Close() defer resp.Body.Close()
for { for {
n, err := resp.Body.Read(buf) n, err := resp.Body.Read(buf)
if n > 0 { if n > 0 {
_, err := f.file.WriteAt(buf[:n], chunk.Begin+chunk.Downloaded) _, err := f.file.WriteAt(buf[:n], chunk.Begin+chunk.Downloaded)
if err != nil { if err != nil {
return false, err return err
} }
chunk.Downloaded += int64(n) chunk.Downloaded += int64(n)
} }
if err != nil { if err != nil {
if err != io.EOF { if err == io.EOF {
return true, err return nil
} }
break return err
} }
} }
return false, nil
}() }()
if !retry { // if chunk is completed, reset other not complete chunks retry times
// 下载成功,跳出重试 if err == nil {
break for _, c := range f.chunks {
if c != chunk && c.retryTimes > 0 {
c.retryTimes = 0
}
}
} }
break
} }
return return
} }
@@ -438,9 +470,7 @@ func (fb *FetcherBuilder) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
fetcher.meta = meta fetcher.meta = meta
base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req) base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)
base.ParseOptsExtra[fhttp.OptsExtra](fetcher.meta.Opts) base.ParseOptsExtra[fhttp.OptsExtra](fetcher.meta.Opts)
if len(fd.Chunks) == 0 { if len(fd.Chunks) > 0 {
fetcher.chunks = fetcher.splitChunk()
} else {
fetcher.chunks = fd.Chunks fetcher.chunks = fd.Chunks
} }
return fetcher return fetcher
+15 -6
View File
@@ -76,7 +76,7 @@ func testResolve(startTestServer func() net.Listener, path string, want *base.Re
func TestFetcher_DownloadNormal(t *testing.T) { func TestFetcher_DownloadNormal(t *testing.T) {
listener := test.StartTestFileServer() listener := test.StartTestFileServer()
defer listener.Close() defer listener.Close()
// 正常下载
downloadNormal(listener, 1, t) downloadNormal(listener, 1, t)
downloadNormal(listener, 5, t) downloadNormal(listener, 5, t)
downloadNormal(listener, 8, t) downloadNormal(listener, 8, t)
@@ -86,7 +86,7 @@ func TestFetcher_DownloadNormal(t *testing.T) {
func TestFetcher_DownloadContinue(t *testing.T) { func TestFetcher_DownloadContinue(t *testing.T) {
listener := test.StartTestFileServer() listener := test.StartTestFileServer()
defer listener.Close() defer listener.Close()
// 暂停继续
downloadContinue(listener, 1, t) downloadContinue(listener, 1, t)
downloadContinue(listener, 5, t) downloadContinue(listener, 5, t)
downloadContinue(listener, 8, t) downloadContinue(listener, 8, t)
@@ -96,7 +96,7 @@ func TestFetcher_DownloadContinue(t *testing.T) {
func TestFetcher_DownloadChunked(t *testing.T) { func TestFetcher_DownloadChunked(t *testing.T) {
listener := test.StartTestCustomServer() listener := test.StartTestCustomServer()
defer listener.Close() defer listener.Close()
// chunked编码下载
downloadNormal(listener, 1, t) downloadNormal(listener, 1, t)
downloadNormal(listener, 2, t) downloadNormal(listener, 2, t)
} }
@@ -104,24 +104,33 @@ func TestFetcher_DownloadChunked(t *testing.T) {
func TestFetcher_DownloadPost(t *testing.T) { func TestFetcher_DownloadPost(t *testing.T) {
listener := test.StartTestPostServer() listener := test.StartTestPostServer()
defer listener.Close() defer listener.Close()
// post下载
downloadPost(listener, 1, t) downloadPost(listener, 1, t)
} }
func TestFetcher_DownloadRetry(t *testing.T) { func TestFetcher_DownloadRetry(t *testing.T) {
listener := test.StartTestRetryServer() listener := test.StartTestRetryServer()
defer listener.Close() defer listener.Close()
// chunked编码下载
downloadNormal(listener, 1, t) downloadNormal(listener, 1, t)
} }
func TestFetcher_DownloadError(t *testing.T) { func TestFetcher_DownloadError(t *testing.T) {
listener := test.StartTestErrorServer() listener := test.StartTestErrorServer()
defer listener.Close() defer listener.Close()
// chunked编码下载
downloadError(listener, 1, t) downloadError(listener, 1, t)
} }
func TestFetcher_DownloadLimit(t *testing.T) {
listener := test.StartTestLimitServer()
defer listener.Close()
downloadNormal(listener, 1, t)
downloadNormal(listener, 2, t)
downloadNormal(listener, 8, t)
}
func TestFetcher_DownloadResume(t *testing.T) { func TestFetcher_DownloadResume(t *testing.T) {
listener := test.StartTestFileServer() listener := test.StartTestFileServer()
defer listener.Close() defer listener.Close()
+77 -1
View File
@@ -9,6 +9,9 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strconv"
"strings"
"sync/atomic"
"time" "time"
) )
@@ -81,7 +84,7 @@ func StartTestRetryServer() net.Listener {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
counter++ counter++
if counter != 1 && counter < 5 { if counter != 1 && counter < 2 {
writer.WriteHeader(500) writer.WriteHeader(500)
return return
} }
@@ -134,6 +137,79 @@ func StartTestErrorServer() net.Listener {
}) })
} }
// StartTestLimitServer connections limit server
func StartTestLimitServer() net.Listener {
var connections atomic.Int32
return startTestServer(func() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
defer func() {
connections.Add(-1)
}()
connections.Add(1)
if connections.Load() > 4 {
writer.WriteHeader(403)
return
}
r := request.Header.Get("Range")
if r == "" {
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
writer.WriteHeader(200)
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
} else {
// split range
s := strings.Split(r, "=")
if len(s) != 2 {
writer.WriteHeader(400)
return
}
s = strings.Split(s[1], "-")
if len(s) != 2 {
writer.WriteHeader(400)
return
}
start, err := strconv.ParseInt(s[0], 10, 64)
if err != nil {
writer.WriteHeader(400)
return
}
end, err := strconv.ParseInt(s[1], 10, 64)
if err != nil {
writer.WriteHeader(400)
return
}
if start < 0 || end < 0 || start > end {
writer.WriteHeader(400)
return
}
if end >= BuildSize {
end = BuildSize - 1
}
writer.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
writer.Header().Set("Accept-Ranges", "bytes")
writer.WriteHeader(206)
file, err := os.Open(BuildFile)
if err != nil {
writer.WriteHeader(500)
return
}
defer file.Close()
file.Seek(start, 0)
io.CopyN(writer, file, end-start+1)
}
})
return mux
})
}
func startTestServer(serverHandle func() http.Handler) net.Listener { func startTestServer(serverHandle func() http.Handler) net.Listener {
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
+9 -1
View File
@@ -9,6 +9,15 @@ import (
type Request struct { type Request struct {
URL string `json:"url"` URL string `json:"url"`
Extra any `json:"extra"` Extra any `json:"extra"`
// Labels is used to mark the download task
Labels map[string]string `json:"labels"`
}
func (r *Request) Validate() error {
if r.URL == "" {
return fmt.Errorf("invalid request url")
}
return nil
} }
// Resource download resource // Resource download resource
@@ -36,7 +45,6 @@ func (r *Resource) Validate() error {
} }
} }
return nil return nil
} }
func (r *Resource) CalcSize() { func (r *Resource) CalcSize() {
+272 -195
View File
@@ -9,7 +9,6 @@ import (
"github.com/GopeedLab/gopeed/pkg/util" "github.com/GopeedLab/gopeed/pkg/util"
gonanoid "github.com/matoous/go-nanoid/v2" gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/virtuald/go-paniclog" "github.com/virtuald/go-paniclog"
"math"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -56,11 +55,13 @@ type Downloader struct {
fetcherCache map[string]fetcher.Fetcher fetcherCache map[string]fetcher.Fetcher
storage Storage storage Storage
tasks []*Task tasks []*Task
waitTasks []*Task
listener Listener listener Listener
lock *sync.Mutex lock *sync.Mutex
fetcherMapLock *sync.RWMutex fetcherMapLock *sync.RWMutex
closed atomic.Bool checkDuplicateLock *sync.Mutex
closed atomic.Bool
extensions []*Extension extensions []*Extension
} }
@@ -72,14 +73,15 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
cfg.Init() cfg.Init()
d := &Downloader{ d := &Downloader{
cfg: cfg, cfg: cfg,
fetcherBuilders: make(map[string]fetcher.FetcherBuilder), fetcherBuilders: make(map[string]fetcher.FetcherBuilder),
fetcherCache: make(map[string]fetcher.Fetcher), fetcherCache: make(map[string]fetcher.Fetcher),
waitTasks: make([]*Task, 0),
storage: cfg.Storage,
storage: cfg.Storage, lock: &sync.Mutex{},
lock: &sync.Mutex{}, fetcherMapLock: &sync.RWMutex{},
fetcherMapLock: &sync.RWMutex{}, checkDuplicateLock: &sync.Mutex{},
extensions: make([]*Extension, 0), extensions: make([]*Extension, 0),
} }
@@ -248,6 +250,33 @@ func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
return return
} }
func (d *Downloader) notifyRunning() {
go func() {
d.lock.Lock()
defer d.lock.Unlock()
remainRunningCount := d.remainRunningCount()
if remainRunningCount == 0 {
return
}
if len(d.waitTasks) > 0 {
wt := d.waitTasks[0]
d.waitTasks = d.waitTasks[1:]
d.doStart(wt)
}
}()
}
func (d *Downloader) remainRunningCount() int {
runningCount := 0
for _, t := range d.tasks {
if t.Status == base.DownloadStatusRunning {
runningCount++
}
}
return d.cfg.MaxRunning - runningCount
}
func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId string, err error) { func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId string, err error) {
var fetcher fetcher.Fetcher var fetcher fetcher.Fetcher
fetcher, err = d.buildFetcher(req.URL) fetcher, err = d.buildFetcher(req.URL)
@@ -281,10 +310,21 @@ func (d *Downloader) Pause(id string) (err error) {
if task == nil { if task == nil {
return ErrTaskNotFound return ErrTaskNotFound
} }
if err = d.doPause(task, base.DownloadStatusPause); err != nil {
return err func() {
} task.lock.Lock()
return d.notifyRunning() defer task.lock.Unlock()
if task.Status == base.DownloadStatusPause {
return
}
err = d.doPause(task)
if err != nil {
return
}
d.notifyRunning()
}()
return
} }
func (d *Downloader) Continue(id string) (err error) { func (d *Downloader) Continue(id string) (err error) {
@@ -292,109 +332,164 @@ func (d *Downloader) Continue(id string) (err error) {
if task == nil { if task == nil {
return ErrTaskNotFound return ErrTaskNotFound
} }
d.tryRunning(func(remain int) {
if remain == 0 { func() {
// no more task can be running, need to doPause a running task d.lock.Lock()
defer d.lock.Unlock()
remainRunningCount := d.remainRunningCount()
if remainRunningCount == 0 {
for _, t := range d.tasks { for _, t := range d.tasks {
if t.Status == base.DownloadStatusRunning { if t.Status == base.DownloadStatusRunning {
err = d.doPause(t, base.DownloadStatusWait) if err = d.doPause(t); err != nil {
return
}
t.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, t)
break break
} }
} }
} }
if err != nil { }()
func() {
task.lock.Lock()
defer task.lock.Unlock()
if task.Status == base.DownloadStatusRunning {
return return
} }
})
if err != nil { if err = d.doStart(task); err != nil {
return return
} }
return d.doContinue(task) }()
return
} }
func (d *Downloader) PauseAll() (err error) { func (d *Downloader) PauseAll() (err error) {
d.lock.Lock() func() {
defer d.lock.Unlock() d.lock.Lock()
return d.doPauseAll() defer d.lock.Unlock()
}
// Clear wait tasks
d.waitTasks = d.waitTasks[:0]
}()
func (d *Downloader) ContinueAll() (err error) {
d.lock.Lock()
defer d.lock.Unlock()
runningCount := 0
continueTasks := make([]*Task, 0)
for _, task := range d.tasks { for _, task := range d.tasks {
if task.Status == base.DownloadStatusRunning { err = func() error {
runningCount++ task.lock.Lock()
} else if task.Status != base.DownloadStatusDone { defer task.lock.Unlock()
continueTasks = append(continueTasks, task)
} return d.doPause(task)
} }()
// calculate how many tasks can be continued, can't exceed maxRunning if err != nil {
continueCount := int(math.Min(float64(d.cfg.MaxRunning-runningCount), float64(len(continueTasks))))
for i := 0; i < continueCount; i++ {
if err = d.doContinue(continueTasks[i]); err != nil {
return return
} }
} }
return
}
func (d *Downloader) ContinueAll() (err error) {
continuedTasks := make([]*Task, 0)
func() {
d.lock.Lock()
defer d.lock.Unlock()
// calculate how many tasks can be continued, can't exceed maxRunning
remainCount := d.remainRunningCount()
for _, task := range d.tasks {
if task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone {
if len(continuedTasks) < remainCount {
continuedTasks = append(continuedTasks, task)
} else {
task.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, task)
}
}
}
}()
for _, task := range continuedTasks {
tt := task
err = func() error {
tt.lock.Lock()
defer tt.lock.Unlock()
return d.doStart(tt)
}()
if err != nil {
return
}
}
return return
} }
func (d *Downloader) Delete(id string, force bool) (err error) { func (d *Downloader) Delete(id string, force bool) (err error) {
d.lock.Lock()
defer d.lock.Unlock()
task := d.GetTask(id) task := d.GetTask(id)
if task == nil { if task == nil {
return ErrTaskNotFound return ErrTaskNotFound
} }
task.lock.Lock()
defer task.lock.Unlock() func() {
if task.fetcher != nil { d.lock.Lock()
err = task.fetcher.Close() defer d.lock.Unlock()
if err != nil {
return for i, t := range d.tasks {
} if t.ID == id {
} d.tasks = append(d.tasks[:i], d.tasks[i+1:]...)
for i, t := range d.tasks {
if t.ID == id {
d.tasks = append(d.tasks[:i], d.tasks[i+1:]...)
break
}
}
err = d.storage.Delete(bucketTask, id)
if err != nil {
return
}
err = d.storage.Delete(bucketSave, id)
if err != nil {
return
}
if force && task.Meta.Res != nil {
if task.Meta.Res.Name != "" {
if err = os.RemoveAll(task.Meta.FolderPath()); err != nil {
return
} }
} else { }
if err = util.SafeRemove(task.Meta.SingleFilepath()); err != nil { }()
err = d.doDelete(task, force)
if err != nil {
return
}
d.notifyRunning()
return
}
func (d *Downloader) doDelete(task *Task, force bool) (err error) {
err = func() error {
if task.fetcher != nil {
if err := task.fetcher.Close(); err != nil {
return err return err
} }
} }
} if err := d.storage.Delete(bucketTask, task.ID); err != nil {
d.emit(EventKeyDelete, task)
if task.Status == base.DownloadStatusRunning {
if err = d.doNotifyRunning(); err != nil {
return err return err
} }
if err := d.storage.Delete(bucketSave, task.ID); err != nil {
return err
}
if force && task.Meta.Res != nil {
if task.Meta.Res.Name != "" {
if err := os.RemoveAll(task.Meta.FolderPath()); err != nil {
return err
}
} else {
if err := util.SafeRemove(task.Meta.SingleFilepath()); err != nil {
return err
}
}
}
d.emit(EventKeyDelete, task)
task = nil
return nil
}()
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("delete task failed, task id: %s", task.ID)
} }
task = nil
return return
} }
func (d *Downloader) Close() error { func (d *Downloader) Close() error {
d.lock.Lock()
defer d.lock.Unlock()
d.closed.Store(true) d.closed.Store(true)
if err := d.doPauseAll(); err != nil { if err := d.PauseAll(); err != nil {
return err return err
} }
return d.storage.Close() return d.storage.Close()
@@ -429,6 +524,9 @@ func (d *Downloader) emit(eventKey EventKey, task *Task, errs ...error) {
Task: task, Task: task,
Err: err, Err: err,
}) })
if eventKey == EventKeyError {
d.emit(EventKeyFinally, task, err)
}
} }
} }
@@ -494,11 +592,9 @@ func (d *Downloader) watch(task *Task) {
task.Status = base.DownloadStatusDone task.Status = base.DownloadStatusDone
d.storage.Put(bucketTask, task.ID, task.clone()) d.storage.Put(bucketTask, task.ID, task.clone())
d.emit(EventKeyDone, task) d.emit(EventKeyDone, task)
d.emit(EventKeyFinally, task, err)
} }
d.emit(EventKeyFinally, task, err) d.notifyRunning()
if task.Status == EventKeyDone {
d.notifyRunning()
}
} }
func (d *Downloader) restoreFetcher(task *Task) error { func (d *Downloader) restoreFetcher(task *Task) error {
@@ -526,6 +622,12 @@ func (d *Downloader) restoreFetcher(task *Task) error {
task.fetcher = fb.Build() task.fetcher = fb.Build()
} }
d.setupFetcher(task.fetcher) d.setupFetcher(task.fetcher)
if task.fetcher.Meta().Req == nil {
task.fetcher.Meta().Req = task.Meta.Req
}
if task.fetcher.Meta().Res == nil {
task.fetcher.Meta().Res = task.Meta.Res
}
go d.watch(task) go d.watch(task)
} }
task.fetcher.Create(task.Meta.Opts) task.fetcher.Create(task.Meta.Opts)
@@ -570,145 +672,120 @@ func (d *Downloader) doCreate(fetcher fetcher.Fetcher, opts *base.Options) (task
func() { func() {
d.lock.Lock() d.lock.Lock()
defer d.lock.Unlock() defer d.lock.Unlock()
d.tasks = append(d.tasks, task) d.tasks = append(d.tasks, task)
}()
go d.watch(task)
go d.tryRunning(func(remain int) {
if remain > 0 {
if err = d.start(task); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("start task failed, task id: %s", task.ID)
return
}
}
})
return
}
func (d *Downloader) tryRunning(cb func(remain int)) { remainRunningCount := d.remainRunningCount()
runningCount := 0 if remainRunningCount == 0 {
for _, task := range d.tasks { task.Status = base.DownloadStatusWait
if task.Status == base.DownloadStatusRunning { d.waitTasks = append(d.waitTasks, task)
runningCount++
}
}
cb(d.cfg.MaxRunning - runningCount)
}
func (d *Downloader) notifyRunning() error {
d.lock.Lock()
defer d.lock.Unlock()
return d.doNotifyRunning()
}
func (d *Downloader) doNotifyRunning() error {
for _, task := range d.tasks {
if task.Status == base.DownloadStatusReady || task.Status == base.DownloadStatusWait {
return d.doContinue(task)
}
}
return nil
}
func (d *Downloader) doPauseAll() (err error) {
for _, task := range d.tasks {
if err = d.doPause(task, base.DownloadStatusPause); err != nil {
return return
} }
}
d.doStart(task)
}()
go d.watch(task)
return return
} }
func (d *Downloader) start(task *Task) error { func (d *Downloader) doStart(task *Task) (err error) {
task.lock.Lock() if task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone {
defer task.lock.Unlock() err := d.restoreFetcher(task)
isCreate := task.Status == base.DownloadStatusReady
task.Status = base.DownloadStatusRunning
if task.Meta.Res == nil {
err := task.fetcher.Resolve(task.Meta.Req)
if err != nil { if err != nil {
task.Status = base.DownloadStatusError d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
d.emit(EventKeyError, task, err)
return err return err
} }
isCreate = true
} }
if isCreate { cloneTask := task.clone()
d.lock.Lock() isCreate := task.Status == base.DownloadStatusReady
defer d.lock.Unlock() task.Status = base.DownloadStatusRunning
// check if the download file is duplicated and rename it automatically.
if task.Meta.Res.Name != "" { doStart := func() error {
fullDirPath := task.Meta.FolderPath() task.lock.Lock()
newName, err := util.CheckDuplicateAndRename(fullDirPath) defer task.lock.Unlock()
if err != nil {
return err req := d.triggerOnStart(cloneTask)
} if req != nil {
task.Meta.Opts.Name = newName task.Meta.Req = req
} else { task.fetcher.Meta().Req = req
fullFilePath := task.Meta.SingleFilepath()
newName, err := util.CheckDuplicateAndRename(fullFilePath)
if err != nil {
return err
}
task.Meta.Opts.Name = newName
} }
}
if err := d.doStart(task); err != nil {
task.Status = base.DownloadStatusError
d.emit(EventKeyError, task, err)
return err
}
return nil
}
func (d *Downloader) doStart(task *Task) error { if task.Meta.Res == nil {
task.Progress.Speed = 0 err := task.fetcher.Resolve(task.Meta.Req)
task.timer.Start() if err != nil {
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil { task.Status = base.DownloadStatusError
return err d.emit(EventKeyError, task, err)
} return err
if err := task.fetcher.Start(); err != nil { }
return err task.Meta.Res = task.fetcher.Meta().Res
} }
d.emit(EventKeyStart, task)
return nil
}
func (d *Downloader) doPause(task *Task, status base.Status) (err error) { if isCreate {
task.lock.Lock() d.checkDuplicateLock.Lock()
defer task.lock.Unlock() defer d.checkDuplicateLock.Unlock()
if task.Status != base.DownloadStatusDone { // check if the download file is duplicated and rename it automatically.
task.Status = status if task.Meta.Res.Name != "" {
task.timer.Pause() fullDirPath := task.Meta.FolderPath()
task.fetcher.Pause() newName, err := util.CheckDuplicateAndRename(fullDirPath)
d.storage.Put(bucketTask, task.ID, task.clone()) if err != nil {
d.emit(EventKeyPause, task) return err
}
task.Meta.Opts.Name = newName
} else {
fullFilePath := task.Meta.SingleFilepath()
newName, err := util.CheckDuplicateAndRename(fullFilePath)
if err != nil {
return err
}
task.Meta.Opts.Name = newName
}
}
task.Progress.Speed = 0
task.timer.Start()
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return err
}
if err := task.fetcher.Start(); err != nil {
return err
}
d.emit(EventKeyStart, task)
return nil
} }
go func() {
err := doStart()
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("start task failed, task id: %s", task.ID)
}
}()
return return
} }
func (d *Downloader) doContinue(task *Task) (err error) { func (d *Downloader) doPause(task *Task) (err error) {
err = func() error { err = func() error {
task.lock.Lock() if task.Status != base.DownloadStatusDone {
defer task.lock.Unlock() task.Status = base.DownloadStatusPause
if task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone { task.timer.Pause()
err := d.restoreFetcher(task) if task.fetcher != nil {
if err != nil { if err := task.fetcher.Pause(); err != nil {
return err
}
}
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return err return err
} }
d.emit(EventKeyPause, task)
} }
return nil return nil
}() }()
if err != nil { if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("pause task failed, task id: %s", task.ID)
return return
} }
go func() {
if err := d.start(task); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("start task failed, task id: %s", task.ID)
return
}
}()
return return
} }
+161 -37
View File
@@ -33,6 +33,7 @@ type ActivationEvent string
const ( const (
EventOnResolve ActivationEvent = "onResolve" EventOnResolve ActivationEvent = "onResolve"
EventOnStart ActivationEvent = "onStart"
//EventOnError ActivationEvent = "onError" //EventOnError ActivationEvent = "onError"
//EventOnDone ActivationEvent = "onDone" //EventOnDone ActivationEvent = "onDone"
) )
@@ -47,7 +48,7 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten
return nil, err return nil, err
} }
// if dev mode, don't copy the extension to the extensions' directory // if dev mode, don't copy to the extensions' directory
if devMode { if devMode {
ext.DevMode = true ext.DevMode = true
ext.DevPath, _ = filepath.Abs(path) ext.DevPath, _ = filepath.Abs(path)
@@ -229,23 +230,70 @@ func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {
} }
func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) { func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
doTrigger(d,
EventOnResolve,
req,
&OnResolveContext{
Req: req,
},
func(ext *Extension, gopeed *Instance, ctx *OnResolveContext) {
// Validate resource structure
if ctx.Res != nil && len(ctx.Res.Files) > 0 {
if err := ctx.Res.Validate(); err != nil {
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity())
return
}
ctx.Res.CalcSize()
}
res = ctx.Res
},
)
return
}
func (d *Downloader) triggerOnStart(task *Task) (req *base.Request) {
doTrigger(d,
EventOnStart,
task.Meta.Req,
&OnStartContext{
Task: task,
},
func(ext *Extension, gopeed *Instance, ctx *OnStartContext) {
// Validate request structure
if ctx.Task.Meta.Req != nil {
if err := ctx.Task.Meta.Req.Validate(); err != nil {
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] request invalid", ext.buildIdentity())
return
}
req = ctx.Task.Meta.Req
}
},
)
return
}
func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, ctx T, handler func(ext *Extension, gopeed *Instance, ctx T)) {
// init extension global object // init extension global object
gopeed := &Instance{ gopeed := &Instance{
Events: make(InstanceEvents), Events: make(InstanceEvents),
} }
ctx := &Context{
Req: req,
}
var err error var err error
for _, ext := range d.extensions { for _, ext := range d.extensions {
if ext.Disabled { if ext.Disabled {
continue continue
} }
for _, script := range ext.Scripts { for _, script := range ext.Scripts {
if script.match(EventOnResolve, req.URL) { if script.match(event, req) {
gopeed.Info = NewExtensionInfo(ext)
gopeed.Logger = newInstanceLogger(ext, d.ExtensionLogger) gopeed.Logger = newInstanceLogger(ext, d.ExtensionLogger)
gopeed.Settings = parseSettings(ext.Settings)
gopeed.Storage = &ContextStorage{
storage: d.storage,
identity: ext.buildIdentity(),
}
scriptFilePath := filepath.Join(d.ExtensionPath(ext), script.Entry) scriptFilePath := filepath.Join(d.ExtensionPath(ext), script.Entry)
if _, err = os.Stat(scriptFilePath); os.IsNotExist(err) { if _, err = os.Stat(scriptFilePath); os.IsNotExist(err) {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] script file not exist", ext.buildIdentity())
continue continue
} }
func() { func() {
@@ -256,17 +304,16 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
return return
} }
defer scriptFile.Close() defer scriptFile.Close()
ctx.Settings = parseSettings(ext.Settings)
ctx.Storage = &ContextStorage{
storage: d.storage,
identity: ext.buildIdentity(),
}
var scriptBuf []byte var scriptBuf []byte
scriptBuf, err = io.ReadAll(scriptFile) scriptBuf, err = io.ReadAll(scriptFile)
if err != nil { if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] read script file failed", ext.buildIdentity()) gopeed.Logger.logger.Error().Err(err).Msgf("[%s] read script file failed", ext.buildIdentity())
return return
} }
// Init request labels
if req.Labels == nil {
req.Labels = make(map[string]string)
}
engine := engine.NewEngine() engine := engine.NewEngine()
defer engine.Close() defer engine.Close()
err = engine.Runtime.Set("gopeed", gopeed) err = engine.Runtime.Set("gopeed", gopeed)
@@ -279,21 +326,13 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity()) gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity())
return return
} }
if fn, ok := gopeed.Events[EventOnResolve]; ok { if fn, ok := gopeed.Events[event]; ok {
_, err = engine.CallFunction(fn, ctx) _, err = engine.CallFunction(fn, ctx)
if err != nil { if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", EventOnResolve, ext.buildIdentity()) gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", ext.buildIdentity(), event)
return return
} }
// calculate resource total size handler(ext, gopeed, ctx)
if ctx.Res != nil && len(ctx.Res.Files) > 0 {
if err = ctx.Res.Validate(); err != nil {
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity())
return
}
ctx.Res.CalcSize()
}
res = ctx.Res
} }
}() }()
} }
@@ -382,8 +421,50 @@ func (e *Extension) update(newExt *Extension) error {
e.Homepage = newExt.Homepage e.Homepage = newExt.Homepage
e.Repository = newExt.Repository e.Repository = newExt.Repository
e.Scripts = newExt.Scripts e.Scripts = newExt.Scripts
// don't override settings // merge settings
//e.Settings = newExt.Settings // if new setting not exist in old settings, append it
for _, newSetting := range newExt.Settings {
var exist bool
for _, setting := range e.Settings {
if setting.Name == newSetting.Name {
exist = true
break
}
}
if !exist {
e.Settings = append(e.Settings, newSetting)
}
}
// if old setting not exist in new settings, remove it
for i := 0; i < len(e.Settings); i++ {
var exist bool
for _, setting := range newExt.Settings {
if setting.Name == e.Settings[i].Name {
exist = true
break
}
}
if !exist {
e.Settings = append(e.Settings[:i], e.Settings[i+1:]...)
i--
}
}
// if new setting exist in old settings, update it
for _, newSetting := range newExt.Settings {
for _, setting := range e.Settings {
if setting.Name == newSetting.Name {
setting.Title = newSetting.Title
setting.Description = newSetting.Description
setting.Options = newSetting.Options
// if type changed, reset value
if setting.Type != newSetting.Type {
setting.Type = newSetting.Type
setting.Value = newSetting.Value
}
break
}
}
}
e.UpdatedAt = time.Now() e.UpdatedAt = time.Now()
return nil return nil
} }
@@ -396,30 +477,46 @@ type Repository struct {
type Script struct { type Script struct {
// Event active event name // Event active event name
Event string `json:"event"` Event string `json:"event"`
// Matches match request url pattern list // Match rules
Matches []string `json:"matches"` Match *Match `json:"match"`
// Entry js script file path // Entry js script file path
Entry string `json:"entry"` Entry string `json:"entry"`
} }
func (s *Script) match(event ActivationEvent, url string) bool { func (s *Script) match(event ActivationEvent, req *base.Request) bool {
if s.Event == "" { if s.Event == "" {
return false return false
} }
if s.Event != string(event) { if s.Event != string(event) {
return false return false
} }
if len(s.Matches) == 0 { if s.Match == nil || (len(s.Match.Urls) == 0 && len(s.Match.Labels) == 0) {
return false return false
} }
for _, match := range s.Matches {
if util.Match(match, url) { // match url
for _, url := range s.Match.Urls {
if util.Match(url, req.URL) {
return true
}
}
// match label
for _, label := range s.Match.Labels {
if _, ok := req.Labels[label]; ok {
return true return true
} }
} }
return false return false
} }
type Match struct {
// Urls match expression, refer to https://developer.chrome.com/docs/extensions/mv3/match_patterns/
Urls []string `json:"urls"`
// Labels match request labels
Labels []string `json:"labels"`
}
type SettingType string type SettingType string
const ( const (
@@ -446,10 +543,13 @@ type Option struct {
Value any `json:"value"` Value any `json:"value"`
} }
// Instance inject to js context // Instance inject to js context when extension script is activated
type Instance struct { type Instance struct {
Events InstanceEvents `json:"events"` Events InstanceEvents `json:"events"`
Logger *InstanceLogger `json:"logger"` Info *ExtensionInfo `json:"info"`
Logger *InstanceLogger `json:"logger"`
Settings map[string]any `json:"settings"`
Storage *ContextStorage `json:"storage"`
} }
type InstanceEvents map[ActivationEvent]goja.Callable type InstanceEvents map[ActivationEvent]goja.Callable
@@ -462,6 +562,10 @@ func (h InstanceEvents) OnResolve(fn goja.Callable) {
h.register(EventOnResolve, fn) h.register(EventOnResolve, fn)
} }
func (h InstanceEvents) OnStart(fn goja.Callable) {
h.register(EventOnStart, fn)
}
//func (h InstanceEvents) OnError(fn goja.Callable) { //func (h InstanceEvents) OnError(fn goja.Callable) {
// h.register(HookEventOnError, fn) // h.register(HookEventOnError, fn)
//} //}
@@ -470,6 +574,24 @@ func (h InstanceEvents) OnResolve(fn goja.Callable) {
// h.register(HookEventOnDone, fn) // h.register(HookEventOnDone, fn)
//} //}
type ExtensionInfo struct {
Identity string `json:"identity"`
Name string `json:"name"`
Author string `json:"author"`
Title string `json:"title"`
Version string `json:"version"`
}
func NewExtensionInfo(ext *Extension) *ExtensionInfo {
return &ExtensionInfo{
Identity: ext.buildIdentity(),
Name: ext.Name,
Author: ext.Author,
Title: ext.Title,
Version: ext.Version,
}
}
type InstanceLogger struct { type InstanceLogger struct {
identity string identity string
devMode bool devMode bool
@@ -510,11 +632,13 @@ func newInstanceLogger(extension *Extension, logger *logger.Logger) *InstanceLog
} }
} }
type Context struct { type OnResolveContext struct {
Req *base.Request `json:"req"` Req *base.Request `json:"req"`
Res *base.Resource `json:"res"` Res *base.Resource `json:"res"`
Settings map[string]any `json:"settings"` }
Storage *ContextStorage `json:"storage"`
type OnStartContext struct {
Task *Task `json:"task"`
} }
func parseSettings(settings []*Setting) map[string]any { func parseSettings(settings []*Setting) map[string]any {
+116 -3
View File
@@ -1,10 +1,12 @@
package download package download
import ( import (
"errors"
"github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"os" "os"
"testing" "testing"
"time"
) )
func TestDownloader_InstallExtensionByFolder(t *testing.T) { func TestDownloader_InstallExtensionByFolder(t *testing.T) {
@@ -93,6 +95,15 @@ func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
} }
func TestDownloader_UpgradeExtension(t *testing.T) { func TestDownloader_UpgradeExtension(t *testing.T) {
getSetting := func(settings []*Setting, name string) *Setting {
for _, setting := range settings {
if setting.Name == name {
return setting
}
}
return nil
}
setupDownloader(func(downloader *Downloader) { setupDownloader(func(downloader *Downloader) {
installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/update", false) installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/update", false)
if err != nil { if err != nil {
@@ -120,14 +131,116 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
t.Fatal("extension update fail") t.Fatal("extension update fail")
} }
// check setting update
s1 := getSetting(upgradeExt.Settings, "s1")
if s1.Title == "S1 old" {
t.Fatal("setting update fail")
}
// check setting type update
s2 := getSetting(upgradeExt.Settings, "s2")
if s2.Type == "number" {
t.Fatal("setting type update fail")
}
// check setting remove
d1 := getSetting(upgradeExt.Settings, "d1")
if d1 != nil {
t.Fatal("setting remove fail")
}
// check setting add
s3 := getSetting(upgradeExt.Settings, "s3")
if s3 == nil {
t.Fatal("setting add fail")
}
rr, err := downloader.Resolve(&base.Request{ rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases", URL: "https://test.com",
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(rr.Res.Files) == 1 { if rr.Res.Name != "test" {
t.Fatal("resolve error") t.Fatal("script update fail")
}
})
}
func TestDownloader_ExtensionByOnStart(t *testing.T) {
downloadAndCheck := func(req *base.Request) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil {
t.Fatal(err)
}
errCh := make(chan error, 1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyFinally {
errCh <- event.Err
}
})
id, err := downloader.CreateDirect(req, nil)
if err != nil {
t.Fatal(err)
}
select {
case err = <-errCh:
break
case <-time.After(time.Second * 10):
err = errors.New("timeout")
}
if err != nil {
panic("extension on start download error: " + err.Error())
}
task := downloader.GetTask(id)
if task.Meta.Req.URL != "https://github.com" {
panic("extension on start modify url error")
}
if task.Meta.Req.Labels["modified"] != "true" {
panic("extension on start modify label error")
}
})
}
// url match
downloadAndCheck(&base.Request{
URL: "https://github.com/gopeed/test/404",
})
// label match
downloadAndCheck(&base.Request{
URL: "https://xxx.com",
Labels: map[string]string{
"test": "true",
},
})
}
func TestDownloader_Extension_Errors(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/script_error", false); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 2 {
t.Fatal("script error catch failed")
}
})
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/function_error", false); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 2 {
t.Fatal("function error catch failed")
} }
}) })
} }
+3
View File
@@ -222,6 +222,9 @@ func (b *BoltStorage) Pop(bucket string, key string, v any) error {
if err != nil { if err != nil {
return err return err
} }
if len(data) == 0 {
return nil
}
return json.Unmarshal(data, v) return json.Unmarshal(data, v)
} }
+3
View File
@@ -6,6 +6,9 @@ gopeed.events.onResolve(async function (ctx) {
size: 1024, size: 1024,
req: { req: {
url: ctx.req.url + "/" + i, url: ctx.req.url + "/" + i,
labels:{
"from": gopeed.info.name,
}
} }
}), }),
), ),
+5 -3
View File
@@ -5,9 +5,11 @@
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"*://github.com/*" "urls": [
], "*://github.com/*"
]
},
"entry": "index.js" "entry": "index.js"
} }
], ],
+18
View File
@@ -0,0 +1,18 @@
gopeed.events.onResolve(async function (ctx) {
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
name: `test-${i}.txt`,
size: 1024,
req: {
url: ctx.req.url + "/" + i,
extra: {
headers: {
'User-Agent': ctx.settings.ua,
},
}
}
}),
),
};
});
+22
View File
@@ -0,0 +1,22 @@
{
"name": "extra",
"title": "gopeed extension extra test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"matches": [
"*://github.com/*"
],
"entry": "index.js"
}
],
"settings": [
{
"name": "ua",
"title": "User-Agent",
"type": "string",
"value": "gopeed"
}
]
}
@@ -0,0 +1,16 @@
gopeed.events.onResolve(async function (ctx) {
const aaa = {};
gopeed.logger.info(aaa.bbb.ccc);
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
name: `test-${i}.txt`,
size: 1024,
req: {
url: ctx.req.url + "/" + i,
}
}),
),
};
});
@@ -0,0 +1,23 @@
{
"name": "function-error",
"title": "gopeed extension function error test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"match": {
"urls": [
"*://github.com/*"
]
},
"entry": "index.js"
}
],
"settings": [
{
"name": "ua",
"title": "User-Agent",
"type": "string"
}
]
}
+5
View File
@@ -0,0 +1,5 @@
gopeed.events.onStart(async function (ctx) {
ctx.task.meta.req.url = "https://github.com";
ctx.task.meta.req.labels['modified'] = 'true';
});
+19
View File
@@ -0,0 +1,19 @@
{
"name": "on-start",
"title": "gopeed extension on start event test",
"version": "0.0.1",
"scripts": [
{
"event": "onStart",
"match": {
"urls": [
"*://github.com/*"
],
"labels": [
"test"
]
},
"entry": "index.js"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
const aaa = {};
gopeed.logger.info(aaa.bbb.ccc);
gopeed.events.onResolve(async function (ctx) {
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
name: `test-${i}.txt`,
size: 1024,
req: {
url: ctx.req.url + "/" + i,
}
}),
),
};
});
@@ -0,0 +1,23 @@
{
"name": "script-error",
"title": "gopeed extension script error test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"match": {
"urls": [
"*://github.com/*"
]
},
"entry": "index.js"
}
],
"settings": [
{
"name": "ua",
"title": "User-Agent",
"type": "string"
}
]
}
+9 -9
View File
@@ -1,31 +1,31 @@
gopeed.events.onResolve(async function (ctx) { gopeed.events.onResolve(async function (ctx) {
if (ctx.settings.string != null) { if (gopeed.settings.string != null) {
throw new Error("string is not null"); throw new Error("string is not null");
} }
if (ctx.settings.number != null) { if (gopeed.settings.number != null) {
throw new Error("number is not null"); throw new Error("number is not null");
} }
if (ctx.settings.boolean != null) { if (gopeed.settings.boolean != null) {
throw new Error("boolean is not null"); throw new Error("boolean is not null");
} }
if (ctx.settings.stringDefault !== "default") { if (gopeed.settings.stringDefault !== "default") {
throw new Error("string default value is incorrect"); throw new Error("string default value is incorrect");
} }
if (ctx.settings.numberDefault !== 1) { if (gopeed.settings.numberDefault !== 1) {
throw new Error("number default value is incorrect"); throw new Error("number default value is incorrect");
} }
if (ctx.settings.booleanDefault !== true) { if (gopeed.settings.booleanDefault !== true) {
throw new Error("boolean default value is incorrect"); throw new Error("boolean default value is incorrect");
} }
if (ctx.settings.stringValued !== "valued") { if (gopeed.settings.stringValued !== "valued") {
throw new Error("string value is incorrect"); throw new Error("string value is incorrect");
} }
if (ctx.settings.numberValued !== 1.1) { if (gopeed.settings.numberValued !== 1.1) {
throw new Error("number value is incorrect"); throw new Error("number value is incorrect");
} }
if (ctx.settings.booleanValued !== true) { if (gopeed.settings.booleanValued !== true) {
throw new Error("boolean value is incorrect"); throw new Error("boolean value is incorrect");
} }
@@ -5,9 +5,11 @@
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"*://*/*" "urls": [
], "*://*/*"
]
},
"entry": "index.js" "entry": "index.js"
} }
], ],
+1 -1
View File
@@ -1,5 +1,5 @@
gopeed.events.onResolve(async function (ctx) { gopeed.events.onResolve(async function (ctx) {
if (Object.keys(ctx.settings).length > 0){ if (Object.keys(gopeed.settings).length > 0){
throw new Error("settings is not empty"); throw new Error("settings is not empty");
} }
@@ -5,9 +5,11 @@
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"*://*/*" "urls": [
], "*://*/*"
]
},
"entry": "index.js" "entry": "index.js"
} }
] ]
+13 -13
View File
@@ -2,36 +2,36 @@ gopeed.events.onResolve(async function (ctx) {
const key = "key" const key = "key"
const value1 = "value1", value2 = JSON.stringify({a: 1, b: "2"}) const value1 = "value1", value2 = JSON.stringify({a: 1, b: "2"})
if (ctx.storage.get(key) !== null) { if (gopeed.storage.get(key) !== null) {
throw new Error("storage get null error") throw new Error("storage get null error")
} }
ctx.storage.remove(key) gopeed.storage.remove(key)
if(ctx.storage.keys().length !== 0) { if(gopeed.storage.keys().length !== 0) {
throw new Error("storage keys null error") throw new Error("storage keys null error")
} }
ctx.storage.set(key, value1) gopeed.storage.set(key, value1)
if (ctx.storage.get(key) !== value1) { if (gopeed.storage.get(key) !== value1) {
throw new Error("storage put1 error") throw new Error("storage put1 error")
} }
ctx.storage.set(key, value2) gopeed.storage.set(key, value2)
if (ctx.storage.get(key) !== value2) { if (gopeed.storage.get(key) !== value2) {
throw new Error("storage put2 error") throw new Error("storage put2 error")
} }
if(ctx.storage.keys().length !== 1) { if(gopeed.storage.keys().length !== 1) {
throw new Error("storage keys error") throw new Error("storage keys error")
} }
ctx.storage.remove(key) gopeed.storage.remove(key)
if (ctx.storage.get(key) !== null) { if (gopeed.storage.get(key) !== null) {
throw new Error("storage delete error") throw new Error("storage delete error")
} }
ctx.storage.set(key, value1) gopeed.storage.set(key, value1)
ctx.storage.clear() gopeed.storage.clear()
if (ctx.storage.get(key) !== null) { if (gopeed.storage.get(key) !== null) {
throw new Error("storage clear error") throw new Error("storage clear error")
} }
+7 -5
View File
@@ -1,13 +1,15 @@
{ {
"name": "basic", "name": "storage",
"title": "gopeed extension basic test", "title": "gopeed extension storage test",
"version": "0.0.1", "version": "0.0.1",
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"*://github.com/*" "urls": [
], "*://*/*"
]
},
"entry": "index.js" "entry": "index.js"
} }
], ],
+33 -7
View File
@@ -1,21 +1,47 @@
{ {
"name": "github-release", "name": "extension-test",
"author": "gopeed", "author": "gopeed",
"title": "Github Release", "title": "Gopeed Extension Test",
"description": "Download github release assets", "description": "Test extension settings and upgrade",
"version": "0.0.1", "version": "0.0.1",
"homepage": "https://gopeed.com", "homepage": "https://gopeed.com",
"repository": { "repository": {
"url": "https://github.com/GopeedLab/gopeed-extension-samples", "url": "https://github.com/GopeedLab/gopeed-extension-samples",
"directory": "github-release-sample" "directory": "extension-test"
}, },
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"*://github.com/*/releases/*" "urls": [
], "*://*/*"
]
},
"entry": "index.js" "entry": "index.js"
} }
],
"settings": [
{
"name": "s1",
"title": "S1 old",
"description": "Test setting update",
"type": "string",
"required": true
},
{
"name": "s2",
"title": "s2 number old",
"description": "Test setting type update",
"type": "number",
"required": true,
"value": 1
},
{
"name": "d1",
"title": "Delete test",
"description": "Test setting delete",
"type": "string",
"required": true
}
] ]
} }
-3
View File
@@ -204,19 +204,16 @@ func TestPauseAllAndContinueALLTasks(t *testing.T) {
// continue all // continue all
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil) httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil)
time.Sleep(time.Millisecond * 100)
tasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusRunning), nil) tasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusRunning), nil)
if len(tasks) != cfg.MaxRunning { if len(tasks) != cfg.MaxRunning {
t.Errorf("ContinueAllTasks() got = %v, want %v", len(tasks), cfg.MaxRunning) t.Errorf("ContinueAllTasks() got = %v, want %v", len(tasks), cfg.MaxRunning)
} }
// pause all // pause all
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/pause", nil) httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/pause", nil)
time.Sleep(time.Millisecond * 500)
tasks = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusPause), nil) tasks = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusPause), nil)
if len(tasks) != total { if len(tasks) != total {
t.Errorf("PauseAllTasks() got = %v, want %v", len(tasks), total) t.Errorf("PauseAllTasks() got = %v, want %v", len(tasks), total)
} }
}) })
} }
+4 -1
View File
@@ -65,6 +65,9 @@
"extensionInstallSuccess": "Installed successfully", "extensionInstallSuccess": "Installed successfully",
"extensionUpdateSuccess": "Updated successfully", "extensionUpdateSuccess": "Updated successfully",
"extensionDelete": "Delete Extension", "extensionDelete": "Delete Extension",
"extensionAlreadyLatest": "It's already the latest version" "extensionAlreadyLatest": "It's already the latest version",
"history": "History",
"clearHistory": "Clear History",
"noHistoryFound": "No History Found"
} }
+5 -2
View File
@@ -65,5 +65,8 @@
"extensionInstallSuccess": "安装成功", "extensionInstallSuccess": "安装成功",
"extensionUpdateSuccess": "更新成功", "extensionUpdateSuccess": "更新成功",
"extensionDelete": "删除扩展", "extensionDelete": "删除扩展",
"extensionAlreadyLatest": "已经是最新版本" "extensionAlreadyLatest": "已经是最新版本",
} "history": "历史记录",
"clearHistory": "清空历史记录",
"noHistoryFound": "暂无历史记录"
}
+2
View File
@@ -16,6 +16,7 @@ class Extension {
List<Setting>? settings; List<Setting>? settings;
bool disabled; bool disabled;
bool devMode; bool devMode;
String devPath;
Extension({ Extension({
required this.identity, required this.identity,
@@ -29,6 +30,7 @@ class Extension {
required this.repository, required this.repository,
required this.disabled, required this.disabled,
required this.devMode, required this.devMode,
required this.devPath,
}); });
factory Extension.fromJson(Map<String, dynamic> json) => factory Extension.fromJson(Map<String, dynamic> json) =>
@@ -20,6 +20,7 @@ Extension _$ExtensionFromJson(Map<String, dynamic> json) => Extension(
: Repository.fromJson(json['repository'] as Map<String, dynamic>), : Repository.fromJson(json['repository'] as Map<String, dynamic>),
disabled: json['disabled'] as bool, disabled: json['disabled'] as bool,
devMode: json['devMode'] as bool, devMode: json['devMode'] as bool,
devPath: json['devPath'] as String,
)..settings = (json['settings'] as List<dynamic>?) )..settings = (json['settings'] as List<dynamic>?)
?.map((e) => Setting.fromJson(e as Map<String, dynamic>)) ?.map((e) => Setting.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
@@ -46,6 +47,7 @@ Map<String, dynamic> _$ExtensionToJson(Extension instance) {
writeNotNull('settings', instance.settings?.map((e) => e.toJson()).toList()); writeNotNull('settings', instance.settings?.map((e) => e.toJson()).toList());
val['disabled'] = instance.disabled; val['disabled'] = instance.disabled;
val['devMode'] = instance.devMode; val['devMode'] = instance.devMode;
val['devPath'] = instance.devPath;
return val; return val;
} }
+2
View File
@@ -6,10 +6,12 @@ part 'request.g.dart';
class Request { class Request {
String url; String url;
Object? extra; Object? extra;
Map<String, String>? labels = {};
Request({ Request({
required this.url, required this.url,
this.extra, this.extra,
this.labels,
}); });
factory Request.fromJson(Map<String, dynamic> json) => factory Request.fromJson(Map<String, dynamic> json) =>
+4
View File
@@ -9,6 +9,9 @@ part of 'request.dart';
Request _$RequestFromJson(Map<String, dynamic> json) => Request( Request _$RequestFromJson(Map<String, dynamic> json) => Request(
url: json['url'] as String, url: json['url'] as String,
extra: json['extra'], extra: json['extra'],
labels: (json['labels'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
); );
Map<String, dynamic> _$RequestToJson(Request instance) { Map<String, dynamic> _$RequestToJson(Request instance) {
@@ -23,6 +26,7 @@ Map<String, dynamic> _$RequestToJson(Request instance) {
} }
writeNotNull('extra', instance.extra); writeNotNull('extra', instance.extra);
writeNotNull('labels', instance.labels);
return val; return val;
} }
@@ -202,6 +202,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
onClick: (menuItem) => {windowManager.destroy()}, onClick: (menuItem) => {windowManager.destroy()},
), ),
]); ]);
if (!Util.isLinux()) {
// Linux seems not support setToolTip, refer to: https://github.com/GopeedLab/gopeed/issues/241
await trayManager.setToolTip('Gopeed');
}
await trayManager.setContextMenu(menu); await trayManager.setContextMenu(menu);
trayManager.addListener(this); trayManager.addListener(this);
} }
@@ -4,10 +4,10 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:gopeed/app/modules/history/controller/history_controller.dart';
import 'package:gopeed/app/modules/history/views/history_view.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:rounded_loading_button/rounded_loading_button.dart'; import 'package:rounded_loading_button/rounded_loading_button.dart';
import '../../../../api/api.dart'; import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart'; import '../../../../api/model/create_task.dart';
import '../../../../api/model/options.dart'; import '../../../../api/model/options.dart';
@@ -32,6 +32,7 @@ class CreateView extends GetView<CreateController> {
final _httpCookieController = TextEditingController(); final _httpCookieController = TextEditingController();
final _httpRefererController = TextEditingController(); final _httpRefererController = TextEditingController();
final _btTrackerController = TextEditingController(); final _btTrackerController = TextEditingController();
final _historyController = HistoryController();
final _availableSchemes = ["http:", "https:", "magnet:"]; final _availableSchemes = ["http:", "https:", "magnet:"];
@@ -91,121 +92,200 @@ class CreateView extends GetView<CreateController> {
FocusScope.of(context).requestFocus(FocusNode()); FocusScope.of(context).requestFocus(FocusNode());
}, },
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Form( child: Form(
key: _resolveFormKey, key: _resolveFormKey,
autovalidateMode: AutovalidateMode.onUserInteraction, autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(children: [ child: Column(
Row(children: [ children: [
Expanded( Row(children: [
child: TextFormField( Expanded(
autofocus: true, child: TextFormField(
controller: _urlController, autofocus: true,
minLines: 1, controller: _urlController,
maxLines: 5, minLines: 1,
decoration: InputDecoration( maxLines: 5,
hintText: _hitText(), decoration: InputDecoration(
hintStyle: const TextStyle(fontSize: 12), hintText: _hitText(),
labelText: 'downloadLink'.tr, hintStyle: const TextStyle(fontSize: 12),
icon: const Icon(Icons.link), labelText: 'downloadLink'.tr,
suffixIcon: IconButton( icon: const Icon(Icons.link),
onPressed: () { suffixIcon: IconButton(
_urlController.clear(); onPressed: () {
controller.clearFileDataUri(); _urlController.clear();
}, controller.clearFileDataUri();
icon: const Icon(Icons.clear), },
), icon: const Icon(Icons.clear),
), ),
validator: (v) { ),
return v!.trim().isNotEmpty validator: (v) {
? null return v!.trim().isNotEmpty
: 'downloadLinkValid'.tr; ? null
}, : 'downloadLinkValid'.tr;
onChanged: (v) async { },
controller.clearFileDataUri(); onChanged: (v) async {
if (controller.oldUrl.value.isEmpty) { controller.clearFileDataUri();
recognizeMagnetUri(v); if (controller.oldUrl.value.isEmpty) {
} recognizeMagnetUri(v);
controller.oldUrl.value = v; }
}, controller.oldUrl.value = v;
},
),
),
IconButton(
icon: const Icon(Icons.folder_open),
onPressed: () async {
var pr = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ["torrent"]);
if (pr != null) {
if (!Util.isWeb()) {
_urlController.text = pr.files[0].path ?? "";
return;
}
_urlController.text = pr.files[0].name;
controller.setFileDataUri(pr.files[0].bytes!);
}
},
),
IconButton(
icon: const Icon(Icons.history_rounded),
onPressed: () async {
List<String> resultOfHistories =
await _historyController.getAllHistory();
// reversing: display last entered history first
List<String> reverseResultOfHistories =
resultOfHistories.reversed.toList();
// show dialog box to list history
if (context.mounted) {
showGeneralDialog(
barrierColor: Colors.black.withOpacity(0.5),
transitionBuilder: (context, a1, a2, widget) {
return Transform.scale(
scale: a1.value,
child: Opacity(
opacity: a1.value,
child: HistoryView(
isHistoryListEmpty:
reverseResultOfHistories.isEmpty,
historyList: ListView.builder(
itemCount: reverseResultOfHistories.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
_urlController.text =
reverseResultOfHistories[index];
Navigator.pop(context);
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
padding:
const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
margin:
const EdgeInsets.symmetric(
horizontal: 10.0,
vertical: 8.0,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.background,
borderRadius:
BorderRadius.circular(10.0),
),
child: Text(
reverseResultOfHistories[index]
.toString(),
),
),
),
);
},
),
),
),
);
},
transitionDuration:
const Duration(milliseconds: 250),
barrierDismissible: true,
barrierLabel: '',
context: context,
pageBuilder: (context, animation1, animation2) {
return const Text('PAGE BUILDER');
},
);
}
},
),
]
//.where((e) => e != null).map((e) => e!).toList(),
),
Obx(
() => Visibility(
visible: controller.showAdvanced.value,
child: Padding(
padding: const EdgeInsets.only(left: 40, top: 15),
child: Column(
children: [
TabBar(
controller: controller.advancedTabController,
tabs: const [
Tab(
text: 'HTTP',
),
Tab(
text: 'BitTorrent',
)
],
),
AutoScaleTabBarView(
controller: controller.advancedTabController,
children: [
Column(
children: [
TextFormField(
controller: _httpUaController,
decoration: const InputDecoration(
labelText: 'User-Agent',
)),
TextFormField(
controller: _httpCookieController,
decoration: const InputDecoration(
labelText: 'Cookie',
)),
TextFormField(
controller: _httpRefererController,
decoration: const InputDecoration(
labelText: 'Referer',
)),
],
),
Column(
children: [
TextFormField(
controller: _btTrackerController,
maxLines: 5,
decoration: InputDecoration(
labelText: 'Trakers',
hintText: 'addTrackerHit'.tr,
)),
],
)
],
)
],
), ),
), ),
IconButton( ),
icon: const Icon(Icons.folder_open), ),
onPressed: () async { Center(
var pr = await FilePicker.platform.pickFiles( child: Padding(
type: FileType.custom,
allowedExtensions: ["torrent"]);
if (pr != null) {
if (!Util.isWeb()) {
_urlController.text = pr.files[0].path ?? "";
return;
}
_urlController.text = pr.files[0].name;
controller.setFileDataUri(pr.files[0].bytes!);
}
}),
]
//.where((e) => e != null).map((e) => e!).toList(),
),
Obx(() => Visibility(
visible: controller.showAdvanced.value,
child: Padding(
padding: const EdgeInsets.only(left: 40, top: 15),
child: Column(
children: [
TabBar(
controller: controller.advancedTabController,
tabs: const [
Tab(
text: 'HTTP',
),
Tab(
text: 'BitTorrent',
)
],
),
AutoScaleTabBarView(
controller: controller.advancedTabController,
children: [
Column(
children: [
TextFormField(
controller: _httpUaController,
decoration: const InputDecoration(
labelText: 'User-Agent',
)),
TextFormField(
controller: _httpCookieController,
decoration: const InputDecoration(
labelText: 'Cookie',
)),
TextFormField(
controller: _httpRefererController,
decoration: const InputDecoration(
labelText: 'Referer',
)),
],
),
Column(
children: [
TextFormField(
controller: _btTrackerController,
maxLines: 5,
decoration: InputDecoration(
labelText: 'Trakers',
hintText: 'addTrackerHit'.tr,
)),
],
)
],
)
],
),
))),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 15), padding: const EdgeInsets.only(top: 15),
child: Column( child: Column(
children: [ children: [
@@ -241,8 +321,12 @@ class CreateView extends GetView<CreateController> {
), ),
], ],
), ),
)), ),
]))), ),
],
),
),
),
), ),
), ),
); );
@@ -273,9 +357,9 @@ class CreateView extends GetView<CreateController> {
// check if is multi line urls // check if is multi line urls
final urls = Util.textToLines(_urlController.text); final urls = Util.textToLines(_urlController.text);
ResolveResult rr; ResolveResult rr;
bool isMuiltiLine = false; bool isMultiLine = false;
if (urls.length > 1) { if (urls.length > 1) {
isMuiltiLine = true; isMultiLine = true;
rr = ResolveResult( rr = ResolveResult(
res: Resource( res: Resource(
files: urls files: urls
@@ -287,12 +371,14 @@ class CreateView extends GetView<CreateController> {
final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty
? controller.fileDataUri.value ? controller.fileDataUri.value
: _urlController.text; : _urlController.text;
// add final submitUrl to the history
_historyController.addHistory(submitUrl);
rr = await resolve(Request( rr = await resolve(Request(
url: submitUrl, url: submitUrl,
extra: parseReqExtra(_urlController.text), extra: parseReqExtra(_urlController.text),
)); ));
} }
await _showResolveDialog(rr, isMuiltiLine); await _showResolveDialog(rr, isMultiLine);
} }
} catch (e) { } catch (e) {
showErrorMessage(e); showErrorMessage(e);
@@ -21,6 +21,7 @@ import '../../../../api/model/install_extension.dart';
import '../../../../api/model/switch_extension.dart'; import '../../../../api/model/switch_extension.dart';
import '../../../../util/util.dart'; import '../../../../util/util.dart';
import '../../../views/icon_button_loading.dart'; import '../../../views/icon_button_loading.dart';
import '../../../views/responsive_builder.dart';
import '../controllers/extension_controller.dart'; import '../controllers/extension_controller.dart';
class ExtensionView extends GetView<ExtensionController> { class ExtensionView extends GetView<ExtensionController> {
@@ -96,7 +97,7 @@ class ExtensionView extends GetView<ExtensionController> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final extension = controller.extensions[index]; final extension = controller.extensions[index];
return SizedBox( return SizedBox(
height: 112, height: ResponsiveBuilder.isNarrow(context) ? 152 : 112,
child: Card( child: Card(
elevation: 4.0, elevation: 4.0,
child: Column( child: Column(
@@ -110,14 +111,22 @@ class ExtensionView extends GetView<ExtensionController> {
height: 48, height: 48,
) )
: Util.isWeb() : Util.isWeb()
? Image.network(join( ? Image.network(
'/fs/extensions/${extension.identity}/${extension.icon}')) join(
'/fs/extensions/${extension.identity}/${extension.icon}'),
width: 48,
height: 48,
)
: Image.file( : Image.file(
File(path.join( extension.devMode
Util.getStorageDir(), ? File(path.join(
"extensions", extension.devPath,
extension.identity, extension.icon))
extension.icon)), : File(path.join(
Util.getStorageDir(),
"extensions",
extension.identity,
extension.icon)),
width: 48, width: 48,
height: 48, height: 48,
), ),
@@ -137,23 +146,35 @@ class ExtensionView extends GetView<ExtensionController> {
title: Row( title: Row(
children: () { children: () {
final list = [ final list = [
Text(extension.title), ResponsiveBuilder.isNarrow(context)
? Expanded(
child: Text(
extension.title,
overflow:
TextOverflow.ellipsis,
),
)
: Text(extension.title),
const SizedBox(width: 8), const SizedBox(width: 8),
Chip( buildChip('v${extension.version}'),
label: Text('v${extension.version}'),
)
]; ];
if (extension.devMode) { if (extension.devMode) {
list.add(const SizedBox(width: 8)); list.add(const SizedBox(width: 8));
list.add(Chip( list.add(buildChip(
label: Text('dev'.tr), 'dev',
backgroundColor: Colors.redAccent, bgColor: Colors.blue.shade300,
)); ));
} }
return list; return list;
}(), }(),
), ),
subtitle: Text(extension.description), subtitle: ResponsiveBuilder.isNarrow(context)
? Text(
extension.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: Text(extension.description),
), ),
), ),
Row( Row(
@@ -225,6 +246,14 @@ class ExtensionView extends GetView<ExtensionController> {
); );
} }
Widget buildChip(String text, {Color? bgColor}) {
return Chip(
padding: const EdgeInsets.all(0),
backgroundColor: bgColor,
label: Text(text, style: const TextStyle(fontSize: 12)),
);
}
Future<void> _showSettingDialog(Extension extension) async { Future<void> _showSettingDialog(Extension extension) async {
final formKey = GlobalKey<FormBuilderState>(); final formKey = GlobalKey<FormBuilderState>();
final confrimController = RoundedLoadingButtonController(); final confrimController = RoundedLoadingButtonController();
@@ -248,9 +277,32 @@ class ExtensionView extends GetView<ExtensionController> {
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
children: extension.settings! children: extension.settings!.map((e) {
.map((e) => _buildSettingItem(e)) final settingItem = _buildSettingItem(e);
.toList()),
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
width: 20,
child: e.description.isEmpty
? null
: Tooltip(
message: e.description,
child: const CircleAvatar(
radius: 10,
backgroundColor:
Colors.grey,
child: Icon(
Icons.question_mark,
size: 10,
)),
))
.paddingOnly(right: 10),
Expanded(child: settingItem),
],
);
}).toList()),
), ),
), ),
]), ]),
@@ -316,8 +368,7 @@ class ExtensionView extends GetView<ExtensionController> {
FormFieldValidator<String>? validator, TextInputType? keyBoardType) { FormFieldValidator<String>? validator, TextInputType? keyBoardType) {
return FormBuilderTextField( return FormBuilderTextField(
name: setting.name, name: setting.name,
decoration: InputDecoration( decoration: InputDecoration(labelText: setting.title),
labelText: setting.title, hintText: setting.description),
initialValue: setting.value?.toString(), initialValue: setting.value?.toString(),
inputFormatters: inputFormatter != null ? [inputFormatter] : null, inputFormatters: inputFormatter != null ? [inputFormatter] : null,
keyboardType: keyBoardType, keyboardType: keyBoardType,
@@ -332,7 +383,8 @@ class ExtensionView extends GetView<ExtensionController> {
return FormBuilderDropdown<String>( return FormBuilderDropdown<String>(
name: setting.name, name: setting.name,
decoration: InputDecoration( decoration: InputDecoration(
labelText: setting.title, hintText: setting.description), labelText: setting.title,
),
initialValue: setting.value?.toString(), initialValue: setting.value?.toString(),
validator: FormBuilderValidators.compose([ validator: FormBuilderValidators.compose([
requiredValidator, requiredValidator,
@@ -0,0 +1,26 @@
import 'package:shared_preferences/shared_preferences.dart';
class HistoryController {
// Get History in List<String>
Future<List<String>> getAllHistory() async {
SharedPreferences preferences = await SharedPreferences.getInstance();
List<String>? historyList = preferences.getStringList('history');
if(historyList == null) {
return [];
}
return historyList;
}
// Get Existing History & Add New History
void addHistory(String historyText) async {
SharedPreferences preferences = await SharedPreferences.getInstance();
List<String> existingHistoryData = await getAllHistory();
existingHistoryData.add(historyText);
preferences.setStringList('history', existingHistoryData);
}
void clearHistory() async {
SharedPreferences preferences = await SharedPreferences.getInstance();
preferences.remove('history');
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:gopeed/app/modules/history/controller/history_controller.dart';
class HistoryView extends StatefulWidget {
const HistoryView({
super.key,
required this.isHistoryListEmpty,
required this.historyList,
});
final bool isHistoryListEmpty;
final Widget historyList;
@override
State<HistoryView> createState() => _HistoryViewState();
}
class _HistoryViewState extends State<HistoryView> {
@override
Widget build(BuildContext context) {
final Size size = MediaQuery.sizeOf(context);
return Scaffold(
backgroundColor: Colors.transparent,
body: Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
width: size.width * 0.8,
height: size.height * 0.8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10.0),
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Icon(
Icons.history_rounded,
size: Theme.of(context)
.textTheme
.headlineSmall
?.fontSize,
),
const SizedBox(width: 8.0),
Text(
'history'.tr,
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
Row(
children: <Widget>[
IconButton(
onPressed: () {
HistoryController _controller = HistoryController();
_controller.clearHistory();
Navigator.pop(context);
},
tooltip: "clearHistory".tr,
icon: const Icon(
Icons.history_toggle_off_rounded,
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(
Icons.close_rounded,
),
),
],
),
],
),
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: widget.isHistoryListEmpty
? <Widget>[
const Icon(
Icons.manage_history_rounded,
),
const SizedBox(height: 10.0),
Text(
'noHistoryFound'.tr,
),
]
: <Widget>[
Expanded(child: widget.historyList),
],
),
),
),
],
),
),
),
);
}
}
@@ -12,6 +12,12 @@ class FileItem {
FileItem(this.isDirectory, this.path, this.name, this.size); FileItem(this.isDirectory, this.path, this.name, this.size);
String get fullPath => "${path == "/" ? path : "$path/"}$name"; String get fullPath => "${path == "/" ? path : "$path/"}$name";
String filePath(String optName) {
return optName.isEmpty
? fullPath
: "${path == "/" ? path : "$path/"}$optName";
}
} }
class TaskFilesController extends GetxController { class TaskFilesController extends GetxController {
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';
import 'package:path/path.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../../../../util/file_icon.dart'; import '../../../../util/file_icon.dart';
@@ -52,13 +53,17 @@ class TaskFilesView extends GetView<TaskFilesController> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final meta = controller.task.value!.meta; final meta = controller.task.value!.meta;
final file = fileList[index]; final file = fileList[index];
final filePath = Util.safePathJoin( final filePath = Util.safePathJoin([
[meta.opts.path, meta.res!.name, file.fullPath]); meta.opts.path,
meta.res!.name,
file.filePath(meta.opts.name)
]);
final fileName = basename(filePath);
return ListTile( return ListTile(
leading: file.isDirectory leading: file.isDirectory
? const Icon(Icons.folder) ? const Icon(Icons.folder)
: Icon(FaIcons.allIcons[findIcon(file.name)]), : Icon(FaIcons.allIcons[findIcon(file.name)]),
title: Text(fileList[index].name), title: Text(fileName),
subtitle: file.isDirectory subtitle: file.isDirectory
? Text('items'.trParams({ ? Text('items'.trParams({
'count': controller 'count': controller
+9
View File
@@ -82,6 +82,9 @@ class LocaleKeys {
static const extensionUpdateSuccess = 'extensionUpdateSuccess'; static const extensionUpdateSuccess = 'extensionUpdateSuccess';
static const extensionDelete = 'extensionDelete'; static const extensionDelete = 'extensionDelete';
static const extensionAlreadyLatest = 'extensionAlreadyLatest'; static const extensionAlreadyLatest = 'extensionAlreadyLatest';
static const history = 'history';
static const clearHistory = 'clearHistory';
static const noHistoryFound = 'noHistoryFound';
} }
class Locales { class Locales {
@@ -155,6 +158,9 @@ class Locales {
'extensionUpdateSuccess': 'Updated successfully', 'extensionUpdateSuccess': 'Updated successfully',
'extensionDelete': 'Delete Extension', 'extensionDelete': 'Delete Extension',
'extensionAlreadyLatest': 'It\'s already the latest version', 'extensionAlreadyLatest': 'It\'s already the latest version',
'history': 'History',
'clearHistory': 'Clear History',
'noHistoryFound': 'No History Found',
}; };
static const fa_IR = { static const fa_IR = {
'label': 'فارسی', 'label': 'فارسی',
@@ -407,6 +413,9 @@ class Locales {
'extensionUpdateSuccess': '更新成功', 'extensionUpdateSuccess': '更新成功',
'extensionDelete': '删除扩展', 'extensionDelete': '删除扩展',
'extensionAlreadyLatest': '已经是最新版本', 'extensionAlreadyLatest': '已经是最新版本',
'history': '历史记录',
'clearHistory': '清空历史记录',
'noHistoryFound': '没有历史记录',
}; };
static const zh_TW = { static const zh_TW = {
'label': '中文 (正體) ', 'label': '中文 (正體) ',
+5
View File
@@ -29,6 +29,7 @@ class Util {
static String safePathJoin(List<String> paths) { static String safePathJoin(List<String> paths) {
return paths return paths
.where((e) => e.isNotEmpty)
.map((e) => safeDir(e)) .map((e) => safeDir(e))
.join("/") .join("/")
.replaceAll(RegExp(r'//'), "/"); .replaceAll(RegExp(r'//'), "/");
@@ -93,6 +94,10 @@ class Util {
return !kIsWeb && Platform.isMacOS; return !kIsWeb && Platform.isMacOS;
} }
static isLinux() {
return !kIsWeb && Platform.isLinux;
}
static isWeb() { static isWeb() {
return kIsWeb; return kIsWeb;
} }
+247 -255
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.4.1+1 version: 1.4.3+1
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -41,7 +41,7 @@ dependencies:
context_menus: ^1.0.2 context_menus: ^1.0.2
json_annotation: ^4.8.1 json_annotation: ^4.8.1
dio: ^5.2.1 dio: ^5.2.1
path_provider: ^2.0.11 path_provider: ^2.1.1
file_picker: 5.3.1 file_picker: 5.3.1
macos_secure_bookmarks: ^0.2.1 macos_secure_bookmarks: ^0.2.1
rounded_loading_button: ^2.1.0 rounded_loading_button: ^2.1.0