mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 04:43:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2321d686 | ||
|
|
daa8c39dad | ||
|
|
78e23217f9 | ||
|
|
61f62ec39a | ||
|
|
5d76d062ad | ||
|
|
fc489b6b97 | ||
|
|
a5cd53f94c | ||
|
|
847e88881d | ||
|
|
3d20e818e5 | ||
|
|
09f7c88ed3 | ||
|
|
f7b26753c1 | ||
|
|
9704d4af27 | ||
|
|
1907f404ac | ||
|
|
72c5ba7d20 | ||
|
|
5b0254f7c0 | ||
|
|
d36b345f92 | ||
|
|
bc5c818eae | ||
|
|
98bd3305b7 | ||
|
|
3f38848051 | ||
|
|
82d7f8ec81 |
No files matched your search
+1
-1
@@ -12,4 +12,4 @@
|
|||||||
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
# github: monkeyWie
|
# github: monkeyWie
|
||||||
ko_fi: gopeed
|
ko_fi: gopeed
|
||||||
custom: https://docs.gopeed.com/donate.html
|
custom: https://gopeed.com/docs/donate
|
||||||
@@ -14,7 +14,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.38.7"
|
FLUTTER_VERSION: "3.41.2"
|
||||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
|
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
|
||||||
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
|
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
uses: shogo82148/actions-upload-release-asset@v1.9.2
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||||
asset_path: ui/flutter/build/windows/Output/*
|
asset_path: ui/flutter/build/windows/Output/*
|
||||||
@@ -324,7 +324,7 @@ jobs:
|
|||||||
channel: master
|
channel: master
|
||||||
- run: |
|
- run: |
|
||||||
sudo apt update -y
|
sudo apt update -y
|
||||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace libkeybinder-3.0-dev
|
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
|
||||||
arch=$(uname -m)
|
arch=$(uname -m)
|
||||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||||
chmod +x appimagetool
|
chmod +x appimagetool
|
||||||
@@ -339,18 +339,11 @@ jobs:
|
|||||||
|
|
||||||
cd ui/flutter
|
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)
|
RPM_ARCH=$(uname -m)
|
||||||
|
|
||||||
mkdir -p linux/packaging/rpm
|
mkdir -p linux/packaging/rpm
|
||||||
cat << EOF > linux/packaging/rpm/make_config.yaml
|
cat << EOF > linux/packaging/rpm/make_config.yaml
|
||||||
display_name: Gopeed
|
display_name: Gopeed
|
||||||
icon: $ICON_PATH
|
icon: assets/icon/icon.svg
|
||||||
summary: High speed downloader.
|
summary: High speed downloader.
|
||||||
description: A high speed downloader that supports all platforms.
|
description: A high speed downloader that supports all platforms.
|
||||||
group: Applications/Internet
|
group: Applications/Internet
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.38.7"
|
FLUTTER_VERSION: "3.41.2"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# lint:
|
# lint:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 Introduction
|
## 🚀 Introduction
|
||||||
|
|
||||||
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://docs.gopeed.com/dev-api.html) or installation and development of [extensions](https://docs.gopeed.com/dev-extension.html).
|
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet, ED2K) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://gopeed.com/docs/dev-api) or installation and development of [extensions](https://gopeed.com/docs/dev-extension).
|
||||||
|
|
||||||
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://docs.gopeed.com)
|
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ Download
|
## ⬇️ Download
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
More about installation, please refer to [Installation](https://docs.gopeed.com/install.html)
|
More about installation, please refer to [Installation](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ Command tool
|
### 🛠️ Command tool
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ Follow our WeChat Official Account to get the latest updates and news.
|
|||||||
|
|
||||||
## 💝 Donate
|
## 💝 Donate
|
||||||
|
|
||||||
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
|
If you like this project, please consider [donating](https://gopeed.com/docs/donate) to support the development of this project, thank you!
|
||||||
|
|
||||||
## 🖼️ Showcase
|
## 🖼️ Showcase
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 はじめに
|
## 🚀 はじめに
|
||||||
|
|
||||||
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://docs.gopeed.com/dev-api.html)との連動や[拡張機能](https://docs.gopeed.com/dev-extension.html)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
|
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet、ED2K) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://gopeed.com/docs/dev-api)との連動や[拡張機能](https://gopeed.com/docs/dev-extension)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
|
||||||
|
|
||||||
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com)
|
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ インストール
|
## ⬇️ インストール
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
インストールについての詳細は、[インストール](https://docs.gopeed.com/install.html)を参照してください。
|
インストールについての詳細は、[インストール](https://gopeed.com/docs/install)を参照してください。
|
||||||
|
|
||||||
### 🛠️ コマンドツール
|
### 🛠️ コマンドツール
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
|
|
||||||
## 💝 寄付
|
## 💝 寄付
|
||||||
|
|
||||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください!
|
||||||
|
|
||||||
## 🖼️ ショーケース
|
## 🖼️ ショーケース
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 Giới thiệu
|
## 🚀 Giới thiệu
|
||||||
|
|
||||||
Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://docs.gopeed.com/dev-api.html) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://docs.gopeed.com/dev-extension.html).
|
Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet, ED2K) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://gopeed.com/docs/dev-api) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://gopeed.com/docs/dev-extension).
|
||||||
|
|
||||||
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://docs.gopeed.com)
|
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ Tải về
|
## ⬇️ Tải về
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://docs.gopeed.com/install.html)
|
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ Công cụ lệnh
|
### 🛠️ Công cụ lệnh
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ Theo dõi tài khoản chính thức để nhận các cập nhật và tin tứ
|
|||||||
|
|
||||||
## 💝 Quyên góp
|
## 💝 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!
|
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://gopeed.com/docs/donate) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
||||||
|
|
||||||
## 🖼️ Trưng bày
|
## 🖼️ Trưng bày
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/zh/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 介绍
|
## 🚀 介绍
|
||||||
|
|
||||||
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://docs.gopeed.com/zh/dev-api.html)或者安装和开发[扩展](https://docs.gopeed.com/zh/dev-extension.html)来实现更多的功能。
|
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet、ED2K)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://gopeed.com/docs/dev-api)或者安装和开发[扩展](https://gopeed.com/docs/dev-extension)来实现更多的功能。
|
||||||
|
|
||||||
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://docs.gopeed.com/zh/)
|
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ 下载
|
## ⬇️ 下载
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
更多关于安装的内容请参考[安装文档](https://docs.gopeed.com/zh/install.html)
|
更多关于安装的内容请参考[安装文档](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ 命令行工具
|
### 🛠️ 命令行工具
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
|||||||
|
|
||||||
## 💝 赞助
|
## 💝 赞助
|
||||||
|
|
||||||
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
|
如果觉得项目对你有帮助,请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展,非常感谢!
|
||||||
|
|
||||||
## 🖼️ 界面展示
|
## 🖼️ 界面展示
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 簡介
|
## 🚀 簡介
|
||||||
|
|
||||||
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet)協定,同時支援所有的平台。
|
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet、ED2K)協定,同時支援所有的平台。
|
||||||
|
|
||||||
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://docs.gopeed.com/zh/)
|
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ 下載
|
## ⬇️ 下載
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
更多關於安裝的內容請參考[安裝文檔](https://docs.gopeed.com/zh/install.html)
|
更多關於安裝的內容請參考[安裝文檔](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ 使用 CLI 安裝
|
### 🛠️ 使用 CLI 安裝
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
## 💝 贊助
|
## 💝 贊助
|
||||||
|
|
||||||
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
|
如果你認為該項目對你有所幫助,請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展,謝謝!
|
||||||
|
|
||||||
## 🖼️ 軟體介面
|
## 🖼️ 軟體介面
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" width="1200" height="400">
|
||||||
|
<rect width="1200" height="400" fill="#040605"/>
|
||||||
|
<g fill="#5dc76c">
|
||||||
|
<rect x="-100" y="50" width="150" height="2" opacity="0.6">
|
||||||
|
<animate attributeName="x" from="-200" to="1300" dur="0.8s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-200" y="150" width="200" height="1" opacity="0.4">
|
||||||
|
<animate attributeName="x" from="-300" to="1300" dur="1.2s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-50" y="250" width="180" height="3" opacity="0.8">
|
||||||
|
<animate attributeName="x" from="-200" to="1300" dur="0.6s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-150" y="350" width="220" height="2" opacity="0.5">
|
||||||
|
<animate attributeName="x" from="-300" to="1300" dur="1s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-100" y="100" width="100" height="1" opacity="0.7">
|
||||||
|
<animate attributeName="x" from="-150" to="1300" dur="0.9s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-250" y="300" width="250" height="2" opacity="0.3">
|
||||||
|
<animate attributeName="x" from="-400" to="1300" dur="1.5s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="logoGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(600, 160)">
|
||||||
|
<!-- Floating animation -->
|
||||||
|
<g>
|
||||||
|
<animateTransform attributeName="transform" type="translate" values="0,-8; 0,8; 0,-8" dur="3s" repeatCount="indefinite" />
|
||||||
|
|
||||||
|
<!-- Pulsing glow behind logo -->
|
||||||
|
<circle cx="0" cy="0" r="45" fill="#5dc76c" filter="url(#logoGlow)" opacity="0.3">
|
||||||
|
<animate attributeName="opacity" values="0.1; 0.6; 0.1" dur="2s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="r" values="40; 65; 40" dur="2s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- Breathing scale animation -->
|
||||||
|
<g>
|
||||||
|
<animateTransform attributeName="transform" type="scale" values="0.95; 1.05; 0.95" dur="2s" repeatCount="indefinite" />
|
||||||
|
<g transform="scale(0.12) translate(-512, -512)">
|
||||||
|
<path style="opacity:0.998" fill="#5dc76c" d="M 951.5,763.5 C 940.739,755.276 929.405,747.776 917.5,741C 908.477,736.319 899.143,732.485 889.5,729.5C 885.844,625.779 840.844,545.613 754.5,489C 674.802,441.453 590.135,430.12 500.5,455C 419.755,480.605 359.588,530.771 320,605.5C 278.585,565.816 230.752,555.649 176.5,575C 131.948,594.935 106.448,629.102 100,677.5C 99.3333,685.833 99.3333,694.167 100,702.5C 102.081,714.824 105.081,726.824 109,738.5C 95.4882,745.492 82.9882,753.826 71.5,763.5C 37.7121,705.639 16.5454,643.306 8,576.5C -9.41698,416.759 35.9164,278.093 144,160.5C 244.661,58.2827 367.161,5.94939 511.5,3.5C 678.975,7.48829 814.142,74.8216 917,205.5C 1011.33,336.426 1040,480.759 1003,638.5C 991.564,682.671 974.397,724.337 951.5,763.5 Z"/>
|
||||||
|
<path style="opacity:0.999" fill="#fefefe" d="M 951.5,763.5 C 949.28,768.272 946.78,772.938 944,777.5C 938.305,786.913 932.138,795.913 925.5,804.5C 915.954,796.888 905.288,791.055 893.5,787C 878.282,782.958 863.115,778.791 848,774.5C 854.013,716.891 841.346,663.891 810,615.5C 768.341,553.228 711.174,512.395 638.5,493C 560.311,479.566 490.311,497.566 428.5,547C 411.963,560.536 397.13,575.703 384,592.5C 366.745,616.923 348.911,640.923 330.5,664.5C 310.135,651.302 290.469,637.135 271.5,622C 238.448,602.307 206.782,604.307 176.5,628C 159.375,642.752 148.875,661.252 145,683.5C 143.968,692.716 144.968,701.716 148,710.5C 154.334,725.339 160.167,740.339 165.5,755.5C 138.118,764.688 114.451,779.688 94.5,800.5C 85.653,788.968 77.9863,776.635 71.5,763.5C 82.9882,753.826 95.4882,745.492 109,738.5C 105.081,726.824 102.081,714.824 100,702.5C 99.3333,694.167 99.3333,685.833 100,677.5C 106.448,629.102 131.948,594.935 176.5,575C 230.752,555.649 278.585,565.816 320,605.5C 359.588,530.771 419.755,480.605 500.5,455C 590.135,430.12 674.802,441.453 754.5,489C 840.844,545.613 885.844,625.779 889.5,729.5C 899.143,732.485 908.477,736.319 917.5,741C 929.405,747.776 940.739,755.276 951.5,763.5 Z"/>
|
||||||
|
<path style="opacity:0.998" fill="#5dc76c" d="M 925.5,804.5 C 850.791,908.608 750.791,975.775 625.5,1006C 609.308,1009.7 592.975,1012.53 576.5,1014.5C 615.708,969.938 655.042,925.438 694.5,881C 664.168,880.5 633.835,880.333 603.5,880.5C 603.5,811.167 603.5,741.833 603.5,672.5C 542.167,672.5 480.833,672.5 419.5,672.5C 419.5,741.5 419.5,810.5 419.5,879.5C 388.493,879.167 357.493,879.5 326.5,880.5C 326.624,881.107 326.957,881.44 327.5,881.5C 366.805,925.808 406.138,970.141 445.5,1014.5C 318.63,996.317 212.796,939.651 128,844.5C 115.85,830.525 104.683,815.859 94.5,800.5C 114.451,779.688 138.118,764.688 165.5,755.5C 160.167,740.339 154.334,725.339 148,710.5C 144.968,701.716 143.968,692.716 145,683.5C 148.875,661.252 159.375,642.752 176.5,628C 206.782,604.307 238.448,602.307 271.5,622C 290.469,637.135 310.135,651.302 330.5,664.5C 348.911,640.923 366.745,616.923 384,592.5C 397.13,575.703 411.963,560.536 428.5,547C 490.311,497.566 560.311,479.566 638.5,493C 711.174,512.395 768.341,553.228 810,615.5C 841.346,663.891 854.013,716.891 848,774.5C 863.115,778.791 878.282,782.958 893.5,787C 905.288,791.055 915.954,796.888 925.5,804.5 Z"/>
|
||||||
|
<path style="opacity:0.999" fill="#fefefe" d="M 576.5,1014.5 C 532.836,1019.82 489.169,1019.82 445.5,1014.5C 406.138,970.141 366.805,925.808 327.5,881.5C 358.167,880.833 388.833,880.167 419.5,879.5C 419.5,810.5 419.5,741.5 419.5,672.5C 480.833,672.5 542.167,672.5 603.5,672.5C 603.5,741.833 603.5,811.167 603.5,880.5C 633.835,880.333 664.168,880.5 694.5,881C 655.042,925.438 615.708,969.938 576.5,1014.5 Z"/>
|
||||||
|
<path style="opacity:1" fill="#ade2b5" d="M 419.5,879.5 C 388.833,880.167 358.167,880.833 327.5,881.5C 326.957,881.44 326.624,881.107 326.5,880.5C 357.493,879.5 388.493,879.167 419.5,879.5 Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="300" font-family="sans-serif" font-size="72" font-style="italic" font-weight="900" text-anchor="middle" fill="#fff" letter-spacing="8">GOPEED</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 1.2 MiB |
@@ -45,6 +45,7 @@ require (
|
|||||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||||
github.com/minio/minlz v1.0.1 // indirect
|
github.com/minio/minlz v1.0.1 // indirect
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 // indirect
|
||||||
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
|
|||||||
@@ -386,6 +386,10 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5 h1:tEOucyKJzFsCz5Gr41jFHj8i2g1zemTyS4uyErJgFHc=
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 h1:88kRsNwkDKPA4NzRUWdOoIL7SyMNDwtmAxzShKBvKTI=
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
|
||||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
|
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
|
||||||
|
|||||||
@@ -127,6 +127,21 @@ type FetcherManager interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StatefulFetcherManager is an optional extension for protocols that keep
|
||||||
|
// shared client state outside individual task fetchers.
|
||||||
|
type StatefulFetcherManager interface {
|
||||||
|
SetStateStore(store ProtocolStateStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtocolStateStore persists shared protocol state for a fetcher manager.
|
||||||
|
// Downloader provides the concrete storage backend, while the protocol decides
|
||||||
|
// when state should be loaded or flushed.
|
||||||
|
type ProtocolStateStore interface {
|
||||||
|
Load(v any) (bool, error)
|
||||||
|
Save(v any) error
|
||||||
|
Delete() error
|
||||||
|
}
|
||||||
|
|
||||||
type DefaultFetcher struct {
|
type DefaultFetcher struct {
|
||||||
Ctl *controller.Controller
|
Ctl *controller.Controller
|
||||||
Meta *FetcherMeta
|
Meta *FetcherMeta
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ed2k
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultServerList = "45.82.80.155:5687,176.123.5.89:4725,85.121.5.137:4232,176.123.2.239:4232,145.239.2.134:4661,91.208.162.87:4232,37.15.61.236:4232"
|
||||||
|
defaultServerMet = "ed2k://|serverlist|http://upd.emule-security.org/server.met|/"
|
||||||
|
defaultNodesDat = "https://upd.emule-security.org/nodes.dat"
|
||||||
|
)
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
ListenPort int `json:"listenPort"`
|
||||||
|
UDPPort int `json:"udpPort"`
|
||||||
|
ServerAddr string `json:"serverAddr"`
|
||||||
|
ServerMet string `json:"serverMet"`
|
||||||
|
NodesDat string `json:"nodesDat"`
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
package ed2k
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testLink = "ed2k://|file|cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso|4630972416|8867C5E54405FF9452225B66EFEE690A|/"
|
||||||
|
|
||||||
|
func TestFetcher_Resolve(t *testing.T) {
|
||||||
|
f := (&FetcherManager{}).Build()
|
||||||
|
f.Setup(controller.NewController())
|
||||||
|
|
||||||
|
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := f.Meta()
|
||||||
|
if meta.Res == nil {
|
||||||
|
t.Fatal("Resolve() resource is nil")
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Hash, "8867C5E54405FF9452225B66EFEE690A"; got != want {
|
||||||
|
t.Fatalf("Resolve() hash = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Size, int64(4630972416); got != want {
|
||||||
|
t.Fatalf("Resolve() size = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Files[0].Name, "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"; got != want {
|
||||||
|
t.Fatalf("Resolve() name = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherManager_ParseName(t *testing.T) {
|
||||||
|
got := (&FetcherManager{}).ParseName(testLink)
|
||||||
|
want := "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ParseName() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -618,6 +618,7 @@ func (f *Fetcher) doStart() error {
|
|||||||
for _, conn := range f.connections {
|
for _, conn := range f.connections {
|
||||||
// Reset connections that can be retried
|
// Reset connections that can be retried
|
||||||
if !conn.Completed && conn.State != connCompleted {
|
if !conn.Completed && conn.State != connCompleted {
|
||||||
|
f.resetConnectionForRestart(conn)
|
||||||
conn.State = connNotStarted
|
conn.State = connNotStarted
|
||||||
conn.failed = false
|
conn.failed = false
|
||||||
conn.retryTimes = 0
|
conn.retryTimes = 0
|
||||||
@@ -1413,6 +1414,27 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
||||||
|
if f.meta.Res.Range {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without range support a new request always starts from byte 0,
|
||||||
|
// so pause/retry must restart instead of continuing from the old offset.
|
||||||
|
if conn.Chunk == nil {
|
||||||
|
conn.Chunk = newChunk(0, 0)
|
||||||
|
} else {
|
||||||
|
conn.Chunk.Begin = 0
|
||||||
|
conn.Chunk.End = 0
|
||||||
|
conn.Chunk.Downloaded = 0
|
||||||
|
}
|
||||||
|
conn.Downloaded = 0
|
||||||
|
conn.Completed = false
|
||||||
|
conn.speed = 0
|
||||||
|
conn.lastSpeedCheck = 0
|
||||||
|
conn.lastSpeedDownload = 0
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fetcher) resumeConnections() {
|
func (f *Fetcher) resumeConnections() {
|
||||||
// Collect connections to resume while holding the lock
|
// Collect connections to resume while holding the lock
|
||||||
var toResume []*connection
|
var toResume []*connection
|
||||||
@@ -1436,6 +1458,7 @@ func (f *Fetcher) resumeConnections() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
f.resetConnectionForRestart(conn)
|
||||||
// Reset the connection state for resume
|
// Reset the connection state for resume
|
||||||
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
||||||
conn.State = connNotStarted
|
conn.State = connNotStarted
|
||||||
@@ -1475,7 +1498,16 @@ func (f *Fetcher) onDownloadComplete() {
|
|||||||
// Check if all chunks are complete (no remaining bytes)
|
// Check if all chunks are complete (no remaining bytes)
|
||||||
allChunksComplete := true
|
allChunksComplete := true
|
||||||
for _, conn := range f.connections {
|
for _, conn := range f.connections {
|
||||||
if conn.Chunk != nil && conn.Chunk.remain() > 0 && !conn.Completed && conn.State != connCompleted {
|
needsMoreData := false
|
||||||
|
if f.meta.Res.Range {
|
||||||
|
needsMoreData = conn.Chunk != nil && conn.Chunk.remain() > 0
|
||||||
|
} else if f.meta.Res.Size > 0 {
|
||||||
|
needsMoreData = conn.Downloaded < f.meta.Res.Size
|
||||||
|
} else {
|
||||||
|
needsMoreData = !conn.Completed && conn.State != connCompleted
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsMoreData && !conn.Completed && conn.State != connCompleted {
|
||||||
// This connection has remaining work and isn't done
|
// This connection has remaining work and isn't done
|
||||||
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
|
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
|
||||||
if conn.State == connFailed && conn.failed {
|
if conn.State == connFailed && conn.failed {
|
||||||
|
|||||||
@@ -299,6 +299,53 @@ func TestFetcher_DownloadContinue(t *testing.T) {
|
|||||||
downloadContinue(listener, 16, t)
|
downloadContinue(listener, 16, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetcher_DownloadContinue_NoRangeRestart(t *testing.T) {
|
||||||
|
listener := test.StartTestNoRangeSlowServer(time.Millisecond)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
fetcher := downloadReady(listener, 4, t)
|
||||||
|
if err := fetcher.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
stats := fetcher.Stats().(*http.Stats)
|
||||||
|
if len(stats.Connections) != 1 {
|
||||||
|
t.Fatalf("expected a single non-range connection, got %d", len(stats.Connections))
|
||||||
|
}
|
||||||
|
if stats.Connections[0].Downloaded <= 0 || stats.Connections[0].Downloaded >= test.BuildSize {
|
||||||
|
t.Fatalf("expected partial download before pause, got %d", stats.Connections[0].Downloaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fetcher.Pause(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := fetcher.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := fetcher.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalStats := fetcher.Stats().(*http.Stats)
|
||||||
|
if len(finalStats.Connections) != 1 {
|
||||||
|
t.Fatalf("expected a single non-range connection after resume, got %d", len(finalStats.Connections))
|
||||||
|
}
|
||||||
|
if finalStats.Connections[0].Downloaded != test.BuildSize {
|
||||||
|
t.Fatalf("downloaded bytes should restart cleanly: got %d, want %d", finalStats.Connections[0].Downloaded, test.BuildSize)
|
||||||
|
}
|
||||||
|
if total := fetcher.Progress().TotalDownloaded(); total != test.BuildSize {
|
||||||
|
t.Fatalf("progress total = %d, want %d", total, test.BuildSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := test.FileMd5(test.BuildFile)
|
||||||
|
got := test.FileMd5(test.DownloadFile)
|
||||||
|
if want != got {
|
||||||
|
t.Errorf("Download() got = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetcher_DownloadChunked(t *testing.T) {
|
func TestFetcher_DownloadChunked(t *testing.T) {
|
||||||
listener := test.StartTestCustomServer()
|
listener := test.StartTestCustomServer()
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
|||||||
@@ -335,6 +335,43 @@ func StartTestOneTimeServer() net.Listener {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartTestNoRangeSlowServer creates a server that always returns the full file
|
||||||
|
// with Content-Length but does not support Range requests.
|
||||||
|
func StartTestNoRangeSlowServer(delayPerChunk time.Duration) net.Listener {
|
||||||
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||||
|
|
||||||
|
file, err := os.Open(BuildFile)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 256*1024)
|
||||||
|
for !sl.isShutdown {
|
||||||
|
n, readErr := file.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, writeErr := writer.Write(buf[:n]); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if flusher, ok := writer.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
if delayPerChunk > 0 {
|
||||||
|
time.Sleep(delayPerChunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs.
|
// StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs.
|
||||||
// The original URL redirects to a temporary URL that expires after a specified number of requests.
|
// The original URL redirects to a temporary URL that expires after a specified number of requests.
|
||||||
// When the temporary URL expires (returns 403), the client should retry with the original URL
|
// When the temporary URL expires (returns 403), the client should retry with the original URL
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const (
|
|||||||
bucketTask = "task"
|
bucketTask = "task"
|
||||||
// task download data bucket
|
// task download data bucket
|
||||||
bucketSave = "save"
|
bucketSave = "save"
|
||||||
|
// protocol-level shared client state bucket
|
||||||
|
bucketProtocolState = "protocol_state"
|
||||||
// downloader config bucket
|
// downloader config bucket
|
||||||
bucketConfig = "config"
|
bucketConfig = "config"
|
||||||
// downloader extension bucket
|
// downloader extension bucket
|
||||||
@@ -141,7 +143,7 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
|||||||
|
|
||||||
func (d *Downloader) Setup() error {
|
func (d *Downloader) Setup() error {
|
||||||
// setup storage
|
// setup storage
|
||||||
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// load config from storage
|
// load config from storage
|
||||||
@@ -165,6 +167,12 @@ func (d *Downloader) Setup() error {
|
|||||||
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
|
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
|
||||||
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
|
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
|
||||||
}
|
}
|
||||||
|
if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {
|
||||||
|
sfm.SetStateStore(&protocolStateStore{
|
||||||
|
storage: d.storage,
|
||||||
|
protocol: protocol,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// load tasks from storage
|
// load tasks from storage
|
||||||
@@ -358,10 +366,17 @@ func (d *Downloader) saveTask(task *Task) error {
|
|||||||
d.Logger.Error().Stack().Err(err).Msgf("serialize fetcher failed: %s", task.ID)
|
d.Logger.Error().Stack().Err(err).Msgf("serialize fetcher failed: %s", task.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if data != nil {
|
||||||
if err := d.storage.Put(bucketSave, task.ID, data); err != nil {
|
if err := d.storage.Put(bucketSave, task.ID, data); err != nil {
|
||||||
d.Logger.Error().Stack().Err(err).Msgf("persist fetcher failed: %s", task.ID)
|
d.Logger.Error().Stack().Err(err).Msgf("persist fetcher failed: %s", task.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if err := d.storage.Delete(bucketSave, task.ID); err != nil {
|
||||||
|
d.Logger.Error().Stack().Err(err).Msgf("clear fetcher state failed: %s", task.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := d.storage.Put(bucketTask, task.ID, task); err != nil {
|
if err := d.storage.Put(bucketTask, task.ID, task); err != nil {
|
||||||
d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID)
|
d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID)
|
||||||
return err
|
return err
|
||||||
@@ -820,6 +835,26 @@ func (d *Downloader) Clear() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type protocolStateStore struct {
|
||||||
|
storage Storage
|
||||||
|
protocol string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *protocolStateStore) Load(v any) (bool, error) {
|
||||||
|
return s.storage.Get(bucketProtocolState, s.protocol, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *protocolStateStore) Save(v any) error {
|
||||||
|
if v == nil {
|
||||||
|
return s.Delete()
|
||||||
|
}
|
||||||
|
return s.storage.Put(bucketProtocolState, s.protocol, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *protocolStateStore) Delete() error {
|
||||||
|
return s.storage.Delete(bucketProtocolState, s.protocol)
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Downloader) Listener(fn Listener) {
|
func (d *Downloader) Listener(fn Listener) {
|
||||||
d.listener = fn
|
d.listener = fn
|
||||||
}
|
}
|
||||||
@@ -1186,7 +1221,7 @@ func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) doStart(task *Task) (err error) {
|
func (d *Downloader) doStart(task *Task) (err error) {
|
||||||
var isCreate bool
|
var needCreate bool
|
||||||
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
|
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
|
||||||
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
|
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
|
||||||
isReturn = true
|
isReturn = true
|
||||||
@@ -1198,7 +1233,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
|||||||
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
|
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isCreate = task.Status == base.DownloadStatusReady
|
needCreate = !task.IsCreated
|
||||||
task.updateStatus(base.DownloadStatusRunning)
|
task.updateStatus(base.DownloadStatusRunning)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1224,7 +1259,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
|||||||
task.Meta.Res = task.fetcher.Meta().Res
|
task.Meta.Res = task.fetcher.Meta().Res
|
||||||
}
|
}
|
||||||
|
|
||||||
if isCreate {
|
if needCreate {
|
||||||
if task.fetcherManager.AutoRename() {
|
if task.fetcherManager.AutoRename() {
|
||||||
d.checkDuplicateLock.Lock()
|
d.checkDuplicateLock.Lock()
|
||||||
defer d.checkDuplicateLock.Unlock()
|
defer d.checkDuplicateLock.Unlock()
|
||||||
@@ -1248,10 +1283,9 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
|||||||
task.Meta.Opts.Name = newName
|
task.Meta.Opts.Name = newName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
task.IsCreated = true
|
||||||
task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
|
task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
task.Progress.Speed = 0
|
task.Progress.Speed = 0
|
||||||
task.timer.Start()
|
task.timer.Start()
|
||||||
if err := task.fetcher.Start(); err != nil {
|
if err := task.fetcher.Start(); err != nil {
|
||||||
@@ -1300,9 +1334,15 @@ func (d *Downloader) doPause(task *Task) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if task.fetcherManager != nil && task.fetcher != nil {
|
||||||
|
if err := d.saveTask(task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
|
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
d.emit(EventKeyPause, task)
|
d.emit(EventKeyPause, task)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
"github.com/GopeedLab/gopeed/internal/protocol/bt"
|
"github.com/GopeedLab/gopeed/internal/protocol/bt"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/protocol/ed2k"
|
||||||
"github.com/GopeedLab/gopeed/internal/protocol/http"
|
"github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
@@ -26,6 +27,7 @@ type Task struct {
|
|||||||
Status base.Status `json:"status"`
|
Status base.Status `json:"status"`
|
||||||
Uploading bool `json:"uploading"`
|
Uploading bool `json:"uploading"`
|
||||||
Progress *Progress `json:"progress"`
|
Progress *Progress `json:"progress"`
|
||||||
|
IsCreated bool `json:"isCreated"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ func NewTask() *Task {
|
|||||||
Status: base.DownloadStatusReady,
|
Status: base.DownloadStatusReady,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
|
IsCreated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +113,14 @@ func (t *Task) updateUploadSpeed(downloaded int64, usedTime float64) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 {
|
func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 {
|
||||||
|
if usedTime <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if downloaded < 0 {
|
||||||
|
*speedArr = (*speedArr)[:0]
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
*speedArr = append(*speedArr, downloaded)
|
*speedArr = append(*speedArr, downloaded)
|
||||||
// Record last 5 seconds of download speed to calculate the average speed
|
// Record last 5 seconds of download speed to calculate the average speed
|
||||||
if len(*speedArr) > int(5.0/usedTime) {
|
if len(*speedArr) > int(5.0/usedTime) {
|
||||||
@@ -156,6 +167,7 @@ func (cfg *DownloaderConfig) Init() *DownloaderConfig {
|
|||||||
cfg.FetchManagers = []fetcher.FetcherManager{
|
cfg.FetchManagers = []fetcher.FetcherManager{
|
||||||
new(http.FetcherManager),
|
new(http.FetcherManager),
|
||||||
new(bt.FetcherManager),
|
new(bt.FetcherManager),
|
||||||
|
new(ed2k.FetcherManager),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.RefreshInterval == 0 {
|
if cfg.RefreshInterval == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCalcSpeedResetOnRollback(t *testing.T) {
|
||||||
|
speedArr := []int64{1024, 2048, 4096}
|
||||||
|
|
||||||
|
if got := calcSpeed(&speedArr, -512, 1); got != 0 {
|
||||||
|
t.Fatalf("calcSpeed() = %d, want 0 after rollback", got)
|
||||||
|
}
|
||||||
|
if len(speedArr) != 0 {
|
||||||
|
t.Fatalf("speed window len = %d, want 0 after rollback", len(speedArr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := calcSpeed(&speedArr, 1024, 1); got != 1024 {
|
||||||
|
t.Fatalf("calcSpeed() = %d, want 1024 after reset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ed2k
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Paused bool `json:"paused"`
|
||||||
|
ActivePeers int `json:"activePeers"`
|
||||||
|
TotalPeers int `json:"totalPeers"`
|
||||||
|
DownloadRate int `json:"downloadRate"`
|
||||||
|
Upload int64 `json:"upload"`
|
||||||
|
UploadRate int `json:"uploadRate"`
|
||||||
|
TotalDone int64 `json:"totalDone"`
|
||||||
|
TotalReceived int64 `json:"totalReceived"`
|
||||||
|
TotalWanted int64 `json:"totalWanted"`
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ android {
|
|||||||
ndkVersion flutter.ndkVersion
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -83,4 +84,5 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(name: 'libgopeed', ext: 'aar')
|
implementation(name: 'libgopeed', ext: 'aar')
|
||||||
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||||
}
|
}
|
||||||
@@ -200,10 +200,10 @@ Future<void> putConfig(DownloaderConfig config) async {
|
|||||||
return _parse(() => _client.dio.put("api/v1/config", data: config), null);
|
return _parse(() => _client.dio.put("api/v1/config", data: config), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> installExtension(InstallExtension installExtension) async {
|
Future<String> installExtension(InstallExtension installExtension) async {
|
||||||
return _parse(
|
return _parse<String>(
|
||||||
() => _client.dio.post("api/v1/extensions", data: installExtension),
|
() => _client.dio.post("api/v1/extensions", data: installExtension),
|
||||||
null);
|
(data) => data as String);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Extension>> getExtensions() async {
|
Future<List<Extension>> getExtensions() async {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'api.dart';
|
||||||
|
import 'model/store_extension.dart';
|
||||||
|
|
||||||
|
class GopeedSiteApi {
|
||||||
|
GopeedSiteApi._();
|
||||||
|
|
||||||
|
static final instance = GopeedSiteApi._();
|
||||||
|
|
||||||
|
static const _host = 'gopeed.com';
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getRelease() async {
|
||||||
|
final json = await _getJson('/api/release');
|
||||||
|
return json as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StoreExtensionPage> getExtensions({
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||||
|
StoreSortOrder order = StoreSortOrder.desc,
|
||||||
|
String? query,
|
||||||
|
}) async {
|
||||||
|
final json = await _getJson('/api/extensions', queryParameters: {
|
||||||
|
'page': page.toString(),
|
||||||
|
'limit': limit.clamp(1, 100).toString(),
|
||||||
|
'sort': sort.name,
|
||||||
|
'order': order.name,
|
||||||
|
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||||
|
});
|
||||||
|
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reportExtensionInstall(String id) async {
|
||||||
|
final uri = Uri.https(_host, '/api/extensions/install');
|
||||||
|
await proxyRequest(
|
||||||
|
uri.toString(),
|
||||||
|
data: {'id': id},
|
||||||
|
options: Options(method: 'POST', contentType: Headers.jsonContentType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _getJson(String path,
|
||||||
|
{Map<String, String>? queryParameters}) async {
|
||||||
|
final uri = Uri.https(_host, path, queryParameters);
|
||||||
|
final Response<String> response = await proxyRequest(uri.toString());
|
||||||
|
if (response.data == null || response.data!.isEmpty) {
|
||||||
|
throw Exception('Empty response from $uri');
|
||||||
|
}
|
||||||
|
return jsonDecode(response.data!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ class DownloaderConfig {
|
|||||||
class ProtocolConfig {
|
class ProtocolConfig {
|
||||||
HttpConfig http = HttpConfig();
|
HttpConfig http = HttpConfig();
|
||||||
BtConfig bt = BtConfig();
|
BtConfig bt = BtConfig();
|
||||||
|
Ed2kConfig ed2k = Ed2kConfig();
|
||||||
|
|
||||||
ProtocolConfig();
|
ProtocolConfig();
|
||||||
|
|
||||||
@@ -80,6 +81,28 @@ class BtConfig {
|
|||||||
Map<String, dynamic> toJson() => _$BtConfigToJson(this);
|
Map<String, dynamic> toJson() => _$BtConfigToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class Ed2kConfig {
|
||||||
|
int listenPort;
|
||||||
|
int udpPort;
|
||||||
|
String serverAddr;
|
||||||
|
String serverMet;
|
||||||
|
String nodesDat;
|
||||||
|
|
||||||
|
Ed2kConfig({
|
||||||
|
this.listenPort = 0,
|
||||||
|
this.udpPort = 0,
|
||||||
|
this.serverAddr = '',
|
||||||
|
this.serverMet = '',
|
||||||
|
this.nodesDat = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Ed2kConfig.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$Ed2kConfigFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$Ed2kConfigToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class ExtraConfig {
|
class ExtraConfig {
|
||||||
String themeMode;
|
String themeMode;
|
||||||
@@ -89,6 +112,7 @@ class ExtraConfig {
|
|||||||
bool defaultBtClient;
|
bool defaultBtClient;
|
||||||
bool notifyWhenNewVersion;
|
bool notifyWhenNewVersion;
|
||||||
bool autoStartTasks;
|
bool autoStartTasks;
|
||||||
|
bool desktopNotification;
|
||||||
List<DownloadCategory> downloadCategories;
|
List<DownloadCategory> downloadCategories;
|
||||||
|
|
||||||
ExtraConfigBt bt = ExtraConfigBt();
|
ExtraConfigBt bt = ExtraConfigBt();
|
||||||
@@ -102,6 +126,7 @@ class ExtraConfig {
|
|||||||
this.defaultBtClient = true,
|
this.defaultBtClient = true,
|
||||||
this.notifyWhenNewVersion = true,
|
this.notifyWhenNewVersion = true,
|
||||||
this.autoStartTasks = false,
|
this.autoStartTasks = false,
|
||||||
|
this.desktopNotification = true,
|
||||||
this.downloadCategories = const [],
|
this.downloadCategories = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
|||||||
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
||||||
..webhook =
|
..webhook =
|
||||||
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
||||||
..script =
|
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
|
||||||
ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
|
|
||||||
..autoTorrent = AutoTorrentConfig.fromJson(
|
..autoTorrent = AutoTorrentConfig.fromJson(
|
||||||
json['autoTorrent'] as Map<String, dynamic>?)
|
json['autoTorrent'] as Map<String, dynamic>?)
|
||||||
..archive =
|
..archive =
|
||||||
@@ -43,12 +42,15 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
|
|||||||
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
|
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
|
||||||
ProtocolConfig()
|
ProtocolConfig()
|
||||||
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
|
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
|
||||||
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>);
|
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)
|
||||||
|
..ed2k = Ed2kConfig.fromJson(
|
||||||
|
json['ed2k'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
|
||||||
Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) =>
|
Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'http': instance.http.toJson(),
|
'http': instance.http.toJson(),
|
||||||
'bt': instance.bt.toJson(),
|
'bt': instance.bt.toJson(),
|
||||||
|
'ed2k': instance.ed2k.toJson(),
|
||||||
};
|
};
|
||||||
|
|
||||||
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig(
|
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig(
|
||||||
@@ -83,6 +85,23 @@ Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
|
|||||||
'seedTime': instance.seedTime,
|
'seedTime': instance.seedTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Ed2kConfig _$Ed2kConfigFromJson(Map<String, dynamic> json) => Ed2kConfig(
|
||||||
|
listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,
|
||||||
|
udpPort: (json['udpPort'] as num?)?.toInt() ?? 0,
|
||||||
|
serverAddr: json['serverAddr'] as String? ?? '',
|
||||||
|
serverMet: json['serverMet'] as String? ?? '',
|
||||||
|
nodesDat: json['nodesDat'] as String? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$Ed2kConfigToJson(Ed2kConfig instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'listenPort': instance.listenPort,
|
||||||
|
'udpPort': instance.udpPort,
|
||||||
|
'serverAddr': instance.serverAddr,
|
||||||
|
'serverMet': instance.serverMet,
|
||||||
|
'nodesDat': instance.nodesDat,
|
||||||
|
};
|
||||||
|
|
||||||
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
||||||
themeMode: json['themeMode'] as String? ?? '',
|
themeMode: json['themeMode'] as String? ?? '',
|
||||||
locale: json['locale'] as String? ?? '',
|
locale: json['locale'] as String? ?? '',
|
||||||
@@ -91,6 +110,7 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
|||||||
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
||||||
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
||||||
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
|
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
|
||||||
|
desktopNotification: json['desktopNotification'] as bool? ?? true,
|
||||||
downloadCategories: (json['downloadCategories'] as List<dynamic>?)
|
downloadCategories: (json['downloadCategories'] as List<dynamic>?)
|
||||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
@@ -109,6 +129,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
|
|||||||
'defaultBtClient': instance.defaultBtClient,
|
'defaultBtClient': instance.defaultBtClient,
|
||||||
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
|
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
|
||||||
'autoStartTasks': instance.autoStartTasks,
|
'autoStartTasks': instance.autoStartTasks,
|
||||||
|
'desktopNotification': instance.desktopNotification,
|
||||||
'downloadCategories':
|
'downloadCategories':
|
||||||
instance.downloadCategories.map((e) => e.toJson()).toList(),
|
instance.downloadCategories.map((e) => e.toJson()).toList(),
|
||||||
'bt': instance.bt.toJson(),
|
'bt': instance.bt.toJson(),
|
||||||
@@ -156,8 +177,7 @@ Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
|
|||||||
'urls': instance.urls,
|
'urls': instance.urls,
|
||||||
};
|
};
|
||||||
|
|
||||||
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) =>
|
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) => ScriptConfig(
|
||||||
ScriptConfig(
|
|
||||||
enable: json['enable'] as bool? ?? false,
|
enable: json['enable'] as bool? ?? false,
|
||||||
paths:
|
paths:
|
||||||
(json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ??
|
(json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ??
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class StoreExtensionPage {
|
||||||
|
final List<StoreExtension> data;
|
||||||
|
final StorePagination pagination;
|
||||||
|
|
||||||
|
StoreExtensionPage({required this.data, required this.pagination});
|
||||||
|
|
||||||
|
factory StoreExtensionPage.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StoreExtensionPage(
|
||||||
|
data: (json['data'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => StoreExtension.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
pagination: StorePagination.fromJson(
|
||||||
|
json['pagination'] as Map<String, dynamic>? ?? {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StorePagination {
|
||||||
|
final int page;
|
||||||
|
final int limit;
|
||||||
|
final int total;
|
||||||
|
final int totalPages;
|
||||||
|
final bool hasNext;
|
||||||
|
final bool hasPrev;
|
||||||
|
|
||||||
|
StorePagination({
|
||||||
|
required this.page,
|
||||||
|
required this.limit,
|
||||||
|
required this.total,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.hasNext,
|
||||||
|
required this.hasPrev,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StorePagination.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StorePagination(
|
||||||
|
page: (json['page'] as num?)?.toInt() ?? 1,
|
||||||
|
limit: (json['limit'] as num?)?.toInt() ?? 20,
|
||||||
|
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||||
|
totalPages: (json['totalPages'] as num?)?.toInt() ?? 1,
|
||||||
|
hasNext: json['hasNext'] as bool? ?? false,
|
||||||
|
hasPrev: json['hasPrev'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreExtension {
|
||||||
|
final String id;
|
||||||
|
final String repoFullName;
|
||||||
|
final String repoUrl;
|
||||||
|
final String? directory;
|
||||||
|
final String? commitSha;
|
||||||
|
final String name;
|
||||||
|
final String author;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String? icon;
|
||||||
|
final String version;
|
||||||
|
final String? homepage;
|
||||||
|
final String? readme;
|
||||||
|
final int installCount;
|
||||||
|
final int stars;
|
||||||
|
final List<String> topics;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
StoreExtension({
|
||||||
|
required this.id,
|
||||||
|
required this.repoFullName,
|
||||||
|
required this.repoUrl,
|
||||||
|
this.directory,
|
||||||
|
this.commitSha,
|
||||||
|
required this.name,
|
||||||
|
required this.author,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
this.icon,
|
||||||
|
required this.version,
|
||||||
|
this.homepage,
|
||||||
|
this.readme,
|
||||||
|
required this.installCount,
|
||||||
|
required this.stars,
|
||||||
|
required this.topics,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StoreExtension(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
repoFullName: json['repoFullName'] as String? ?? '',
|
||||||
|
repoUrl: json['repoUrl'] as String? ?? '',
|
||||||
|
directory: json['directory'] as String?,
|
||||||
|
commitSha: json['commitSha'] as String?,
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
author: json['author'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
icon: json['icon'] as String?,
|
||||||
|
version: json['version'] as String? ?? '0.0.0',
|
||||||
|
homepage: json['homepage'] as String?,
|
||||||
|
readme: json['readme'] as String?,
|
||||||
|
installCount: (json['installCount'] as num?)?.toInt() ?? 0,
|
||||||
|
stars: (json['stars'] as num?)?.toInt() ?? 0,
|
||||||
|
topics: _parseTopics(json['topics']),
|
||||||
|
createdAt: _parseDate(json['createdAt']),
|
||||||
|
updatedAt: _parseDate(json['updatedAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _parseTopics(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final parsed = jsonDecode(value);
|
||||||
|
if (parsed is List) {
|
||||||
|
return parsed.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is int) {
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(value);
|
||||||
|
}
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
return DateTime.tryParse(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreExtensionSort {
|
||||||
|
stars,
|
||||||
|
installs,
|
||||||
|
updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreSortOrder {
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ part 'task.g.dart';
|
|||||||
|
|
||||||
enum Status { ready, running, pause, wait, error, done }
|
enum Status { ready, running, pause, wait, error, done }
|
||||||
|
|
||||||
enum Protocol { http, bt }
|
enum Protocol { http, bt, ed2k }
|
||||||
|
|
||||||
// ExtractStatus enum matching Go backend
|
// ExtractStatus enum matching Go backend
|
||||||
enum ExtractStatus {
|
enum ExtractStatus {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const _$StatusEnumMap = {
|
|||||||
const _$ProtocolEnumMap = {
|
const _$ProtocolEnumMap = {
|
||||||
Protocol.http: 'http',
|
Protocol.http: 'http',
|
||||||
Protocol.bt: 'bt',
|
Protocol.bt: 'bt',
|
||||||
|
Protocol.ed2k: 'ed2k',
|
||||||
};
|
};
|
||||||
|
|
||||||
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
|
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:uri_to_file/uri_to_file.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
|
||||||
import '../../../../api/api.dart';
|
import '../../../../api/api.dart';
|
||||||
import '../../../../api/model/create_task.dart';
|
import '../../../../api/model/create_task.dart';
|
||||||
import '../../../../api/model/downloader_config.dart';
|
import '../../../../api/model/downloader_config.dart';
|
||||||
@@ -38,6 +39,7 @@ import '../../../../util/util.dart';
|
|||||||
import '../../../routes/app_pages.dart';
|
import '../../../routes/app_pages.dart';
|
||||||
import '../../../rpc/rpc.dart';
|
import '../../../rpc/rpc.dart';
|
||||||
import '../../redirect/views/redirect_view.dart';
|
import '../../redirect/views/redirect_view.dart';
|
||||||
|
import '../../../services/notification_service.dart';
|
||||||
|
|
||||||
const unixSocketPath = 'gopeed.sock';
|
const unixSocketPath = 'gopeed.sock';
|
||||||
|
|
||||||
@@ -97,6 +99,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
|||||||
_initWindows().onError((error, stackTrace) =>
|
_initWindows().onError((error, stackTrace) =>
|
||||||
logger.w("initWindows error", error, stackTrace));
|
logger.w("initWindows error", error, stackTrace));
|
||||||
|
|
||||||
|
if (Util.isDesktop()) {
|
||||||
|
Get.put(NotificationService());
|
||||||
|
}
|
||||||
|
|
||||||
_initTray().onError(
|
_initTray().onError(
|
||||||
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
|
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
|
||||||
|
|
||||||
@@ -291,7 +297,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
|||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'donate'.tr,
|
label: 'donate'.tr,
|
||||||
onClick: (menuItem) => {
|
onClick: (menuItem) => {
|
||||||
launchUrl(Uri.parse("https://docs.gopeed.com/donate.html"),
|
launchUrl(Uri.parse("https://gopeed.com/docs/donate"),
|
||||||
mode: LaunchMode.externalApplication)
|
mode: LaunchMode.externalApplication)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class CreateView extends GetView<CreateController> {
|
|||||||
final _btTrackerController = TextEditingController();
|
final _btTrackerController = TextEditingController();
|
||||||
final _archivePasswordController = TextEditingController();
|
final _archivePasswordController = TextEditingController();
|
||||||
|
|
||||||
final _availableSchemes = ["http:", "https:", "magnet:"];
|
final _availableSchemes = ["http:", "https:", "magnet:", "ed2k:"];
|
||||||
|
|
||||||
final _skipVerifyCertController = false.obs;
|
final _skipVerifyCertController = false.obs;
|
||||||
final _autoTorrentController = Rxn<bool>();
|
final _autoTorrentController = Rxn<bool>();
|
||||||
@@ -116,6 +116,7 @@ class CreateView extends GetView<CreateController> {
|
|||||||
jsonDecode(jsonEncode(routerParams.req!.extra)));
|
jsonDecode(jsonEncode(routerParams.req!.extra)));
|
||||||
_btTrackerController.text = reqExtra.trackers.join("\n");
|
_btTrackerController.text = reqExtra.trackers.join("\n");
|
||||||
},
|
},
|
||||||
|
Protocol.ed2k: null,
|
||||||
};
|
};
|
||||||
if (routerParams.req?.extra != null) {
|
if (routerParams.req?.extra != null) {
|
||||||
extraHandlers[protocol]?.call();
|
extraHandlers[protocol]?.call();
|
||||||
@@ -139,6 +140,7 @@ class CreateView extends GetView<CreateController> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Protocol.bt: null,
|
Protocol.bt: null,
|
||||||
|
Protocol.ed2k: null,
|
||||||
};
|
};
|
||||||
if (routerParams.opts?.extra != null) {
|
if (routerParams.opts?.extra != null) {
|
||||||
optionsHandlers[protocol]?.call();
|
optionsHandlers[protocol]?.call();
|
||||||
@@ -804,6 +806,9 @@ class CreateView extends GetView<CreateController> {
|
|||||||
uppercaseUrl.endsWith(".TORRENT")) {
|
uppercaseUrl.endsWith(".TORRENT")) {
|
||||||
protocol = Protocol.bt;
|
protocol = Protocol.bt;
|
||||||
}
|
}
|
||||||
|
if (uppercaseUrl.startsWith("ED2K:")) {
|
||||||
|
protocol = Protocol.ed2k;
|
||||||
|
}
|
||||||
return protocol;
|
return protocol;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,6 +1012,9 @@ class CreateView extends GetView<CreateController> {
|
|||||||
..trackers = Util.textToLines(_btTrackerController.text);
|
..trackers = Util.textToLines(_btTrackerController.text);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case Protocol.ed2k:
|
||||||
|
case null:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return reqExtra;
|
return reqExtra;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,278 @@
|
|||||||
import 'package:get/get.dart';
|
import 'dart:async';
|
||||||
import 'package:gopeed/api/api.dart';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../../../api/api.dart';
|
||||||
|
import '../../../../api/gopeed_site_api.dart';
|
||||||
import '../../../../api/model/extension.dart';
|
import '../../../../api/model/extension.dart';
|
||||||
|
import '../../../../api/model/install_extension.dart';
|
||||||
|
import '../../../../api/model/store_extension.dart';
|
||||||
|
import '../../../../api/model/switch_extension.dart';
|
||||||
|
|
||||||
|
enum ExtensionListFilter {
|
||||||
|
market,
|
||||||
|
installed,
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExtensionListItem {
|
||||||
|
final Extension? installed;
|
||||||
|
final StoreExtension? store;
|
||||||
|
|
||||||
|
const ExtensionListItem({this.installed, this.store});
|
||||||
|
|
||||||
|
bool get isInstalled => installed != null;
|
||||||
|
|
||||||
|
String get id => installed?.identity ?? store!.id;
|
||||||
|
|
||||||
|
String get title => installed?.title ?? store?.title ?? '';
|
||||||
|
|
||||||
|
String get author => installed?.author ?? store?.author ?? '';
|
||||||
|
|
||||||
|
String get description => installed?.description ?? store?.description ?? '';
|
||||||
|
|
||||||
|
String get version => installed?.version ?? store?.version ?? '0.0.0';
|
||||||
|
|
||||||
|
String? get homepage {
|
||||||
|
final installedHomepage = installed?.homepage;
|
||||||
|
if (installedHomepage != null && installedHomepage.isNotEmpty) {
|
||||||
|
return installedHomepage;
|
||||||
|
}
|
||||||
|
return store?.homepage;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get repoUrl {
|
||||||
|
final installedRepo = installed?.repository?.url;
|
||||||
|
if (installedRepo != null && installedRepo.isNotEmpty) {
|
||||||
|
return installedRepo;
|
||||||
|
}
|
||||||
|
return store?.repoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
int get stars => store?.stars ?? 0;
|
||||||
|
|
||||||
|
int get installCount => store?.installCount ?? 0;
|
||||||
|
|
||||||
|
String? get icon => store?.icon;
|
||||||
|
}
|
||||||
|
|
||||||
class ExtensionController extends GetxController {
|
class ExtensionController extends GetxController {
|
||||||
final extensions = <Extension>[].obs;
|
static const manualInstallBusyKey = '__manual_install__';
|
||||||
|
|
||||||
|
final installedExtensions = <Extension>[].obs;
|
||||||
final updateFlags = <String, String>{}.obs;
|
final updateFlags = <String, String>{}.obs;
|
||||||
|
|
||||||
|
final storeExtensions = <StoreExtension>[].obs;
|
||||||
|
final storePagination = Rxn<StorePagination>();
|
||||||
|
final storeQuery = ''.obs;
|
||||||
|
final storeSort = StoreExtensionSort.stars.obs;
|
||||||
|
|
||||||
|
final listFilter = ExtensionListFilter.market.obs;
|
||||||
|
|
||||||
|
final loadingInstalled = false.obs;
|
||||||
|
final loadingStore = false.obs;
|
||||||
|
final loadingMoreStore = false.obs;
|
||||||
|
final busyExtensionIds = <String>{}.obs;
|
||||||
|
final showInstallTools = false.obs;
|
||||||
|
|
||||||
final devMode = false.obs;
|
final devMode = false.obs;
|
||||||
var _devModeCount = 0;
|
var _devModeCount = 0;
|
||||||
|
|
||||||
/// Flag to prevent handling pending install multiple times
|
|
||||||
bool pendingInstallHandled = false;
|
bool pendingInstallHandled = false;
|
||||||
|
|
||||||
@override
|
UnmodifiableMapView<String, Extension> get installedMap =>
|
||||||
void onInit() async {
|
UnmodifiableMapView(
|
||||||
super.onInit();
|
{for (final ext in installedExtensions) ext.identity: ext});
|
||||||
await load();
|
|
||||||
checkUpdate();
|
UnmodifiableMapView<String, StoreExtension> get storeMap =>
|
||||||
|
UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext});
|
||||||
|
|
||||||
|
List<ExtensionListItem> get displayItems {
|
||||||
|
if (listFilter.value == ExtensionListFilter.installed) {
|
||||||
|
return installedExtensions
|
||||||
|
.map((ext) =>
|
||||||
|
ExtensionListItem(installed: ext, store: storeMap[ext.identity]))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> load() async {
|
return storeExtensions
|
||||||
extensions.value = await getExtensions();
|
.map((ext) =>
|
||||||
|
ExtensionListItem(installed: installedMap[ext.id], store: ext))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onInit() async {
|
||||||
|
super.onInit();
|
||||||
|
await loadInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadInitialData() async {
|
||||||
|
await Future.wait([loadInstalled(refreshUpdates: true), refreshStore()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
||||||
|
loadingInstalled.value = true;
|
||||||
|
try {
|
||||||
|
installedExtensions.value = await getExtensions();
|
||||||
|
if (refreshUpdates) {
|
||||||
|
await checkUpdate();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingInstalled.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkUpdate() async {
|
Future<void> checkUpdate() async {
|
||||||
for (final ext in extensions) {
|
final nextFlags = <String, String>{};
|
||||||
|
for (final ext in installedExtensions) {
|
||||||
|
try {
|
||||||
final resp = await upgradeCheckExtension(ext.identity);
|
final resp = await upgradeCheckExtension(ext.identity);
|
||||||
if (resp.newVersion.isNotEmpty) {
|
if (resp.newVersion.isNotEmpty) {
|
||||||
updateFlags[ext.identity] = resp.newVersion;
|
nextFlags[ext.identity] = resp.newVersion;
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore single extension check failures to avoid breaking the whole list.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateFlags.assignAll(nextFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshStore() async {
|
||||||
|
loadingStore.value = true;
|
||||||
|
try {
|
||||||
|
final page = await GopeedSiteApi.instance.getExtensions(
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sort: storeSort.value,
|
||||||
|
query: storeQuery.value,
|
||||||
|
);
|
||||||
|
storeExtensions.assignAll(page.data);
|
||||||
|
storePagination.value = page.pagination;
|
||||||
|
} finally {
|
||||||
|
loadingStore.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to open dev mode when install button is clicked 5 times in 2 seconds
|
Future<void> loadMoreStore() async {
|
||||||
|
final pagination = storePagination.value;
|
||||||
|
if (pagination == null || !pagination.hasNext || loadingMoreStore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingMoreStore.value = true;
|
||||||
|
try {
|
||||||
|
final page = await GopeedSiteApi.instance.getExtensions(
|
||||||
|
page: pagination.page + 1,
|
||||||
|
limit: pagination.limit,
|
||||||
|
sort: storeSort.value,
|
||||||
|
query: storeQuery.value,
|
||||||
|
);
|
||||||
|
storeExtensions.addAll(page.data);
|
||||||
|
storePagination.value = page.pagination;
|
||||||
|
} finally {
|
||||||
|
loadingMoreStore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> searchStore(String query) async {
|
||||||
|
storeQuery.value = query.trim();
|
||||||
|
await refreshStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changeSort(StoreExtensionSort sort) async {
|
||||||
|
if (storeSort.value == sort) {
|
||||||
|
await refreshStore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storeSort.value = sort;
|
||||||
|
await refreshStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
void changeFilter(ExtensionListFilter filter) {
|
||||||
|
listFilter.value = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleInstallTools() {
|
||||||
|
showInstallTools.value = !showInstallTools.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Extension? findInstalled(StoreExtension extension) {
|
||||||
|
return installedMap[extension.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canUpdateFromStore(StoreExtension extension) {
|
||||||
|
final installed = findInstalled(extension);
|
||||||
|
if (installed == null) return false;
|
||||||
|
return _compareVersion(extension.version, installed.version) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canUpdateItem(ExtensionListItem item) {
|
||||||
|
if (item.installed == null) return false;
|
||||||
|
if (listFilter.value == ExtensionListFilter.market && item.store != null) {
|
||||||
|
return canUpdateFromStore(item.store!);
|
||||||
|
}
|
||||||
|
return updateFlags.containsKey(item.installed!.identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> installFromStore(StoreExtension extension) async {
|
||||||
|
await _runBusy(extension.id, () async {
|
||||||
|
final installUrl = (extension.directory ?? '').trim().isEmpty
|
||||||
|
? extension.repoUrl
|
||||||
|
: '${extension.repoUrl}#${extension.directory!.trim()}';
|
||||||
|
|
||||||
|
final installedId =
|
||||||
|
await installExtension(InstallExtension(url: installUrl));
|
||||||
|
final statsId = installedId.isNotEmpty ? installedId : extension.id;
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
_backgroundCheckUpdate();
|
||||||
|
_bumpStoreInstallCount(statsId);
|
||||||
|
_reportInstallSafe(statsId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> installFromUrl(String url,
|
||||||
|
{bool devInstall = false, String? statsId}) async {
|
||||||
|
await _runBusy(manualInstallBusyKey, () async {
|
||||||
|
final installedId = await installExtension(
|
||||||
|
InstallExtension(devMode: devInstall, url: url));
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
_backgroundCheckUpdate();
|
||||||
|
if (installedId.isNotEmpty) {
|
||||||
|
_bumpStoreInstallCount(installedId);
|
||||||
|
_reportInstallSafe(installedId);
|
||||||
|
} else if (statsId != null && statsId.isNotEmpty) {
|
||||||
|
_bumpStoreInstallCount(statsId);
|
||||||
|
_reportInstallSafe(statsId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleExtension(Extension extension, bool enabled) async {
|
||||||
|
await _runBusy(extension.identity, () async {
|
||||||
|
await switchExtension(
|
||||||
|
extension.identity, SwitchExtension(status: enabled));
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeExtension(Extension extension) async {
|
||||||
|
await _runBusy(extension.identity, () async {
|
||||||
|
await deleteExtension(extension.identity);
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
updateFlags.remove(extension.identity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upgradeExtension(Extension extension) async {
|
||||||
|
await _runBusy(extension.identity, () async {
|
||||||
|
await updateExtension(extension.identity);
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
_backgroundCheckUpdate();
|
||||||
|
_bumpStoreInstallCount(extension.identity);
|
||||||
|
_reportInstallSafe(extension.identity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void tryOpenDevMode() {
|
void tryOpenDevMode() {
|
||||||
if (_devModeCount == 0) {
|
if (_devModeCount == 0) {
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
@@ -46,4 +286,76 @@ class ExtensionController extends GetxController {
|
|||||||
devMode.value = true;
|
devMode.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _compareVersion(String a, String b) {
|
||||||
|
final aNums = _toVersionNumbers(a);
|
||||||
|
final bNums = _toVersionNumbers(b);
|
||||||
|
final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;
|
||||||
|
|
||||||
|
for (var i = 0; i < maxLen; i++) {
|
||||||
|
final left = i < aNums.length ? aNums[i] : 0;
|
||||||
|
final right = i < bNums.length ? bNums[i] : 0;
|
||||||
|
if (left > right) return 1;
|
||||||
|
if (left < right) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> _toVersionNumbers(String version) {
|
||||||
|
return version
|
||||||
|
.split(RegExp(r'[^0-9]+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.map((part) => int.tryParse(part) ?? 0)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
||||||
|
if (busyExtensionIds.contains(id)) return;
|
||||||
|
busyExtensionIds.add(id);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
busyExtensionIds.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _backgroundCheckUpdate() {
|
||||||
|
unawaited(checkUpdate());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportInstallSafe(String id) {
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await GopeedSiteApi.instance.reportExtensionInstall(id);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore stats reporting failures.
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bumpStoreInstallCount(String id) {
|
||||||
|
final index = storeExtensions.indexWhere((e) => e.id == id);
|
||||||
|
if (index < 0) return;
|
||||||
|
final ext = storeExtensions[index];
|
||||||
|
storeExtensions[index] = StoreExtension(
|
||||||
|
id: ext.id,
|
||||||
|
repoFullName: ext.repoFullName,
|
||||||
|
repoUrl: ext.repoUrl,
|
||||||
|
directory: ext.directory,
|
||||||
|
commitSha: ext.commitSha,
|
||||||
|
name: ext.name,
|
||||||
|
author: ext.author,
|
||||||
|
title: ext.title,
|
||||||
|
description: ext.description,
|
||||||
|
icon: ext.icon,
|
||||||
|
version: ext.version,
|
||||||
|
homepage: ext.homepage,
|
||||||
|
readme: ext.readme,
|
||||||
|
installCount: ext.installCount + 1,
|
||||||
|
stars: ext.stars,
|
||||||
|
topics: ext.topics,
|
||||||
|
createdAt: ext.createdAt,
|
||||||
|
updatedAt: ext.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../../../api/api.dart';
|
||||||
|
import '../../../../database/database.dart';
|
||||||
|
import '../../../../util/util.dart';
|
||||||
|
import '../controllers/extension_controller.dart';
|
||||||
|
|
||||||
|
class ExtensionCard extends StatelessWidget {
|
||||||
|
const ExtensionCard({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.busy,
|
||||||
|
required this.canUpdate,
|
||||||
|
this.onTap,
|
||||||
|
this.onToggle,
|
||||||
|
this.onOpenSetting,
|
||||||
|
this.onUpdate,
|
||||||
|
this.onDelete,
|
||||||
|
this.onInstall,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ExtensionListItem item;
|
||||||
|
final bool busy;
|
||||||
|
final bool canUpdate;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final Future<void> Function(bool value)? onToggle;
|
||||||
|
final VoidCallback? onOpenSetting;
|
||||||
|
final VoidCallback? onUpdate;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
final VoidCallback? onInstall;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final installed = item.installed;
|
||||||
|
final store = item.store;
|
||||||
|
final installedFlag = item.isInstalled;
|
||||||
|
|
||||||
|
final content = Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildCardIcon(item),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${item.author} · v${item.version}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (installed != null) ...[
|
||||||
|
Transform.scale(
|
||||||
|
scale: 0.82,
|
||||||
|
child: Switch(
|
||||||
|
value: !installed.disabled,
|
||||||
|
onChanged: busy || onToggle == null
|
||||||
|
? null
|
||||||
|
: (value) async {
|
||||||
|
await onToggle!(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item.description,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (store != null) ...[
|
||||||
|
_metricItem(
|
||||||
|
context, Icons.star_rounded, item.stars.toString()),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_metricItem(context, Icons.download_outlined,
|
||||||
|
item.installCount.toString()),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (installed != null && canUpdate)
|
||||||
|
IconButton.filledTonal(
|
||||||
|
tooltip: 'newVersionUpdate'.tr,
|
||||||
|
onPressed: busy ? null : onUpdate,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh_rounded),
|
||||||
|
),
|
||||||
|
if (!installedFlag && store != null)
|
||||||
|
IconButton.filledTonal(
|
||||||
|
tooltip: 'extensionInstall'.tr,
|
||||||
|
onPressed: busy ? null : onInstall,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
),
|
||||||
|
if ((item.homepage ?? '').isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'homepage'.tr,
|
||||||
|
onPressed: () => launchUrl(Uri.parse(item.homepage!)),
|
||||||
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
),
|
||||||
|
if ((item.repoUrl ?? '').isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'GitHub',
|
||||||
|
onPressed: () => launchUrl(Uri.parse(item.repoUrl!)),
|
||||||
|
icon: const Icon(Icons.code),
|
||||||
|
),
|
||||||
|
if (installed != null &&
|
||||||
|
installed.settings?.isNotEmpty == true)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'setting'.tr,
|
||||||
|
onPressed: busy ? null : onOpenSetting,
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
if (installed != null)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'delete'.tr,
|
||||||
|
onPressed: busy ? null : onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (installedFlag && canUpdate)
|
||||||
|
Positioned(
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
child: _updateDot(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onTap == null) return content;
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: onTap,
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCardIcon(ExtensionListItem item) {
|
||||||
|
final storeIcon = item.icon;
|
||||||
|
if (storeIcon != null && storeIcon.isNotEmpty) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.network(
|
||||||
|
storeIcon,
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Image.asset(
|
||||||
|
'assets/extension/default_icon.png',
|
||||||
|
width: 42,
|
||||||
|
height: 42),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final extension = item.installed;
|
||||||
|
if (extension == null) {
|
||||||
|
return Image.asset('assets/extension/default_icon.png',
|
||||||
|
width: 42, height: 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
final image = extension.icon.isEmpty
|
||||||
|
? Image.asset('assets/extension/default_icon.png',
|
||||||
|
width: 42, height: 42)
|
||||||
|
: Util.isWeb()
|
||||||
|
? Image.network(
|
||||||
|
join('/fs/extensions/${extension.identity}/${extension.icon}'),
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ${Database.instance.getWebToken()}'
|
||||||
|
},
|
||||||
|
errorBuilder: (_, __, ___) => Image.asset(
|
||||||
|
'assets/extension/default_icon.png',
|
||||||
|
width: 42,
|
||||||
|
height: 42),
|
||||||
|
)
|
||||||
|
: Image.file(
|
||||||
|
extension.devMode
|
||||||
|
? File(path.join(extension.devPath, extension.icon))
|
||||||
|
: File(path.join(Util.getStorageDir(), 'extensions',
|
||||||
|
extension.identity, extension.icon)),
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
errorBuilder: (_, __, ___) => Image.asset(
|
||||||
|
'assets/extension/default_icon.png',
|
||||||
|
width: 42,
|
||||||
|
height: 42),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ClipRRect(borderRadius: BorderRadius.circular(8), child: image);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _metricItem(BuildContext context, IconData icon, String text) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: Theme.of(context).hintColor),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(text, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _updateDot() {
|
||||||
|
return Container(
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.redAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../../../api/model/extension.dart';
|
||||||
|
import '../../../../api/model/store_extension.dart';
|
||||||
|
import '../../../../util/message.dart';
|
||||||
|
import '../../../../util/util.dart';
|
||||||
|
import '../controllers/extension_controller.dart';
|
||||||
|
|
||||||
|
class ExtensionDetailDrawer extends GetView<ExtensionController> {
|
||||||
|
const ExtensionDetailDrawer({
|
||||||
|
super.key,
|
||||||
|
required this.extension,
|
||||||
|
required this.onClose,
|
||||||
|
this.installed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final StoreExtension extension;
|
||||||
|
final Extension? installed;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 12, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
extension.title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onClose,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Obx(() {
|
||||||
|
final localInstalled =
|
||||||
|
installed ?? controller.findInstalled(extension);
|
||||||
|
final canUpdate = controller.canUpdateFromStore(extension);
|
||||||
|
final busy =
|
||||||
|
controller.busyExtensionIds.contains(extension.id) ||
|
||||||
|
(localInstalled != null &&
|
||||||
|
controller.busyExtensionIds
|
||||||
|
.contains(localInstalled.identity));
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildIcon(),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${extension.author} • v${extension.version}'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
extension.description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
if (localInstalled == null)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
await controller
|
||||||
|
.installFromStore(extension);
|
||||||
|
showMessage('tip'.tr,
|
||||||
|
'extensionInstallSuccess'.tr);
|
||||||
|
} catch (e) {
|
||||||
|
showErrorMessage(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
label: Text('extensionInstall'.tr),
|
||||||
|
),
|
||||||
|
if (localInstalled != null && canUpdate)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
await controller
|
||||||
|
.upgradeExtension(localInstalled);
|
||||||
|
showMessage('tip'.tr,
|
||||||
|
'extensionUpdateSuccess'.tr);
|
||||||
|
} catch (e) {
|
||||||
|
showErrorMessage(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh_rounded),
|
||||||
|
label: Text('newVersionUpdate'.tr),
|
||||||
|
),
|
||||||
|
if ((extension.homepage ?? '').isNotEmpty)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
launchUrl(Uri.parse(extension.homepage!)),
|
||||||
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
label: Text('homepage'.tr),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
launchUrl(Uri.parse(extension.repoUrl)),
|
||||||
|
icon: const Icon(Icons.code),
|
||||||
|
label: const Text('GitHub'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildReadme(context, localInstalled),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReadme(BuildContext context, Extension? installed) {
|
||||||
|
return FutureBuilder<_ReadmeInfo>(
|
||||||
|
future: _loadReadme(installed),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final info = snapshot.data;
|
||||||
|
final markdown = info?.content ?? extension.readme ?? '';
|
||||||
|
if (markdown.trim().isEmpty) {
|
||||||
|
return Text('No README',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MarkdownBody(
|
||||||
|
data: markdown,
|
||||||
|
selectable: true,
|
||||||
|
onTapLink: (text, href, title) {
|
||||||
|
if (href == null || href.isEmpty) return;
|
||||||
|
final resolved = _resolvePath(href, info, forImage: false);
|
||||||
|
if (resolved == null) return;
|
||||||
|
launchUrl(Uri.parse(resolved),
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
imageBuilder: (uri, title, alt) {
|
||||||
|
final resolved = _resolvePath(uri.toString(), info, forImage: true);
|
||||||
|
if (resolved == null) return const SizedBox.shrink();
|
||||||
|
if (resolved.startsWith('file://')) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Image.file(
|
||||||
|
File(Uri.parse(resolved).toFilePath()),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Image.network(
|
||||||
|
resolved,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_ReadmeInfo> _loadReadme(Extension? installed) async {
|
||||||
|
if (installed == null) {
|
||||||
|
return _ReadmeInfo(
|
||||||
|
content: extension.readme ?? '',
|
||||||
|
mode: _ReadmeMode.remote,
|
||||||
|
localReadmePath: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rootDir = installed.devMode
|
||||||
|
? installed.devPath
|
||||||
|
: path.join(Util.getStorageDir(), 'extensions', installed.identity);
|
||||||
|
final candidates = [
|
||||||
|
path.join(rootDir, 'README.md'),
|
||||||
|
path.join(rootDir, 'readme.md'),
|
||||||
|
path.join(rootDir, 'README.MD'),
|
||||||
|
];
|
||||||
|
for (final filePath in candidates) {
|
||||||
|
final file = File(filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
return _ReadmeInfo(
|
||||||
|
content: await file.readAsString(),
|
||||||
|
mode: _ReadmeMode.local,
|
||||||
|
localReadmePath: filePath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ReadmeInfo(
|
||||||
|
content: extension.readme ?? '',
|
||||||
|
mode: _ReadmeMode.remote,
|
||||||
|
localReadmePath: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _resolvePath(String raw, _ReadmeInfo? info,
|
||||||
|
{required bool forImage}) {
|
||||||
|
final value = raw.trim();
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
final uri = Uri.tryParse(value);
|
||||||
|
if (uri != null && uri.hasScheme) {
|
||||||
|
return uri.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info?.mode == _ReadmeMode.local &&
|
||||||
|
info?.localReadmePath != null &&
|
||||||
|
forImage) {
|
||||||
|
final readmeDir = path.dirname(info!.localReadmePath!);
|
||||||
|
final clean = value.split('#').first;
|
||||||
|
final absolute = path.normalize(path.join(readmeDir, clean));
|
||||||
|
return Uri.file(absolute).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
final ref =
|
||||||
|
extension.commitSha?.isNotEmpty == true ? extension.commitSha! : 'HEAD';
|
||||||
|
final dir = (extension.directory ?? '').trim();
|
||||||
|
final baseSegments = [
|
||||||
|
if (dir.isNotEmpty) ...dir.split('/').where((e) => e.isNotEmpty),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
final base = forImage
|
||||||
|
? Uri.https('raw.githubusercontent.com',
|
||||||
|
'/${extension.repoFullName}/$ref/${baseSegments.join('/')}')
|
||||||
|
: Uri.https('github.com',
|
||||||
|
'/${extension.repoFullName}/blob/$ref/${baseSegments.join('/')}');
|
||||||
|
return base.resolve(value).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIcon() {
|
||||||
|
if ((extension.icon ?? '').isEmpty) {
|
||||||
|
return Image.asset('assets/extension/default_icon.png',
|
||||||
|
width: 56, height: 56);
|
||||||
|
}
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.network(
|
||||||
|
extension.icon!,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) {
|
||||||
|
return Image.asset('assets/extension/default_icon.png',
|
||||||
|
width: 56, height: 56);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ReadmeMode {
|
||||||
|
remote,
|
||||||
|
local,
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReadmeInfo {
|
||||||
|
final String content;
|
||||||
|
final _ReadmeMode mode;
|
||||||
|
final String? localReadmePath;
|
||||||
|
|
||||||
|
_ReadmeInfo({
|
||||||
|
required this.content,
|
||||||
|
required this.mode,
|
||||||
|
required this.localReadmePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -498,6 +498,29 @@ class SettingView extends GetView<SettingController> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final buildDesktopNotification = !Util.isDesktop()
|
||||||
|
? () => null
|
||||||
|
: _buildConfigItem('desktopNotification', () {
|
||||||
|
return appController
|
||||||
|
.downloaderConfig.value.extra.desktopNotification
|
||||||
|
? 'on'.tr
|
||||||
|
: 'off'.tr;
|
||||||
|
}, (Key key) {
|
||||||
|
return Container(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Switch(
|
||||||
|
value: appController
|
||||||
|
.downloaderConfig.value.extra.desktopNotification,
|
||||||
|
onChanged: (bool value) async {
|
||||||
|
appController.downloaderConfig.update((val) {
|
||||||
|
val!.extra.desktopNotification = value;
|
||||||
|
});
|
||||||
|
await debounceSave();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// http config items start
|
// http config items start
|
||||||
final httpConfig = downloaderCfg.value.protocolConfig.http;
|
final httpConfig = downloaderCfg.value.protocolConfig.http;
|
||||||
final buildHttpUa =
|
final buildHttpUa =
|
||||||
@@ -766,6 +789,138 @@ class SettingView extends GetView<SettingController> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ed2k config items start
|
||||||
|
final ed2kConfig = downloaderCfg.value.protocolConfig.ed2k;
|
||||||
|
List<String> parseEd2kEntries(String value) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String summarizeEd2kEntries(String value) {
|
||||||
|
final entries = parseEd2kEntries(value);
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
return 'notSet'.tr;
|
||||||
|
}
|
||||||
|
return 'items'.trParams({'count': entries.length.toString()});
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatEd2kMultilineValue(String value) {
|
||||||
|
return parseEd2kEntries(value).join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
final buildEd2kListenPort = _buildConfigItem(
|
||||||
|
'ed2kTcpPort', () => ed2kConfig.listenPort.toString(), (Key key) {
|
||||||
|
final controller =
|
||||||
|
TextEditingController(text: ed2kConfig.listenPort.toString());
|
||||||
|
controller.addListener(() async {
|
||||||
|
if (controller.text.isNotEmpty &&
|
||||||
|
controller.text != ed2kConfig.listenPort.toString()) {
|
||||||
|
ed2kConfig.listenPort = int.parse(controller.text);
|
||||||
|
await debounceSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return TextField(
|
||||||
|
key: key,
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
NumericalRangeFormatter(min: 0, max: 65535),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final buildEd2kUdpPort = _buildConfigItem(
|
||||||
|
'ed2kUdpPort', () => ed2kConfig.udpPort.toString(), (Key key) {
|
||||||
|
final controller =
|
||||||
|
TextEditingController(text: ed2kConfig.udpPort.toString());
|
||||||
|
controller.addListener(() async {
|
||||||
|
if (controller.text.isNotEmpty &&
|
||||||
|
controller.text != ed2kConfig.udpPort.toString()) {
|
||||||
|
ed2kConfig.udpPort = int.parse(controller.text);
|
||||||
|
await debounceSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return TextField(
|
||||||
|
key: key,
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
NumericalRangeFormatter(min: 0, max: 65535),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final buildEd2kServerAddr = _buildConfigItem(
|
||||||
|
'ed2kServerList', () => summarizeEd2kEntries(ed2kConfig.serverAddr),
|
||||||
|
(Key key) {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: formatEd2kMultilineValue(ed2kConfig.serverAddr));
|
||||||
|
return TextField(
|
||||||
|
key: key,
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
maxLines: 5,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'ed2kServersHint'.tr,
|
||||||
|
helperText: 'ed2kOnePerLine'.tr,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
ed2kConfig.serverAddr = Util.textToLines(value).join(',');
|
||||||
|
await debounceSave();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final buildEd2kServerMet = _buildConfigItem(
|
||||||
|
'ed2kServerMet', () => summarizeEd2kEntries(ed2kConfig.serverMet),
|
||||||
|
(Key key) {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: formatEd2kMultilineValue(ed2kConfig.serverMet));
|
||||||
|
return TextField(
|
||||||
|
key: key,
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'ed2kServerMetHint'.tr,
|
||||||
|
helperText: 'ed2kOnePerLine'.tr,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
ed2kConfig.serverMet = Util.textToLines(value).join(',');
|
||||||
|
await debounceSave();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final buildEd2kNodesDat = _buildConfigItem(
|
||||||
|
'ed2kNodesDat', () => summarizeEd2kEntries(ed2kConfig.nodesDat),
|
||||||
|
(Key key) {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: formatEd2kMultilineValue(ed2kConfig.nodesDat));
|
||||||
|
return TextField(
|
||||||
|
key: key,
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'ed2kNodesDatHint'.tr,
|
||||||
|
helperText: 'ed2kOnePerLine'.tr,
|
||||||
|
),
|
||||||
|
onChanged: (value) async {
|
||||||
|
ed2kConfig.nodesDat = Util.textToLines(value).join(',');
|
||||||
|
await debounceSave();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// ui config items start
|
// ui config items start
|
||||||
final buildTheme = _buildConfigItem(
|
final buildTheme = _buildConfigItem(
|
||||||
'theme',
|
'theme',
|
||||||
@@ -1592,6 +1747,7 @@ class SettingView extends GetView<SettingController> {
|
|||||||
buildBrowserExtension(),
|
buildBrowserExtension(),
|
||||||
buildAutoStartup(),
|
buildAutoStartup(),
|
||||||
buildMenubarMode(),
|
buildMenubarMode(),
|
||||||
|
buildDesktopNotification(),
|
||||||
]),
|
]),
|
||||||
)),
|
)),
|
||||||
Text('archives'.tr),
|
Text('archives'.tr),
|
||||||
@@ -1626,6 +1782,17 @@ class SettingView extends GetView<SettingController> {
|
|||||||
buildBtDefaultClientConfig(),
|
buildBtDefaultClientConfig(),
|
||||||
]),
|
]),
|
||||||
)),
|
)),
|
||||||
|
Text('ed2k'.tr),
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
children: _addDivider([
|
||||||
|
buildEd2kListenPort(),
|
||||||
|
buildEd2kUdpPort(),
|
||||||
|
buildEd2kServerAddr(),
|
||||||
|
buildEd2kServerMet(),
|
||||||
|
buildEd2kNodesDat(),
|
||||||
|
]),
|
||||||
|
)),
|
||||||
Text('ui'.tr),
|
Text('ui'.tr),
|
||||||
Card(
|
Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../api/api.dart';
|
||||||
|
import '../../api/model/task.dart';
|
||||||
|
import '../../util/util.dart';
|
||||||
|
import '../modules/app/controllers/app_controller.dart';
|
||||||
|
|
||||||
|
class NotificationService extends GetxService {
|
||||||
|
Timer? _timer;
|
||||||
|
final Map<String, Status> _previousStatus = {};
|
||||||
|
|
||||||
|
final AppController appController = Get.find<AppController>();
|
||||||
|
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
_initNotifications();
|
||||||
|
_startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initNotifications() async {
|
||||||
|
const DarwinInitializationSettings initializationSettingsDarwin =
|
||||||
|
DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
requestSoundPermission: false);
|
||||||
|
|
||||||
|
final LinuxInitializationSettings initializationSettingsLinux =
|
||||||
|
LinuxInitializationSettings(
|
||||||
|
defaultActionName: 'Open notification',
|
||||||
|
defaultIcon: AssetsLinuxIcon('assets/icon/icon.png'),
|
||||||
|
);
|
||||||
|
|
||||||
|
String? windowsIconPath;
|
||||||
|
try {
|
||||||
|
if (Util.isWindows()) {
|
||||||
|
final byteData = await rootBundle.load('assets/icon/icon.ico');
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final file = File('${tempDir.path}/notification_icon.ico');
|
||||||
|
await file.writeAsBytes(byteData.buffer
|
||||||
|
.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));
|
||||||
|
windowsIconPath = file.path;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
final WindowsInitializationSettings initializationSettingsWindows =
|
||||||
|
WindowsInitializationSettings(
|
||||||
|
appName: 'Gopeed',
|
||||||
|
appUserModelId: 'com.gopeed.gopeed',
|
||||||
|
guid: '3c1bf3f4-3d91-4eaa-a33f-8705e71cf1ce', // unique guid
|
||||||
|
iconPath: windowsIconPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
final InitializationSettings initializationSettings =
|
||||||
|
InitializationSettings(
|
||||||
|
macOS: initializationSettingsDarwin,
|
||||||
|
linux: initializationSettingsLinux,
|
||||||
|
windows: initializationSettingsWindows,
|
||||||
|
);
|
||||||
|
|
||||||
|
await flutterLocalNotificationsPlugin.initialize(
|
||||||
|
settings: initializationSettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPolling() {
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 2), (timer) async {
|
||||||
|
try {
|
||||||
|
final config = appController.downloaderConfig.value;
|
||||||
|
if (!config.extra.desktopNotification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tasks = await getTasks([
|
||||||
|
Status.ready,
|
||||||
|
Status.running,
|
||||||
|
Status.pause,
|
||||||
|
Status.wait,
|
||||||
|
Status.error,
|
||||||
|
Status.done,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (var task in tasks) {
|
||||||
|
final prevStatus = _previousStatus[task.id];
|
||||||
|
final currentStatus = task.status;
|
||||||
|
|
||||||
|
if (prevStatus != null && prevStatus != currentStatus) {
|
||||||
|
if (currentStatus == Status.done) {
|
||||||
|
_showNotification(
|
||||||
|
title: 'notificationTaskDone'.tr,
|
||||||
|
body: task.name,
|
||||||
|
);
|
||||||
|
} else if (currentStatus == Status.error) {
|
||||||
|
_showNotification(
|
||||||
|
title: 'notificationTaskError'.tr,
|
||||||
|
body: task.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_previousStatus[task.id] = currentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up deleted tasks from map
|
||||||
|
final currentTaskIds = tasks.map((t) => t.id).toSet();
|
||||||
|
_previousStatus
|
||||||
|
.removeWhere((id, status) => !currentTaskIds.contains(id));
|
||||||
|
} catch (e) {
|
||||||
|
// Ignored
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int _notificationId = 0;
|
||||||
|
|
||||||
|
Future<void> _showNotification(
|
||||||
|
{required String title, required String body}) async {
|
||||||
|
const NotificationDetails notificationDetails = NotificationDetails(
|
||||||
|
macOS: DarwinNotificationDetails(),
|
||||||
|
linux: LinuxNotificationDetails(),
|
||||||
|
windows: WindowsNotificationDetails(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await flutterLocalNotificationsPlugin.show(
|
||||||
|
id: _notificationId++,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: notificationDetails,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
const caES = {
|
||||||
|
'ca_ES': {
|
||||||
|
'label': 'Català',
|
||||||
|
'error': 'Error',
|
||||||
|
'tip': 'Consell',
|
||||||
|
'confirm': 'Confirma',
|
||||||
|
'confirmDelete': 'Esteu segur que voleu suprimir-lo?',
|
||||||
|
'cancel': 'Cancel·la',
|
||||||
|
'on': 'Activat',
|
||||||
|
'off': 'Desactivat',
|
||||||
|
'selectAll': 'Selecciona-ho tot',
|
||||||
|
'select': 'Selecciona',
|
||||||
|
'task': 'Tasques',
|
||||||
|
'downloading': 's\'està baixant',
|
||||||
|
'downloaded': 's\'ha baixat',
|
||||||
|
'setting': 'Configuració',
|
||||||
|
'donate': 'Donació',
|
||||||
|
'exit': 'Surt',
|
||||||
|
'create': 'Crea una tasca',
|
||||||
|
'directDownload': 'Baixada directa',
|
||||||
|
'advancedOptions': 'Opcions avançades',
|
||||||
|
'followSettings': 'Segueix la configuració',
|
||||||
|
'downloadLink': 'Enllaç de baixada',
|
||||||
|
'downloadLinkValid': 'Introduïu l\'enllaç de baixada',
|
||||||
|
'downloadLinkHit': 'Introduïu l\'enllaç de baixada, un per línia@append',
|
||||||
|
'downloadLinkHitDesktop': 'o arrossegueu el fitxer torrent aquí',
|
||||||
|
'download': 'Baixa',
|
||||||
|
'noFileSelected': 'Seleccioneu almenys un fitxer per continuar.',
|
||||||
|
'noStoragePermission': 'Cal permís d\'emmagatzematge',
|
||||||
|
'selectFile': 'Seleccioneu el fitxer',
|
||||||
|
'rename': 'Canvia el nom',
|
||||||
|
'basic': 'Bàsic',
|
||||||
|
'advanced': 'Avançat',
|
||||||
|
'general': 'General',
|
||||||
|
'downloadDir': 'Carpeta de baixades',
|
||||||
|
'downloadDirValid': 'Seleccioneu la carpeta de baixades',
|
||||||
|
'connections': 'Connexions',
|
||||||
|
'useServerCtime':
|
||||||
|
'Utilitza l\'hora del servidor per a la creació de fitxers',
|
||||||
|
'maxRunning': 'Màxim de tasques en execució',
|
||||||
|
'defaultDirectDownload': 'Activa la baixada directa per defecte',
|
||||||
|
'autoStartTasks':
|
||||||
|
'Inicia automàticament les tasques incompletes a l\'inici',
|
||||||
|
'autoTorrentEnable':
|
||||||
|
'Crea automàticament tasques BT a partir de fitxers .torrent',
|
||||||
|
'autoTorrentDeleteAfterDownload':
|
||||||
|
'Suprimeix el fitxer .torrent després de crear la tasca BT',
|
||||||
|
'autoDeleteMissingFileTasks':
|
||||||
|
'Suprimeix automàticament les tasques dels fitxers que falten',
|
||||||
|
'items': '@count elements',
|
||||||
|
'subscribeTracker': 'Subscripció al rastrejador',
|
||||||
|
'subscribeFail':
|
||||||
|
'La subscripció ha fallat. Comproveu la xarxa o torneu-ho a provar més tard',
|
||||||
|
'update': 'Actualitza',
|
||||||
|
'updateDaily': 'Actualització diària',
|
||||||
|
'lastUpdate': 'Última actualització: @time',
|
||||||
|
'addTracker': 'Afegeix un rastrejador',
|
||||||
|
'addTrackerHit':
|
||||||
|
'Introduïu la URL del servidor del rastrejador, un per línia',
|
||||||
|
'ui': 'UI',
|
||||||
|
'theme': 'Tema',
|
||||||
|
'themeSystem': 'Sistema',
|
||||||
|
'themeLight': 'Clar',
|
||||||
|
'themeDark': 'Fosc',
|
||||||
|
'locale': 'Idioma',
|
||||||
|
'notifyWhenNewVersion': 'Notificació d\'actualitzacions',
|
||||||
|
'analyticsEnabled': 'Puja les estadístiques',
|
||||||
|
'analyticsEnabledDesc':
|
||||||
|
'Compartiu dades d\'ús anònimes per ajudar-nos a millorar',
|
||||||
|
'about': 'Quant a',
|
||||||
|
'homepage': 'Pàgina d\'inici',
|
||||||
|
'version': 'Versió',
|
||||||
|
'protocol': 'Protocol',
|
||||||
|
'port': 'Port',
|
||||||
|
'apiToken': 'Token de l\'API',
|
||||||
|
'notSet': 'Sense congigurar',
|
||||||
|
'set': 'Configurat',
|
||||||
|
'portInUse': 'El port [@port] està en ús, canvieu-lo',
|
||||||
|
'effectAfterRestart': 'Els efectes s\'aplicaran després de reiniciar',
|
||||||
|
'developer': 'Desenvolupador',
|
||||||
|
'logDirectory': 'Carpeta dels registres',
|
||||||
|
'webhook': 'Webhook',
|
||||||
|
'webhookEnable': 'Activa el Webhook',
|
||||||
|
'webhookDesc':
|
||||||
|
'Envia notificacions HTTP POST quan les tasques finalitzin o fallin',
|
||||||
|
'webhookUrlHint': 'Introduïu la URL del webhook',
|
||||||
|
'webhookTest': 'Prova',
|
||||||
|
'webhookTestSuccess': 'La prova del webhook ha estat correcta',
|
||||||
|
'webhookTestFail': 'La prova del webhook ha fallat',
|
||||||
|
'script': 'Seqüència d\'ordres',
|
||||||
|
'scriptEnable': 'Activa la seqüència d\'ordres',
|
||||||
|
'scriptDesc':
|
||||||
|
'Executa seqüències d\'ordres personalitzades quan les baixades finalitzin correctament',
|
||||||
|
'scriptPathHint':
|
||||||
|
'Introduïu la ruta del fitxer de seqüències d\'ordres (p.ex., /ruta/al/fitxer.sh)',
|
||||||
|
'urlInvalid': 'Introduïu una URL HTTP o HTTPS vàlida',
|
||||||
|
'required': 'Aquest camp és obligatori',
|
||||||
|
'show': 'Mostra',
|
||||||
|
'continue': 'Contina',
|
||||||
|
'pause': 'Pausa',
|
||||||
|
'startAll': 'Inicia-ho tot',
|
||||||
|
'pauseAll': 'Pausa-ho tot',
|
||||||
|
'deleteTask': 'Suprimeix @count tasques',
|
||||||
|
'deleteTaskTip': 'Mantén els fitxers baixats',
|
||||||
|
'delete': 'Suprimeix',
|
||||||
|
'add': 'Afegeix',
|
||||||
|
'edit': 'Edita',
|
||||||
|
'newVersionTitle': 'Descobriu la nova versió @version',
|
||||||
|
'newVersionUpdate': 'Actualitza ara',
|
||||||
|
'newVersionLater': 'Més tard',
|
||||||
|
'extensions': 'Extensions',
|
||||||
|
'extensionInstallUrl': 'URL d\'instal·lació',
|
||||||
|
'extensionInstallSuccess': 'S\'ha instal·lat amb èxit',
|
||||||
|
'extensionUpdateSuccess': 'S\'ha actualitzat amb èxit',
|
||||||
|
'extensionDelete': 'Suprimeix l\'extensió',
|
||||||
|
'extensionAlreadyLatest': 'Ja està actualitzada',
|
||||||
|
'extensionFind': 'Cerca extensions',
|
||||||
|
'extensionDevelop': 'Desenvolupa extensions',
|
||||||
|
'extensionStoreTitle': 'Botiga d\'extensions',
|
||||||
|
'extensionStoreSection': 'Extensions de la botiga',
|
||||||
|
'extensionInstalledSection': 'Extensions instal·lades',
|
||||||
|
'extensionInstalledEmpty': 'Encara no s\'ha instal·lat cap extensió',
|
||||||
|
'extensionStoreEmpty': 'No s\'han trobat extensions coincidents',
|
||||||
|
'extensionSearchHint': 'Cerca per nom, títol, descripció o autor',
|
||||||
|
'extensionSortStars': 'Més estrelles',
|
||||||
|
'extensionSortInstalls': 'Més instal·lacions',
|
||||||
|
'extensionSortUpdated': 'Actualitzada recentment',
|
||||||
|
'extensionSortToggle': 'Commuta la direcció d\'ordenació',
|
||||||
|
'extensionLoadMore': 'Carrega\'n més',
|
||||||
|
'extensionNoMore': 'No hi ha més extensions',
|
||||||
|
'extensionInstalled': 'Instal·lada',
|
||||||
|
'extensionCanUpdate': 'Actualització disponible',
|
||||||
|
'extensionInstall': 'Instal·la',
|
||||||
|
'extensionLoadLocal': 'Instal·la de d\'una carpeta local',
|
||||||
|
'extensionFilterAll': 'Totes',
|
||||||
|
'extensionFilterMarket': 'Mercat',
|
||||||
|
'extensionFilterInstalled': 'Instal·lada',
|
||||||
|
'extensionManualInstall': 'Instal·lació manual',
|
||||||
|
'extensionInstallTools': 'Opcions d\'instal·lació',
|
||||||
|
'history': 'Historial',
|
||||||
|
'clearHistory': 'Neteja l\'historial',
|
||||||
|
'noHistoryFound': 'No s\'ha trobat cap historial',
|
||||||
|
'serviceTitle': 'Servei de baixades',
|
||||||
|
'serviceText': 'S\'està executant',
|
||||||
|
'network': 'Xarxa',
|
||||||
|
'proxy': 'Servidor intermediari',
|
||||||
|
'noProxy': 'Sense servidor intermediari',
|
||||||
|
'systemProxy': 'Servidor intermediari del sistema',
|
||||||
|
'customProxy': 'Servidor intermediari personalitzat',
|
||||||
|
'server': 'Servidor',
|
||||||
|
'username': 'Nom d\'usuari',
|
||||||
|
'password': 'Contrasenya',
|
||||||
|
'thanks': 'Gràcies',
|
||||||
|
'thanksDesc':
|
||||||
|
'Gràcies a tots els col·laboradors que han ajudat a construir i desenvolupar la comunitat del Gopeed!',
|
||||||
|
'browserExtension': 'Extensió del navegador',
|
||||||
|
'launchAtStartup': 'Executa a l\'inici',
|
||||||
|
'runAsMenubarApp': 'Executa com a aplicació de barra de menú',
|
||||||
|
'runAsMenubarAppDesc':
|
||||||
|
'Amaga la icona de l\'acoblador i executa només a la barra de menú',
|
||||||
|
'seedConfig': 'Configuració de les llavors',
|
||||||
|
'seedKeep': 'Continua sembrant fins que s\'aturi manualment',
|
||||||
|
'seedRatio': 'Proporció de les llavors',
|
||||||
|
'seedTime': 'Temps de la llavor (minuts)',
|
||||||
|
'setAsDefaultBtClient': 'Estableix com a client BT per defecte',
|
||||||
|
'taskDetail': 'Detall de la tasca',
|
||||||
|
'taskName': 'Nom de la tasca',
|
||||||
|
'taskUrl': 'URL de la tasca',
|
||||||
|
'downloadPath': 'Ruta de la baixada',
|
||||||
|
'skipVerifyCert': 'Omet la verificació del certificat',
|
||||||
|
'archives': 'Comprimits',
|
||||||
|
'autoExtract': 'Extreu els comprimits automàticament',
|
||||||
|
'archivePassword': 'Contrasenya del comprimit',
|
||||||
|
'archivePasswordHint':
|
||||||
|
'Deixeu-lo en blanc si no està protegit amb contrasenya',
|
||||||
|
'deleteAfterExtract': 'Suprimeix el comprimit després de l\'extracció',
|
||||||
|
'extracting': 'S\'està extraient',
|
||||||
|
'extractDone': 'Extracció completada',
|
||||||
|
'extractError': 'Extracció fallida',
|
||||||
|
'waitingParts': 'S\'està esperant a les parts',
|
||||||
|
'name': 'Nom',
|
||||||
|
'size': 'Mida',
|
||||||
|
'unknown': 'Desconegut',
|
||||||
|
'fileSelectedCount': 'Fitxers: ',
|
||||||
|
'fileSelectedSize': 'Mida: ',
|
||||||
|
'httpHeader': 'Capçalera',
|
||||||
|
'httpHeaderName': 'Nomm de la capçalera',
|
||||||
|
'httpHeaderValue': 'Valor de la capçalera',
|
||||||
|
'login': 'Inici de sessió',
|
||||||
|
'username_required': 'Introduïu el vostre nom d\'usuari',
|
||||||
|
'password_required': 'Introduïu la vostra contrasenya',
|
||||||
|
'login_success': 'Inici de sessió correcte',
|
||||||
|
'login_failed':
|
||||||
|
'No s\'ha pogut iniciar la sessió, comproveu el vostre nom d\'usuari i contrasenya',
|
||||||
|
'login_failed_network':
|
||||||
|
'No s\'ha pogut iniciar la sessió, comproveu la vostra connexió de xarxa',
|
||||||
|
'insertPlaceholder': 'Inseriu un marcador de posició',
|
||||||
|
'placeholderYear': 'Any actual',
|
||||||
|
'placeholderMonth': 'Mes actual (01-12)',
|
||||||
|
'placeholderDay': 'Dia actual (01-31)',
|
||||||
|
'placeholderDate': 'Data completa (AAAA-MM-DD)',
|
||||||
|
'example': 'p.ex. @value',
|
||||||
|
'downloadCategories': 'Categories de baixades',
|
||||||
|
'categoryMusic': 'Música',
|
||||||
|
'categoryVideo': 'Vídeo',
|
||||||
|
'categoryDocument': 'Document',
|
||||||
|
'categoryProgram': 'Programa',
|
||||||
|
'categoryName': 'Nom de la categoria',
|
||||||
|
'categoryPath': 'Ruta de la categoria',
|
||||||
|
'builtInCategory': 'La categoria integrada no es pot suprimir',
|
||||||
|
'selectCategory': 'Seleccioneu una categoria',
|
||||||
|
'githubMirror': 'Rèplica del GitHub',
|
||||||
|
'githubMirrorEnable': 'Activa la rèplica del GitHub',
|
||||||
|
'githubMirrorDesc':
|
||||||
|
'Utilitzeu rèpliques per accelerar les baixades de contingut del GitHub',
|
||||||
|
'githubMirrorType': 'Tipus de rèplica',
|
||||||
|
'githubMirrorUrl': 'URL de la rèplica',
|
||||||
|
'githubMirrorUrlHint': 'Introduïu la URL de la rèplica',
|
||||||
|
'updateUrl': 'Actualitza la URL',
|
||||||
|
'updateUrlManual': 'Actualització manual',
|
||||||
|
'updateUrlListen': 'Escolta l\'actualització',
|
||||||
|
'updateUrlListeningTip': 'S\'està esperant l\'actualització de l\'URL',
|
||||||
|
'updateUrlCancelListen': 'Cancel·la l\'escolta',
|
||||||
|
'updateUrlDialogHint': 'Introduïu una nova URL de baixada',
|
||||||
|
'pendingUpdateFound': 'S\'ha trobat una tasca d\'actualització pendent',
|
||||||
|
'pendingUpdateConfirm':
|
||||||
|
'La tasca "@name" està esperant l\'actualització de la URL. Voleu actualitzar-la amb la URL nova?',
|
||||||
|
'pendingUpdateYes': 'Actualitza la tasca',
|
||||||
|
'pendingUpdateNo': 'Crea una tasca nova',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'Port d\'escolta TCP',
|
||||||
|
'ed2kUdpPort': 'Port d\'escolta UDP',
|
||||||
|
'ed2kServerList': 'Adreces del servidor',
|
||||||
|
'ed2kServerMet': 'Fonts del Server.met',
|
||||||
|
'ed2kNodesDat': 'Fonts del nodes.dat',
|
||||||
|
'ed2kAutoPort': 'Assignació automàtica',
|
||||||
|
'ed2kOnePerLine': 'Una entrada per línia',
|
||||||
|
'ed2kServersHint': 'amfitrió:port, un per línia',
|
||||||
|
'ed2kServerMetHint': 'URL del Server.met o enllaç del llistat de servidors ed2k, un per línia',
|
||||||
|
'ed2kNodesDatHint': 'Ruta o URL del nodes.dat, una per línia',
|
||||||
|
'desktopNotification': 'Notificacions d\'escriptori',
|
||||||
|
'notificationTaskDone': 'Tasca completada',
|
||||||
|
'notificationTaskError': 'Error de tasca',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -164,5 +164,19 @@ const deDE = {
|
|||||||
'categoryPath': 'Kategoriepfad',
|
'categoryPath': 'Kategoriepfad',
|
||||||
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
|
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
|
||||||
'selectCategory': 'Kategorie auswählen',
|
'selectCategory': 'Kategorie auswählen',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'Desktop-Benachrichtigungen',
|
||||||
|
'notificationTaskDone': 'Aufgabe abgeschlossen',
|
||||||
|
'notificationTaskError': 'Aufgabenfehler',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -107,6 +107,27 @@ const enUS = {
|
|||||||
'extensionAlreadyLatest': 'It\'s already the latest version',
|
'extensionAlreadyLatest': 'It\'s already the latest version',
|
||||||
'extensionFind': 'Find Extensions',
|
'extensionFind': 'Find Extensions',
|
||||||
'extensionDevelop': 'Develop Extensions',
|
'extensionDevelop': 'Develop Extensions',
|
||||||
|
'extensionStoreTitle': 'Extension Store',
|
||||||
|
'extensionStoreSection': 'Store Extensions',
|
||||||
|
'extensionInstalledSection': 'Installed Extensions',
|
||||||
|
'extensionInstalledEmpty': 'No extensions installed yet',
|
||||||
|
'extensionStoreEmpty': 'No matching extensions found',
|
||||||
|
'extensionSearchHint': 'Search by name, title, description or author',
|
||||||
|
'extensionSortStars': 'Most Stars',
|
||||||
|
'extensionSortInstalls': 'Most Installs',
|
||||||
|
'extensionSortUpdated': 'Recently Updated',
|
||||||
|
'extensionSortToggle': 'Toggle sort direction',
|
||||||
|
'extensionLoadMore': 'Load More',
|
||||||
|
'extensionNoMore': 'No more extensions',
|
||||||
|
'extensionInstalled': 'Installed',
|
||||||
|
'extensionCanUpdate': 'Update Available',
|
||||||
|
'extensionInstall': 'Install',
|
||||||
|
'extensionLoadLocal': 'Install Local Folder',
|
||||||
|
'extensionFilterAll': 'All',
|
||||||
|
'extensionFilterMarket': 'Market',
|
||||||
|
'extensionFilterInstalled': 'Installed',
|
||||||
|
'extensionManualInstall': 'Manual Install',
|
||||||
|
'extensionInstallTools': 'Install Options',
|
||||||
'history': 'History',
|
'history': 'History',
|
||||||
'clearHistory': 'Clear History',
|
'clearHistory': 'Clear History',
|
||||||
'noHistoryFound': 'No History Found',
|
'noHistoryFound': 'No History Found',
|
||||||
@@ -193,5 +214,19 @@ const enUS = {
|
|||||||
'Task "@name" is waiting for URL update. Do you want to update it with the new URL?',
|
'Task "@name" is waiting for URL update. Do you want to update it with the new URL?',
|
||||||
'pendingUpdateYes': 'Update Task',
|
'pendingUpdateYes': 'Update Task',
|
||||||
'pendingUpdateNo': 'Create New Task',
|
'pendingUpdateNo': 'Create New Task',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'Desktop Notifications',
|
||||||
|
'notificationTaskDone': 'Task completed',
|
||||||
|
'notificationTaskError': 'Task error',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -142,5 +142,19 @@ const esES = {
|
|||||||
'categoryPath': 'Ruta de categoría',
|
'categoryPath': 'Ruta de categoría',
|
||||||
'builtInCategory': 'La categoría incorporada no se puede eliminar',
|
'builtInCategory': 'La categoría incorporada no se puede eliminar',
|
||||||
'selectCategory': 'Seleccionar categoría',
|
'selectCategory': 'Seleccionar categoría',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'Notificaciones de escritorio',
|
||||||
|
'notificationTaskDone': 'Tarea completada',
|
||||||
|
'notificationTaskError': 'Error de tarea',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -93,5 +93,19 @@ const faIR = {
|
|||||||
'launchAtStartup': 'راهاندازی در استارتآپ',
|
'launchAtStartup': 'راهاندازی در استارتآپ',
|
||||||
'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو',
|
'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو',
|
||||||
'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو',
|
'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'اعلانهای دسکتاپ',
|
||||||
|
'notificationTaskDone': 'وظیفه تکمیل شد',
|
||||||
|
'notificationTaskError': 'خطای وظیفه',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,5 +135,19 @@ const frFR = {
|
|||||||
'categoryPath': 'Chemin de la catégorie',
|
'categoryPath': 'Chemin de la catégorie',
|
||||||
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
|
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
|
||||||
'selectCategory': 'Sélectionner une catégorie',
|
'selectCategory': 'Sélectionner une catégorie',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'Notifications de bureau',
|
||||||
|
'notificationTaskDone': 'Tâche terminée',
|
||||||
|
'notificationTaskError': 'Erreur de tâche',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -156,5 +156,19 @@ const huHU = {
|
|||||||
'categoryPath': 'Kategória útvonala',
|
'categoryPath': 'Kategória útvonala',
|
||||||
'builtInCategory': 'A beépített kategória nem törölhető',
|
'builtInCategory': 'A beépített kategória nem törölhető',
|
||||||
'selectCategory': 'Kategória kiválasztása',
|
'selectCategory': 'Kategória kiválasztása',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'Asztali értesítések',
|
||||||
|
'notificationTaskDone': 'Feladat befejezve',
|
||||||
|
'notificationTaskError': 'Feladat hiba',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -142,5 +142,19 @@ const idID = {
|
|||||||
'categoryPath': 'Path Kategori',
|
'categoryPath': 'Path Kategori',
|
||||||
'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
|
'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
|
||||||
'selectCategory': 'Pilih Kategori',
|
'selectCategory': 'Pilih Kategori',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'Notifikasi Desktop',
|
||||||
|
'notificationTaskDone': 'Tugas selesai',
|
||||||
|
'notificationTaskError': 'Tugas error',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -130,5 +130,19 @@ const itIT = {
|
|||||||
'categoryPath': 'Percorso categoria',
|
'categoryPath': 'Percorso categoria',
|
||||||
'builtInCategory': 'La categoria predefinita non può essere eliminata',
|
'builtInCategory': 'La categoria predefinita non può essere eliminata',
|
||||||
'selectCategory': 'Seleziona categoria',
|
'selectCategory': 'Seleziona categoria',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'Notifiche Desktop',
|
||||||
|
'notificationTaskDone': 'Attività completata',
|
||||||
|
'notificationTaskError': 'Errore attività',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -95,5 +95,19 @@ const jaJP = {
|
|||||||
'launchAtStartup': '起動時に起動',
|
'launchAtStartup': '起動時に起動',
|
||||||
'runAsMenubarApp': 'メニューバーアプリとして実行',
|
'runAsMenubarApp': 'メニューバーアプリとして実行',
|
||||||
'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行',
|
'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行',
|
||||||
|
'ed2k': 'ED2K',
|
||||||
|
'ed2kTcpPort': 'TCP Listen Port',
|
||||||
|
'ed2kUdpPort': 'UDP Listen Port',
|
||||||
|
'ed2kServerList': 'Server Addresses',
|
||||||
|
'ed2kServerMet': 'Server.met Sources',
|
||||||
|
'ed2kNodesDat': 'nodes.dat Sources',
|
||||||
|
'ed2kAutoPort': 'Auto assign',
|
||||||
|
'ed2kOnePerLine': 'One entry per line',
|
||||||
|
'ed2kServersHint': 'host:port, one per line',
|
||||||
|
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
|
||||||
|
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
|
||||||
|
'desktopNotification': 'デスクトップ通知',
|
||||||
|
'notificationTaskDone': 'タスク完了',
|
||||||
|
'notificationTaskError': 'タスクエラー',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Loaded 50 of 64 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user