mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 02:20:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa9d4c707 | ||
|
|
6bc587752a | ||
|
|
11befc09bd | ||
|
|
ff6c0da262 | ||
|
|
aab4a37a7e | ||
|
|
74c9491893 | ||
|
|
256faf2cbd | ||
|
|
32e3620f5e |
No files matched your search
@@ -13,7 +13,8 @@
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -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/)
|
||||
|
||||
|
||||
@@ -314,6 +314,10 @@ func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
// 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)
|
||||
@@ -466,9 +470,7 @@ func (fb *FetcherBuilder) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
|
||||
fetcher.meta = meta
|
||||
base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)
|
||||
base.ParseOptsExtra[fhttp.OptsExtra](fetcher.meta.Opts)
|
||||
if len(fd.Chunks) == 0 {
|
||||
fetcher.chunks = fetcher.splitChunk()
|
||||
} else {
|
||||
if len(fd.Chunks) > 0 {
|
||||
fetcher.chunks = fd.Chunks
|
||||
}
|
||||
return fetcher
|
||||
|
||||
+268
-196
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/virtuald/go-paniclog"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -56,11 +55,13 @@ type Downloader struct {
|
||||
fetcherCache map[string]fetcher.Fetcher
|
||||
storage Storage
|
||||
tasks []*Task
|
||||
waitTasks []*Task
|
||||
listener Listener
|
||||
|
||||
lock *sync.Mutex
|
||||
fetcherMapLock *sync.RWMutex
|
||||
closed atomic.Bool
|
||||
lock *sync.Mutex
|
||||
fetcherMapLock *sync.RWMutex
|
||||
checkDuplicateLock *sync.Mutex
|
||||
closed atomic.Bool
|
||||
|
||||
extensions []*Extension
|
||||
}
|
||||
@@ -72,14 +73,15 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
cfg.Init()
|
||||
|
||||
d := &Downloader{
|
||||
cfg: cfg,
|
||||
|
||||
cfg: cfg,
|
||||
fetcherBuilders: make(map[string]fetcher.FetcherBuilder),
|
||||
fetcherCache: make(map[string]fetcher.Fetcher),
|
||||
waitTasks: make([]*Task, 0),
|
||||
storage: cfg.Storage,
|
||||
|
||||
storage: cfg.Storage,
|
||||
lock: &sync.Mutex{},
|
||||
fetcherMapLock: &sync.RWMutex{},
|
||||
lock: &sync.Mutex{},
|
||||
fetcherMapLock: &sync.RWMutex{},
|
||||
checkDuplicateLock: &sync.Mutex{},
|
||||
|
||||
extensions: make([]*Extension, 0),
|
||||
}
|
||||
@@ -248,6 +250,33 @@ func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
|
||||
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) {
|
||||
var fetcher fetcher.Fetcher
|
||||
fetcher, err = d.buildFetcher(req.URL)
|
||||
@@ -281,10 +310,21 @@ func (d *Downloader) Pause(id string) (err error) {
|
||||
if task == nil {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
if err = d.doPause(task, base.DownloadStatusPause); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.notifyRunning()
|
||||
|
||||
func() {
|
||||
task.lock.Lock()
|
||||
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) {
|
||||
@@ -292,109 +332,164 @@ func (d *Downloader) Continue(id string) (err error) {
|
||||
if task == nil {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
d.tryRunning(func(remain int) {
|
||||
if remain == 0 {
|
||||
// no more task can be running, need to doPause a running task
|
||||
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
remainRunningCount := d.remainRunningCount()
|
||||
if remainRunningCount == 0 {
|
||||
for _, t := range d.tasks {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
}()
|
||||
|
||||
func() {
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
|
||||
if task.Status == base.DownloadStatusRunning {
|
||||
return
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return d.doContinue(task)
|
||||
|
||||
if err = d.doStart(task); err != nil {
|
||||
return
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) PauseAll() (err error) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
return d.doPauseAll()
|
||||
}
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
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 {
|
||||
if task.Status == base.DownloadStatusRunning {
|
||||
runningCount++
|
||||
} else if task.Status != base.DownloadStatusDone {
|
||||
continueTasks = append(continueTasks, task)
|
||||
}
|
||||
}
|
||||
// calculate how many tasks can be continued, can't exceed maxRunning
|
||||
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 {
|
||||
err = func() error {
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
|
||||
return d.doPause(task)
|
||||
}()
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func (d *Downloader) Delete(id string, force bool) (err error) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
task := d.GetTask(id)
|
||||
if task == nil {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
if task.fetcher != nil {
|
||||
err = task.fetcher.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for i, t := range d.tasks {
|
||||
if t.ID == id {
|
||||
d.tasks = append(d.tasks[:i], d.tasks[i+1:]...)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
d.emit(EventKeyDelete, task)
|
||||
if task.Status == base.DownloadStatusRunning {
|
||||
if err = d.doNotifyRunning(); err != nil {
|
||||
if err := d.storage.Delete(bucketTask, task.ID); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func (d *Downloader) Close() error {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
d.closed.Store(true)
|
||||
if err := d.doPauseAll(); err != nil {
|
||||
if err := d.PauseAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.storage.Close()
|
||||
@@ -499,9 +594,7 @@ func (d *Downloader) watch(task *Task) {
|
||||
d.emit(EventKeyDone, task)
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
}
|
||||
if task.Status == EventKeyDone {
|
||||
d.notifyRunning()
|
||||
}
|
||||
d.notifyRunning()
|
||||
}
|
||||
|
||||
func (d *Downloader) restoreFetcher(task *Task) error {
|
||||
@@ -529,6 +622,12 @@ func (d *Downloader) restoreFetcher(task *Task) error {
|
||||
task.fetcher = fb.Build()
|
||||
}
|
||||
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)
|
||||
}
|
||||
task.fetcher.Create(task.Meta.Opts)
|
||||
@@ -573,147 +672,120 @@ func (d *Downloader) doCreate(fetcher fetcher.Fetcher, opts *base.Options) (task
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
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)) {
|
||||
runningCount := 0
|
||||
for _, task := range d.tasks {
|
||||
if task.Status == base.DownloadStatusRunning {
|
||||
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 {
|
||||
remainRunningCount := d.remainRunningCount()
|
||||
if remainRunningCount == 0 {
|
||||
task.Status = base.DownloadStatusWait
|
||||
d.waitTasks = append(d.waitTasks, task)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
d.doStart(task)
|
||||
}()
|
||||
|
||||
go d.watch(task)
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) start(task *Task) error {
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
d.triggerOnStart(task)
|
||||
|
||||
isCreate := task.Status == base.DownloadStatusReady
|
||||
task.Status = base.DownloadStatusRunning
|
||||
if task.Meta.Res == nil {
|
||||
err := task.fetcher.Resolve(task.Meta.Req)
|
||||
func (d *Downloader) doStart(task *Task) (err error) {
|
||||
if task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone {
|
||||
err := d.restoreFetcher(task)
|
||||
if err != nil {
|
||||
task.Status = base.DownloadStatusError
|
||||
d.emit(EventKeyError, task, err)
|
||||
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
|
||||
return err
|
||||
}
|
||||
isCreate = true
|
||||
}
|
||||
|
||||
if isCreate {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
// check if the download file is duplicated and rename it automatically.
|
||||
if task.Meta.Res.Name != "" {
|
||||
fullDirPath := task.Meta.FolderPath()
|
||||
newName, err := util.CheckDuplicateAndRename(fullDirPath)
|
||||
if err != nil {
|
||||
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
|
||||
cloneTask := task.clone()
|
||||
isCreate := task.Status == base.DownloadStatusReady
|
||||
task.Status = base.DownloadStatusRunning
|
||||
|
||||
doStart := func() error {
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
|
||||
req := d.triggerOnStart(cloneTask)
|
||||
if req != nil {
|
||||
task.Meta.Req = req
|
||||
task.fetcher.Meta().Req = req
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
if task.Meta.Res == nil {
|
||||
err := task.fetcher.Resolve(task.Meta.Req)
|
||||
if err != nil {
|
||||
task.Status = base.DownloadStatusError
|
||||
d.emit(EventKeyError, task, err)
|
||||
return err
|
||||
}
|
||||
task.Meta.Res = task.fetcher.Meta().Res
|
||||
}
|
||||
|
||||
func (d *Downloader) doPause(task *Task, status base.Status) (err error) {
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
if task.Status != base.DownloadStatusDone {
|
||||
task.Status = status
|
||||
task.timer.Pause()
|
||||
task.fetcher.Pause()
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
d.emit(EventKeyPause, task)
|
||||
if isCreate {
|
||||
d.checkDuplicateLock.Lock()
|
||||
defer d.checkDuplicateLock.Unlock()
|
||||
// check if the download file is duplicated and rename it automatically.
|
||||
if task.Meta.Res.Name != "" {
|
||||
fullDirPath := task.Meta.FolderPath()
|
||||
newName, err := util.CheckDuplicateAndRename(fullDirPath)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func (d *Downloader) doContinue(task *Task) (err error) {
|
||||
func (d *Downloader) doPause(task *Task) (err error) {
|
||||
err = func() error {
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
if task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone {
|
||||
err := d.restoreFetcher(task)
|
||||
if err != nil {
|
||||
if task.Status != base.DownloadStatusDone {
|
||||
task.Status = base.DownloadStatusPause
|
||||
task.timer.Pause()
|
||||
if task.fetcher != nil {
|
||||
if err := task.fetcher.Pause(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
|
||||
return err
|
||||
}
|
||||
d.emit(EventKeyPause, task)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
d.Logger.Error().Stack().Err(err).Msgf("pause task failed, task id: %s", task.ID)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -251,12 +251,12 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) triggerOnStart(task *Task) {
|
||||
func (d *Downloader) triggerOnStart(task *Task) (req *base.Request) {
|
||||
doTrigger(d,
|
||||
EventOnStart,
|
||||
task.Meta.Req,
|
||||
&OnStartContext{
|
||||
Task: task.clone(),
|
||||
Task: task,
|
||||
},
|
||||
func(ext *Extension, gopeed *Instance, ctx *OnStartContext) {
|
||||
// Validate request structure
|
||||
@@ -265,8 +265,7 @@ func (d *Downloader) triggerOnStart(task *Task) {
|
||||
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] request invalid", ext.buildIdentity())
|
||||
return
|
||||
}
|
||||
// Modify real task request
|
||||
task.Meta.Req = ctx.Task.Meta.Req
|
||||
req = ctx.Task.Meta.Req
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -422,8 +421,50 @@ func (e *Extension) update(newExt *Extension) error {
|
||||
e.Homepage = newExt.Homepage
|
||||
e.Repository = newExt.Repository
|
||||
e.Scripts = newExt.Scripts
|
||||
// don't override settings
|
||||
//e.Settings = newExt.Settings
|
||||
// merge 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()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,6 +95,15 @@ func TestDownloader_InstallExtensionByGitFull(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) {
|
||||
installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/update", false)
|
||||
if err != nil {
|
||||
@@ -122,14 +131,35 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
|
||||
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{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
URL: "https://test.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rr.Res.Files) == 1 {
|
||||
t.Fatal("resolve error")
|
||||
if rr.Res.Name != "test" {
|
||||
t.Fatal("script update fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -222,6 +222,9 @@ func (b *BoltStorage) Pop(bucket string, key string, v any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
|
||||
+28
-4
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "github-release",
|
||||
"name": "extension-test",
|
||||
"author": "gopeed",
|
||||
"title": "Github Release",
|
||||
"description": "Download github release assets",
|
||||
"title": "Gopeed Extension Test",
|
||||
"description": "Test extension settings and upgrade",
|
||||
"version": "0.0.1",
|
||||
"homepage": "https://gopeed.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/GopeedLab/gopeed-extension-samples",
|
||||
"directory": "github-release-sample"
|
||||
"directory": "extension-test"
|
||||
},
|
||||
"scripts": [
|
||||
{
|
||||
@@ -19,5 +19,29 @@
|
||||
},
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -204,19 +204,16 @@ func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||
|
||||
// continue all
|
||||
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)
|
||||
if len(tasks) != cfg.MaxRunning {
|
||||
t.Errorf("ContinueAllTasks() got = %v, want %v", len(tasks), cfg.MaxRunning)
|
||||
}
|
||||
// pause all
|
||||
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)
|
||||
if len(tasks) != total {
|
||||
t.Errorf("PauseAllTasks() got = %v, want %v", len(tasks), total)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
"extensionInstallSuccess": "Installed successfully",
|
||||
"extensionUpdateSuccess": "Updated successfully",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -65,5 +65,8 @@
|
||||
"extensionInstallSuccess": "安装成功",
|
||||
"extensionUpdateSuccess": "更新成功",
|
||||
"extensionDelete": "删除扩展",
|
||||
"extensionAlreadyLatest": "已经是最新版本"
|
||||
}
|
||||
"extensionAlreadyLatest": "已经是最新版本",
|
||||
"history": "历史记录",
|
||||
"clearHistory": "清空历史记录",
|
||||
"noHistoryFound": "暂无历史记录"
|
||||
}
|
||||
@@ -202,7 +202,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
onClick: (menuItem) => {windowManager.destroy()},
|
||||
),
|
||||
]);
|
||||
await trayManager.setToolTip('Gopeed');
|
||||
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);
|
||||
trayManager.addListener(this);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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:rounded_loading_button/rounded_loading_button.dart';
|
||||
|
||||
import '../../../../api/api.dart';
|
||||
import '../../../../api/model/create_task.dart';
|
||||
import '../../../../api/model/options.dart';
|
||||
@@ -32,6 +32,7 @@ class CreateView extends GetView<CreateController> {
|
||||
final _httpCookieController = TextEditingController();
|
||||
final _httpRefererController = TextEditingController();
|
||||
final _btTrackerController = TextEditingController();
|
||||
final _historyController = HistoryController();
|
||||
|
||||
final _availableSchemes = ["http:", "https:", "magnet:"];
|
||||
|
||||
@@ -91,121 +92,200 @@ class CreateView extends GetView<CreateController> {
|
||||
FocusScope.of(context).requestFocus(FocusNode());
|
||||
},
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
|
||||
child: Form(
|
||||
key: _resolveFormKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
autofocus: true,
|
||||
controller: _urlController,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
decoration: InputDecoration(
|
||||
hintText: _hitText(),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
labelText: 'downloadLink'.tr,
|
||||
icon: const Icon(Icons.link),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
_urlController.clear();
|
||||
controller.clearFileDataUri();
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
|
||||
child: Form(
|
||||
key: _resolveFormKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
autofocus: true,
|
||||
controller: _urlController,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
decoration: InputDecoration(
|
||||
hintText: _hitText(),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
labelText: 'downloadLink'.tr,
|
||||
icon: const Icon(Icons.link),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
_urlController.clear();
|
||||
controller.clearFileDataUri();
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
validator: (v) {
|
||||
return v!.trim().isNotEmpty
|
||||
? null
|
||||
: 'downloadLinkValid'.tr;
|
||||
},
|
||||
onChanged: (v) async {
|
||||
controller.clearFileDataUri();
|
||||
if (controller.oldUrl.value.isEmpty) {
|
||||
recognizeMagnetUri(v);
|
||||
}
|
||||
controller.oldUrl.value = v;
|
||||
},
|
||||
),
|
||||
validator: (v) {
|
||||
return v!.trim().isNotEmpty
|
||||
? null
|
||||
: 'downloadLinkValid'.tr;
|
||||
},
|
||||
onChanged: (v) async {
|
||||
controller.clearFileDataUri();
|
||||
if (controller.oldUrl.value.isEmpty) {
|
||||
recognizeMagnetUri(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 {
|
||||
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!);
|
||||
}
|
||||
}),
|
||||
]
|
||||
//.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(
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: Column(
|
||||
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
|
||||
final urls = Util.textToLines(_urlController.text);
|
||||
ResolveResult rr;
|
||||
bool isMuiltiLine = false;
|
||||
bool isMultiLine = false;
|
||||
if (urls.length > 1) {
|
||||
isMuiltiLine = true;
|
||||
isMultiLine = true;
|
||||
rr = ResolveResult(
|
||||
res: Resource(
|
||||
files: urls
|
||||
@@ -287,12 +371,14 @@ class CreateView extends GetView<CreateController> {
|
||||
final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty
|
||||
? controller.fileDataUri.value
|
||||
: _urlController.text;
|
||||
// add final submitUrl to the history
|
||||
_historyController.addHistory(submitUrl);
|
||||
rr = await resolve(Request(
|
||||
url: submitUrl,
|
||||
extra: parseReqExtra(_urlController.text),
|
||||
));
|
||||
}
|
||||
await _showResolveDialog(rr, isMuiltiLine);
|
||||
await _showResolveDialog(rr, isMultiLine);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../../../../api/model/install_extension.dart';
|
||||
import '../../../../api/model/switch_extension.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../../views/icon_button_loading.dart';
|
||||
import '../../../views/responsive_builder.dart';
|
||||
import '../controllers/extension_controller.dart';
|
||||
|
||||
class ExtensionView extends GetView<ExtensionController> {
|
||||
@@ -96,7 +97,7 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
itemBuilder: (context, index) {
|
||||
final extension = controller.extensions[index];
|
||||
return SizedBox(
|
||||
height: 112,
|
||||
height: ResponsiveBuilder.isNarrow(context) ? 152 : 112,
|
||||
child: Card(
|
||||
elevation: 4.0,
|
||||
child: Column(
|
||||
@@ -145,23 +146,35 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
title: Row(
|
||||
children: () {
|
||||
final list = [
|
||||
Text(extension.title),
|
||||
ResponsiveBuilder.isNarrow(context)
|
||||
? Expanded(
|
||||
child: Text(
|
||||
extension.title,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
: Text(extension.title),
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
label: Text('v${extension.version}'),
|
||||
)
|
||||
buildChip('v${extension.version}'),
|
||||
];
|
||||
if (extension.devMode) {
|
||||
list.add(const SizedBox(width: 8));
|
||||
list.add(Chip(
|
||||
label: Text('dev'.tr),
|
||||
backgroundColor: Colors.redAccent,
|
||||
list.add(buildChip(
|
||||
'dev',
|
||||
bgColor: Colors.blue.shade300,
|
||||
));
|
||||
}
|
||||
return list;
|
||||
}(),
|
||||
),
|
||||
subtitle: Text(extension.description),
|
||||
subtitle: ResponsiveBuilder.isNarrow(context)
|
||||
? Text(
|
||||
extension.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(extension.description),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
@@ -233,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 {
|
||||
final formKey = GlobalKey<FormBuilderState>();
|
||||
final confrimController = RoundedLoadingButtonController();
|
||||
@@ -256,9 +277,32 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: extension.settings!
|
||||
.map((e) => _buildSettingItem(e))
|
||||
.toList()),
|
||||
children: extension.settings!.map((e) {
|
||||
final settingItem = _buildSettingItem(e);
|
||||
|
||||
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()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
@@ -324,8 +368,7 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
FormFieldValidator<String>? validator, TextInputType? keyBoardType) {
|
||||
return FormBuilderTextField(
|
||||
name: setting.name,
|
||||
decoration: InputDecoration(
|
||||
labelText: setting.title, hintText: setting.description),
|
||||
decoration: InputDecoration(labelText: setting.title),
|
||||
initialValue: setting.value?.toString(),
|
||||
inputFormatters: inputFormatter != null ? [inputFormatter] : null,
|
||||
keyboardType: keyBoardType,
|
||||
@@ -340,7 +383,8 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
return FormBuilderDropdown<String>(
|
||||
name: setting.name,
|
||||
decoration: InputDecoration(
|
||||
labelText: setting.title, hintText: setting.description),
|
||||
labelText: setting.title,
|
||||
),
|
||||
initialValue: setting.value?.toString(),
|
||||
validator: FormBuilderValidators.compose([
|
||||
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);
|
||||
|
||||
String get fullPath => "${path == "/" ? path : "$path/"}$name";
|
||||
|
||||
String filePath(String optName) {
|
||||
return optName.isEmpty
|
||||
? fullPath
|
||||
: "${path == "/" ? path : "$path/"}$optName";
|
||||
}
|
||||
}
|
||||
|
||||
class TaskFilesController extends GetxController {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../../util/file_icon.dart';
|
||||
@@ -52,13 +53,17 @@ class TaskFilesView extends GetView<TaskFilesController> {
|
||||
itemBuilder: (context, index) {
|
||||
final meta = controller.task.value!.meta;
|
||||
final file = fileList[index];
|
||||
final filePath = Util.safePathJoin(
|
||||
[meta.opts.path, meta.res!.name, file.fullPath]);
|
||||
final filePath = Util.safePathJoin([
|
||||
meta.opts.path,
|
||||
meta.res!.name,
|
||||
file.filePath(meta.opts.name)
|
||||
]);
|
||||
final fileName = basename(filePath);
|
||||
return ListTile(
|
||||
leading: file.isDirectory
|
||||
? const Icon(Icons.folder)
|
||||
: Icon(FaIcons.allIcons[findIcon(file.name)]),
|
||||
title: Text(fileList[index].name),
|
||||
title: Text(fileName),
|
||||
subtitle: file.isDirectory
|
||||
? Text('items'.trParams({
|
||||
'count': controller
|
||||
|
||||
@@ -82,6 +82,9 @@ class LocaleKeys {
|
||||
static const extensionUpdateSuccess = 'extensionUpdateSuccess';
|
||||
static const extensionDelete = 'extensionDelete';
|
||||
static const extensionAlreadyLatest = 'extensionAlreadyLatest';
|
||||
static const history = 'history';
|
||||
static const clearHistory = 'clearHistory';
|
||||
static const noHistoryFound = 'noHistoryFound';
|
||||
}
|
||||
|
||||
class Locales {
|
||||
@@ -155,6 +158,9 @@ class Locales {
|
||||
'extensionUpdateSuccess': 'Updated successfully',
|
||||
'extensionDelete': 'Delete Extension',
|
||||
'extensionAlreadyLatest': 'It\'s already the latest version',
|
||||
'history': 'History',
|
||||
'clearHistory': 'Clear History',
|
||||
'noHistoryFound': 'No History Found',
|
||||
};
|
||||
static const fa_IR = {
|
||||
'label': 'فارسی',
|
||||
@@ -407,6 +413,9 @@ class Locales {
|
||||
'extensionUpdateSuccess': '更新成功',
|
||||
'extensionDelete': '删除扩展',
|
||||
'extensionAlreadyLatest': '已经是最新版本',
|
||||
'history': '历史记录',
|
||||
'clearHistory': '清空历史记录',
|
||||
'noHistoryFound': '没有历史记录',
|
||||
};
|
||||
static const zh_TW = {
|
||||
'label': '中文 (正體) ',
|
||||
|
||||
@@ -29,6 +29,7 @@ class Util {
|
||||
|
||||
static String safePathJoin(List<String> paths) {
|
||||
return paths
|
||||
.where((e) => e.isNotEmpty)
|
||||
.map((e) => safeDir(e))
|
||||
.join("/")
|
||||
.replaceAll(RegExp(r'//'), "/");
|
||||
@@ -93,6 +94,10 @@ class Util {
|
||||
return !kIsWeb && Platform.isMacOS;
|
||||
}
|
||||
|
||||
static isLinux() {
|
||||
return !kIsWeb && Platform.isLinux;
|
||||
}
|
||||
|
||||
static isWeb() {
|
||||
return kIsWeb;
|
||||
}
|
||||
|
||||
+247
-255
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 1.4.2+1
|
||||
version: 1.4.3+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -41,7 +41,7 @@ dependencies:
|
||||
context_menus: ^1.0.2
|
||||
json_annotation: ^4.8.1
|
||||
dio: ^5.2.1
|
||||
path_provider: ^2.0.11
|
||||
path_provider: ^2.1.1
|
||||
file_picker: 5.3.1
|
||||
macos_secure_bookmarks: ^0.2.1
|
||||
rounded_loading_button: ^2.1.0
|
||||
|
||||
Reference in new issue
Block a user