Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d315f01289 | ||
|
|
a45b6455be | ||
|
|
f0aa44455c | ||
|
|
99ed770017 |
No files matched your search
@@ -7,7 +7,7 @@ description: Design or revise Gopeed file-classification icons as one cohesive S
|
|||||||
|
|
||||||
Maintain Gopeed's file-classification visual language and its deterministic SVG → TTF → Flutter `IconData` pipeline.
|
Maintain Gopeed's file-classification visual language and its deterministic SVG → TTF → Flutter `IconData` pipeline.
|
||||||
|
|
||||||
The canonical editable materials live in `design/file-category-icons/` as tooling-only sources inside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets.
|
The canonical editable materials live in `design/file-category-icons/`, outside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets.
|
||||||
|
|
||||||
## Classification grammar
|
## Classification grammar
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ Use [assets/manifest.example.json](assets/manifest.example.json) and [assets/fol
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Existing codepoints are API: never renumber or reuse one unless the user explicitly approves a breaking migration.
|
- Existing codepoints are API: never renumber or reuse one unless the user explicitly approves a breaking migration.
|
||||||
- `design/file-category-icons/` is the sole editable source of truth. The packaged Flutter runtime uses only the generated TTF and Dart constants.
|
- `design/file-category-icons/` is the sole editable source of truth. Flutter keeps only the generated runtime TTF and Dart constants.
|
||||||
- Never add the design-material directory to `pubspec.yaml`; it must remain outside the packaged Flutter asset graph.
|
- Never add the design-material directory to `pubspec.yaml`; it must remain outside the packaged Flutter asset graph.
|
||||||
- The manifest is the source of truth. Do not hand-edit generated Dart constants or patch the binary font.
|
- The manifest is the source of truth. Do not hand-edit generated Dart constants or patch the binary font.
|
||||||
- Keep icon names lowercase `snake_case`; the generator emits lowerCamelCase Dart fields.
|
- Keep icon names lowercase `snake_case`; the generator emits lowerCamelCase Dart fields.
|
||||||
|
Before Width: | Height: | Size: 818 B After Width: | Height: | Size: 818 B |
@@ -1,2 +0,0 @@
|
|||||||
# Keep upstream plugin sources recognizable and collapsed in GitHub reviews.
|
|
||||||
ui/flutter/third_party/** linguist-vendored whitespace=-blank-at-eol,-blank-at-eof
|
|
||||||
@@ -106,32 +106,17 @@ jobs:
|
|||||||
if ("${{ matrix.arch }}" -eq "amd64") {
|
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||||
# for amd64
|
# for amd64
|
||||||
$release = "build\windows\x64\runner\Release\"
|
$release = "build\windows\x64\runner\Release\"
|
||||||
# Use the same toolchain as cgo instead of Git's bundled DLLs.
|
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||||
$cc = (Get-Command -Name (go env CC) -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
cp $mingw\libstdc++-6.dll $release
|
||||||
foreach ($dll in @("libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll")) {
|
cp $mingw\libgcc_s_seh-1.dll $release
|
||||||
$runtimePath = [string](& $cc "-print-file-name=$dll")
|
|
||||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runtimePath)) {
|
|
||||||
throw "Failed to locate $dll using $cc"
|
|
||||||
}
|
|
||||||
$runtimePath = $runtimePath.Trim()
|
|
||||||
# Some MinGW distributions keep runtime DLLs beside gcc.exe.
|
|
||||||
if ($runtimePath -eq $dll) {
|
|
||||||
$runtimePath = Join-Path (Split-Path -Parent $cc) $dll
|
|
||||||
}
|
|
||||||
if (!(Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
|
|
||||||
throw "MinGW runtime $dll was not found by $cc (reported path: $runtimePath)"
|
|
||||||
}
|
|
||||||
Write-Host "Copying MinGW runtime: $runtimePath"
|
|
||||||
Copy-Item -LiteralPath $runtimePath -Destination $release
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
# for ARM64
|
# for ARM64
|
||||||
$release = "build\windows\arm64\runner\Release\"
|
$release = "build\windows\arm64\runner\Release\"
|
||||||
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||||
cp $mingw\libc++.dll $release
|
cp $mingw\libc++.dll $release
|
||||||
cp $mingw\libunwind.dll $release
|
cp $mingw\libunwind.dll $release
|
||||||
cp $mingw\libwinpthread-1.dll $release
|
|
||||||
}
|
}
|
||||||
|
cp $mingw\libwinpthread-1.dll $release
|
||||||
cp $system\msvcp140.dll $release
|
cp $system\msvcp140.dll $release
|
||||||
cp $system\vcruntime140.dll $release
|
cp $system\vcruntime140.dll $release
|
||||||
cp $system\vcruntime140_1.dll $release
|
cp $system\vcruntime140_1.dll $release
|
||||||
@@ -798,5 +783,5 @@ jobs:
|
|||||||
linux/arm64
|
linux/arm64
|
||||||
linux/arm/v7
|
linux/arm/v7
|
||||||
tags: |
|
tags: |
|
||||||
${{ !contains(env.VERSION, 'beta') && 'liwei2633/gopeed:latest' || '' }}
|
liwei2633/gopeed:latest
|
||||||
liwei2633/gopeed:v${{ env.VERSION }}
|
liwei2633/gopeed:v${{ env.VERSION }}
|
||||||
@@ -69,49 +69,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||||
test-flutter-category:
|
|
||||||
# The settings editor and the backend have to read an extension list the
|
|
||||||
# same way, so both sides run the shared rule table. The Go side is covered
|
|
||||||
# by the test job above, this runs the ui side of it.
|
|
||||||
name: Test shared category rules
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
|
||||||
- name: Run the shared category rule table on the ui side
|
|
||||||
working-directory: ui/flutter
|
|
||||||
run: |
|
|
||||||
flutter pub get
|
|
||||||
flutter gen-l10n
|
|
||||||
flutter test test/api/downloader_config_test.dart
|
|
||||||
test-flutter-task-context-menu:
|
|
||||||
name: Test task context menus and text inputs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
env:
|
|
||||||
CHROME_EXECUTABLE: /usr/bin/google-chrome
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
|
||||||
- name: Prepare Flutter tests
|
|
||||||
working-directory: ui/flutter
|
|
||||||
run: |
|
|
||||||
flutter pub get
|
|
||||||
flutter gen-l10n
|
|
||||||
- name: Test task menu actions and native input policy
|
|
||||||
working-directory: ui/flutter
|
|
||||||
run: |
|
|
||||||
flutter test --no-pub test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
|
|
||||||
flutter test --no-pub test/widget_test.dart --plain-name 'task context menu exposes'
|
|
||||||
- name: Test browser menu cancellation and input policy
|
|
||||||
working-directory: ui/flutter
|
|
||||||
run: |
|
|
||||||
"$CHROME_EXECUTABLE" --version
|
|
||||||
flutter test --no-pub --platform chrome test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
|
|
||||||
test-webview-integration:
|
test-webview-integration:
|
||||||
name: Test webview integration (${{ matrix.name }})
|
name: Test webview integration (${{ matrix.name }})
|
||||||
strategy:
|
strategy:
|
||||||
@@ -120,8 +77,6 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- name: Windows
|
- name: Windows
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
- name: Linux
|
|
||||||
os: ubuntu-latest
|
|
||||||
- name: macOS
|
- name: macOS
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -134,25 +89,9 @@ jobs:
|
|||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
with:
|
with:
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
- name: Install Linux WebView dependencies
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
|
||||||
- name: Test webview
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
# Match the full suite's coverage instrumentation and repeat on the same
|
|
||||||
# GTK event loop to catch queued callbacks after engine destruction.
|
|
||||||
run: |
|
|
||||||
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
|
||||||
go test -c -tags webview -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -o "$RUNNER_TEMP/goprovider.test" ./internal/webview/goprovider
|
|
||||||
xvfb-run -a "$RUNNER_TEMP/goprovider.test" -test.v -test.count=5 -test.timeout=300s -test.coverprofile="$RUNNER_TEMP/webview-coverage.out"
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
- name: Test webview
|
- name: Test webview
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
# Repeat close/reopen coverage to catch browser shutdown races.
|
run: go test -tags webview -v -count=1 -timeout 300s ./internal/webview/goprovider/
|
||||||
run: go test -tags webview -v -count=3 -timeout 300s ./internal/webview/goprovider/
|
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
- name: Test webview
|
- name: Test webview
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Beyond core download management, Gopeed offers browser integration, JavaScript e
|
|||||||
|
|
||||||
Visit ✈ [Official Website](https://gopeed.com)
|
Visit ✈ [Official Website](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex
|
|||||||
|
|
||||||
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
|
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
|
||||||
|
|
||||||
- [Download Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
- [Download Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||||
|
|
||||||
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
|
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で
|
|||||||
|
|
||||||
✈ [公式サイト](https://gopeed.com)
|
✈ [公式サイト](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主な機能
|
## ✨ 主な機能
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
|
|||||||
|
|
||||||
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
|
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
|
||||||
|
|
||||||
- [Gopeed 2.0.0 Beta 3 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
- [Gopeed 2.0.0 Beta 1 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||||
|
|
||||||
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Ngoài việc quản lý tải xuống hằng ngày, Gopeed còn hỗ trợ tíc
|
|||||||
|
|
||||||
Truy cập ✈ [Trang web chính thức](https://gopeed.com)
|
Truy cập ✈ [Trang web chính thức](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ Tính năng chính
|
## ✨ Tính năng chính
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu
|
|||||||
|
|
||||||
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
|
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
|
||||||
|
|
||||||
- [Tải Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
- [Tải Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||||
|
|
||||||
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
|
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
|||||||
|
|
||||||
访问 ✈ [官方网站](https://gopeed.com)
|
访问 ✈ [官方网站](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主要特性
|
## ✨ 主要特性
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
|||||||
|
|
||||||
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
|
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
|
||||||
|
|
||||||
- [下载 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
- [下载 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||||
|
|
||||||
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
|
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
|||||||
|
|
||||||
瀏覽 ✈ [官方網站](https://gopeed.com)
|
瀏覽 ✈ [官方網站](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主要功能
|
## ✨ 主要功能
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
|||||||
|
|
||||||
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
|
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
|
||||||
|
|
||||||
- [下載 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
- [下載 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
|
||||||
|
|
||||||
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 787 KiB |
@@ -1,52 +0,0 @@
|
|||||||
# Batch task creation
|
|
||||||
|
|
||||||
`POST /api/v1/tasks/batch` accepts ordinary and grouped batches:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"group": true,
|
|
||||||
"groupName": "album",
|
|
||||||
"opts": {"path": "/downloads"},
|
|
||||||
"reqs": [
|
|
||||||
{"rid": "resolved-resource-id", "opts": {"selectFiles": [1]}},
|
|
||||||
{"req": {"url": "https://example.com/cover.jpg"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Each member accepts `rid` or `req`. A nonempty `rid` takes priority and creates a
|
|
||||||
task from the cached resolve result. A member's `opts`, when present, replaces
|
|
||||||
batch `opts` as a whole; omitted fields do not inherit individual batch values.
|
|
||||||
Members without `opts` use batch `opts`. BT file selections use the indexes from
|
|
||||||
the resolve result. Requests without `rid` continue to work.
|
|
||||||
|
|
||||||
For groups, batch `opts.path` selects the parent directory. Members inheriting
|
|
||||||
batch options, or with an empty path, use the reserved group directory. Explicit
|
|
||||||
relative member paths are resolved under that directory; do not prefix them with
|
|
||||||
the batch parent path. For example, parent `downloads` and member path `nested`
|
|
||||||
place the member in `downloads/<group name>/nested`. Absolute paths under the
|
|
||||||
parent directory preserve their relative structure within the group. In ordinary
|
|
||||||
batches, an empty member path uses the configured default download directory.
|
|
||||||
|
|
||||||
All members are attempted. If at least one task is persisted, the response is
|
|
||||||
successful and contains the created task IDs. A task that fails to start after
|
|
||||||
being persisted is included; use the existing task controls to retry it.
|
|
||||||
Successful tasks are kept when other members fail. If every member fails, the
|
|
||||||
API returns an error and removes any newly reserved empty group and directory.
|
|
||||||
|
|
||||||
Both ordinary and grouped batches now return an object in `data`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"code": 0, "msg": "", "data": {"taskIds": ["task-1", "task-2"], "parentId": "group-id"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ordinary batches omit `parentId`. **This replaces the former array response for
|
|
||||||
ordinary batches.** The outer `code`/`msg`/`data` envelope is unchanged. Clients
|
|
||||||
must read `data.taskIds`; there are no per-member results. A successful response
|
|
||||||
completes the batch, including partial success, so clients must not automatically
|
|
||||||
resubmit missing members.
|
|
||||||
|
|
||||||
With `group: true`, the server generates the group ID and reserves its directory.
|
|
||||||
Creation does not accept a client group ID or append to an existing group. Group
|
|
||||||
metadata remains available through the existing task list and detail responses;
|
|
||||||
`parentId` identifies membership for group operations.
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
||||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||||
goapi "github.com/GopeedLab/gopeed/pkg/api"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
)
|
)
|
||||||
@@ -91,14 +90,3 @@ func applyWebViewProvider(config *model.StartConfig) {
|
|||||||
}
|
}
|
||||||
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
func LiveActivityTaskEventMask() int64 {
|
|
||||||
return int64(
|
|
||||||
goapi.TaskEventDone |
|
|
||||||
goapi.TaskEventError |
|
|
||||||
goapi.TaskEventStart |
|
|
||||||
goapi.TaskEventProgress |
|
|
||||||
goapi.TaskEventPause |
|
|
||||||
goapi.TaskEventDelete,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/cmd"
|
"github.com/GopeedLab/gopeed/cmd"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
)
|
)
|
||||||
@@ -16,8 +13,5 @@ func main() {
|
|||||||
Storage: model.StorageBolt,
|
Storage: model.StorageBolt,
|
||||||
WebEnable: true,
|
WebEnable: true,
|
||||||
}
|
}
|
||||||
if err := cmd.Start(cfg); err != nil {
|
cmd.Start(cfg)
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ func runResolve(args []string) error {
|
|||||||
result, err := d.Resolve(&base.Request{
|
result, err := d.Resolve(&base.Request{
|
||||||
URL: url,
|
URL: url,
|
||||||
Labels: map[string]string(labels),
|
Labels: map[string]string(labels),
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,7 @@ func runDownload(args []string) error {
|
|||||||
result, err := d.Resolve(&base.Request{
|
result, err := d.Resolve(&base.Request{
|
||||||
URL: url,
|
URL: url,
|
||||||
Labels: map[string]string(labels),
|
Labels: map[string]string(labels),
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
url string
|
url string
|
||||||
connections *int
|
connections *int
|
||||||
dir *string
|
dir *string
|
||||||
checksum *string
|
|
||||||
checksumAlgo *string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parse() *args {
|
func parse() *args {
|
||||||
@@ -22,8 +20,6 @@ func parse() *args {
|
|||||||
var args args
|
var args args
|
||||||
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
||||||
args.dir = flag.String("D", dir, "Store directory.")
|
args.dir = flag.String("D", dir, "Store directory.")
|
||||||
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
|
|
||||||
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
t := flag.Args()
|
t := flag.Args()
|
||||||
if len(t) > 0 {
|
if len(t) > 0 {
|
||||||
|
|||||||
@@ -19,18 +19,6 @@ func main() {
|
|||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
var checksumOpt *http.ChecksumOption
|
|
||||||
if args.checksum != nil && *args.checksum != "" {
|
|
||||||
algo := "md5"
|
|
||||||
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
|
|
||||||
algo = *args.checksumAlgo
|
|
||||||
}
|
|
||||||
checksumOpt = &http.ChecksumOption{
|
|
||||||
Algorithm: algo,
|
|
||||||
Expected: *args.checksum,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := download.Boot().
|
_, err := download.Boot().
|
||||||
URL(args.url).
|
URL(args.url).
|
||||||
Listener(func(event *download.Event) {
|
Listener(func(event *download.Event) {
|
||||||
@@ -55,11 +43,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
}).
|
}).
|
||||||
Create(&base.Options{
|
Create(&base.Options{
|
||||||
Path: *args.dir,
|
Path: *args.dir,
|
||||||
Extra: http.OptsExtra{
|
Extra: http.OptsExtra{Connections: *args.connections},
|
||||||
Connections: *args.connections,
|
|
||||||
Checksum: checksumOpt,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
@@ -16,18 +16,17 @@ import (
|
|||||||
//go:embed banner.txt
|
//go:embed banner.txt
|
||||||
var banner string
|
var banner string
|
||||||
|
|
||||||
func Start(cfg *model.StartConfig) error {
|
func Start(cfg *model.StartConfig) {
|
||||||
|
fmt.Println(banner)
|
||||||
srv, listener, err := rest.BuildServer(cfg)
|
srv, listener, err := rest.BuildServer(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
panic(err)
|
||||||
}
|
}
|
||||||
fmt.Println(banner)
|
|
||||||
downloadCfg, err := rest.Downloader.GetConfig()
|
downloadCfg, err := rest.Downloader.GetConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
listener.Close()
|
panic(err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if rest.Downloader.IsFirstLoad() {
|
if downloadCfg.FirstLoad {
|
||||||
// Set default download config
|
// Set default download config
|
||||||
if cfg.DownloadConfig != nil {
|
if cfg.DownloadConfig != nil {
|
||||||
cfg.DownloadConfig.Merge(downloadCfg)
|
cfg.DownloadConfig.Merge(downloadCfg)
|
||||||
@@ -52,20 +51,13 @@ func Start(cfg *model.StartConfig) error {
|
|||||||
rest.Downloader.PutConfig(downloadCfg)
|
rest.Downloader.PutConfig(downloadCfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The default download dir is resolved only after the core starts, so
|
|
||||||
// seed the built-in categories now that the dir is known. Init is
|
|
||||||
// idempotent and leaves an existing category list untouched.
|
|
||||||
downloadCfg.Init()
|
|
||||||
rest.Downloader.PutConfig(downloadCfg)
|
|
||||||
}
|
}
|
||||||
watchExit()
|
watchExit()
|
||||||
|
|
||||||
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
|
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
|
||||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
return err
|
panic(err)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func watchExit() {
|
func watchExit() {
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStartInvalidWebAPITokenReturnsError(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
webAuth *model.WebAuth
|
|
||||||
}{
|
|
||||||
{name: "no credentials"},
|
|
||||||
{name: "missing username", webAuth: &model.WebAuth{Password: "secret"}},
|
|
||||||
{name: "missing password", webAuth: &model.WebAuth{Username: "gopeed"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := Start(&model.StartConfig{
|
|
||||||
WebEnable: true,
|
|
||||||
ApiToken: "test-token",
|
|
||||||
WebAuth: tt.webAuth,
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Start() error = nil, want web authentication validation error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "web authentication username and password are required") {
|
|
||||||
t.Fatalf("Start() error = %q, want missing web authentication credentials", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,17 +37,15 @@ func installByInstaller(killSignalChan chan<- any, packagePath, destDir string)
|
|||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Find the installer file
|
||||||
var installerPath string
|
var installerPath string
|
||||||
for _, file := range reader.File {
|
for _, file := range reader.File {
|
||||||
cleanName := filepath.Clean(file.Name)
|
|
||||||
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(tempDir, cleanName)
|
// Extract file
|
||||||
|
path := filepath.Join(tempDir, file.Name)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -103,11 +101,7 @@ func installByPortable(killSignalChan chan<- any, packagePath, destDir string) e
|
|||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|
||||||
for _, file := range reader.File {
|
for _, file := range reader.File {
|
||||||
cleanName := filepath.Clean(file.Name)
|
path := filepath.Join(destDir, file.Name)
|
||||||
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := filepath.Join(destDir, cleanName)
|
|
||||||
|
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
os.MkdirAll(path, file.Mode())
|
os.MkdirAll(path, file.Mode())
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
TempDir *string `json:"tempDir"`
|
|
||||||
Address *string `json:"address"`
|
Address *string `json:"address"`
|
||||||
Port *int `json:"port"`
|
Port *int `json:"port"`
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
@@ -52,10 +51,9 @@ func loadCliArgs() *args {
|
|||||||
flag.StringVar(cfg.Username, "username", "gopeed", "Web Authentication Username")
|
flag.StringVar(cfg.Username, "username", "gopeed", "Web Authentication Username")
|
||||||
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
||||||
flag.StringVar(cfg.Password, "password", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
flag.StringVar(cfg.Password, "password", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
||||||
cfg.ApiToken = flag.String("T", "", "API token (requires web authentication with username and password)")
|
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
||||||
flag.StringVar(cfg.ApiToken, "api-token", "", "API token (requires web authentication with username and password)")
|
flag.StringVar(cfg.ApiToken, "api-token", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
||||||
flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
|
flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
|
||||||
cfg.TempDir = flag.String("temp-dir", "", "Temporary directory (defaults to the OS temporary directory)")
|
|
||||||
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
||||||
flag.StringVar(cfg.StorageDir, "storage-dir", "", "Storage directory")
|
flag.StringVar(cfg.StorageDir, "storage-dir", "", "Storage directory")
|
||||||
whiteDownloadDirs := flag.String("white-download-dirs", "", "White download directories, comma-separated")
|
whiteDownloadDirs := flag.String("white-download-dirs", "", "White download directories, comma-separated")
|
||||||
@@ -91,8 +89,6 @@ func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
|||||||
cfg.ApiToken = cliConfig.ApiToken
|
cfg.ApiToken = cliConfig.ApiToken
|
||||||
case "mcp-enable":
|
case "mcp-enable":
|
||||||
cfg.MCPEnable = cliConfig.MCPEnable
|
cfg.MCPEnable = cliConfig.MCPEnable
|
||||||
case "temp-dir":
|
|
||||||
cfg.TempDir = cliConfig.TempDir
|
|
||||||
case "d", "storage-dir":
|
case "d", "storage-dir":
|
||||||
cfg.StorageDir = cliConfig.StorageDir
|
cfg.StorageDir = cliConfig.StorageDir
|
||||||
case "white-download-dirs":
|
case "white-download-dirs":
|
||||||
@@ -105,9 +101,6 @@ func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
|||||||
|
|
||||||
// setDefaults sets default values for any unset configuration fields
|
// setDefaults sets default values for any unset configuration fields
|
||||||
func setDefaults(cfg *args, cliConfig *args) {
|
func setDefaults(cfg *args, cliConfig *args) {
|
||||||
if cfg.TempDir == nil {
|
|
||||||
cfg.TempDir = cliConfig.TempDir
|
|
||||||
}
|
|
||||||
if cfg.Address == nil {
|
if cfg.Address == nil {
|
||||||
cfg.Address = cliConfig.Address
|
cfg.Address = cliConfig.Address
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,16 +43,11 @@ func main() {
|
|||||||
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
tempDir := ""
|
|
||||||
if args.TempDir != nil {
|
|
||||||
tempDir = *args.TempDir
|
|
||||||
}
|
|
||||||
cfg := &model.StartConfig{
|
cfg := &model.StartConfig{
|
||||||
Network: "tcp",
|
Network: "tcp",
|
||||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||||
Storage: model.StorageBolt,
|
Storage: model.StorageBolt,
|
||||||
StorageDir: storageDir,
|
StorageDir: storageDir,
|
||||||
TempDir: tempDir,
|
|
||||||
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
||||||
ApiToken: *args.ApiToken,
|
ApiToken: *args.ApiToken,
|
||||||
MCPEnable: args.MCPEnable,
|
MCPEnable: args.MCPEnable,
|
||||||
@@ -62,10 +57,7 @@ func main() {
|
|||||||
WebFS: sub,
|
WebFS: sub,
|
||||||
WebAuth: webAuth,
|
WebAuth: webAuth,
|
||||||
}
|
}
|
||||||
if err := cmd.Start(cfg); err != nil {
|
cmd.Start(cfg)
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNotBlank(str *string) bool {
|
func isNotBlank(str *string) bool {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 文件分类图标源物料
|
# 文件分类图标源物料
|
||||||
|
|
||||||
此目录是 Gopeed 文件分类图标的唯一源物料目录。它位于 Flutter 工程内,但只用于设计和生成流程,不得加入 `pubspec.yaml` 的 `assets` 配置。
|
此目录是 Gopeed 文件分类图标的唯一源物料目录,位于 Flutter 工程之外,不得加入 `ui/flutter/pubspec.yaml` 的 `assets` 配置。
|
||||||
|
|
||||||
## 维护限制
|
## 维护限制
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
|
|
||||||
## 生成产物
|
## 生成产物
|
||||||
|
|
||||||
- Flutter 字体:`assets/fonts/GopeedIcons.ttf`
|
- Flutter 字体:`ui/flutter/assets/fonts/GopeedIcons.ttf`
|
||||||
- Flutter 常量:`lib/core/icons/gopeed_icons.dart`
|
- Flutter 常量:`ui/flutter/lib/core/icons/gopeed_icons.dart`
|
||||||
|
|
||||||
所有修改都应遵循:
|
所有修改都应遵循:
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"fontFamily": "GopeedIcons",
|
"fontFamily": "GopeedIcons",
|
||||||
"fontOutput": "../../assets/fonts/GopeedIcons.ttf",
|
"fontOutput": "../../ui/flutter/assets/fonts/GopeedIcons.ttf",
|
||||||
"dartOutput": "../../lib/core/icons/gopeed_icons.dart",
|
"dartOutput": "../../ui/flutter/lib/core/icons/gopeed_icons.dart",
|
||||||
"previewOutput": "preview/gopeed_icons_preview.png",
|
"previewOutput": "preview/gopeed_icons_preview.png",
|
||||||
"dartClass": "GopeedIcons",
|
"dartClass": "GopeedIcons",
|
||||||
"codepointStart": "0xE900",
|
"codepointStart": "0xE900",
|
||||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 526 B After Width: | Height: | Size: 526 B |
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 387 B |
|
Before Width: | Height: | Size: 342 B After Width: | Height: | Size: 342 B |
|
Before Width: | Height: | Size: 323 B After Width: | Height: | Size: 323 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 495 B After Width: | Height: | Size: 495 B |
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 293 B |
|
Before Width: | Height: | Size: 355 B After Width: | Height: | Size: 355 B |
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 293 B |
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 395 B |
|
Before Width: | Height: | Size: 256 B After Width: | Height: | Size: 256 B |
|
Before Width: | Height: | Size: 262 B After Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 479 B |