mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 10:30:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af40e8aa63 | ||
|
|
64c5ece02c | ||
|
|
4ccbc3ebc5 | ||
|
|
14d9642d3c | ||
|
|
2f5fbfb17c | ||
|
|
49b4d99b5e | ||
|
|
9d9f1caaaa | ||
|
|
5bb8541385 | ||
|
|
f7189668fd | ||
|
|
1b36ed1807 | ||
|
|
07dc196c3c | ||
|
|
76bec67ab2 | ||
|
|
a2a9e3dc2a | ||
|
|
9a5680fb45 | ||
|
|
75a8a3a9ac | ||
|
|
3e8c85b945 | ||
|
|
90f70d1bf6 | ||
|
|
20a70d7f91 | ||
|
|
d33546a4b5 |
No files matched your search
+39
-12
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
build-windows:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2022
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
name: Build Amd64 Libraries
|
||||
run: |
|
||||
mkdir -p ui/flutter/macos/Frameworks
|
||||
|
||||
|
||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib
|
||||
cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host
|
||||
cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater
|
||||
@@ -206,17 +206,17 @@ jobs:
|
||||
name: Build Universal Libraries
|
||||
run: |
|
||||
mkdir -p ui/flutter/macos/Frameworks
|
||||
|
||||
|
||||
# arm64 lib
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/.temp/libgopeed.dylib.arm64 github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater
|
||||
|
||||
|
||||
# amd64 lib
|
||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64
|
||||
cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64
|
||||
cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64
|
||||
|
||||
|
||||
# universal lib
|
||||
lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64
|
||||
lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
EOF
|
||||
mkdir dist
|
||||
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
|
||||
|
||||
|
||||
if [[ "${{ matrix.channel }}" == "arm64" ]]; then
|
||||
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg
|
||||
fi
|
||||
@@ -258,7 +258,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-24.04, ubuntu-24.04-arm]
|
||||
os: [ubuntu-22.04, ubuntu-22.04-arm]
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -271,10 +271,7 @@ jobs:
|
||||
channel: master
|
||||
- run: |
|
||||
sudo apt update -y
|
||||
# flutter build dependencies
|
||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev
|
||||
# appimage build dependencies
|
||||
sudo apt install -y libfuse2 locate
|
||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace
|
||||
arch=$(uname -m)
|
||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||
chmod +x appimagetool
|
||||
@@ -288,6 +285,29 @@ jobs:
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||
|
||||
cd ui/flutter
|
||||
|
||||
ICON_PATH=$(find assets -name "logo.png" -o -name "icon.png" | head -n 1)
|
||||
[ -z "$ICON_PATH" ] && ICON_PATH="assets/icon.png" && touch assets/icon.png
|
||||
|
||||
convert "$ICON_PATH" assets/icon.svg
|
||||
ICON_PATH="assets/icon.svg"
|
||||
|
||||
RPM_ARCH=$(uname -m)
|
||||
|
||||
mkdir -p linux/packaging/rpm
|
||||
cat << EOF > linux/packaging/rpm/make_config.yaml
|
||||
display_name: Gopeed
|
||||
icon: $ICON_PATH
|
||||
summary: High speed downloader.
|
||||
description: A high speed downloader that supports all platforms.
|
||||
group: Applications/Internet
|
||||
license: GPL-3.0
|
||||
url: https://github.com/GopeedLab/gopeed
|
||||
vendor: GopeedLab
|
||||
maintainer: GopeedLab <[email protected]>
|
||||
build_arch: $RPM_ARCH
|
||||
EOF
|
||||
|
||||
cat << EOF > distribute_options.yaml
|
||||
output: dist/
|
||||
releases:
|
||||
@@ -307,12 +327,18 @@ jobs:
|
||||
build_args:
|
||||
dart-define:
|
||||
UPDATE_CHANNEL: linuxDeb
|
||||
- name: rpm
|
||||
package:
|
||||
platform: linux
|
||||
target: rpm
|
||||
build_args:
|
||||
dart-define:
|
||||
UPDATE_CHANNEL: linuxRpm
|
||||
EOF
|
||||
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
|
||||
flutter_distributor release --name linux
|
||||
cd dist/*
|
||||
|
||||
# Get current running architecture, x86_64 -> amd64 and aarch64 -> arm64
|
||||
ARCH="amd64"
|
||||
if [[ "${{ matrix.os }}" == *-arm ]]; then
|
||||
ARCH="arm64"
|
||||
@@ -320,6 +346,7 @@ jobs:
|
||||
|
||||
mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
|
||||
mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
|
||||
mv gopeed-*-linux.rpm Gopeed-v${VERSION}-linux-${ARCH}.rpm
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest, ubuntu-24.04, ubuntu-24.04-arm]
|
||||
os: [windows-2022, macos-latest, ubuntu-22.04, ubuntu-22.04-arm]
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -170,6 +171,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
|
||||
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||
|
||||
## 📱 WeChat Official Account
|
||||
|
||||
Follow our WeChat Official Account to get the latest updates and news.
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 Donate
|
||||
|
||||
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -159,6 +160,12 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
||||
|
||||
### 🛠️ コマンドツール
|
||||
|
||||
## 📱 WeChat 公式アカウント
|
||||
|
||||
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 寄付
|
||||
|
||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -165,6 +166,12 @@ Sử dụng `go install`:
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
|
||||
## 📱 WeChat Official Account
|
||||
|
||||
Theo dõi tài khoản chính thức để nhận các cập nhật và tin tức mới nhất.
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 Quyên góp
|
||||
|
||||
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/zh/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -169,6 +170,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
|
||||
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||
|
||||
## 📱 微信公众号
|
||||
|
||||
关注公众号获取项目最新动态和资讯。
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 赞助
|
||||
|
||||
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
@@ -166,6 +167,12 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
|
||||
## 📱 微信公眾號
|
||||
|
||||
關注公眾號獲取項目最新動態和資訊。
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 贊助
|
||||
|
||||
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func Dial() (net.Conn, error) {
|
||||
// Get binary path
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.Dial("unix", filepath.Join(filepath.Dir(exe), "gopeed_host.sock"))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
)
|
||||
|
||||
func Dial() (net.Conn, error) {
|
||||
return winio.DialPipe(`\\.\pipe\gopeed_host`, nil)
|
||||
}
|
||||
+69
-24
@@ -1,22 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
)
|
||||
|
||||
const identifier = "gopeed"
|
||||
|
||||
type Message struct {
|
||||
Method string `json:"method"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
@@ -26,31 +28,74 @@ type Response struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
var apiMap = map[string]func(params json.RawMessage) (data any, err error){
|
||||
"ping": func(params json.RawMessage) (data any, err error) {
|
||||
processes, err := process.Processes()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
func check() (data bool, err error) {
|
||||
conn, err := Dial()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
for _, p := range processes {
|
||||
name, err := p.Name()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
func wakeup(hidden bool) error {
|
||||
running, _ := check()
|
||||
if running {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(name), strings.ToLower(identifier)) {
|
||||
return true, nil
|
||||
}
|
||||
uri := "gopeed:"
|
||||
if hidden {
|
||||
uri = uri + "?hidden=true"
|
||||
}
|
||||
if err := browser.OpenURL(uri); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if ok, _ := check(); ok {
|
||||
return nil
|
||||
}
|
||||
return false, nil
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("start gopeed failed")
|
||||
}
|
||||
|
||||
var apiMap = map[string]func(message *Message) (data any, err error){
|
||||
"ping": func(message *Message) (data any, err error) {
|
||||
return check()
|
||||
},
|
||||
"create": func(params json.RawMessage) (data any, err error) {
|
||||
var strParams string
|
||||
if err = json.Unmarshal(params, &strParams); err != nil {
|
||||
"create": func(message *Message) (data any, err error) {
|
||||
buf, err := message.Params.MarshalJSON()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = browser.OpenURL(fmt.Sprintf("%s:///create?params=%s", identifier, strParams))
|
||||
|
||||
silent := false
|
||||
if v, ok := message.Meta["silent"]; ok {
|
||||
silent, _ = v.(bool)
|
||||
}
|
||||
|
||||
if err := wakeup(silent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return Dial()
|
||||
},
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
req, err := http.NewRequest("POST", "http://127.0.0.1/create", bytes.NewBuffer(buf))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if message.Meta != nil {
|
||||
metaJson, _ := json.Marshal(message.Meta)
|
||||
req.Header.Set("X-Gopeed-Host-Meta", string(metaJson))
|
||||
}
|
||||
_, err = client.Do(req)
|
||||
return
|
||||
},
|
||||
}
|
||||
@@ -88,7 +133,7 @@ func main() {
|
||||
var data any
|
||||
var err error
|
||||
if handler, ok := apiMap[message.Method]; ok {
|
||||
data, err = handler(message.Params)
|
||||
data, err = handler(&message)
|
||||
} else {
|
||||
err = errors.New("Unknown method: " + message.Method)
|
||||
}
|
||||
|
||||
+5
-6
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +18,7 @@ func main() {
|
||||
pid := flag.Int("pid", 0, "PID of the process to update")
|
||||
updateChannel := flag.String("channel", "", "Update channel")
|
||||
packagePath := flag.String("asset", "", "Path to the package asset")
|
||||
exeDir := flag.String("exeDir", "", "Directory of the entry executable")
|
||||
logPath := flag.String("log", "", "Log file path")
|
||||
flag.Parse()
|
||||
|
||||
@@ -43,7 +43,7 @@ func main() {
|
||||
restart bool
|
||||
err error
|
||||
)
|
||||
if restart, err = update(*pid, *updateChannel, *packagePath); err != nil {
|
||||
if restart, err = update(*pid, *updateChannel, *packagePath, *exeDir); err != nil {
|
||||
log.Printf("Update failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func update(pid int, updateChannel, packagePath string) (restart bool, err error) {
|
||||
func update(pid int, updateChannel, packagePath, exeDir string) (restart bool, err error) {
|
||||
killSignalChan := make(chan any, 1)
|
||||
|
||||
go func() {
|
||||
@@ -76,9 +76,8 @@ func update(pid int, updateChannel, packagePath string) (restart bool, err error
|
||||
}
|
||||
}()
|
||||
|
||||
appDir := filepath.Dir(os.Args[0])
|
||||
log.Printf("Updating process updateChannel=%s packagePath=%s appDir=%s\n", updateChannel, packagePath, appDir)
|
||||
if restart, err = install(killSignalChan, updateChannel, packagePath, appDir); err != nil {
|
||||
log.Printf("Updating process updateChannel=%s packagePath=%s exeDir=%s\n", updateChannel, packagePath, exeDir)
|
||||
if restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil {
|
||||
return false, errors.Wrap(err, "failed to install package")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,16 +25,20 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
|
||||
mountPoint := ""
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
if strings.Contains(line, "/Volumes/") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 2 {
|
||||
mountPoint = fields[len(fields)-1]
|
||||
// Find the /Volumes/ path in the line
|
||||
// hdiutil output format: /dev/disk4s1 Apple_HFS /Volumes/Gopeed
|
||||
// or with sequence number: /dev/disk4s1 Apple_HFS /Volumes/Gopeed 1
|
||||
idx := strings.Index(line, "/Volumes/")
|
||||
if idx != -1 {
|
||||
// Extract everything from /Volumes/ onwards and trim whitespace
|
||||
mountPoint = strings.TrimSpace(line[idx:])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mountPoint == "" {
|
||||
return fmt.Errorf("failed to get mount point")
|
||||
return fmt.Errorf("failed to get mount point from hdiutil output: %s", string(output))
|
||||
}
|
||||
|
||||
// Detach the mounted DMG
|
||||
@@ -45,7 +49,7 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
|
||||
return err
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("no .app found in dmg")
|
||||
return fmt.Errorf("no .app found in dmg, mountPoint: %s", mountPoint)
|
||||
}
|
||||
|
||||
killSignalChan <- nil
|
||||
|
||||
@@ -27,29 +27,23 @@ require (
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/ebitengine/purego v0.8.1 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.51.0 // indirect
|
||||
github.com/refraction-networking/utls v1.6.7 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/mock v0.5.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
||||
github.com/acomagu/bufpipe v1.0.4 // indirect
|
||||
@@ -126,7 +120,6 @@ require (
|
||||
github.com/protolambda/ctxlock v0.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.24.12
|
||||
github.com/skeema/knownhosts v1.2.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/tidwall/btree v1.7.0 // indirect
|
||||
@@ -139,7 +132,7 @@ require (
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
|
||||
@@ -146,8 +146,6 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
|
||||
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
|
||||
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
|
||||
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
|
||||
@@ -192,8 +190,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
@@ -308,8 +304,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMnOEbMWQtSEUgU64U4s+GHk7hZK+jtY=
|
||||
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
|
||||
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
|
||||
@@ -398,8 +392,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI=
|
||||
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
@@ -450,8 +442,6 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
|
||||
github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
@@ -488,10 +478,6 @@ github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EU
|
||||
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
@@ -505,8 +491,6 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
|
||||
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
|
||||
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
@@ -596,7 +580,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -606,7 +589,6 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -622,8 +604,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
|
||||
@@ -3,17 +3,18 @@ package bt
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
gohttp "net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
)
|
||||
|
||||
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||
|
||||
@@ -5,11 +5,12 @@ package bt
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
|
||||
"github.com/anacrolix/missinggo/v2"
|
||||
|
||||
"github.com/anacrolix/torrent/common"
|
||||
@@ -34,7 +35,7 @@ func newFileOpts(opts newFileClientOpts) storage.ClientImplCloser {
|
||||
if opts.FilePathMaker == nil {
|
||||
opts.FilePathMaker = func(opts storage.FilePathMakerOpts) string {
|
||||
var parts []string
|
||||
if opts.Info.Length == 0 {
|
||||
if opts.Info.Length == 0 || opts.File.Path == nil {
|
||||
parts = append(parts, opts.Info.Name)
|
||||
}
|
||||
return filepath.Join(append(parts, opts.File.Path...)...)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/xiaoqidun/setft"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -164,24 +166,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
}
|
||||
contentDisposition := httpResp.Header.Get(base.HttpHeaderContentDisposition)
|
||||
if contentDisposition != "" {
|
||||
_, params, _ := mime.ParseMediaType(contentDisposition)
|
||||
filename := params["filename"]
|
||||
if filename != "" {
|
||||
// Check if the filename is MIME encoded-word
|
||||
if strings.HasPrefix(filename, "=?") {
|
||||
decoder := new(mime.WordDecoder)
|
||||
filename = strings.Replace(filename, "UTF8", "UTF-8", 1)
|
||||
file.Name, _ = decoder.Decode(filename)
|
||||
} else {
|
||||
file.Name = util.TryUrlQueryUnescape(filename)
|
||||
}
|
||||
} else {
|
||||
substr := "attachment; filename="
|
||||
index := strings.Index(contentDisposition, substr)
|
||||
if index != -1 {
|
||||
file.Name = util.TryUrlQueryUnescape(contentDisposition[index+len(substr):])
|
||||
}
|
||||
}
|
||||
file.Name = parseFilename(contentDisposition)
|
||||
}
|
||||
// get file filePath by URL
|
||||
if file.Name == "" {
|
||||
@@ -386,19 +371,20 @@ func (f *Fetcher) run(index int, ctx context.Context) (err error) {
|
||||
resp *http.Response
|
||||
)
|
||||
f.redirectLock.Lock()
|
||||
if f.redirectURL != "" {
|
||||
redirectURL := f.redirectURL
|
||||
if redirectURL != "" {
|
||||
f.redirectLock.Unlock()
|
||||
}
|
||||
err = func() (err error) {
|
||||
} else {
|
||||
// Only hold the lock when we need to potentially set redirectURL
|
||||
defer func() {
|
||||
if f.redirectURL == "" {
|
||||
if err == nil {
|
||||
f.redirectURL = resp.Request.URL.String()
|
||||
}
|
||||
f.redirectLock.Unlock()
|
||||
if redirectURL == "" && err == nil {
|
||||
f.redirectURL = resp.Request.URL.String()
|
||||
}
|
||||
f.redirectLock.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
err = func() (err error) {
|
||||
httpReq, err = f.buildRequest(ctx, f.meta.Req)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -617,12 +603,158 @@ func (f *Fetcher) buildClient() *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMangledString(mangled string) string {
|
||||
rawBytes := make([]byte, 0, len(mangled)*3)
|
||||
for _, r := range mangled {
|
||||
rawBytes = append(rawBytes, byte(r))
|
||||
// parseFilename extracts filename from Content-Disposition header
|
||||
// It handles multiple encoding scenarios:
|
||||
// 1. RFC 5987/RFC 2231 format: filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip (preferred, checked first)
|
||||
// 2. MIME encoded-word: filename="=?UTF-8?B?5rWL6K+VLnppcA==?="
|
||||
// 3. URL-encoded: filename="%E6%B5%8B%E8%AF%95.zip"
|
||||
// 4. Plain ASCII filename
|
||||
//
|
||||
// The key fix is checking filename*= first before mime.ParseMediaType, because
|
||||
// some servers send Content-Disposition headers with invalid characters that cause
|
||||
// mime.ParseMediaType to fail, but the filename*= parameter is still valid.
|
||||
func parseFilename(contentDisposition string) string {
|
||||
// First, try to find filename*= (RFC 5987 format, most reliable for non-ASCII)
|
||||
if filename := parseFilenameExtended(contentDisposition); filename != "" {
|
||||
return filename
|
||||
}
|
||||
return string(rawBytes)
|
||||
|
||||
// Try standard MIME parsing for regular filename= parameter
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err == nil {
|
||||
if filename := params["filename"]; filename != "" {
|
||||
return decodeFilenameParam(filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: manual parsing if mime.ParseMediaType fails
|
||||
return parseFilenameFallback(contentDisposition)
|
||||
}
|
||||
|
||||
// parseFilenameExtended parses RFC 5987/RFC 2231 extended parameter format
|
||||
// Format: filename*=charset'language'value (e.g., UTF-8''%E6%B5%8B%E8%AF%95.zip)
|
||||
func parseFilenameExtended(cd string) string {
|
||||
// Look for filename*= (case-insensitive)
|
||||
lower := strings.ToLower(cd)
|
||||
idx := strings.Index(lower, "filename*=")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract the value after filename*=
|
||||
value := cd[idx+len("filename*="):]
|
||||
|
||||
// Find the end of the value (next ; or end of string)
|
||||
if endIdx := strings.Index(value, ";"); endIdx != -1 {
|
||||
value = value[:endIdx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Parse charset'language'encoded-value format
|
||||
// Common format: UTF-8''%E6%B5%8B%E8%AF%95.zip
|
||||
parts := strings.SplitN(value, "''", 2)
|
||||
if len(parts) == 2 {
|
||||
// parts[0] is charset (e.g., "UTF-8")
|
||||
// parts[1] is percent-encoded value
|
||||
decoded, err := url.QueryUnescape(parts[1])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Try with single quote delimiter as well (some servers use this)
|
||||
parts = strings.SplitN(value, "'", 3)
|
||||
if len(parts) >= 3 {
|
||||
decoded, err := url.QueryUnescape(parts[2])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// decodeFilenameParam decodes a filename parameter value
|
||||
// Handles MIME encoded-word, URL encoding, and GBK encoding fallback
|
||||
func decodeFilenameParam(filename string) string {
|
||||
// Check if the filename is MIME encoded-word (e.g., =?UTF-8?B?...?=)
|
||||
if strings.HasPrefix(filename, "=?") {
|
||||
decoder := new(mime.WordDecoder)
|
||||
// Some servers use "UTF8" instead of "UTF-8", create a normalized copy
|
||||
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
|
||||
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Try URL decoding
|
||||
decoded := util.TryUrlQueryUnescape(filename)
|
||||
|
||||
// Check if the result is valid UTF-8. If not, try GBK decoding.
|
||||
// This handles the case where Chinese Windows servers send GBK-encoded filenames
|
||||
// which appear as garbled characters (e.g., "下载地址.zip" -> "���ص�ַ.zip")
|
||||
if !utf8.ValidString(decoded) {
|
||||
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
|
||||
return gbkDecoded
|
||||
}
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// gbkDecoder is a reusable GBK decoder for better performance
|
||||
var gbkDecoder = simplifiedchinese.GBK.NewDecoder()
|
||||
|
||||
// tryDecodeGBK attempts to decode a string as GBK/GB2312/GB18030 encoding
|
||||
// Returns empty string if decoding fails or result is not valid UTF-8
|
||||
func tryDecodeGBK(s string) string {
|
||||
// GBK uses 1-2 bytes per character. Single-byte chars are 0x00-0x7F (ASCII compatible).
|
||||
// Double-byte chars have first byte 0x81-0xFE and second byte 0x40-0xFE.
|
||||
// Skip if string is empty or all ASCII (valid UTF-8)
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a fresh decoder since the transform state may be corrupted
|
||||
decoder := simplifiedchinese.GBK.NewDecoder()
|
||||
decoded, err := decoder.Bytes([]byte(s))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
result := string(decoded)
|
||||
if utf8.ValidString(result) {
|
||||
return result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
|
||||
func parseFilenameFallback(cd string) string {
|
||||
// Look for filename= (case-insensitive)
|
||||
lower := strings.ToLower(cd)
|
||||
idx := strings.Index(lower, "filename=")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Skip "filename=" prefix
|
||||
value := cd[idx+len("filename="):]
|
||||
|
||||
// Find the end of the value
|
||||
if endIdx := strings.Index(value, ";"); endIdx != -1 {
|
||||
value = value[:endIdx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Remove quotes if present
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return decodeFilenameParam(value)
|
||||
}
|
||||
|
||||
type fetcherData struct {
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFetcher_Resolve(t *testing.T) {
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: "测试.zip",
|
||||
Name: test.TestChineseFileName,
|
||||
Size: test.BuildSize,
|
||||
},
|
||||
},
|
||||
@@ -52,7 +52,7 @@ func TestFetcher_Resolve(t *testing.T) {
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: "测试.zip",
|
||||
Name: test.TestChineseFileName,
|
||||
Size: test.BuildSize,
|
||||
},
|
||||
},
|
||||
@@ -62,7 +62,7 @@ func TestFetcher_Resolve(t *testing.T) {
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: "测试.zip",
|
||||
Name: test.TestChineseFileName,
|
||||
Size: 0,
|
||||
},
|
||||
},
|
||||
@@ -77,6 +77,42 @@ func TestFetcher_Resolve(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, t)
|
||||
// Test mixed encoding Content-Disposition where mime.ParseMediaType fails
|
||||
// due to invalid characters, but filename*= contains the correct UTF-8 encoded name
|
||||
testResolve(test.StartTestCustomServer, "mixed-encoding", &base.Resource{
|
||||
Size: test.BuildSize,
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: test.TestChineseFileName,
|
||||
Size: test.BuildSize,
|
||||
},
|
||||
},
|
||||
}, t)
|
||||
// Test filename*= only (RFC 5987 format)
|
||||
testResolve(test.StartTestCustomServer, "filename-star", &base.Resource{
|
||||
Size: test.BuildSize,
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: test.TestChineseFileName,
|
||||
Size: test.BuildSize,
|
||||
},
|
||||
},
|
||||
}, t)
|
||||
// Test GBK-encoded filename (common on Chinese Windows servers)
|
||||
// Before fix: "测试.zip" sent as GBK bytes -> parsed as "²âÊÔ.zip" (garbled)
|
||||
// After fix: correctly decoded back to "测试.zip"
|
||||
testResolve(test.StartTestCustomServer, "gbk-encoded", &base.Resource{
|
||||
Size: test.BuildSize,
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: test.TestChineseFileName,
|
||||
Size: test.BuildSize,
|
||||
},
|
||||
},
|
||||
}, t)
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/armon/go-socks5"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -17,6 +15,10 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/armon/go-socks5"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +36,10 @@ const (
|
||||
DownloadRename = "download (1).data"
|
||||
DownloadFile = Dir + DownloadName
|
||||
DownloadRenameFile = Dir + DownloadRename
|
||||
|
||||
// TestChineseFileName is a common test filename with Chinese characters
|
||||
// Used to test Content-Disposition parsing with various encodings
|
||||
TestChineseFileName = "测试.zip"
|
||||
)
|
||||
|
||||
func StartTestFileServer() net.Listener {
|
||||
@@ -103,7 +109,57 @@ func StartTestCustomServer() net.Listener {
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Disposition", "attachment; filename=测试.zip")
|
||||
writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
|
||||
// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
|
||||
// due to invalid characters like <a> tags in the filename.
|
||||
mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// This simulates a server that sends a garbled filename= with special chars that cause
|
||||
// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
|
||||
// The filename*= should be preferred and correctly parsed.
|
||||
writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for filename*= only (RFC 5987 format)
|
||||
mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
|
||||
// This simulates the case where Chinese characters are sent as GBK bytes
|
||||
// which appear as garbled characters when interpreted as UTF-8.
|
||||
// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
|
||||
// Our fix detects invalid UTF-8 and attempts GBK decoding.
|
||||
mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Encode TestChineseFileName as GBK
|
||||
gbkEncoder := simplifiedchinese.GBK.NewEncoder()
|
||||
gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
|
||||
// Send GBK bytes directly in filename (simulating broken server behavior)
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
|
||||
@@ -182,6 +182,7 @@ type DownloaderStoreConfig struct {
|
||||
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
||||
Extra map[string]any `json:"extra"`
|
||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
||||
}
|
||||
|
||||
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
@@ -194,6 +195,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = &DownloaderProxyConfig{}
|
||||
}
|
||||
if cfg.Webhook == nil {
|
||||
cfg.Webhook = &WebhookConfig{}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -216,9 +220,18 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = beforeCfg.Proxy
|
||||
}
|
||||
if cfg.Webhook == nil {
|
||||
cfg.Webhook = beforeCfg.Webhook
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WebhookConfig is the webhook configuration
|
||||
type WebhookConfig struct {
|
||||
Enable bool `json:"enable"` // Enable is the flag to enable/disable webhooks
|
||||
URLs []string `json:"urls"` // URLs is the list of webhook URLs
|
||||
}
|
||||
|
||||
type DownloaderProxyConfig struct {
|
||||
Enable bool `json:"enable"`
|
||||
// System is the flag that use system proxy
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -29,6 +30,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
MaxRunning: 10,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -43,7 +45,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "value",
|
||||
},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -59,6 +62,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
},
|
||||
Webhook: &WebhookConfig{},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -71,6 +75,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
}
|
||||
if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Init() = %v, want %v", got, tt.want)
|
||||
@@ -255,6 +260,7 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
}
|
||||
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Merge() = %v, want %v", got, tt.want)
|
||||
|
||||
+18
-12
@@ -2,15 +2,6 @@ package download
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
"math"
|
||||
gohttp "net/http"
|
||||
"net/url"
|
||||
@@ -22,6 +13,16 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -532,7 +533,7 @@ func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) {
|
||||
|
||||
func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
|
||||
if filter == nil || filter.IsEmpty() {
|
||||
return d.deleteAll()
|
||||
return d.deleteAll(force)
|
||||
}
|
||||
|
||||
deleteTasks := d.GetTasksByFilter(filter)
|
||||
@@ -577,7 +578,7 @@ func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) deleteAll() (err error) {
|
||||
func (d *Downloader) deleteAll(force bool) (err error) {
|
||||
var deleteTasksTemp []*Task
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
@@ -591,7 +592,7 @@ func (d *Downloader) deleteAll() (err error) {
|
||||
}()
|
||||
|
||||
for _, task := range deleteTasksTemp {
|
||||
if err = d.doDelete(task, true); err != nil {
|
||||
if err = d.doDelete(task, force); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -856,6 +857,7 @@ func (d *Downloader) watch(task *Task) {
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
d.triggerOnDone(task)
|
||||
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
||||
downloadFilePath := task.Meta.SingleFilepath()
|
||||
@@ -886,6 +888,7 @@ func (d *Downloader) doOnError(task *Task, err error) {
|
||||
d.emit(EventKeyError, task, err)
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
d.triggerWebhooks(WebhookEventDownloadError, task, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,6 +943,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
||||
opts.Path = storeConfig.DownloadDir
|
||||
}
|
||||
|
||||
// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)
|
||||
opts.Path = util.ReplacePathPlaceholders(opts.Path)
|
||||
|
||||
// if enable white download directory, check if the download directory is in the white list
|
||||
if len(d.cfg.WhiteDownloadDirs) > 0 {
|
||||
inWhiteList := false
|
||||
|
||||
@@ -131,6 +131,7 @@ type XMLHttpRequest struct {
|
||||
responseHeaders http.Header
|
||||
aborted bool
|
||||
client *req.Client
|
||||
fingerprint string
|
||||
|
||||
WithCredentials bool `json:"withCredentials"`
|
||||
Upload *XMLHttpRequestUpload `json:"upload"`
|
||||
@@ -164,7 +165,7 @@ func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||
setFingerprint(xhr.client)
|
||||
setFingerprint(xhr.client, xhr.fingerprint)
|
||||
|
||||
d := xhr.parseData(data)
|
||||
var (
|
||||
@@ -381,7 +382,8 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
|
||||
}
|
||||
|
||||
instance := &XMLHttpRequest{
|
||||
client: client,
|
||||
client: client,
|
||||
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
|
||||
Upload: &XMLHttpRequestUpload{
|
||||
EventProp: &EventProp{
|
||||
eventListeners: make(map[string]func(event *ProgressEvent)),
|
||||
@@ -395,8 +397,8 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
return instanceValue
|
||||
})
|
||||
runtime.Set("__gopeed_setFingerprint", func(fingerprint Fingerprint) {
|
||||
currentFingerprint = fingerprint
|
||||
runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
|
||||
runtime.Set(FingerprintMagicKey, fingerprint)
|
||||
})
|
||||
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
package xhr
|
||||
|
||||
import (
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
import "github.com/imroc/req/v3"
|
||||
|
||||
type Fingerprint string
|
||||
|
||||
const (
|
||||
fingerprintChrome Fingerprint = "chrome"
|
||||
fingerprintFirefox Fingerprint = "firefox"
|
||||
fingerprintSafari Fingerprint = "safari"
|
||||
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
|
||||
|
||||
fingerprintChrome = "chrome"
|
||||
fingerprintFirefox = "firefox"
|
||||
fingerprintSafari = "safari"
|
||||
)
|
||||
|
||||
var currentFingerprint Fingerprint = ""
|
||||
|
||||
func setFingerprint(client *req.Client) {
|
||||
switch currentFingerprint {
|
||||
func setFingerprint(client *req.Client, fingerprint string) {
|
||||
switch fingerprint {
|
||||
case fingerprintChrome:
|
||||
client.ImpersonateChrome()
|
||||
case fingerprintFirefox:
|
||||
|
||||
@@ -22,3 +22,16 @@ func AssertError[T error](err error) (t T, r bool) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func SafeGet[T any](vm *goja.Runtime, name string) T {
|
||||
v := vm.Get(name)
|
||||
if v == nil {
|
||||
var init T
|
||||
return init
|
||||
}
|
||||
if e, ok := v.Export().(T); ok {
|
||||
return e
|
||||
}
|
||||
var init T
|
||||
return init
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
const (
|
||||
webhookTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// WebhookEvent represents the type of webhook event
|
||||
type WebhookEvent string
|
||||
|
||||
const (
|
||||
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
|
||||
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
|
||||
)
|
||||
|
||||
// WebhookData is the data sent to webhook URLs
|
||||
type WebhookData struct {
|
||||
Event WebhookEvent `json:"event"`
|
||||
Time int64 `json:"time"` // Unix timestamp in milliseconds
|
||||
Payload *WebhookPayload `json:"payload"`
|
||||
}
|
||||
|
||||
// WebhookPayload contains the task data
|
||||
type WebhookPayload struct {
|
||||
Task *Task `json:"task"`
|
||||
}
|
||||
|
||||
// getWebhookUrls extracts webhook URLs from config
|
||||
// Supports both new webhook config format and legacy extra field for backward compatibility
|
||||
func (d *Downloader) getWebhookUrls() []string {
|
||||
cfg := d.cfg.DownloaderStoreConfig
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try new webhook config first
|
||||
if cfg.Webhook != nil && cfg.Webhook.Enable && len(cfg.Webhook.URLs) > 0 {
|
||||
urls := make([]string, 0, len(cfg.Webhook.URLs))
|
||||
for _, url := range cfg.Webhook.URLs {
|
||||
if url != "" {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
}
|
||||
if len(urls) > 0 {
|
||||
return urls
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to legacy extra field for backward compatibility
|
||||
if cfg.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
webhookUrls, ok := cfg.Extra["webhookUrls"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try direct string slice first
|
||||
if urlsStr, ok := webhookUrls.([]string); ok {
|
||||
if len(urlsStr) == 0 {
|
||||
return nil
|
||||
}
|
||||
return urlsStr
|
||||
}
|
||||
|
||||
// Convert []interface{} to []string
|
||||
if urlsInterface, ok := webhookUrls.([]any); ok {
|
||||
if len(urlsInterface) == 0 {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(urlsInterface))
|
||||
for _, urlInterface := range urlsInterface {
|
||||
if url, ok := urlInterface.(string); ok && url != "" {
|
||||
urls = append(urls, url)
|
||||
}
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendWebhookToUrl sends webhook data to a single URL
|
||||
// Returns the HTTP status code and any error that occurred
|
||||
func (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) {
|
||||
if url == "" {
|
||||
return 0, fmt.Errorf("webhook URL is empty")
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: webhookTimeout,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Gopeed-Webhook/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// triggerWebhooks sends webhook notifications to all configured URLs
|
||||
func (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) {
|
||||
urls := d.getWebhookUrls()
|
||||
if len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
data := &WebhookData{
|
||||
Event: event,
|
||||
Time: time.Now().UnixMilli(),
|
||||
Payload: &WebhookPayload{
|
||||
Task: task.clone(),
|
||||
},
|
||||
}
|
||||
|
||||
go d.sendWebhooks(urls, data)
|
||||
}
|
||||
|
||||
func (d *Downloader) sendWebhooks(urls []string, data *WebhookData) {
|
||||
for _, url := range urls {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
go func(webhookUrl string) {
|
||||
statusCode, err := d.sendWebhookToUrl(webhookUrl, data)
|
||||
if err != nil {
|
||||
d.Logger.Warn().Err(err).Str("url", webhookUrl).Msg("webhook: failed to send request")
|
||||
return
|
||||
}
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
d.Logger.Debug().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: sent successfully")
|
||||
} else {
|
||||
d.Logger.Warn().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: received non-success status")
|
||||
}
|
||||
}(url)
|
||||
}
|
||||
}
|
||||
|
||||
// SendTestWebhook sends a test webhook with a simulated payload
|
||||
// Returns error if any webhook URL does not respond with HTTP 200
|
||||
func (d *Downloader) SendTestWebhook() error {
|
||||
urls := d.getWebhookUrls()
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, url := range urls {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if err := d.TestWebhookUrl(url); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestWebhookUrl tests a single webhook URL with a simulated payload
|
||||
// Returns error if the URL does not respond with HTTP 200
|
||||
func (d *Downloader) TestWebhookUrl(url string) error {
|
||||
// Create a simulated test task with minimal required fields
|
||||
testTask := NewTask()
|
||||
testTask.Protocol = "http"
|
||||
testTask.Status = base.DownloadStatusDone
|
||||
testTask.Meta = &fetcher.FetcherMeta{
|
||||
Req: &base.Request{
|
||||
URL: "https://example.com/test-file.zip",
|
||||
},
|
||||
Opts: &base.Options{
|
||||
Name: "test-file.zip",
|
||||
Path: "/downloads",
|
||||
},
|
||||
Res: &base.Resource{
|
||||
Size: 1024 * 1024 * 100, // 100MB
|
||||
Files: []*base.FileInfo{
|
||||
{Name: "test-file.zip", Size: 1024 * 1024 * 100},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create test data
|
||||
testData := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
Payload: &WebhookPayload{
|
||||
Task: testTask,
|
||||
},
|
||||
}
|
||||
|
||||
statusCode, err := d.sendWebhookToUrl(url, testData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
return fmt.Errorf("webhook test failed: %s returned status %d", url, statusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
var mockFetcherMeta = fetcher.FetcherMeta{
|
||||
Req: &base.Request{
|
||||
URL: "https://example.com/test.zip",
|
||||
},
|
||||
Opts: &base.Options{
|
||||
Path: "/downloads",
|
||||
},
|
||||
Res: &base.Resource{
|
||||
Size: 1024 * 1024,
|
||||
Files: []*base.FileInfo{
|
||||
{Name: "test.zip", Size: 1024 * 1024},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestWebhook_TriggerOnDone(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger webhook
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
} else if data.Payload.Task.ID != task.ID {
|
||||
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
|
||||
}
|
||||
if data.Time == 0 {
|
||||
t.Error("Expected time to be set")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TriggerOnError(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger webhook with error
|
||||
testError := http.ErrServerClosed
|
||||
downloader.triggerWebhooks(WebhookEventDownloadError, task, testError)
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadError {
|
||||
t.Errorf("Expected event 'DOWNLOAD_ERROR', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendTestWebhook(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Send test webhook
|
||||
err := downloader.SendTestWebhook()
|
||||
if err != nil {
|
||||
t.Errorf("SendTestWebhook failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
}
|
||||
if data.Time == 0 {
|
||||
t.Error("Expected time to be set")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_NoWebhookConfigured(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger webhook (should not panic with no webhooks configured)
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
// Send test webhook (should not panic)
|
||||
err := downloader.SendTestWebhook()
|
||||
if err != nil {
|
||||
t.Errorf("SendTestWebhook failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_MultipleUrls(t *testing.T) {
|
||||
count := 0
|
||||
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server1.Close()
|
||||
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server2.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure multiple webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server1.URL, server2.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger webhook
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
// Wait for webhooks
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 webhook calls, got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) {
|
||||
// Test that SendTestWebhook returns error for non-200 status codes
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError) // 500
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Send test webhook - should fail with non-200 status
|
||||
err := downloader.SendTestWebhook()
|
||||
if err == nil {
|
||||
t.Error("Expected SendTestWebhook to return error for non-200 status")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookFailsOn201(t *testing.T) {
|
||||
// Test that SendTestWebhook returns error for 201 (only 200 is success)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated) // 201
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Configure webhook URLs
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Send test webhook - should fail with 201 status (only 200 is success)
|
||||
err := downloader.SendTestWebhook()
|
||||
if err == nil {
|
||||
t.Error("Expected SendTestWebhook to return error for 201 status")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookUrl(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Test single URL
|
||||
err := downloader.TestWebhookUrl(server.URL)
|
||||
if err != nil {
|
||||
t.Errorf("TestWebhookUrl failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload.task to be present")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookUrlEmpty(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
// Test with empty URL - should return error
|
||||
err := downloader.TestWebhookUrl("")
|
||||
if err == nil {
|
||||
t.Error("Expected TestWebhookUrl to return error for empty URL")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = nil
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{Enable: true} // No URLs set
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{"http://example.com", "http://example2.com"},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if len(urls) != 2 {
|
||||
t.Errorf("Expected 2 URLs, got %d", len(urls))
|
||||
}
|
||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
||||
t.Errorf("URLs don't match expected values: %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{"http://example.com", "http://example2.com"},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if len(urls) != 2 {
|
||||
t.Errorf("Expected 2 URLs, got %d", len(urls))
|
||||
}
|
||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
||||
t.Errorf("URLs don't match expected values: %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Extra = make(map[string]any)
|
||||
cfg.Webhook.URLs = []string{}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{"", "", ""},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil for all empty strings, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{"http://example.com", "", "http://example2.com", ""},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if len(urls) != 2 {
|
||||
t.Errorf("Expected 2 valid URLs (ignoring empty strings), got %d: %v", len(urls), urls)
|
||||
}
|
||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
||||
t.Errorf("URLs don't match expected values: %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{Enable: false} // Disabled webhook
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil for disabled webhook, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_GetWebhookUrls_DisabledWebhook(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: false,
|
||||
URLs: []string{"http://example.com"},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
urls := downloader.getWebhookUrls()
|
||||
if urls != nil {
|
||||
t.Errorf("Expected nil for disabled webhook even with URLs, got %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_Success(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify headers
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
if r.Header.Get("User-Agent") != "Gopeed-Webhook/1.0" {
|
||||
t.Errorf("Expected User-Agent Gopeed-Webhook/1.0, got %s", r.Header.Get("User-Agent"))
|
||||
}
|
||||
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
Payload: &WebhookPayload{
|
||||
Task: task,
|
||||
},
|
||||
}
|
||||
|
||||
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
|
||||
if err != nil {
|
||||
t.Errorf("sendWebhookToUrl failed: %v", err)
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", statusCode)
|
||||
}
|
||||
|
||||
select {
|
||||
case received := <-receivedData:
|
||||
if received.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", received.Event)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := downloader.sendWebhookToUrl("", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty URL")
|
||||
}
|
||||
if err.Error() != "webhook URL is empty" {
|
||||
t.Errorf("Expected 'webhook URL is empty' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := downloader.sendWebhookToUrl("://invalid-url", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := downloader.sendWebhookToUrl("http://non-existent-host-12345.example", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent host")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) {
|
||||
// Create a server that delays response beyond webhook timeout
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(15 * time.Second) // Longer than webhookTimeout (10s)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := downloader.sendWebhookToUrl(server.URL, data)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
}{
|
||||
{"200 OK", http.StatusOK},
|
||||
{"201 Created", http.StatusCreated},
|
||||
{"204 No Content", http.StatusNoContent},
|
||||
{"400 Bad Request", http.StatusBadRequest},
|
||||
{"404 Not Found", http.StatusNotFound},
|
||||
{"500 Internal Server Error", http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.statusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
data := &WebhookData{
|
||||
Event: WebhookEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
|
||||
if err != nil {
|
||||
t.Errorf("sendWebhookToUrl failed: %v", err)
|
||||
}
|
||||
if statusCode != tc.statusCode {
|
||||
t.Errorf("Expected status %d, got %d", tc.statusCode, statusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL, "", server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if requestCount != 2 {
|
||||
t.Errorf("Expected 2 requests (empty URL should be skipped), got %d", requestCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_WebhookDataStructure(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Status = base.DownloadStatusDone
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
// Verify event
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
|
||||
}
|
||||
// Verify time is set
|
||||
if data.Time == 0 {
|
||||
t.Error("Expected time to be set")
|
||||
}
|
||||
// Verify payload
|
||||
if data.Payload == nil {
|
||||
t.Error("Expected payload to be present")
|
||||
}
|
||||
if data.Payload.Task == nil {
|
||||
t.Error("Expected task in payload to be present")
|
||||
}
|
||||
// Verify task is cloned (has same ID but different pointer)
|
||||
if data.Payload.Task.ID != task.ID {
|
||||
t.Errorf("Expected task ID %s, got %s", task.ID, data.Payload.Task.ID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Extra = make(map[string]any)
|
||||
cfg.Webhook.URLs = []string{}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
err := downloader.SendTestWebhook()
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for empty URLs, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) {
|
||||
// First server returns 200
|
||||
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server1.Close()
|
||||
|
||||
// Second server returns 500
|
||||
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server2.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Webhook = &base.WebhookConfig{
|
||||
Enable: true,
|
||||
URLs: []string{server1.URL, server2.URL},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
// Should fail because server2 returns 500
|
||||
err := downloader.SendTestWebhook()
|
||||
if err == nil {
|
||||
t.Error("Expected error when one server returns non-200 status")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) {
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
err := downloader.TestWebhookUrl("://invalid")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
|
||||
receivedData := make(chan *WebhookData, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var data WebhookData
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Errorf("Failed to decode data: %v", err)
|
||||
return
|
||||
}
|
||||
receivedData <- &data
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupWebhookTest(t, func(downloader *Downloader) {
|
||||
err := downloader.TestWebhookUrl(server.URL)
|
||||
if err != nil {
|
||||
t.Errorf("TestWebhookUrl failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-receivedData:
|
||||
// Verify it's a test webhook
|
||||
if data.Event != WebhookEventDownloadDone {
|
||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
|
||||
}
|
||||
if data.Payload == nil || data.Payload.Task == nil {
|
||||
t.Error("Expected payload with task")
|
||||
}
|
||||
// Verify test task properties
|
||||
task := data.Payload.Task
|
||||
if task.Protocol != "http" {
|
||||
t.Errorf("Expected protocol 'http', got '%s'", task.Protocol)
|
||||
}
|
||||
if task.Status != base.DownloadStatusDone {
|
||||
t.Errorf("Expected status Done, got %s", task.Status)
|
||||
}
|
||||
if task.Meta == nil || task.Meta.Req == nil {
|
||||
t.Error("Expected meta and request in test task")
|
||||
}
|
||||
if task.Meta.Req.URL != "https://example.com/test-file.zip" {
|
||||
t.Errorf("Expected test URL, got %s", task.Meta.Req.URL)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("Timeout waiting for webhook")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
||||
defer func() {
|
||||
defaultDownloader.Clear()
|
||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
||||
}()
|
||||
fn(defaultDownloader)
|
||||
}
|
||||
@@ -389,3 +389,14 @@ func getServerConfig() *base.DownloaderStoreConfig {
|
||||
cfg, _ := Downloader.GetConfig()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.TestWebhookReq
|
||||
if ReadJson(r, w, &req) {
|
||||
if err := Downloader.TestWebhookUrl(req.URL); err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
WriteJson(w, model.NewNilResult())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package model
|
||||
|
||||
// TestWebhookReq is the request body for testing a single webhook URL
|
||||
type TestWebhookReq struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -141,6 +141,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension)
|
||||
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
|
||||
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
|
||||
r.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook)
|
||||
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
|
||||
|
||||
enableApiToken := startCfg.ApiToken != ""
|
||||
|
||||
+115
-4
@@ -6,10 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -20,6 +16,11 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -606,6 +607,116 @@ func TestDoProxy(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestWebhook(t *testing.T) {
|
||||
doTest(func() {
|
||||
// Set up a mock webhook server
|
||||
webhookReceived := false
|
||||
var receivedData map[string]interface{}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
webhookServer := http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &receivedData); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check Content-Type
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("TestWebhook() Content-Type got = %v, want %v", r.Header.Get("Content-Type"), "application/json")
|
||||
}
|
||||
|
||||
webhookReceived = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
wg.Done()
|
||||
}),
|
||||
}
|
||||
|
||||
// Start webhook server
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
webhookPort := listener.Addr().(*net.TCPAddr).Port
|
||||
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", webhookPort)
|
||||
|
||||
go webhookServer.Serve(listener)
|
||||
defer webhookServer.Close()
|
||||
|
||||
// Test with valid webhook URL
|
||||
httpRequestCheckOk[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
||||
URL: webhookURL,
|
||||
})
|
||||
|
||||
// Wait for webhook to be received
|
||||
wg.Wait()
|
||||
|
||||
if !webhookReceived {
|
||||
t.Error("TestWebhook() webhook was not received")
|
||||
}
|
||||
|
||||
// Verify webhook data structure
|
||||
if receivedData["event"] == nil {
|
||||
t.Error("TestWebhook() missing 'event' field")
|
||||
}
|
||||
if receivedData["time"] == nil {
|
||||
t.Error("TestWebhook() missing 'time' field")
|
||||
}
|
||||
if receivedData["payload"] == nil {
|
||||
t.Error("TestWebhook() missing 'payload' field")
|
||||
}
|
||||
|
||||
// Test with invalid webhook URL
|
||||
code, _ := httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
||||
URL: "http://invalid-webhook-url-that-does-not-exist.local:99999/webhook",
|
||||
})
|
||||
checkCode(code, model.CodeError)
|
||||
|
||||
// Test with empty URL
|
||||
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
||||
URL: "",
|
||||
})
|
||||
checkCode(code, model.CodeError)
|
||||
|
||||
// Test with webhook server returning non-200 status
|
||||
wg.Add(1)
|
||||
badWebhookServer := http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
wg.Done()
|
||||
}),
|
||||
}
|
||||
badListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badWebhookPort := badListener.Addr().(*net.TCPAddr).Port
|
||||
badWebhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", badWebhookPort)
|
||||
|
||||
go badWebhookServer.Serve(badListener)
|
||||
defer badWebhookServer.Close()
|
||||
|
||||
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
||||
URL: badWebhookURL,
|
||||
})
|
||||
checkCode(code, model.CodeError)
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiToken(t *testing.T) {
|
||||
var cfg = &model.StartConfig{}
|
||||
cfg.Init()
|
||||
|
||||
+34
-3
@@ -8,6 +8,7 @@ import (
|
||||
syspath "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Dir(path string) string {
|
||||
@@ -50,12 +51,17 @@ func CheckDuplicateAndRename(path string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
index := strings.LastIndex(name, ".")
|
||||
ext := syspath.Ext(name)
|
||||
var nameTpl string
|
||||
if index == -1 {
|
||||
// Special case: if the extension is the entire filename (like .gitignore),
|
||||
// or if index of last dot is 0 (starts with dot), treat it as no extension
|
||||
if ext == "" || ext == name || (len(ext) > 0 && strings.LastIndex(name, ".") == 0) {
|
||||
// No extension or hidden file without extension
|
||||
nameTpl = name + " (%d)"
|
||||
} else {
|
||||
nameTpl = name[:index] + " (%d)" + name[index:]
|
||||
// Has extension
|
||||
nameWithoutExt := name[:len(name)-len(ext)]
|
||||
nameTpl = nameWithoutExt + " (%d)" + ext
|
||||
}
|
||||
for i := 1; ; i++ {
|
||||
newName := fmt.Sprintf(nameTpl, i)
|
||||
@@ -194,3 +200,28 @@ func ReplaceInvalidFilename(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ReplacePathPlaceholders replaces date placeholders in a path with actual values
|
||||
// Supported placeholders:
|
||||
// - %year% - Current year (e.g., 2025)
|
||||
// - %month% - Current month (01-12)
|
||||
// - %day% - Current day (01-31)
|
||||
// - %date% - Full date format (2025-01-01)
|
||||
func ReplacePathPlaceholders(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
year := fmt.Sprintf("%d", now.Year())
|
||||
month := fmt.Sprintf("%02d", now.Month())
|
||||
day := fmt.Sprintf("%02d", now.Day())
|
||||
date := fmt.Sprintf("%s-%s-%s", year, month, day)
|
||||
|
||||
path = strings.ReplaceAll(path, "%year%", year)
|
||||
path = strings.ReplaceAll(path, "%month%", month)
|
||||
path = strings.ReplaceAll(path, "%day%", day)
|
||||
path = strings.ReplaceAll(path, "%date%", date)
|
||||
|
||||
return path
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDir(t *testing.T) {
|
||||
@@ -106,13 +109,28 @@ func TestSafeRemove(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckDuplicateAndRename(t *testing.T) {
|
||||
// Test with extension
|
||||
doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt")
|
||||
doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt")
|
||||
doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt")
|
||||
|
||||
// Test without extension
|
||||
doCheckDuplicateAndRename(t, []string{}, "a", "a")
|
||||
doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)")
|
||||
doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)")
|
||||
|
||||
// Test hidden files (starting with dot)
|
||||
doCheckDuplicateAndRename(t, []string{}, ".gitignore", ".gitignore")
|
||||
doCheckDuplicateAndRename(t, []string{".gitignore"}, ".gitignore", ".gitignore (1)")
|
||||
doCheckDuplicateAndRename(t, []string{".gitignore", ".gitignore (1)"}, ".gitignore", ".gitignore (2)")
|
||||
|
||||
// Test hidden files with extension
|
||||
doCheckDuplicateAndRename(t, []string{}, ".config.json", ".config.json")
|
||||
doCheckDuplicateAndRename(t, []string{".config.json"}, ".config.json", ".config (1).json")
|
||||
|
||||
// Test multiple dots
|
||||
doCheckDuplicateAndRename(t, []string{}, "test.tar.gz", "test.tar.gz")
|
||||
doCheckDuplicateAndRename(t, []string{"test.tar.gz"}, "test.tar.gz", "test.tar (1).gz")
|
||||
}
|
||||
|
||||
func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {
|
||||
@@ -225,3 +243,75 @@ func TestReplaceInvalidFilename(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePathPlaceholders(t *testing.T) {
|
||||
now := time.Now()
|
||||
year := fmt.Sprintf("%d", now.Year())
|
||||
month := fmt.Sprintf("%02d", now.Month())
|
||||
day := fmt.Sprintf("%02d", now.Day())
|
||||
date := fmt.Sprintf("%s-%s-%s", year, month, day)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty path",
|
||||
path: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no placeholders",
|
||||
path: "/home/user/Downloads",
|
||||
want: "/home/user/Downloads",
|
||||
},
|
||||
{
|
||||
name: "year placeholder",
|
||||
path: "/Downloads/%year%",
|
||||
want: "/Downloads/" + year,
|
||||
},
|
||||
{
|
||||
name: "month placeholder",
|
||||
path: "/Downloads/%month%",
|
||||
want: "/Downloads/" + month,
|
||||
},
|
||||
{
|
||||
name: "day placeholder",
|
||||
path: "/Downloads/%day%",
|
||||
want: "/Downloads/" + day,
|
||||
},
|
||||
{
|
||||
name: "date placeholder",
|
||||
path: "/Downloads/%date%",
|
||||
want: "/Downloads/" + date,
|
||||
},
|
||||
{
|
||||
name: "multiple placeholders",
|
||||
path: "/Downloads/%year%-%month%",
|
||||
want: "/Downloads/" + year + "-" + month,
|
||||
},
|
||||
{
|
||||
name: "mixed path with placeholders",
|
||||
path: "/home/user/Downloads/%year%/%month%/%day%",
|
||||
want: "/home/user/Downloads/" + year + "/" + month + "/" + day,
|
||||
},
|
||||
{
|
||||
name: "windows style path",
|
||||
path: "D:\\Downloads\\%year%-%month%",
|
||||
want: "D:\\Downloads\\" + year + "-" + month,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ReplacePathPlaceholders(tt.path)
|
||||
if !strings.Contains(got, year) && tt.path != "" && strings.Contains(tt.path, "%year%") {
|
||||
t.Errorf("ReplacePathPlaceholders() = %v, want containing year %v", got, year)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ReplacePathPlaceholders() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,11 @@ Future<void> updateExtension(String identity) async {
|
||||
() => _client.dio.post("api/v1/extensions/$identity/update"), null);
|
||||
}
|
||||
|
||||
Future<void> testWebhook(String url) async {
|
||||
return _parse(
|
||||
() => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null);
|
||||
}
|
||||
|
||||
Future<String> login(LoginReq loginReq) async {
|
||||
return _parse(() => _client.dio.post("api/web/login", data: loginReq),
|
||||
(data) => data as String);
|
||||
|
||||
@@ -9,6 +9,7 @@ class DownloaderConfig {
|
||||
ProtocolConfig protocolConfig = ProtocolConfig();
|
||||
ExtraConfig extra = ExtraConfig();
|
||||
ProxyConfig proxy = ProxyConfig();
|
||||
WebhookConfig webhook = WebhookConfig();
|
||||
|
||||
DownloaderConfig({
|
||||
this.downloadDir = '',
|
||||
@@ -82,8 +83,10 @@ class ExtraConfig {
|
||||
bool defaultDirectDownload;
|
||||
bool defaultBtClient;
|
||||
bool notifyWhenNewVersion;
|
||||
List<DownloadCategory> downloadCategories;
|
||||
|
||||
ExtraConfigBt bt = ExtraConfigBt();
|
||||
ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();
|
||||
|
||||
ExtraConfig({
|
||||
this.themeMode = '',
|
||||
@@ -92,6 +95,7 @@ class ExtraConfig {
|
||||
this.defaultDirectDownload = false,
|
||||
this.defaultBtClient = true,
|
||||
this.notifyWhenNewVersion = true,
|
||||
this.downloadCategories = const [],
|
||||
});
|
||||
|
||||
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
@@ -100,6 +104,45 @@ class ExtraConfig {
|
||||
Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class DownloadCategory {
|
||||
String name;
|
||||
String path;
|
||||
bool isBuiltIn;
|
||||
String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic')
|
||||
bool
|
||||
isDeleted; // Mark built-in categories as deleted instead of removing them
|
||||
|
||||
DownloadCategory({
|
||||
required this.name,
|
||||
required this.path,
|
||||
this.isBuiltIn = false,
|
||||
this.nameKey,
|
||||
this.isDeleted = false,
|
||||
});
|
||||
|
||||
factory DownloadCategory.fromJson(Map<String, dynamic> json) =>
|
||||
_$DownloadCategoryFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$DownloadCategoryToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class WebhookConfig {
|
||||
bool enable;
|
||||
List<String> urls;
|
||||
|
||||
WebhookConfig({
|
||||
this.enable = false,
|
||||
this.urls = const [],
|
||||
});
|
||||
|
||||
factory WebhookConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? WebhookConfig() : _$WebhookConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ProxyConfig {
|
||||
bool enable;
|
||||
@@ -140,3 +183,46 @@ class ExtraConfigBt {
|
||||
|
||||
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
|
||||
}
|
||||
|
||||
enum GithubMirrorType {
|
||||
jsdelivr,
|
||||
ghProxy,
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class GithubMirror {
|
||||
GithubMirrorType type;
|
||||
String url;
|
||||
bool isBuiltIn;
|
||||
bool isDeleted;
|
||||
|
||||
GithubMirror({
|
||||
required this.type,
|
||||
required this.url,
|
||||
this.isBuiltIn = false,
|
||||
this.isDeleted = false,
|
||||
});
|
||||
|
||||
factory GithubMirror.fromJson(Map<String, dynamic> json) =>
|
||||
_$GithubMirrorFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$GithubMirrorToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class ExtraConfigGithubMirror {
|
||||
bool enabled;
|
||||
List<GithubMirror> mirrors;
|
||||
|
||||
ExtraConfigGithubMirror({
|
||||
this.enabled = true,
|
||||
this.mirrors = const [],
|
||||
});
|
||||
|
||||
factory ExtraConfigGithubMirror.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null
|
||||
? ExtraConfigGithubMirror()
|
||||
: _$ExtraConfigGithubMirrorFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ExtraConfigGithubMirrorToJson(this);
|
||||
}
|
||||
@@ -14,7 +14,9 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
||||
..protocolConfig = ProtocolConfig.fromJson(
|
||||
json['protocolConfig'] as Map<String, dynamic>?)
|
||||
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
|
||||
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>);
|
||||
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
||||
..webhook =
|
||||
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?);
|
||||
|
||||
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
|
||||
<String, dynamic>{
|
||||
@@ -23,6 +25,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
|
||||
'protocolConfig': instance.protocolConfig.toJson(),
|
||||
'extra': instance.extra.toJson(),
|
||||
'proxy': instance.proxy.toJson(),
|
||||
'webhook': instance.webhook.toJson(),
|
||||
};
|
||||
|
||||
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
|
||||
@@ -75,7 +78,14 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
||||
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
|
||||
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
||||
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
||||
)..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>);
|
||||
downloadCategories: (json['downloadCategories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
)
|
||||
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)
|
||||
..githubMirror = ExtraConfigGithubMirror.fromJson(
|
||||
json['githubMirror'] as Map<String, dynamic>?);
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
|
||||
<String, dynamic>{
|
||||
@@ -85,7 +95,51 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
|
||||
'defaultDirectDownload': instance.defaultDirectDownload,
|
||||
'defaultBtClient': instance.defaultBtClient,
|
||||
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
|
||||
'downloadCategories':
|
||||
instance.downloadCategories.map((e) => e.toJson()).toList(),
|
||||
'bt': instance.bt.toJson(),
|
||||
'githubMirror': instance.githubMirror.toJson(),
|
||||
};
|
||||
|
||||
DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) =>
|
||||
DownloadCategory(
|
||||
name: json['name'] as String,
|
||||
path: json['path'] as String,
|
||||
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
|
||||
nameKey: json['nameKey'] as String?,
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) {
|
||||
final val = <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'path': instance.path,
|
||||
'isBuiltIn': instance.isBuiltIn,
|
||||
};
|
||||
|
||||
void writeNotNull(String key, dynamic value) {
|
||||
if (value != null) {
|
||||
val[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeNotNull('nameKey', instance.nameKey);
|
||||
val['isDeleted'] = instance.isDeleted;
|
||||
return val;
|
||||
}
|
||||
|
||||
WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) =>
|
||||
WebhookConfig(
|
||||
enable: json['enable'] as bool? ?? false,
|
||||
urls:
|
||||
(json['urls'] as List<dynamic>?)?.map((e) => e as String).toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
|
||||
<String, dynamic>{
|
||||
'enable': instance.enable,
|
||||
'urls': instance.urls,
|
||||
};
|
||||
|
||||
ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
|
||||
@@ -141,3 +195,40 @@ Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) {
|
||||
val['customTrackers'] = instance.customTrackers;
|
||||
return val;
|
||||
}
|
||||
|
||||
GithubMirror _$GithubMirrorFromJson(Map<String, dynamic> json) => GithubMirror(
|
||||
type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']),
|
||||
url: json['url'] as String,
|
||||
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GithubMirrorToJson(GithubMirror instance) =>
|
||||
<String, dynamic>{
|
||||
'type': _$GithubMirrorTypeEnumMap[instance.type]!,
|
||||
'url': instance.url,
|
||||
'isBuiltIn': instance.isBuiltIn,
|
||||
'isDeleted': instance.isDeleted,
|
||||
};
|
||||
|
||||
const _$GithubMirrorTypeEnumMap = {
|
||||
GithubMirrorType.jsdelivr: 'jsdelivr',
|
||||
GithubMirrorType.ghProxy: 'ghProxy',
|
||||
};
|
||||
|
||||
ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
ExtraConfigGithubMirror(
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
mirrors: (json['mirrors'] as List<dynamic>?)
|
||||
?.map((e) => GithubMirror.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigGithubMirrorToJson(
|
||||
ExtraConfigGithubMirror instance) =>
|
||||
<String, dynamic>{
|
||||
'enabled': instance.enabled,
|
||||
'mirrors': instance.mirrors.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:launch_at_startup/launch_at_startup.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
@@ -16,6 +17,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../../../api/api.dart';
|
||||
import '../../../../api/model/create_task.dart';
|
||||
import '../../../../api/model/downloader_config.dart';
|
||||
import '../../../../api/model/request.dart';
|
||||
import '../../../../core/common/start_config.dart';
|
||||
@@ -31,7 +33,7 @@ import '../../../../util/package_info.dart';
|
||||
import '../../../../util/updater.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../../routes/app_pages.dart';
|
||||
import '../../create/dto/create_router_params.dart';
|
||||
import '../../../rpc/rpc.dart';
|
||||
import '../../redirect/views/redirect_view.dart';
|
||||
|
||||
const unixSocketPath = 'gopeed.sock';
|
||||
@@ -78,6 +80,9 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
_initTray().onError(
|
||||
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
|
||||
|
||||
_initRpcServer().onError((error, stackTrace) =>
|
||||
logger.w("initRpcServer error", error, stackTrace));
|
||||
|
||||
_initForegroundTask().onError((error, stackTrace) =>
|
||||
logger.w("initForegroundTask error", error, stackTrace));
|
||||
|
||||
@@ -271,6 +276,37 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
trayManager.addListener(this);
|
||||
}
|
||||
|
||||
Future<void> _initRpcServer() async {
|
||||
if (!Util.isDesktop()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startRpcServer({
|
||||
"/create": (ctx) async {
|
||||
final meta =
|
||||
ctx.request.headers["X-Gopeed-Host-Meta"]?.firstOrNull ?? "{}";
|
||||
final jsonMeta = jsonDecode(meta);
|
||||
final silent = jsonMeta['silent'] as bool? ?? false;
|
||||
final params = await ctx.readText();
|
||||
final createTaskParams = _decodeToCreatTaskParams(params);
|
||||
if (!silent) {
|
||||
await windowManager.show();
|
||||
_handleToCreate0(createTaskParams);
|
||||
} else {
|
||||
try {
|
||||
await createTask(createTaskParams);
|
||||
} catch (e) {
|
||||
logger.w(
|
||||
"create task from extension fail", e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.w("start rpc server fail", e, StackTrace.current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initForegroundTask() async {
|
||||
if (!Util.isMobile()) {
|
||||
return;
|
||||
@@ -317,13 +353,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
if (uri.path == "/create") {
|
||||
final params = uri.queryParameters["params"];
|
||||
if (params?.isNotEmpty == true) {
|
||||
final safeParams = params!.replaceAll(" ", "+");
|
||||
final paramsJson =
|
||||
String.fromCharCodes(base64Decode(base64.normalize(safeParams)));
|
||||
Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
|
||||
arguments: RedirectArgs(Routes.CREATE,
|
||||
arguments:
|
||||
CreateRouterParams.fromJson(jsonDecode(paramsJson))));
|
||||
_handleToCreate(params!);
|
||||
return;
|
||||
}
|
||||
Get.rootDelegate.offAndToNamed(Routes.CREATE);
|
||||
@@ -346,7 +376,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
}
|
||||
Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
|
||||
arguments: RedirectArgs(Routes.CREATE,
|
||||
arguments: CreateRouterParams(req: Request(url: path))));
|
||||
arguments: CreateTask(req: Request(url: path))));
|
||||
}
|
||||
|
||||
String runningAddress() {
|
||||
@@ -475,6 +505,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
// default select all tracker subscribe urls
|
||||
extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
|
||||
}
|
||||
|
||||
final proxy = config.proxy;
|
||||
if (proxy.scheme.isEmpty) {
|
||||
proxy.scheme = 'http';
|
||||
@@ -492,6 +523,68 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
config.downloadDir = './';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize default download categories if empty
|
||||
if (extra.downloadCategories.isEmpty) {
|
||||
_initDefaultDownloadCategories();
|
||||
}
|
||||
|
||||
// Initialize default GitHub mirrors if empty
|
||||
if (extra.githubMirror.mirrors.isEmpty) {
|
||||
_initDefaultGithubMirrors();
|
||||
}
|
||||
}
|
||||
|
||||
void _initDefaultDownloadCategories() {
|
||||
final extra = downloaderConfig.value.extra;
|
||||
final downloadDir = downloaderConfig.value.downloadDir;
|
||||
|
||||
// Add default built-in categories with i18n keys
|
||||
// No need to set initial name value, it will be retrieved via nameKey
|
||||
extra.downloadCategories = [
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(downloadDir, 'Music'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryMusic',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(downloadDir, 'Video'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryVideo',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(downloadDir, 'Document'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryDocument',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(downloadDir, 'Program'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryProgram',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _initDefaultGithubMirrors() {
|
||||
final extra = downloaderConfig.value.extra;
|
||||
|
||||
// Add default built-in GitHub mirrors
|
||||
extra.githubMirror.mirrors = [
|
||||
GithubMirror(
|
||||
type: GithubMirrorType.jsdelivr,
|
||||
url: 'https://fastly.jsdelivr.net/gh',
|
||||
isBuiltIn: true,
|
||||
),
|
||||
GithubMirror(
|
||||
type: GithubMirrorType.ghProxy,
|
||||
url: 'https://fastgit.cc',
|
||||
isBuiltIn: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _initLaunchAtStartup() async {
|
||||
@@ -524,4 +617,21 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
apiToken: startConfig.value.apiToken));
|
||||
await putConfig(downloaderConfig.value);
|
||||
}
|
||||
|
||||
CreateTask _decodeToCreatTaskParams(String params) {
|
||||
final safeParams = params.replaceAll('"', "").replaceAll(" ", "+");
|
||||
final paramsJson =
|
||||
String.fromCharCodes(base64Decode(base64.normalize(safeParams)));
|
||||
return CreateTask.fromJson(jsonDecode(paramsJson));
|
||||
}
|
||||
|
||||
_handleToCreate(String params) {
|
||||
final createTaskParams = _decodeToCreatTaskParams(params);
|
||||
_handleToCreate0(createTaskParams);
|
||||
}
|
||||
|
||||
_handleToCreate0(CreateTask createTaskParams) {
|
||||
Get.rootDelegate.offAndToNamed(Routes.REDIRECT,
|
||||
arguments: RedirectArgs(Routes.CREATE, arguments: createTaskParams));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../../../api/model/options.dart';
|
||||
import '../../../../api/model/request.dart';
|
||||
|
||||
part 'create_router_params.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class CreateRouterParams {
|
||||
Request? req;
|
||||
Options? opt;
|
||||
|
||||
CreateRouterParams({
|
||||
this.req,
|
||||
this.opt,
|
||||
});
|
||||
|
||||
factory CreateRouterParams.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
) =>
|
||||
_$CreateRouterParamsFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$CreateRouterParamsToJson(this);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'create_router_params.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
CreateRouterParams _$CreateRouterParamsFromJson(Map<String, dynamic> json) =>
|
||||
CreateRouterParams(
|
||||
req: json['req'] == null
|
||||
? null
|
||||
: Request.fromJson(json['req'] as Map<String, dynamic>),
|
||||
opt: json['opt'] == null
|
||||
? null
|
||||
: Options.fromJson(json['opt'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CreateRouterParamsToJson(CreateRouterParams instance) {
|
||||
final val = <String, dynamic>{};
|
||||
|
||||
void writeNotNull(String key, dynamic value) {
|
||||
if (value != null) {
|
||||
val[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeNotNull('req', instance.req?.toJson());
|
||||
writeNotNull('opt', instance.opt?.toJson());
|
||||
return val;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:rounded_loading_button_plus/rounded_loading_button.dart';
|
||||
import '../../../../api/api.dart';
|
||||
import '../../../../api/model/create_task.dart';
|
||||
import '../../../../api/model/create_task_batch.dart';
|
||||
import '../../../../api/model/downloader_config.dart';
|
||||
import '../../../../api/model/options.dart';
|
||||
import '../../../../api/model/request.dart';
|
||||
import '../../../../api/model/resolve_result.dart';
|
||||
@@ -27,7 +28,6 @@ import '../../../views/file_tree_view.dart';
|
||||
import '../../app/controllers/app_controller.dart';
|
||||
import '../../history/views/history_view.dart';
|
||||
import '../controllers/create_controller.dart';
|
||||
import '../dto/create_router_params.dart';
|
||||
|
||||
class CreateView extends GetView<CreateController> {
|
||||
final _confirmFormKey = GlobalKey<FormState>();
|
||||
@@ -73,10 +73,12 @@ class CreateView extends GetView<CreateController> {
|
||||
.toString();
|
||||
}
|
||||
if (_pathController.text.isEmpty) {
|
||||
_pathController.text = appController.downloaderConfig.value.downloadDir;
|
||||
// Render placeholders when initializing the path
|
||||
final downloadDir = appController.downloaderConfig.value.downloadDir;
|
||||
_pathController.text = renderPathPlaceholders(downloadDir);
|
||||
}
|
||||
|
||||
final CreateRouterParams? routerParams = Get.rootDelegate.arguments();
|
||||
final CreateTask? routerParams = Get.rootDelegate.arguments();
|
||||
if (routerParams?.req?.url.isNotEmpty ?? false) {
|
||||
// get url from route arguments
|
||||
final url = routerParams!.req!.url;
|
||||
@@ -329,6 +331,8 @@ class CreateView extends GetView<CreateController> {
|
||||
DirectorySelector(
|
||||
controller: _pathController,
|
||||
),
|
||||
// Category selector
|
||||
_buildCategorySelector(appController),
|
||||
Obx(
|
||||
() => Visibility(
|
||||
visible: controller.showAdvanced.value,
|
||||
@@ -913,4 +917,66 @@ class CreateView extends GetView<CreateController> {
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildCategorySelector(AppController appController) {
|
||||
final categories =
|
||||
appController.downloaderConfig.value.extra.downloadCategories
|
||||
.where((c) => !c.isDeleted) // Filter out deleted categories
|
||||
.toList();
|
||||
if (categories.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Helper to get display name
|
||||
String getCategoryDisplayName(DownloadCategory category) {
|
||||
if (category.nameKey != null && category.nameKey!.isNotEmpty) {
|
||||
return category.nameKey!.tr;
|
||||
}
|
||||
return category.name;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'selectCategory'.tr,
|
||||
style: TextStyle(
|
||||
color: Get.theme.hintColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: categories.map((category) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
_pathController.text = renderPathPlaceholders(category.path);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
),
|
||||
child: Text(
|
||||
getCategoryDisplayName(category),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:launch_at_startup/launch_at_startup.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../../api/api.dart' as api;
|
||||
import '../../../../api/model/downloader_config.dart';
|
||||
import '../../../../i18n/message.dart';
|
||||
import '../../../../util/input_formatter.dart';
|
||||
@@ -24,6 +25,7 @@ import '../../../views/check_list_view.dart';
|
||||
import '../../../views/directory_selector.dart';
|
||||
import '../../../views/open_in_new.dart';
|
||||
import '../../../views/outlined_button_loading.dart';
|
||||
import '../../../views/text_button_loading.dart';
|
||||
import '../../app/controllers/app_controller.dart';
|
||||
import '../controllers/setting_controller.dart';
|
||||
|
||||
@@ -33,6 +35,14 @@ final _divider = const Divider().paddingOnly(left: 10, right: 10);
|
||||
class SettingView extends GetView<SettingController> {
|
||||
const SettingView({Key? key}) : super(key: key);
|
||||
|
||||
// Helper function to get display name for a category
|
||||
static String _getCategoryDisplayName(DownloadCategory category) {
|
||||
if (category.nameKey != null && category.nameKey!.isNotEmpty) {
|
||||
return category.nameKey!.tr;
|
||||
}
|
||||
return category.name;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appController = Get.find<AppController>();
|
||||
@@ -69,20 +79,25 @@ class SettingView extends GetView<SettingController> {
|
||||
'downloadDir', () => downloaderCfg.value.downloadDir, (Key key) {
|
||||
final downloadDirController =
|
||||
TextEditingController(text: downloaderCfg.value.downloadDir);
|
||||
downloadDirController.addListener(() async {
|
||||
|
||||
// Update config only when editing is done (on focus lost or submit)
|
||||
void onEditComplete() {
|
||||
if (downloadDirController.text != downloaderCfg.value.downloadDir) {
|
||||
downloaderCfg.value.downloadDir = downloadDirController.text;
|
||||
if (Util.isDesktop()) {
|
||||
controller.clearTap();
|
||||
}
|
||||
|
||||
await debounceSave();
|
||||
debounceSave();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return DirectorySelector(
|
||||
controller: downloadDirController,
|
||||
showLabel: false,
|
||||
showAndoirdToggle: true,
|
||||
allowEdit: true,
|
||||
showPlaceholderButton: true,
|
||||
onEditComplete: onEditComplete,
|
||||
);
|
||||
});
|
||||
final buildMaxRunning = _buildConfigItem(
|
||||
@@ -133,6 +148,133 @@ class SettingView extends GetView<SettingController> {
|
||||
);
|
||||
});
|
||||
|
||||
// Download categories configuration
|
||||
buildDownloadCategories() {
|
||||
final categories = downloaderCfg.value.extra.downloadCategories
|
||||
.where((c) => !c.isDeleted) // Filter out deleted categories
|
||||
.toList();
|
||||
return ListTile(
|
||||
title: Text('downloadCategories'.tr),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (categories.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'add'.tr,
|
||||
style: TextStyle(color: Theme.of(context).hintColor),
|
||||
),
|
||||
),
|
||||
...categories.map((category) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_getCategoryDisplayName(category),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
category.path,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.edit,
|
||||
size: 20,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: () {
|
||||
_showCategoryDialog(
|
||||
context,
|
||||
debounceSave,
|
||||
downloaderCfg,
|
||||
category: category,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete,
|
||||
size: 20,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: () async {
|
||||
// Show confirmation dialog
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('tip'.tr),
|
||||
content: Text('confirmDelete'.tr),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(false),
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(true),
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
if (category.isBuiltIn) {
|
||||
// Mark built-in category as deleted instead of removing it
|
||||
downloaderCfg.update((val) {
|
||||
category.isDeleted = true;
|
||||
});
|
||||
} else {
|
||||
// Remove custom categories completely
|
||||
downloaderCfg.update((val) {
|
||||
val!.extra.downloadCategories = val
|
||||
.extra.downloadCategories
|
||||
.where((c) => c != category)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
debounceSave();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: Text('add'.tr),
|
||||
onPressed: () {
|
||||
_showCategoryDialog(
|
||||
context,
|
||||
debounceSave,
|
||||
downloaderCfg,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildBrowserExtension() {
|
||||
return ListTile(
|
||||
title: Text('browserExtension'.tr),
|
||||
@@ -687,6 +829,19 @@ class SettingView extends GetView<SettingController> {
|
||||
final usrController = TextEditingController(text: proxy.usr);
|
||||
final pwdController = TextEditingController(text: proxy.pwd);
|
||||
|
||||
updateAuth() async {
|
||||
if (usrController.text != proxy.usr ||
|
||||
pwdController.text != proxy.pwd) {
|
||||
proxy.usr = usrController.text;
|
||||
proxy.pwd = pwdController.text;
|
||||
|
||||
await debounceSave();
|
||||
}
|
||||
}
|
||||
|
||||
usrController.addListener(updateAuth);
|
||||
pwdController.addListener(updateAuth);
|
||||
|
||||
final auth = Row(children: [
|
||||
Flexible(
|
||||
child: TextFormField(
|
||||
@@ -705,6 +860,7 @@ class SettingView extends GetView<SettingController> {
|
||||
labelText: 'password'.tr,
|
||||
contentPadding: const EdgeInsets.all(0.0),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
),
|
||||
]);
|
||||
@@ -728,6 +884,136 @@ class SettingView extends GetView<SettingController> {
|
||||
},
|
||||
);
|
||||
|
||||
// advanced config GitHub mirror items start
|
||||
final buildGithubMirror = _buildConfigItem(
|
||||
'githubMirror',
|
||||
() => downloaderCfg.value.extra.githubMirror.enabled ? 'on'.tr : 'off'.tr,
|
||||
(Key key) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'githubMirrorEnable'.tr,
|
||||
style: Theme.of(Get.context!).textTheme.bodyMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: downloaderCfg.value.extra.githubMirror.enabled,
|
||||
onChanged: (value) {
|
||||
downloaderCfg.update((val) {
|
||||
val!.extra.githubMirror.enabled = value;
|
||||
});
|
||||
debounceSave();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
_padding,
|
||||
Text(
|
||||
'githubMirrorDesc'.tr,
|
||||
style: Theme.of(Get.context!).textTheme.bodySmall,
|
||||
),
|
||||
_padding,
|
||||
// List of existing GitHub mirrors
|
||||
...downloaderCfg.value.extra.githubMirror.mirrors
|
||||
.where((m) => !m.isDeleted)
|
||||
.toList()
|
||||
.asMap()
|
||||
.entries
|
||||
.map((entry) {
|
||||
// Get the original index in the full list
|
||||
final mirror = entry.value;
|
||||
final index = downloaderCfg.value.extra.githubMirror.mirrors
|
||||
.indexOf(mirror);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
mirror.url,
|
||||
style:
|
||||
Theme.of(Get.context!).textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
tooltip: 'edit'.tr,
|
||||
onPressed: () {
|
||||
_showGithubMirrorDialog(
|
||||
index: index, initialMirror: mirror);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 20),
|
||||
tooltip: 'delete'.tr,
|
||||
onPressed: () async {
|
||||
// Show confirmation dialog
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('tip'.tr),
|
||||
content: Text('confirmDelete'.tr),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(false),
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(true),
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
if (mirror.isBuiltIn) {
|
||||
// Mark built-in mirror as deleted (logical delete)
|
||||
downloaderCfg.update((val) {
|
||||
mirror.isDeleted = true;
|
||||
});
|
||||
} else {
|
||||
// Remove custom mirrors completely (physical delete)
|
||||
final mirrors = List<GithubMirror>.from(
|
||||
downloaderCfg.value.extra.githubMirror.mirrors);
|
||||
mirrors.removeAt(index);
|
||||
downloaderCfg.update((val) {
|
||||
val!.extra.githubMirror.mirrors = mirrors;
|
||||
});
|
||||
}
|
||||
await debounceSave();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
_padding,
|
||||
// Add button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_showGithubMirrorDialog();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('add'.tr),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// advanced config API items start
|
||||
final buildApiProtocol = _buildConfigItem(
|
||||
'protocol',
|
||||
@@ -877,6 +1163,92 @@ class SettingView extends GetView<SettingController> {
|
||||
);
|
||||
});
|
||||
|
||||
// advanced config webhook items
|
||||
final buildWebhook = _buildConfigItem(
|
||||
'webhook',
|
||||
() => downloaderCfg.value.webhook.enable ? 'on'.tr : 'off'.tr,
|
||||
(Key key) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'webhookEnable'.tr,
|
||||
style: Theme.of(Get.context!).textTheme.bodyMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: downloaderCfg.value.webhook.enable,
|
||||
onChanged: (value) {
|
||||
downloaderCfg.update((val) {
|
||||
val!.webhook.enable = value;
|
||||
});
|
||||
debounceSave();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
_padding,
|
||||
Text(
|
||||
'webhookDesc'.tr,
|
||||
style: Theme.of(Get.context!).textTheme.bodySmall,
|
||||
),
|
||||
_padding,
|
||||
// List of existing webhook URLs
|
||||
...downloaderCfg.value.webhook.urls.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final url = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
url,
|
||||
style: Theme.of(Get.context!).textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
tooltip: 'edit'.tr,
|
||||
onPressed: () {
|
||||
_showWebhookDialog(index: index, initialUrl: url);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 20),
|
||||
tooltip: 'delete'.tr,
|
||||
onPressed: () async {
|
||||
// Create new list to avoid unmodifiable list error
|
||||
final urls =
|
||||
List<String>.from(downloaderCfg.value.webhook.urls);
|
||||
urls.removeAt(index);
|
||||
downloaderCfg.update((val) {
|
||||
val!.webhook.urls = urls;
|
||||
});
|
||||
await debounceSave();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
_padding,
|
||||
// Add button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_showWebhookDialog();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('add'.tr),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// advanced config log items start
|
||||
buildLogsDir() {
|
||||
return ListTile(
|
||||
@@ -936,6 +1308,7 @@ class SettingView extends GetView<SettingController> {
|
||||
child: Column(
|
||||
children: _addDivider([
|
||||
buildDownloadDir(),
|
||||
buildDownloadCategories(),
|
||||
buildMaxRunning(),
|
||||
buildDefaultDirectDownload(),
|
||||
buildBrowserExtension(),
|
||||
@@ -1005,7 +1378,10 @@ class SettingView extends GetView<SettingController> {
|
||||
Text('network'.tr),
|
||||
Card(
|
||||
child: Column(
|
||||
children: _addDivider([buildProxy()]),
|
||||
children: _addDivider([
|
||||
buildProxy(),
|
||||
buildGithubMirror(),
|
||||
]),
|
||||
)),
|
||||
const Text('API'),
|
||||
Card(
|
||||
@@ -1021,6 +1397,7 @@ class SettingView extends GetView<SettingController> {
|
||||
Card(
|
||||
child: Column(
|
||||
children: _addDivider([
|
||||
buildWebhook(),
|
||||
buildLogsDir(),
|
||||
]),
|
||||
)),
|
||||
@@ -1033,6 +1410,244 @@ class SettingView extends GetView<SettingController> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showWebhookDialog({int? index, String? initialUrl}) {
|
||||
final urlController = TextEditingController(text: initialUrl ?? '');
|
||||
final testController = OutlinedButtonLoadingController();
|
||||
final saveController = TextButtonLoadingController();
|
||||
final appController = Get.find<AppController>();
|
||||
final downloaderCfg = appController.downloaderConfig;
|
||||
final isEdit = index != null;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
showDialog(
|
||||
context: Get.context!,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: urlController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'webhookUrlHint'.tr,
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'required'.tr;
|
||||
}
|
||||
final url = value.trim().toLowerCase();
|
||||
if (!url.startsWith('http://') &&
|
||||
!url.startsWith('https://')) {
|
||||
return 'urlInvalid'.tr;
|
||||
}
|
||||
try {
|
||||
Uri.parse(value.trim());
|
||||
} catch (e) {
|
||||
return 'urlInvalid'.tr;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actionsAlignment: MainAxisAlignment.spaceBetween,
|
||||
actions: [
|
||||
// Test button on the left
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: OutlinedButtonLoading(
|
||||
controller: testController,
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
final url = urlController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
testController.start();
|
||||
try {
|
||||
await api.testWebhook(url);
|
||||
showMessage('tip'.tr, 'webhookTestSuccess'.tr);
|
||||
} catch (e) {
|
||||
showErrorMessage('webhookTestFail'.tr);
|
||||
} finally {
|
||||
testController.stop();
|
||||
}
|
||||
},
|
||||
child: Text('webhookTest'.tr),
|
||||
),
|
||||
),
|
||||
// Cancel and Confirm on the right
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
TextButtonLoading(
|
||||
controller: saveController,
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
final url = urlController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
|
||||
saveController.start();
|
||||
try {
|
||||
// Create new list to avoid unmodifiable list error
|
||||
final urls =
|
||||
List<String>.from(downloaderCfg.value.webhook.urls);
|
||||
if (isEdit) {
|
||||
urls[index] = url;
|
||||
} else {
|
||||
urls.add(url);
|
||||
}
|
||||
downloaderCfg.update((val) {
|
||||
val!.webhook.urls = urls;
|
||||
});
|
||||
await appController.saveConfig();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
} finally {
|
||||
saveController.stop();
|
||||
}
|
||||
},
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) {
|
||||
final isEdit = index != null;
|
||||
GithubMirrorType selectedType =
|
||||
initialMirror?.type ?? GithubMirrorType.jsdelivr;
|
||||
final urlController = TextEditingController(text: initialMirror?.url ?? '');
|
||||
final saveController = TextButtonLoadingController();
|
||||
final appController = Get.find<AppController>();
|
||||
final downloaderCfg = appController.downloaderConfig;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
showDialog(
|
||||
context: Get.context!,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setState) => AlertDialog(
|
||||
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButtonFormField<GithubMirrorType>(
|
||||
value: selectedType,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'githubMirrorType'.tr,
|
||||
),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
selectedType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
items: GithubMirrorType.values
|
||||
.map((type) => DropdownMenuItem(
|
||||
value: type,
|
||||
child: Text(type.name),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'githubMirrorUrl'.tr,
|
||||
hintText: 'githubMirrorUrlHint'.tr,
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'required'.tr;
|
||||
}
|
||||
final url = value.trim().toLowerCase();
|
||||
if (!url.startsWith('http://') &&
|
||||
!url.startsWith('https://')) {
|
||||
return 'urlInvalid'.tr;
|
||||
}
|
||||
try {
|
||||
Uri.parse(value.trim());
|
||||
} catch (e) {
|
||||
return 'urlInvalid'.tr;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
TextButtonLoading(
|
||||
controller: saveController,
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
final url = urlController.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
|
||||
saveController.start();
|
||||
try {
|
||||
// Create new list to avoid unmodifiable list error
|
||||
final mirrors = List<GithubMirror>.from(
|
||||
downloaderCfg.value.extra.githubMirror.mirrors);
|
||||
|
||||
final newMirror = GithubMirror(
|
||||
type: selectedType,
|
||||
url: url,
|
||||
);
|
||||
|
||||
if (isEdit) {
|
||||
mirrors[index] = newMirror;
|
||||
} else {
|
||||
mirrors.add(newMirror);
|
||||
}
|
||||
|
||||
downloaderCfg.update((val) {
|
||||
val!.extra.githubMirror.mirrors = mirrors;
|
||||
});
|
||||
await appController.saveConfig();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
} finally {
|
||||
saveController.stop();
|
||||
}
|
||||
},
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _tapInputWidget(GlobalKey key) {
|
||||
if (key.currentContext == null) {
|
||||
return;
|
||||
@@ -1105,6 +1720,88 @@ class SettingView extends GetView<SettingController> {
|
||||
return 'themeSystem'.tr;
|
||||
}
|
||||
}
|
||||
|
||||
void _showCategoryDialog(
|
||||
BuildContext context,
|
||||
Future<bool> Function() debounceSave,
|
||||
Rx<DownloaderConfig> downloaderCfg, {
|
||||
DownloadCategory? category,
|
||||
}) {
|
||||
final isEdit = category != null;
|
||||
final nameController = TextEditingController(
|
||||
text: isEdit ? _getCategoryDisplayName(category) : '',
|
||||
);
|
||||
final pathController = TextEditingController(text: category?.path ?? '');
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'categoryName'.tr,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DirectorySelector(
|
||||
controller: pathController,
|
||||
showLabel: true,
|
||||
allowEdit: true,
|
||||
showPlaceholderButton: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (nameController.text.isEmpty || pathController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
// Trigger UI update by wrapping changes in update()
|
||||
downloaderCfg.update((val) {
|
||||
// If name changed, clear nameKey so it won't be re-translated
|
||||
final nameChanged =
|
||||
nameController.text != _getCategoryDisplayName(category);
|
||||
category.name = nameController.text;
|
||||
category.path = pathController.text;
|
||||
if (nameChanged) {
|
||||
category.nameKey = null;
|
||||
}
|
||||
// If editing a deleted built-in category, unmark it as deleted
|
||||
if (category.isBuiltIn && category.isDeleted) {
|
||||
category.isDeleted = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
downloaderCfg.update((val) {
|
||||
val!.extra.downloadCategories = [
|
||||
...val.extra.downloadCategories,
|
||||
DownloadCategory(
|
||||
name: nameController.text,
|
||||
path: pathController.text,
|
||||
),
|
||||
];
|
||||
});
|
||||
}
|
||||
debounceSave();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ProxyModeEnum {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dart_ipc/dart_ipc.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../util/util.dart';
|
||||
|
||||
/// RPC context class that contains request information and response methods
|
||||
class RpcContext {
|
||||
final HttpRequest request;
|
||||
final HttpResponse response;
|
||||
String? _bodyCache;
|
||||
|
||||
RpcContext(this.request, this.response);
|
||||
|
||||
/// Read and parse JSON request body
|
||||
Future<Map<String, dynamic>> readJSON() async {
|
||||
if (_bodyCache == null) {
|
||||
final content = <int>[];
|
||||
await for (var data in request) {
|
||||
content.addAll(data);
|
||||
}
|
||||
_bodyCache = String.fromCharCodes(content);
|
||||
}
|
||||
|
||||
if (_bodyCache!.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return jsonDecode(_bodyCache!) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<String> readText() async {
|
||||
if (_bodyCache == null) {
|
||||
final content = <int>[];
|
||||
await for (var data in request) {
|
||||
content.addAll(data);
|
||||
}
|
||||
_bodyCache = String.fromCharCodes(content);
|
||||
}
|
||||
|
||||
return _bodyCache ?? '';
|
||||
}
|
||||
|
||||
/// Write JSON response
|
||||
Future<void> writeJSON(Map<String, dynamic> data) async {
|
||||
response.headers.contentType = ContentType.json;
|
||||
response.write(jsonEncode(data));
|
||||
await response.close();
|
||||
}
|
||||
|
||||
/// Write error response
|
||||
Future<void> writeError(String message, [int statusCode = 500]) async {
|
||||
response.statusCode = statusCode;
|
||||
await writeJSON({'error': message});
|
||||
}
|
||||
}
|
||||
|
||||
/// Route handler type definition
|
||||
typedef RouteHandler = Future<void> Function(RpcContext ctx);
|
||||
|
||||
/// Route registry
|
||||
class RouteRegistry {
|
||||
final Map<String, RouteHandler> _routes = {};
|
||||
|
||||
/// Register a route
|
||||
void register(String path, RouteHandler handler) {
|
||||
_routes[path] = handler;
|
||||
}
|
||||
|
||||
/// Get route handler
|
||||
RouteHandler? getHandler(String path) {
|
||||
return _routes[path];
|
||||
}
|
||||
|
||||
/// Get all registered routes
|
||||
List<String> get routes => _routes.keys.toList();
|
||||
}
|
||||
|
||||
/// Start RPC server
|
||||
Future<void> startRpcServer([Map<String, RouteHandler>? routes]) async {
|
||||
String path;
|
||||
if (Util.isWindows()) {
|
||||
path = r'\\.\pipe\gopeed_host';
|
||||
} else {
|
||||
path = await Util.homePathJoin("gopeed_host.sock");
|
||||
// try to delete existing socket file
|
||||
final socketFile = File(path);
|
||||
if (await socketFile.exists()) {
|
||||
try {
|
||||
await socketFile.delete();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create route registry
|
||||
final registry = RouteRegistry();
|
||||
|
||||
// Register provided routes
|
||||
if (routes != null) {
|
||||
routes.forEach((path, handler) {
|
||||
registry.register(path, handler);
|
||||
});
|
||||
}
|
||||
|
||||
final serverSocket = await bind(path);
|
||||
final httpServer = HttpServer.listenOn(serverSocket);
|
||||
|
||||
httpServer.forEach((HttpRequest request) async {
|
||||
final ctx = RpcContext(request, request.response);
|
||||
|
||||
try {
|
||||
// Get request path
|
||||
final requestPath = request.uri.path;
|
||||
// Find route handler
|
||||
final handler = registry.getHandler(requestPath);
|
||||
if (handler != null) {
|
||||
// Execute route handler
|
||||
await handler(ctx);
|
||||
} else {
|
||||
// 404 Route not found
|
||||
await ctx.writeError('Route not found: $requestPath', 404);
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.writeError('Internal server error: $e', 500);
|
||||
} finally {
|
||||
await request.response.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,17 +14,86 @@ import '../../util/util.dart';
|
||||
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
// Placeholder information for download directory
|
||||
class PathPlaceholder {
|
||||
final String placeholder;
|
||||
final String description;
|
||||
final String example;
|
||||
|
||||
const PathPlaceholder({
|
||||
required this.placeholder,
|
||||
required this.description,
|
||||
required this.example,
|
||||
});
|
||||
}
|
||||
|
||||
// Available placeholders for download directory
|
||||
List<PathPlaceholder> getPathPlaceholders() {
|
||||
final now = DateTime.now();
|
||||
final year = now.year.toString();
|
||||
final month = now.month.toString().padLeft(2, '0');
|
||||
final day = now.day.toString().padLeft(2, '0');
|
||||
|
||||
return [
|
||||
PathPlaceholder(
|
||||
placeholder: '%year%',
|
||||
description: 'placeholderYear'.tr,
|
||||
example: year,
|
||||
),
|
||||
PathPlaceholder(
|
||||
placeholder: '%month%',
|
||||
description: 'placeholderMonth'.tr,
|
||||
example: month,
|
||||
),
|
||||
PathPlaceholder(
|
||||
placeholder: '%day%',
|
||||
description: 'placeholderDay'.tr,
|
||||
example: day,
|
||||
),
|
||||
PathPlaceholder(
|
||||
placeholder: '%date%',
|
||||
description: 'placeholderDate'.tr,
|
||||
example: '$year-$month-$day',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Render placeholders in a path with actual values
|
||||
String renderPathPlaceholders(String path) {
|
||||
if (path.isEmpty) return path;
|
||||
|
||||
final now = DateTime.now();
|
||||
final year = now.year.toString();
|
||||
final month = now.month.toString().padLeft(2, '0');
|
||||
final day = now.day.toString().padLeft(2, '0');
|
||||
final date = '$year-$month-$day';
|
||||
|
||||
return path
|
||||
.replaceAll('%year%', year)
|
||||
.replaceAll('%month%', month)
|
||||
.replaceAll('%day%', day)
|
||||
.replaceAll('%date%', date);
|
||||
}
|
||||
|
||||
class DirectorySelector extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final bool showLabel;
|
||||
final bool showAndoirdToggle;
|
||||
final bool allowEdit;
|
||||
final bool showPlaceholderButton;
|
||||
final VoidCallback? onEditComplete;
|
||||
final bool showRenderedPlaceholders;
|
||||
|
||||
const DirectorySelector(
|
||||
{Key? key,
|
||||
required this.controller,
|
||||
this.showLabel = true,
|
||||
this.showAndoirdToggle = false})
|
||||
: super(key: key);
|
||||
const DirectorySelector({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
this.showLabel = true,
|
||||
this.showAndoirdToggle = false,
|
||||
this.allowEdit = false,
|
||||
this.showPlaceholderButton = false,
|
||||
this.onEditComplete,
|
||||
this.showRenderedPlaceholders = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DirectorySelector> createState() => _DirectorySelectorState();
|
||||
@@ -108,22 +177,117 @@ class _DirectorySelectorState extends State<DirectorySelector> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget? buildPlaceholderButton() {
|
||||
if (!widget.showPlaceholderButton) return null;
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.data_object),
|
||||
tooltip: 'insertPlaceholder'.tr,
|
||||
onSelected: (String placeholder) {
|
||||
final currentText = widget.controller.text;
|
||||
final selection = widget.controller.selection;
|
||||
final cursorPosition = selection.baseOffset >= 0
|
||||
? selection.baseOffset
|
||||
: currentText.length;
|
||||
|
||||
final newText = currentText.substring(0, cursorPosition) +
|
||||
placeholder +
|
||||
currentText.substring(cursorPosition);
|
||||
widget.controller.text = newText;
|
||||
widget.controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: cursorPosition + placeholder.length),
|
||||
);
|
||||
},
|
||||
itemBuilder: (BuildContext context) {
|
||||
final placeholders = getPathPlaceholders();
|
||||
return placeholders.map((p) {
|
||||
return PopupMenuItem<String>(
|
||||
value: p.placeholder,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${p.placeholder} - ${p.description}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'example'.trParams({'value': p.example}),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
readOnly: Util.isWeb() ? false : true,
|
||||
controller: widget.controller,
|
||||
decoration: widget.showLabel
|
||||
? InputDecoration(
|
||||
labelText: 'downloadDir'.tr,
|
||||
)
|
||||
: null,
|
||||
validator: (v) {
|
||||
return v!.trim().isNotEmpty ? null : 'downloadDirValid'.tr;
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: widget.controller,
|
||||
builder: (context, value, child) {
|
||||
Widget? suffix;
|
||||
if (widget.showRenderedPlaceholders && value.text.contains('%')) {
|
||||
final renderedPath = renderPathPlaceholders(value.text);
|
||||
// Show rendered path as a chip/badge in the input field
|
||||
suffix = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.blue[200]!),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.arrow_forward, size: 14, color: Colors.blue[700]),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
renderedPath,
|
||||
style: TextStyle(
|
||||
color: Colors.blue[700],
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return TextFormField(
|
||||
readOnly: widget.allowEdit ? false : (Util.isWeb() ? false : true),
|
||||
controller: widget.controller,
|
||||
decoration: widget.showLabel
|
||||
? InputDecoration(
|
||||
labelText: 'downloadDir'.tr,
|
||||
suffix: suffix,
|
||||
)
|
||||
: InputDecoration(
|
||||
suffix: suffix,
|
||||
),
|
||||
validator: (v) {
|
||||
return v!.trim().isNotEmpty ? null : 'downloadDirValid'.tr;
|
||||
},
|
||||
onEditingComplete: widget.onEditComplete,
|
||||
onTapOutside: (event) {
|
||||
// Call onEditComplete when user taps outside the field
|
||||
widget.onEditComplete?.call();
|
||||
},
|
||||
);
|
||||
},
|
||||
)),
|
||||
buildSelectWidget()
|
||||
buildSelectWidget(),
|
||||
buildPlaceholderButton(),
|
||||
].where((e) => e != null).map((e) => e!).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const deDE = {
|
||||
'error': 'Fehler',
|
||||
'tip': 'Tipp',
|
||||
'confirm': 'Bestätigen',
|
||||
'confirmDelete': 'Möchten Sie wirklich löschen?',
|
||||
'cancel': 'Abbrechen',
|
||||
'on': 'An',
|
||||
'off': 'Aus',
|
||||
@@ -21,10 +22,13 @@ const deDE = {
|
||||
'followSettings': 'Einstellungen Folgen',
|
||||
'downloadLink': 'Download Link',
|
||||
'downloadLinkValid': 'Bitte geben Sie den Download-Link ein',
|
||||
'downloadLinkHit': 'Bitte geben Sie den Download-Link ein, einen pro Zeile@append',
|
||||
'downloadLinkHitDesktop': ', oder ziehen Sie die Torrent-Datei direkt hierher',
|
||||
'downloadLinkHit':
|
||||
'Bitte geben Sie den Download-Link ein, einen pro Zeile@append',
|
||||
'downloadLinkHitDesktop':
|
||||
', oder ziehen Sie die Torrent-Datei direkt hierher',
|
||||
'download': 'Download',
|
||||
'noFileSelected': 'Bitte wählen Sie mindestens eine Datei aus, um fortzufahren.',
|
||||
'noFileSelected':
|
||||
'Bitte wählen Sie mindestens eine Datei aus, um fortzufahren.',
|
||||
'noStoragePermission': 'Speicherberechtigung erforderlich',
|
||||
'selectFile': 'Datei auswählen',
|
||||
'rename': 'Umbenennen',
|
||||
@@ -45,7 +49,8 @@ const deDE = {
|
||||
'updateDaily': 'Tägliche Aktualisierung',
|
||||
'lastUpdate': 'Letzte Aktualisierung: @time',
|
||||
'addTracker': 'Tracker hinzufügen',
|
||||
'addTrackerHit': 'Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile',
|
||||
'addTrackerHit':
|
||||
'Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile',
|
||||
'ui': 'Benutzeroberfläche',
|
||||
'theme': 'Thema',
|
||||
'themeSystem': 'System',
|
||||
@@ -122,8 +127,24 @@ const deDE = {
|
||||
'username_required': 'Bitte geben Sie Ihren Benutzernamen ein',
|
||||
'password_required': 'Bitte geben Sie Ihr Passwort ein',
|
||||
'login_success': 'Anmeldung erfolgreich',
|
||||
'login_failed': 'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort',
|
||||
'login_failed':
|
||||
'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort',
|
||||
'login_failed_network':
|
||||
'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihre Netzwerkverbindung',
|
||||
'insertPlaceholder': 'Platzhalter einfügen',
|
||||
'placeholderYear': 'Aktuelles Jahr',
|
||||
'placeholderMonth': 'Aktueller Monat (01-12)',
|
||||
'placeholderDay': 'Aktueller Tag (01-31)',
|
||||
'placeholderDate': 'Vollständiges Datum (JJJJ-MM-TT)',
|
||||
'example': 'z.B. @value',
|
||||
'downloadCategories': 'Download-Kategorien',
|
||||
'categoryMusic': 'Musik',
|
||||
'categoryVideo': 'Video',
|
||||
'categoryDocument': 'Dokument',
|
||||
'categoryProgram': 'Programm',
|
||||
'categoryName': 'Kategoriename',
|
||||
'categoryPath': 'Kategoriepfad',
|
||||
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
|
||||
'selectCategory': 'Kategorie auswählen',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const enUS = {
|
||||
'error': 'Error',
|
||||
'tip': 'Tip',
|
||||
'confirm': 'Confirm',
|
||||
'confirmDelete': 'Are you sure you want to delete?',
|
||||
'cancel': 'Cancel',
|
||||
'on': 'On',
|
||||
'off': 'Off',
|
||||
@@ -65,6 +66,15 @@ const enUS = {
|
||||
'effectAfterRestart': 'Effect after restart',
|
||||
'developer': 'Developer',
|
||||
'logDirectory': 'Log Directory',
|
||||
'webhook': 'Webhook',
|
||||
'webhookEnable': 'Enable Webhook',
|
||||
'webhookDesc': 'Send HTTP POST notifications when tasks complete or fail',
|
||||
'webhookUrlHint': 'Enter webhook URL',
|
||||
'webhookTest': 'Test',
|
||||
'webhookTestSuccess': 'Webhook test successful',
|
||||
'webhookTestFail': 'Webhook test failed',
|
||||
'urlInvalid': 'Please enter a valid HTTP or HTTPS URL',
|
||||
'required': 'This field is required',
|
||||
'show': 'Show',
|
||||
'continue': 'Continue',
|
||||
'pause': 'Pause',
|
||||
@@ -73,6 +83,8 @@ const enUS = {
|
||||
'deleteTask': 'Delete @count tasks',
|
||||
'deleteTaskTip': 'Keep downloaded files',
|
||||
'delete': 'Delete',
|
||||
'add': 'Add',
|
||||
'edit': 'Edit',
|
||||
'newVersionTitle': 'Discover new version @version',
|
||||
'newVersionUpdate': 'Update Now',
|
||||
'newVersionLater': 'Later',
|
||||
@@ -126,5 +138,26 @@ const enUS = {
|
||||
'login_failed': 'Login failed, please check your username and password',
|
||||
'login_failed_network':
|
||||
'Login failed, please check your network connection',
|
||||
'insertPlaceholder': 'Insert Placeholder',
|
||||
'placeholderYear': 'Current year',
|
||||
'placeholderMonth': 'Current month (01-12)',
|
||||
'placeholderDay': 'Current day (01-31)',
|
||||
'placeholderDate': 'Full date (YYYY-MM-DD)',
|
||||
'example': 'e.g. @value',
|
||||
'downloadCategories': 'Download Categories',
|
||||
'categoryMusic': 'Music',
|
||||
'categoryVideo': 'Video',
|
||||
'categoryDocument': 'Document',
|
||||
'categoryProgram': 'Program',
|
||||
'categoryName': 'Category Name',
|
||||
'categoryPath': 'Category Path',
|
||||
'builtInCategory': 'Built-in category cannot be deleted',
|
||||
'selectCategory': 'Select Category',
|
||||
'githubMirror': 'GitHub Mirror',
|
||||
'githubMirrorEnable': 'Enable GitHub Mirror',
|
||||
'githubMirrorDesc': 'Use mirrors to accelerate GitHub content downloads',
|
||||
'githubMirrorType': 'Mirror Type',
|
||||
'githubMirrorUrl': 'Mirror URL',
|
||||
'githubMirrorUrlHint': 'Enter mirror URL',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const esES = {
|
||||
'error': 'Error',
|
||||
'tip': 'Consejo',
|
||||
'confirm': 'Confirmar',
|
||||
'confirmDelete': '¿Está seguro de que desea eliminar?',
|
||||
'cancel': 'Cancelar',
|
||||
'on': 'Activado',
|
||||
'off': 'Desactivado',
|
||||
@@ -21,9 +22,11 @@ const esES = {
|
||||
'downloadLinkValid': 'Por favor, introduce el enlace de descarga',
|
||||
'downloadLinkHit':
|
||||
'Por favor, introduce el enlace de descarga, se admite HTTP/HTTPS/MAGNET@append',
|
||||
'downloadLinkHitDesktop': ', o arrastra el archivo torrent aquí directamente',
|
||||
'downloadLinkHitDesktop':
|
||||
', o arrastra el archivo torrent aquí directamente',
|
||||
'download': 'Descargar',
|
||||
'noFileSelected': 'Por favor, selecciona al menos un archivo para continuar.',
|
||||
'noFileSelected':
|
||||
'Por favor, selecciona al menos un archivo para continuar.',
|
||||
'noStoragePermission': 'Se requiere permiso de almacenamiento',
|
||||
'selectFile': 'Seleccionar Archivo',
|
||||
'rename': 'Renombrar',
|
||||
@@ -43,7 +46,8 @@ const esES = {
|
||||
'updateDaily': 'Actualizar diariamente',
|
||||
'lastUpdate': 'Última actualización: @time',
|
||||
'addTracker': 'Añadir Tracker',
|
||||
'addTrackerHit': 'Por favor, introduce la URL del servidor tracker, una por línea',
|
||||
'addTrackerHit':
|
||||
'Por favor, introduce la URL del servidor tracker, una por línea',
|
||||
'ui': 'Interfaz',
|
||||
'theme': 'Tema',
|
||||
'themeSystem': 'Sistema',
|
||||
@@ -104,5 +108,20 @@ const esES = {
|
||||
'taskUrl': 'URL de la Tarea',
|
||||
'downloadPath': 'Ruta de Descarga',
|
||||
'skipVerifyCert': 'Omitir Verificación de Certificado',
|
||||
'insertPlaceholder': 'Insertar marcador de posición',
|
||||
'placeholderYear': 'Año actual',
|
||||
'placeholderMonth': 'Mes actual (01-12)',
|
||||
'placeholderDay': 'Día actual (01-31)',
|
||||
'placeholderDate': 'Fecha completa (AAAA-MM-DD)',
|
||||
'example': 'ej. @value',
|
||||
'downloadCategories': 'Categorías de descarga',
|
||||
'categoryMusic': 'Música',
|
||||
'categoryVideo': 'Video',
|
||||
'categoryDocument': 'Documento',
|
||||
'categoryProgram': 'Programa',
|
||||
'categoryName': 'Nombre de categoría',
|
||||
'categoryPath': 'Ruta de categoría',
|
||||
'builtInCategory': 'La categoría incorporada no se puede eliminar',
|
||||
'selectCategory': 'Seleccionar categoría',
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const faIR = {
|
||||
'error': 'اررور',
|
||||
'tip': 'Tip',
|
||||
'confirm': 'تایید',
|
||||
'confirmDelete': 'آیا مطمئن هستید که می خواهید حذف کنید؟',
|
||||
'cancel': 'انصراف',
|
||||
'selectAll': 'انتخاب همه',
|
||||
'task': 'کار',
|
||||
@@ -60,7 +61,22 @@ const faIR = {
|
||||
'thanks': 'تشکر',
|
||||
'thanksDesc':
|
||||
'از تمامی افرادی که در ساخت و توسعهی جامعهی Gopeed مشارکت کردهاند، سپاسگزاری میکنیم!',
|
||||
'browserExtension': 'فایل های دانلود شده را نگه دارد',
|
||||
'browserExtension': 'افزونه مرورگر',
|
||||
'skipVerifyCert': 'رد کردن تأیید گواهینامه',
|
||||
'insertPlaceholder': 'درج نگهدارنده',
|
||||
'placeholderYear': 'سال جاری',
|
||||
'placeholderMonth': 'ماه جاری (01-12)',
|
||||
'placeholderDay': 'روز جاری (01-31)',
|
||||
'placeholderDate': 'تاریخ کامل (YYYY-MM-DD)',
|
||||
'example': 'مثلا @value',
|
||||
'downloadCategories': 'دستهبندیهای دانلود',
|
||||
'categoryMusic': 'موسیقی',
|
||||
'categoryVideo': 'ویدیو',
|
||||
'categoryDocument': 'سند',
|
||||
'categoryProgram': 'برنامه',
|
||||
'categoryName': 'نام دستهبندی',
|
||||
'categoryPath': 'مسیر دستهبندی',
|
||||
'builtInCategory': 'دستهبندی داخلی قابل حذف نیست',
|
||||
'selectCategory': 'انتخاب دستهبندی',
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const frFR = {
|
||||
'error': 'Erreur',
|
||||
'tip': 'Conseil',
|
||||
'confirm': 'Confirmer',
|
||||
'confirmDelete': 'Êtes-vous sûr de vouloir supprimer ?',
|
||||
'cancel': 'Annuler',
|
||||
'on': 'Activé',
|
||||
'off': 'Désactivé',
|
||||
@@ -19,10 +20,12 @@ const frFR = {
|
||||
'advancedOptions': 'Options avancées',
|
||||
'downloadLink': 'Lien de téléchargement',
|
||||
'downloadLinkValid': 'Veuillez entrer le lien de téléchargement',
|
||||
'downloadLinkHit': 'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge',
|
||||
'downloadLinkHit':
|
||||
'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge',
|
||||
'downloadLinkHitDesktop': ', ou glissez directement le fichier torrent ici',
|
||||
'download': 'Télécharger',
|
||||
'noFileSelected': 'Veuillez sélectionner au moins un fichier pour continuer.',
|
||||
'noFileSelected':
|
||||
'Veuillez sélectionner au moins un fichier pour continuer.',
|
||||
'noStoragePermission': 'Permission de stockage requise',
|
||||
'selectFile': 'Sélectionner un fichier',
|
||||
'rename': 'Renommer',
|
||||
@@ -32,16 +35,19 @@ const frFR = {
|
||||
'downloadDir': 'Répertoire de téléchargement',
|
||||
'downloadDirValid': 'Veuillez sélectionner le répertoire de téléchargement',
|
||||
'connections': 'Connexions',
|
||||
'useServerCtime': 'Utiliser l\'heure du serveur pour la création de fichier',
|
||||
'useServerCtime':
|
||||
'Utiliser l\'heure du serveur pour la création de fichier',
|
||||
'maxRunning': 'Tâches en cours maximum',
|
||||
'items': '@count éléments',
|
||||
'subscribeTracker': 'S\'abonner au tracker',
|
||||
'subscribeFail': 'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard',
|
||||
'subscribeFail':
|
||||
'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard',
|
||||
'update': 'Mettre à jour',
|
||||
'updateDaily': 'Mettre à jour quotidiennement',
|
||||
'lastUpdate': 'Dernière mise à jour : @time',
|
||||
'addTracker': 'Ajouter un tracker',
|
||||
'addTrackerHit': 'Veuillez entrer l\'URL du serveur de tracker, une par ligne',
|
||||
'addTrackerHit':
|
||||
'Veuillez entrer l\'URL du serveur de tracker, une par ligne',
|
||||
'ui': 'Interface utilisateur',
|
||||
'theme': 'Thème',
|
||||
'themeSystem': 'Système',
|
||||
@@ -89,9 +95,25 @@ const frFR = {
|
||||
'username': 'Nom d\'utilisateur',
|
||||
'password': 'Mot de passe',
|
||||
'thanks': 'Merci',
|
||||
'thanksDesc': 'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !',
|
||||
'thanksDesc':
|
||||
'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !',
|
||||
'browserExtension': 'Extension du navigateur',
|
||||
'launchAtStartup': 'Lancer au démarrage',
|
||||
'skipVerifyCert': 'Bypass Verifikasi Sertifikat',
|
||||
'skipVerifyCert': 'Ignorer la vérification du certificat',
|
||||
'insertPlaceholder': 'Insérer un espace réservé',
|
||||
'placeholderYear': 'Année en cours',
|
||||
'placeholderMonth': 'Mois en cours (01-12)',
|
||||
'placeholderDay': 'Jour en cours (01-31)',
|
||||
'placeholderDate': 'Date complète (AAAA-MM-JJ)',
|
||||
'example': 'ex. @value',
|
||||
'downloadCategories': 'Catégories de téléchargement',
|
||||
'categoryMusic': 'Musique',
|
||||
'categoryVideo': 'Vidéo',
|
||||
'categoryDocument': 'Document',
|
||||
'categoryProgram': 'Programme',
|
||||
'categoryName': 'Nom de la catégorie',
|
||||
'categoryPath': 'Chemin de la catégorie',
|
||||
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
|
||||
'selectCategory': 'Sélectionner une catégorie',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const huHU = {
|
||||
'error': 'Hiba',
|
||||
'tip': 'Tipp',
|
||||
'confirm': 'Megerősít',
|
||||
'confirmDelete': 'Biztosan törölni szeretné?',
|
||||
'cancel': 'Mégsem',
|
||||
'on': 'Bekapcsolva',
|
||||
'off': 'Kikapcsolva',
|
||||
@@ -21,7 +22,8 @@ const huHU = {
|
||||
'followSettings': 'Beállítások követése',
|
||||
'downloadLink': 'Letöltési hivatkozás',
|
||||
'downloadLinkValid': 'Kérjük, adja meg a letöltési hivatkozást',
|
||||
'downloadLinkHit': 'Kérjük, adja meg a letöltési hivatkozást, soronként egyet@append',
|
||||
'downloadLinkHit':
|
||||
'Kérjük, adja meg a letöltési hivatkozást, soronként egyet@append',
|
||||
'downloadLinkHitDesktop': ', vagy húzza ide a torrent fájlt közvetlenül',
|
||||
'download': 'Letöltés',
|
||||
'noFileSelected': 'Kérjük, válasszon ki legalább egy fájlt a folytatáshoz.',
|
||||
@@ -36,7 +38,8 @@ const huHU = {
|
||||
'connections': 'Kapcsolatok',
|
||||
'useServerCtime': 'Használja a szerveridőt a fájl létrehozásához',
|
||||
'maxRunning': 'Maximális futó feladatok',
|
||||
'defaultDirectDownload': 'Alapértelmezettként ellenőrizze a közvetlen letöltést',
|
||||
'defaultDirectDownload':
|
||||
'Alapértelmezettként ellenőrizze a közvetlen letöltést',
|
||||
'items': '@count elem',
|
||||
'subscribeTracker': 'Nyomkövető előfizetése',
|
||||
'subscribeFail':
|
||||
@@ -45,7 +48,8 @@ const huHU = {
|
||||
'updateDaily': 'Napi frissítés',
|
||||
'lastUpdate': 'Utolsó frissítés: @time',
|
||||
'addTracker': 'Nyomkövető hozzáadása',
|
||||
'addTrackerHit': 'Kérjük, adja meg a nyomkövető szerver URL-jét, soronként egyet',
|
||||
'addTrackerHit':
|
||||
'Kérjük, adja meg a nyomkövető szerver URL-jét, soronként egyet',
|
||||
'ui': 'Felhasználói felület',
|
||||
'theme': 'Téma',
|
||||
'themeSystem': 'Rendszer',
|
||||
@@ -60,7 +64,8 @@ const huHU = {
|
||||
'apiToken': 'API token',
|
||||
'notSet': 'NS',
|
||||
'set': 'BEÁLLÍT',
|
||||
'portInUse': 'A [@port] port használatban van, kérjük, változtassa meg a portot',
|
||||
'portInUse':
|
||||
'A [@port] port használatban van, kérjük, változtassa meg a portot',
|
||||
'effectAfterRestart': 'Kihatás indítás után',
|
||||
'developer': 'Fejlesztő',
|
||||
'logDirectory': 'Napló könyvtár',
|
||||
@@ -118,5 +123,20 @@ const huHU = {
|
||||
'fileSelectedSize': 'Méret: ',
|
||||
'httpHeaderName': 'Fejléc neve',
|
||||
'httpHeaderValue': 'Fejléc értéke',
|
||||
'insertPlaceholder': 'Helyőrző beszúrása',
|
||||
'placeholderYear': 'Aktuális év',
|
||||
'placeholderMonth': 'Aktuális hónap (01-12)',
|
||||
'placeholderDay': 'Aktuális nap (01-31)',
|
||||
'placeholderDate': 'Teljes dátum (ÉÉÉÉ-HH-NN)',
|
||||
'example': 'pl. @value',
|
||||
'downloadCategories': 'Letöltési kategóriák',
|
||||
'categoryMusic': 'Zene',
|
||||
'categoryVideo': 'Videó',
|
||||
'categoryDocument': 'Dokumentum',
|
||||
'categoryProgram': 'Program',
|
||||
'categoryName': 'Kategória neve',
|
||||
'categoryPath': 'Kategória útvonala',
|
||||
'builtInCategory': 'A beépített kategória nem törölhető',
|
||||
'selectCategory': 'Kategória kiválasztása',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const idID = {
|
||||
'error': 'Kesalahan',
|
||||
'tip': 'Tips',
|
||||
'confirm': 'Konfirmasi',
|
||||
'confirmDelete': 'Apakah Anda yakin ingin menghapus?',
|
||||
'cancel': 'Batal',
|
||||
'on': 'Aktif',
|
||||
'off': 'Nonaktif',
|
||||
@@ -22,7 +23,8 @@ const idID = {
|
||||
'downloadLinkValid': 'Silakan masukkan tautan unduhan',
|
||||
'downloadLinkHit':
|
||||
'Tolong masukkan tautan unduhan, HTTP/HTTPS/MAGNET didukung@append',
|
||||
'downloadLinkHitDesktop': ', atau seret file torrent ke sini secara langsung',
|
||||
'downloadLinkHitDesktop':
|
||||
', atau seret file torrent ke sini secara langsung',
|
||||
'download': 'Download',
|
||||
'noFileSelected': 'Silakan pilih setidaknya satu file untuk melanjutkan.',
|
||||
'noStoragePermission': 'Izin penyimpanan diperlukan',
|
||||
@@ -108,5 +110,20 @@ const idID = {
|
||||
'taskUrl': 'URL Tugas',
|
||||
'downloadPath': 'Direktori Unduhan',
|
||||
'skipVerifyCert': 'Lupakan verifikasi sertifikat',
|
||||
'insertPlaceholder': 'Sisipkan placeholder',
|
||||
'placeholderYear': 'Tahun saat ini',
|
||||
'placeholderMonth': 'Bulan saat ini (01-12)',
|
||||
'placeholderDay': 'Hari saat ini (01-31)',
|
||||
'placeholderDate': 'Tanggal lengkap (YYYY-MM-DD)',
|
||||
'example': 'cth. @value',
|
||||
'downloadCategories': 'Kategori Unduhan',
|
||||
'categoryMusic': 'Musik',
|
||||
'categoryVideo': 'Video',
|
||||
'categoryDocument': 'Dokumen',
|
||||
'categoryProgram': 'Program',
|
||||
'categoryName': 'Nama Kategori',
|
||||
'categoryPath': 'Path Kategori',
|
||||
'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
|
||||
'selectCategory': 'Pilih Kategori',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const itIT = {
|
||||
'error': 'Errore',
|
||||
'tip': 'Suggerimento',
|
||||
'confirm': 'Conferma',
|
||||
'confirmDelete': 'Sei sicuro di voler eliminare?',
|
||||
'cancel': 'Annullla',
|
||||
'on': 'Acceso',
|
||||
'off': 'Spento',
|
||||
@@ -97,5 +98,20 @@ const itIT = {
|
||||
'browserExtension': 'Estensione del browser',
|
||||
'launchAtStartup': "Lancia all'avvio",
|
||||
'skipVerifyCert': 'Salta la verifica del certificato',
|
||||
'insertPlaceholder': 'Inserisci segnaposto',
|
||||
'placeholderYear': 'Anno corrente',
|
||||
'placeholderMonth': 'Mese corrente (01-12)',
|
||||
'placeholderDay': 'Giorno corrente (01-31)',
|
||||
'placeholderDate': 'Data completa (AAAA-MM-GG)',
|
||||
'example': 'es. @value',
|
||||
'downloadCategories': 'Categorie di download',
|
||||
'categoryMusic': 'Musica',
|
||||
'categoryVideo': 'Video',
|
||||
'categoryDocument': 'Documento',
|
||||
'categoryProgram': 'Programma',
|
||||
'categoryName': 'Nome categoria',
|
||||
'categoryPath': 'Percorso categoria',
|
||||
'builtInCategory': 'La categoria predefinita non può essere eliminata',
|
||||
'selectCategory': 'Seleziona categoria',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const jaJP = {
|
||||
'error': 'エラー',
|
||||
'tip': 'ヒント',
|
||||
'confirm': '確認',
|
||||
'confirmDelete': '削除してもよろしいですか?',
|
||||
'cancel': 'キャンセル',
|
||||
'selectAll': 'すべてを選択',
|
||||
'task': 'タスク',
|
||||
@@ -63,5 +64,20 @@ const jaJP = {
|
||||
'thanksDesc': 'Gopeedコミュニティの建設に協力してくださったすべての貢献者の方々に感謝します!',
|
||||
'browserExtension': 'ブラウザ拡張機能',
|
||||
'skipVerifyCert': '証明書の検証をスキップ',
|
||||
'insertPlaceholder': 'プレースホルダーを挿入',
|
||||
'placeholderYear': '現在の年',
|
||||
'placeholderMonth': '現在の月 (01-12)',
|
||||
'placeholderDay': '現在の日 (01-31)',
|
||||
'placeholderDate': '完全な日付 (年-月-日)',
|
||||
'example': '例: @value',
|
||||
'downloadCategories': 'ダウンロードカテゴリ',
|
||||
'categoryMusic': '音楽',
|
||||
'categoryVideo': '動画',
|
||||
'categoryDocument': 'ドキュメント',
|
||||
'categoryProgram': 'プログラム',
|
||||
'categoryName': 'カテゴリ名',
|
||||
'categoryPath': 'カテゴリパス',
|
||||
'builtInCategory': '内蔵カテゴリは削除できません',
|
||||
'selectCategory': 'カテゴリを選択',
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const plPL = {
|
||||
'error': 'Błąd',
|
||||
'tip': 'Tip',
|
||||
'confirm': 'Potwierdź',
|
||||
'confirmDelete': 'Czy na pewno chcesz usunąć?',
|
||||
'cancel': 'Anuluj',
|
||||
'on': 'Włącz',
|
||||
'off': 'Wyłącz',
|
||||
@@ -95,5 +96,20 @@ const plPL = {
|
||||
'browserExtension': 'Wtyczka dla przeglądarki',
|
||||
'launchAtStartup': 'Uruchom na starcie',
|
||||
'skipVerifyCert': 'Pomiń weryfikację certyfikatu',
|
||||
'insertPlaceholder': 'Wstaw placeholder',
|
||||
'placeholderYear': 'Bieżący rok',
|
||||
'placeholderMonth': 'Bieżący miesiąc (01-12)',
|
||||
'placeholderDay': 'Bieżący dzień (01-31)',
|
||||
'placeholderDate': 'Pełna data (RRRR-MM-DD)',
|
||||
'example': 'np. @value',
|
||||
'downloadCategories': 'Kategorie pobierania',
|
||||
'categoryMusic': 'Muzyka',
|
||||
'categoryVideo': 'Wideo',
|
||||
'categoryDocument': 'Dokument',
|
||||
'categoryProgram': 'Program',
|
||||
'categoryName': 'Nazwa kategorii',
|
||||
'categoryPath': 'Ścieżka kategorii',
|
||||
'builtInCategory': 'Wbudowanej kategorii nie można usunąć',
|
||||
'selectCategory': 'Wybierz kategorię',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const ptBR = {
|
||||
'error': 'Erro',
|
||||
'tip': 'Dica',
|
||||
'confirm': 'Confirmar',
|
||||
'confirmDelete': 'Tem certeza de que deseja excluir?',
|
||||
'cancel': 'Cancelar',
|
||||
'on': 'Ligado',
|
||||
'off': 'Desligado',
|
||||
@@ -21,7 +22,8 @@ const ptBR = {
|
||||
'followSettings': 'Follow Settings',
|
||||
'downloadLink': 'Link de Download',
|
||||
'downloadLinkValid': 'Por favor, insira o link de download',
|
||||
'downloadLinkHit': 'Por favor, insira o link de download, um por linha@append',
|
||||
'downloadLinkHit':
|
||||
'Por favor, insira o link de download, um por linha@append',
|
||||
'downloadLinkHitDesktop': ', ou arraste o arquivo torrent aqui',
|
||||
'download': 'Download',
|
||||
'noFileSelected': 'Por favor, selecione ao menos um arquivo para continuar',
|
||||
@@ -118,5 +120,20 @@ const ptBR = {
|
||||
'fileSelectedSize': 'Tamanho: ',
|
||||
'httpHeaderName': 'Nome do Header',
|
||||
'httpHeaderValue': 'Valor do Header',
|
||||
'insertPlaceholder': 'Inserir marcador',
|
||||
'placeholderYear': 'Ano atual',
|
||||
'placeholderMonth': 'Mês atual (01-12)',
|
||||
'placeholderDay': 'Dia atual (01-31)',
|
||||
'placeholderDate': 'Data completa (AAAA-MM-DD)',
|
||||
'example': 'ex. @value',
|
||||
'downloadCategories': 'Categorias de Download',
|
||||
'categoryMusic': 'Música',
|
||||
'categoryVideo': 'Vídeo',
|
||||
'categoryDocument': 'Documento',
|
||||
'categoryProgram': 'Programa',
|
||||
'categoryName': 'Nome da Categoria',
|
||||
'categoryPath': 'Caminho da Categoria',
|
||||
'builtInCategory': 'Categoria integrada não pode ser excluída',
|
||||
'selectCategory': 'Selecionar Categoria',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const ruRU = {
|
||||
'error': 'Ошибка',
|
||||
'tip': 'Подсказка',
|
||||
'confirm': 'Подтвердить',
|
||||
'confirmDelete': 'Вы уверены, что хотите удалить?',
|
||||
'cancel': 'Отмена',
|
||||
'on': 'Вкл.',
|
||||
'off': 'Откл.',
|
||||
@@ -119,5 +120,20 @@ const ruRU = {
|
||||
'fileSelectedSize': 'Размер: ',
|
||||
'httpHeaderName': 'Название заголовка HTTP',
|
||||
'httpHeaderValue': 'Значение заголовка HTTP',
|
||||
'insertPlaceholder': 'Вставить заполнитель',
|
||||
'placeholderYear': 'Текущий год',
|
||||
'placeholderMonth': 'Текущий месяц (01-12)',
|
||||
'placeholderDay': 'Текущий день (01-31)',
|
||||
'placeholderDate': 'Полная дата (ГГГГ-ММ-ДД)',
|
||||
'example': 'напр. @value',
|
||||
'downloadCategories': 'Категории загрузки',
|
||||
'categoryMusic': 'Музыка',
|
||||
'categoryVideo': 'Видео',
|
||||
'categoryDocument': 'Документ',
|
||||
'categoryProgram': 'Программа',
|
||||
'categoryName': 'Название категории',
|
||||
'categoryPath': 'Путь категории',
|
||||
'builtInCategory': 'Встроенную категорию нельзя удалить',
|
||||
'selectCategory': 'Выбрать категорию',
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const taTA = {
|
||||
'error': 'பிழை',
|
||||
'tip': 'குறிப்பு',
|
||||
'confirm': 'சரி',
|
||||
'confirmDelete': 'நிச்சயமாக நீக்க விரும்புகிறீர்களா?',
|
||||
'cancel': 'ரத்துசெய்',
|
||||
'on': 'On',
|
||||
'off': 'Off',
|
||||
@@ -21,7 +22,8 @@ const taTA = {
|
||||
'downloadLinkValid': 'பதிவிறக்க இணைப்பை உள்ளிடவும்',
|
||||
'downloadLinkHit':
|
||||
'பதிவிறக்க இணைப்பை உள்ளிடவும், HTTP/HTTPS/MAGNET ஆகியவற்றை ஏற்கும்@append',
|
||||
'downloadLinkHitDesktop': ', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்',
|
||||
'downloadLinkHitDesktop':
|
||||
', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்',
|
||||
'download': 'பதிவிறக்கு',
|
||||
'noFileSelected': 'குறைந்தபட்சம் ஒரு கோப்பைத் தேர்ந்தெடுக்கவும்.',
|
||||
'noStoragePermission': 'சேமிப்பக அனுமதி தேவை',
|
||||
@@ -96,5 +98,20 @@ const taTA = {
|
||||
'browserExtension': 'உலாவி நீட்டிப்பு',
|
||||
'launchAtStartup': 'தொடக்கத்தின் போது இயங்கு',
|
||||
'skipVerifyCert': 'திருத்தி சான்று சரிபார்க்கவும்',
|
||||
'insertPlaceholder': 'இடங்காட்டி செருக',
|
||||
'placeholderYear': 'தற்போதைய ஆண்டு',
|
||||
'placeholderMonth': 'தற்போதைய மாதம் (01-12)',
|
||||
'placeholderDay': 'தற்போதைய நாள் (01-31)',
|
||||
'placeholderDate': 'முழு தேதி (YYYY-MM-DD)',
|
||||
'example': 'எ.கா. @value',
|
||||
'downloadCategories': 'பதிவிறக்க வகைகள்',
|
||||
'categoryMusic': 'இசை',
|
||||
'categoryVideo': 'காணொளி',
|
||||
'categoryDocument': 'ஆவணம்',
|
||||
'categoryProgram': 'நிரல்',
|
||||
'categoryName': 'வகை பெயர்',
|
||||
'categoryPath': 'வகை பாதை',
|
||||
'builtInCategory': 'உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது',
|
||||
'selectCategory': 'வகையைத் தேர்ந்தெடுக்கவும்',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const trTR = {
|
||||
'error': 'Hata',
|
||||
'tip': 'İpucu',
|
||||
'confirm': 'Onayla',
|
||||
'confirmDelete': 'Silmek istediğinizden emin misiniz?',
|
||||
'cancel': 'İptal',
|
||||
'on': 'Açık',
|
||||
'off': 'Kapalı',
|
||||
@@ -22,7 +23,8 @@ const trTR = {
|
||||
'downloadLink': 'İndirme Bağlantısı',
|
||||
'downloadLinkValid': 'Lütfen indirme bağlantısını girin',
|
||||
'downloadLinkHit': 'Lütfen her satıra bir indirme bağlantısı girin@append',
|
||||
'downloadLinkHitDesktop': ', veya doğrudan torrent dosyasını buraya sürükleyin',
|
||||
'downloadLinkHitDesktop':
|
||||
', veya doğrudan torrent dosyasını buraya sürükleyin',
|
||||
'download': 'İndir',
|
||||
'noFileSelected': 'Devam etmek için en az bir dosya seçin.',
|
||||
'noStoragePermission': 'Depolama izni gerekli',
|
||||
@@ -39,12 +41,14 @@ const trTR = {
|
||||
'defaultDirectDownload': 'Varsayılan olarak doğrudan indirmeyi seç',
|
||||
'items': '@count öğe',
|
||||
'subscribeTracker': 'Tracker Aboneliği',
|
||||
'subscribeFail': 'Abonelik başarısız oldu, lütfen ağı kontrol edin veya daha sonra tekrar deneyin',
|
||||
'subscribeFail':
|
||||
'Abonelik başarısız oldu, lütfen ağı kontrol edin veya daha sonra tekrar deneyin',
|
||||
'update': 'Güncelle',
|
||||
'updateDaily': 'Günlük güncelle',
|
||||
'lastUpdate': 'Son güncelleme: @time',
|
||||
'addTracker': 'Tracker Ekle',
|
||||
'addTrackerHit': 'Lütfen tracker sunucu URL’sini girin, her satıra bir tane',
|
||||
'addTrackerHit':
|
||||
'Lütfen tracker sunucu URL’sini girin, her satıra bir tane',
|
||||
'ui': 'Arayüz',
|
||||
'theme': 'Tema',
|
||||
'themeSystem': 'Sistem',
|
||||
@@ -96,8 +100,8 @@ const trTR = {
|
||||
'username': 'Kullanıcı Adı',
|
||||
'password': 'Şifre',
|
||||
'thanks': 'Teşekkürler',
|
||||
'thanksDesc':
|
||||
'Gopeed topluluğunun inşasında ve geliştirilmesinde katkıda bulunan herkese teşekkürler!',
|
||||
'thanksDesc':
|
||||
'Gopeed topluluğunun inşasında ve geliştirilmesinde katkıda bulunan herkese teşekkürler!',
|
||||
'browserExtension': 'Tarayıcı Eklentisi',
|
||||
'launchAtStartup': 'Başlangıçta Çalıştır',
|
||||
'seedConfig': 'Seed Ayarları',
|
||||
@@ -117,5 +121,20 @@ const trTR = {
|
||||
'fileSelectedSize': 'Boyut: ',
|
||||
'httpHeaderName': 'Başlık Adı',
|
||||
'httpHeaderValue': 'Başlık Değeri',
|
||||
'insertPlaceholder': 'Yer tutucu ekle',
|
||||
'placeholderYear': 'Geçerli yıl',
|
||||
'placeholderMonth': 'Geçerli ay (01-12)',
|
||||
'placeholderDay': 'Geçerli gün (01-31)',
|
||||
'placeholderDate': 'Tam tarih (YYYY-AA-GG)',
|
||||
'example': 'örn. @value',
|
||||
'downloadCategories': 'İndirme Kategorileri',
|
||||
'categoryMusic': 'Müzik',
|
||||
'categoryVideo': 'Video',
|
||||
'categoryDocument': 'Belge',
|
||||
'categoryProgram': 'Program',
|
||||
'categoryName': 'Kategori Adı',
|
||||
'categoryPath': 'Kategori Yolu',
|
||||
'builtInCategory': 'Yerleşik kategori silinemez',
|
||||
'selectCategory': 'Kategori Seç',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const ukUA = {
|
||||
'error': 'Помилка',
|
||||
'tip': 'Підказка',
|
||||
'confirm': 'Підтвердити',
|
||||
'confirmDelete': 'Ви впевнені, що хочете видалити?',
|
||||
'cancel': 'Скасувати',
|
||||
'on': 'Увімк.',
|
||||
'off': 'Вимк.',
|
||||
@@ -119,5 +120,20 @@ const ukUA = {
|
||||
'fileSelectedSize': 'Розмір: ',
|
||||
'httpHeaderName': 'Назва заголовка HTTP',
|
||||
'httpHeaderValue': 'Значення заголовка HTTP',
|
||||
'insertPlaceholder': 'Вставити заповнювач',
|
||||
'placeholderYear': 'Поточний рік',
|
||||
'placeholderMonth': 'Поточний місяць (01-12)',
|
||||
'placeholderDay': 'Поточний день (01-31)',
|
||||
'placeholderDate': 'Повна дата (РРРР-ММ-ДД)',
|
||||
'example': 'напр. @value',
|
||||
'downloadCategories': 'Категорії завантажень',
|
||||
'categoryMusic': 'Музика',
|
||||
'categoryVideo': 'Відео',
|
||||
'categoryDocument': 'Документ',
|
||||
'categoryProgram': 'Програма',
|
||||
'categoryName': 'Назва категорії',
|
||||
'categoryPath': 'Шлях категорії',
|
||||
'builtInCategory': 'Вбудовану категорію неможливо видалити',
|
||||
'selectCategory': 'Вибрати категорію',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const viVN = {
|
||||
'error': 'Lỗi',
|
||||
'tip': 'Mẹo',
|
||||
'confirm': 'Xác nhận',
|
||||
'confirmDelete': 'Bạn có chắc chắn muốn xóa?',
|
||||
'cancel': 'Hủy',
|
||||
'on': 'Bật',
|
||||
'off': 'Tắt',
|
||||
@@ -90,5 +91,20 @@ const viVN = {
|
||||
'Cảm ơn tất cả những người đóng góp đã giúp xây dựng và phát triển cộng đồng Gopeed!',
|
||||
'browserExtension': 'Tiện ích mở rộng trình duyệt',
|
||||
'skipVerifyCert': 'Bỏ qua xác thực chứng chỉ',
|
||||
'insertPlaceholder': 'Chèn placeholder',
|
||||
'placeholderYear': 'Năm hiện tại',
|
||||
'placeholderMonth': 'Tháng hiện tại (01-12)',
|
||||
'placeholderDay': 'Ngày hiện tại (01-31)',
|
||||
'placeholderDate': 'Ngày đầy đủ (YYYY-MM-DD)',
|
||||
'example': 'vd. @value',
|
||||
'downloadCategories': 'Danh mục tải xuống',
|
||||
'categoryMusic': 'Nhạc',
|
||||
'categoryVideo': 'Video',
|
||||
'categoryDocument': 'Tài liệu',
|
||||
'categoryProgram': 'Chương trình',
|
||||
'categoryName': 'Tên danh mục',
|
||||
'categoryPath': 'Đường dẫn danh mục',
|
||||
'builtInCategory': 'Không thể xóa danh mục tích hợp',
|
||||
'selectCategory': 'Chọn danh mục',
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const zhCN = {
|
||||
'error': '错误',
|
||||
'tip': '提示',
|
||||
'confirm': '确认',
|
||||
'confirmDelete': '确定要删除吗?',
|
||||
'cancel': '取消',
|
||||
'on': '开启',
|
||||
'off': '关闭',
|
||||
@@ -64,6 +65,15 @@ const zhCN = {
|
||||
'effectAfterRestart': '此配置项将在重启应用后生效',
|
||||
'developer': '开发者',
|
||||
'logDirectory': '日志目录',
|
||||
'webhook': 'Webhook 推送',
|
||||
'webhookEnable': '启用 Webhook',
|
||||
'webhookDesc': '在任务完成或失败时发送 HTTP POST 通知',
|
||||
'webhookUrlHint': '请输入 Webhook URL',
|
||||
'webhookTest': '测试',
|
||||
'webhookTestSuccess': 'Webhook 测试成功',
|
||||
'webhookTestFail': 'Webhook 测试失败',
|
||||
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
|
||||
'required': '此项为必填项',
|
||||
'show': '显示',
|
||||
'continue': '继续',
|
||||
'pause': '暂停',
|
||||
@@ -72,6 +82,8 @@ const zhCN = {
|
||||
'deleteTask': '删除 @count 个任务',
|
||||
'deleteTaskTip': '保留已下载的文件',
|
||||
'delete': '删除',
|
||||
'add': '添加',
|
||||
'edit': '编辑',
|
||||
'newVersionTitle': '发现新版本 @version',
|
||||
'newVersionUpdate': '立即更新',
|
||||
'newVersionLater': '稍后再说',
|
||||
@@ -123,5 +135,26 @@ const zhCN = {
|
||||
'login_success': '登录成功',
|
||||
'login_failed': '登录失败,请检查用户名和密码',
|
||||
'login_failed_network': '登录失败,请检查网络连接',
|
||||
'insertPlaceholder': '插入占位符',
|
||||
'placeholderYear': '当前年份',
|
||||
'placeholderMonth': '当前月份 (01-12)',
|
||||
'placeholderDay': '当前日期 (01-31)',
|
||||
'placeholderDate': '完整日期 (年-月-日)',
|
||||
'example': '例如 @value',
|
||||
'downloadCategories': '下载目录分类',
|
||||
'categoryMusic': '音乐',
|
||||
'categoryVideo': '视频',
|
||||
'categoryDocument': '文档',
|
||||
'categoryProgram': '程序',
|
||||
'categoryName': '分类名称',
|
||||
'categoryPath': '分类路径',
|
||||
'builtInCategory': '内置分类不能删除',
|
||||
'selectCategory': '选择分类',
|
||||
'githubMirror': 'GitHub 镜像',
|
||||
'githubMirrorEnable': '启用 GitHub 镜像',
|
||||
'githubMirrorDesc': '使用镜像站点加速 GitHub 内容下载',
|
||||
'githubMirrorType': '镜像类型',
|
||||
'githubMirrorUrl': '镜像地址',
|
||||
'githubMirrorUrlHint': '请输入镜像地址',
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const zhTW = {
|
||||
'error': '錯誤',
|
||||
'tip': '提示',
|
||||
'confirm': '確認',
|
||||
'confirmDelete': '確定要刪除嗎?',
|
||||
'cancel': '取消',
|
||||
'on': '開啟',
|
||||
'off': '關閉',
|
||||
@@ -116,5 +117,20 @@ const zhTW = {
|
||||
'fileSelectedSize': '大小:',
|
||||
'httpHeaderName': '標頭名稱',
|
||||
'httpHeaderValue': '標頭值',
|
||||
'insertPlaceholder': '插入佔位符',
|
||||
'placeholderYear': '當前年份',
|
||||
'placeholderMonth': '當前月份 (01-12)',
|
||||
'placeholderDay': '當前日期 (01-31)',
|
||||
'placeholderDate': '完整日期 (年-月-日)',
|
||||
'example': '例如 @value',
|
||||
'downloadCategories': '下載目錄分類',
|
||||
'categoryMusic': '音樂',
|
||||
'categoryVideo': '影片',
|
||||
'categoryDocument': '文件',
|
||||
'categoryProgram': '程式',
|
||||
'categoryName': '分類名稱',
|
||||
'categoryPath': '分類路徑',
|
||||
'builtInCategory': '內建分類無法刪除',
|
||||
'selectCategory': '選擇分類',
|
||||
}
|
||||
};
|
||||
@@ -24,6 +24,8 @@ class Args {
|
||||
|
||||
bool hidden = false;
|
||||
|
||||
Args();
|
||||
|
||||
Args.parse(List<String> args) {
|
||||
final parser = ArgParser();
|
||||
parser.addFlag(flagHidden);
|
||||
@@ -33,7 +35,22 @@ class Args {
|
||||
}
|
||||
|
||||
void main(List<String> arguments) async {
|
||||
final args = Args.parse(arguments);
|
||||
Args args;
|
||||
// Parse url scheme arguments, e.g. gopeed:?hidden=true
|
||||
// TODO: macos open url handle
|
||||
// TODO: macos updater test
|
||||
if (arguments.firstOrNull?.startsWith("gopeed:") == true) {
|
||||
try {
|
||||
final uri = Uri.parse(arguments.first);
|
||||
args = Args()..hidden = uri.queryParameters["hidden"] == "true";
|
||||
} catch (e) {
|
||||
// ignore
|
||||
args = Args.parse([]);
|
||||
}
|
||||
} else {
|
||||
args = Args.parse(arguments);
|
||||
}
|
||||
|
||||
await init(args);
|
||||
onStart();
|
||||
|
||||
|
||||
+20
-8
@@ -26,7 +26,7 @@ const _firefoxNativeHostsKey = r'Software\Mozilla\NativeMessagingHosts';
|
||||
|
||||
/// Install host binary for browser extension
|
||||
Future<void> doInstallHost() async {
|
||||
final hostPath = Util.homePathJoin(_hostExecName);
|
||||
final hostPath = await Util.homePathJoin(_hostExecName);
|
||||
await Util.installAsset('assets/exec/$_hostExecName', hostPath,
|
||||
executable: true);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ Future<bool> doCheckBrowserInstalled(Browser browser) async {
|
||||
return await _checkWindowsRegistry(r'SOFTWARE\Mozilla\Firefox') ||
|
||||
await _checkWindowsRegistry(
|
||||
r'SOFTWARE\WOW6432Node\Mozilla\Firefox') ||
|
||||
await _checkWindowsRegistry(r'SOFTWARE\BrowserWorks\Waterfox') ||
|
||||
await _checkWindowsExecutable(browser);
|
||||
}
|
||||
} else {
|
||||
@@ -62,7 +63,7 @@ Future<bool> doCheckBrowserInstalled(Browser browser) async {
|
||||
Future<bool> doCheckManifestInstalled(Browser browser) async {
|
||||
if (await checkBrowserInstalled(browser) == false) return false;
|
||||
|
||||
final manifestPath = _getManifestPath(browser);
|
||||
final manifestPath = await _getManifestPath(browser);
|
||||
if (manifestPath == null) return false;
|
||||
|
||||
if (Platform.isWindows) {
|
||||
@@ -86,7 +87,7 @@ Future<void> doInstallManifest(Browser browser) async {
|
||||
if (await checkBrowserInstalled(browser) == false) return;
|
||||
if (await checkManifestInstalled(browser)) return;
|
||||
|
||||
final manifestPath = _getManifestPath(browser)!;
|
||||
final manifestPath = (await _getManifestPath(browser))!;
|
||||
final manifestContent = await _getManifestContent(browser);
|
||||
final manifestDir = path.dirname(manifestPath);
|
||||
await Directory(manifestDir).create(recursive: true);
|
||||
@@ -144,6 +145,10 @@ List<String> _getWindowsExecutablePaths(Browser browser) {
|
||||
path.join(programFiles, 'Mozilla Firefox', 'firefox.exe'),
|
||||
if (programFilesX86 != null)
|
||||
path.join(programFilesX86, 'Mozilla Firefox', 'firefox.exe'),
|
||||
if (programFiles != null)
|
||||
path.join(programFiles, 'Waterfox', 'waterfox.exe'),
|
||||
if (programFilesX86 != null)
|
||||
path.join(programFilesX86, 'Waterfox', 'waterfox.exe'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -178,7 +183,10 @@ List<String> _getUnixExecutablePaths(Browser browser) {
|
||||
return [
|
||||
'/Applications/Firefox.app',
|
||||
'~/Applications/Firefox.app',
|
||||
'/Users/${Platform.environment['USER']}/Applications/Firefox.app'
|
||||
'/Users/${Platform.environment['USER']}/Applications/Firefox.app',
|
||||
'/Applications/Waterfox.app',
|
||||
'~/Applications/Waterfox.app',
|
||||
'/Users/${Platform.environment['USER']}/Applications/Waterfox.app'
|
||||
];
|
||||
}
|
||||
} else {
|
||||
@@ -203,17 +211,21 @@ List<String> _getUnixExecutablePaths(Browser browser) {
|
||||
'/usr/bin/firefox',
|
||||
'/snap/bin/firefox',
|
||||
'/usr/lib/firefox/firefox',
|
||||
'/opt/firefox/firefox'
|
||||
'/opt/firefox/firefox',
|
||||
'/usr/bin/waterfox',
|
||||
'/snap/bin/waterfox',
|
||||
'/usr/lib/waterfox/waterfox',
|
||||
'/opt/waterfox/waterfox'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _getManifestPath(Browser browser) {
|
||||
Future<String?> _getManifestPath(Browser browser) async {
|
||||
final manifestName =
|
||||
browser == Browser.firefox ? '$_hostName.moz.json' : '$_hostName.json';
|
||||
if (Platform.isWindows) {
|
||||
return Util.homePathJoin(manifestName);
|
||||
return await Util.homePathJoin(manifestName);
|
||||
}
|
||||
|
||||
final home =
|
||||
@@ -259,7 +271,7 @@ Future<bool> _checkWindowsRegistry(String keyPath) async {
|
||||
}
|
||||
|
||||
Future<String> _getManifestContent(Browser browser) async {
|
||||
final hostPath = Util.homePathJoin(_hostExecName);
|
||||
final hostPath = await Util.homePathJoin(_hostExecName);
|
||||
final manifest = {
|
||||
'name': _hostName,
|
||||
'description': 'Gopeed browser extension host',
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
// List of mirrors for GitHub
|
||||
const _sourceMirror = [
|
||||
// ghfast.top: https://ghfast.top/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
|
||||
["https://ghfast.top/", r"(.*)"],
|
||||
// github.moeyy.xyz: https://github.moeyy.xyz/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
|
||||
["https://github.moeyy.xyz/", r"(.*)"],
|
||||
// jsdelivr: https://fastly.jsdelivr.net/gh/ngosang/trackerslist/trackers_all.txt
|
||||
[
|
||||
"https://fastly.jsdelivr.net/gh",
|
||||
r".*raw.githubusercontent.com(/.*)(/.*)/(?:master|main)(/.*)"
|
||||
],
|
||||
];
|
||||
|
||||
// List of mirrors for GitHub release
|
||||
const _assertMirror = [
|
||||
// ghfast.top: https://ghfast.top/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
|
||||
["https://ghfast.top/", r"(.*)"],
|
||||
// github.moeyy.xyz: https://github.moeyy.xyz/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
|
||||
["https://github.moeyy.xyz/", r"(.*)"],
|
||||
];
|
||||
import '../api/model/downloader_config.dart';
|
||||
import '../app/modules/app/controllers/app_controller.dart';
|
||||
|
||||
enum MirrorType {
|
||||
githubSource,
|
||||
githubRelease,
|
||||
}
|
||||
|
||||
/// Get the configured mirrors
|
||||
List<GithubMirror> _getConfiguredMirrors() {
|
||||
try {
|
||||
final appController = Get.find<AppController>();
|
||||
final config = appController.downloaderConfig.value.extra.githubMirror;
|
||||
|
||||
if (!config.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return configured mirrors (filtering not deleted ones)
|
||||
return config.mirrors.where((m) => !m.isDeleted).toList();
|
||||
} catch (e) {
|
||||
// Fallback to empty list if controller not found
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto detect the best mirror for the given [rawUrl] and [type]
|
||||
Future<String> githubAutoMirror(String rawUrl, MirrorType type) async {
|
||||
final mirrorUrls = githubMirrorUrls(rawUrl, type);
|
||||
|
||||
// If no mirrors, return original URL
|
||||
if (mirrorUrls.isEmpty) {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
// Ping all mirrors and get the fastest one
|
||||
final pingResult = await Future.wait(mirrorUrls.map((e) async {
|
||||
final client = Dio()
|
||||
@@ -60,20 +66,39 @@ Future<String> githubAutoMirror(String rawUrl, MirrorType type) async {
|
||||
}
|
||||
|
||||
List<String> githubMirrorUrls(String rawUrl, MirrorType type) {
|
||||
final mirrors = switch (type) {
|
||||
MirrorType.githubSource => _sourceMirror,
|
||||
MirrorType.githubRelease => _assertMirror,
|
||||
};
|
||||
final mirrors = _getConfiguredMirrors();
|
||||
|
||||
final ret = <String>[];
|
||||
for (final cdn in mirrors) {
|
||||
final reg = RegExp(cdn[1]);
|
||||
final match = reg.firstMatch(rawUrl.toString());
|
||||
var matchStr = "";
|
||||
for (var i = 1; i <= match!.groupCount; i++) {
|
||||
matchStr += match.group(i)!;
|
||||
for (final mirror in mirrors) {
|
||||
String? mirrorUrl;
|
||||
|
||||
if (mirror.type == GithubMirrorType.jsdelivr) {
|
||||
// jsdelivr only supports source files
|
||||
if (type != MirrorType.githubSource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform: https://raw.githubusercontent.com/user/repo/master/path
|
||||
// To: https://fastly.jsdelivr.net/gh/user/repo/path
|
||||
final jsDelivrPattern = RegExp(
|
||||
r'.*raw\.githubusercontent\.com(/[^/]+)(/[^/]+)/(?:master|main)(/.*)');
|
||||
final match = jsDelivrPattern.firstMatch(rawUrl);
|
||||
if (match != null) {
|
||||
final user = match.group(1);
|
||||
final repo = match.group(2);
|
||||
final path = match.group(3);
|
||||
mirrorUrl = '${mirror.url}$user$repo$path';
|
||||
}
|
||||
} else if (mirror.type == GithubMirrorType.ghProxy) {
|
||||
// gh-proxy supports both source and release
|
||||
// Simply prepend the mirror URL to the original URL
|
||||
mirrorUrl = '${mirror.url}/$rawUrl';
|
||||
}
|
||||
|
||||
if (mirrorUrl != null) {
|
||||
ret.add(mirrorUrl);
|
||||
}
|
||||
ret.add("${cdn[0]}$matchStr");
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ final _updaterBin = "updater${Util.isWindows() ? ".exe" : ""}";
|
||||
|
||||
Future<void> installUpdater() async {
|
||||
await Util.installAsset(
|
||||
'assets/exec/$_updaterBin', Util.homePathJoin(_updaterBin),
|
||||
'assets/exec/$_updaterBin', await Util.homePathJoin(_updaterBin),
|
||||
executable: true);
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ Future<void> _update(String version, Function(int, int) onProgress) async {
|
||||
case Channel.linuxFlathub:
|
||||
case Channel.linuxSnap:
|
||||
case Channel.linuxDeb:
|
||||
final updaterPath = Util.homePathJoin(_updaterBin);
|
||||
final updaterPath = await Util.homePathJoin(_updaterBin);
|
||||
// Check the updater binary is exists
|
||||
if (!await File(updaterPath).exists()) {
|
||||
await launchUrl(
|
||||
@@ -363,6 +363,8 @@ Future<void> _update(String version, Function(int, int) onProgress) async {
|
||||
Update channel
|
||||
-asset string
|
||||
Path to the package asset
|
||||
-exeDir string
|
||||
Directory of the entry executable
|
||||
-log string
|
||||
Log file path
|
||||
*/
|
||||
@@ -373,6 +375,8 @@ Future<void> _update(String version, Function(int, int) onProgress) async {
|
||||
_channel!.name,
|
||||
"-asset",
|
||||
newVersionAssetPath,
|
||||
"-exeDir",
|
||||
path.dirname(Platform.resolvedExecutable),
|
||||
"-log",
|
||||
path.join(logsDir(), "updater.log")
|
||||
]);
|
||||
|
||||
@@ -157,10 +157,15 @@ class Util {
|
||||
};
|
||||
}
|
||||
|
||||
static String homePathJoin(String fileName) {
|
||||
final execPath = Platform.resolvedExecutable;
|
||||
final execDir = path.dirname(execPath);
|
||||
return path.join(execDir, fileName);
|
||||
static Future<String> homePathJoin(String fileName) async {
|
||||
if (Util.isWindows()) {
|
||||
final execPath = Platform.resolvedExecutable;
|
||||
final execDir = path.dirname(execPath);
|
||||
return path.join(execDir, fileName);
|
||||
}
|
||||
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return path.join(dir.path, fileName);
|
||||
}
|
||||
|
||||
static Future<void> installAsset(String assetPath, String targetPath,
|
||||
|
||||
+10
-2
@@ -286,6 +286,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dart_ipc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dart_ipc
|
||||
sha256: "6cad558cda5304017c1f581df4c96fd4f8e4ee212aae7bfa4357716236faa9ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1414,10 +1422,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f"
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.3"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -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.8.1+1
|
||||
version: 1.8.3+1
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
@@ -82,6 +82,7 @@ dependencies:
|
||||
url: https://github.com/hui-z/flutter_install_plugin.git
|
||||
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
|
||||
flutter_svg: ^2.0.17
|
||||
dart_ipc: ^1.0.1
|
||||
dependency_overrides:
|
||||
permission_handler_windows:
|
||||
git:
|
||||
|
||||
Reference in new issue
Block a user