Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andmonkeyWie 0bd306773d Replace redundant chunked tests with targeted non-range pause/resume test
Remove TestFetcher_DownloadChunkedContinue (redundant with existing tests).
Replace TestFetcher_DownloadChunkedNotExceedSize with
TestFetcher_DownloadNoRangePauseResume which specifically targets the bug:
- Documents the root cause (non-range resume writes at accumulated offset
  instead of byte 0)
- Asserts downloaded file size does not exceed source file size (the exact
  bug symptom from the issue)
- Verifies file integrity via MD5

Co-authored-by: monkeyWie <[email protected]>
2026-03-12 01:22:06 +00:00
copilot-swe-agent[bot]andmonkeyWie 8b02844ec3 Fix tests: use StartTestCustomServer (no Content-Length) to match real bug scenario
Remove StartTestNoRangeServer and its tests since the real bug is about
servers that don't return Content-Length at all (like GitHub's zip downloads),
not servers with Content-Length but no Range support.

The chunked tests (TestFetcher_DownloadChunkedContinue and
TestFetcher_DownloadChunkedNotExceedSize) correctly simulate the real-world
bug using StartTestCustomServer which serves files without Content-Length.

Co-authored-by: monkeyWie <[email protected]>
2026-03-11 10:53:52 +00:00
copilot-swe-agent[bot]andmonkeyWie b8e517b35b Fix data race in expandConnections: remove orphaned waitForCompletion goroutine
The go f.waitForCompletion() goroutine in expandConnections was redundant
since the downloadLoop's slow start loop already handles stateSteady by
calling waitForCompletion directly. The orphaned goroutine could race with
doStart() when writing f.ctx during pause/resume.

Co-authored-by: monkeyWie <[email protected]>
2026-03-11 02:59:41 +00:00
copilot-swe-agent[bot]andmonkeyWie 2daa9c45df Fix non-range download bug: downloaded bytes exceeding total file size
When HTTP server doesn't return Content-Length or doesn't support Range
requests, the download can accumulate extra bytes on pause/resume because:
1. Each retry/resume re-downloads from the beginning but writes at the
   accumulated offset
2. No size limit enforcement for non-range downloads
3. onDownloadComplete incorrectly uses chunk.remain() for non-range
   downloads, which gives negative values for chunk(0,0)

Fixes:
- Reset chunk state in downloadChunkOnce and runConnectionFallback for
  non-range downloads since server will send from beginning
- Add size limit checks in download loops when Size is known
- Fix onDownloadComplete to use conn.Completed for non-range downloads
  instead of unreliable chunk.remain()
- Add test cases for non-range download, pause/resume, and size limit

Co-authored-by: monkeyWie <[email protected]>
2026-03-11 02:50:48 +00:00
copilot-swe-agent[bot] 27c0f81a1e Initial plan 2026-03-11 02:30:03 +00:00
Jaime Muñoz Martín 1907f404ac feat(lang): Catalan language added (#1303) 2026-03-11 10:26:55 +08:00
Tuna Dinçer 72c5ba7d20 feat(lang): update Turkish translation (#1304) 2026-03-10 13:38:02 +08:00
Levi 5b0254f7c0 release: v1.9.2 2026-03-08 19:52:32 +08:00
Levi d36b345f92 feat: extension store (#1301) 2026-03-06 21:53:19 +08:00
Locon213 bc5c818eae feat(desktop): implement native desktop notifications for task completion and errors (#1293) 2026-03-01 11:12:40 +08:00
Levi 98bd3305b7 feat: update Flutter version to 3.41.2 (#1290) 2026-02-26 20:42:34 +08:00
Tuna Dinçer 3f38848051 feat: Turkish localization improvements 2026-02-25 09:54:15 +08:00
Levi 82d7f8ec81 feat: update debug extension IDs retrieval to use environment (#1281) 2026-02-14 17:25:46 +08:00
49 changed files with 2624 additions and 504 deletions

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ on:
env:
GO_VERSION: "1.24"
FLUTTER_VERSION: "3.38.7"
FLUTTER_VERSION: "3.41.2"
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
+1 -1
View File
@@ -29,7 +29,7 @@ on:
env:
GO_VERSION: "1.24"
FLUTTER_VERSION: "3.38.7"
FLUTTER_VERSION: "3.41.2"
jobs:
# lint:
+1 -1
View File
@@ -1,4 +1,4 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
+1 -1
View File
@@ -1,4 +1,4 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
+1 -1
View File
@@ -1,4 +1,4 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Trạng thái kiểm tra](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
+1 -1
View File
@@ -1,4 +1,4 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
+1 -1
View File
@@ -1,4 +1,4 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+54
View File
@@ -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

+89 -11
View File
@@ -872,9 +872,9 @@ func (f *Fetcher) expandConnections() {
f.connMu.Unlock()
if len(newConns) == 0 {
// No new connections could be created, stop expansion
// No new connections could be created, stop expansion.
// The downloadLoop will check stateSteady and call waitForCompletion.
f.setState(stateSteady)
go f.waitForCompletion()
return
}
@@ -987,6 +987,14 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
f.connMu.Unlock()
return nil
}
// For non-range downloads, reset chunk state since server will send from beginning.
// Without Range support, each new request restarts the entire transfer.
if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.End = 0
conn.Chunk.Downloaded = 0
conn.Downloaded = 0
}
rangeStart := conn.Chunk.Begin + conn.Chunk.Downloaded
rangeEnd := conn.Chunk.End
f.connMu.Unlock()
@@ -1080,6 +1088,17 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
n = int(remain)
finished = true
}
} else if f.meta.Res.Size > 0 {
// For non-range downloads with known size, enforce size limit
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
f.connMu.Unlock()
return nil
}
if remaining < int64(n) {
n = int(remaining)
finished = true
}
}
writeOffset = conn.Chunk.Begin + conn.Chunk.Downloaded
f.connMu.Unlock()
@@ -1174,6 +1193,22 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
n, err := reader.Read(buf)
if n > 0 {
// For non-range downloads with known size, enforce size limit
if !f.meta.Res.Range && f.meta.Res.Size > 0 {
f.connMu.Lock()
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
if remaining < int64(n) {
n = int(remaining)
}
f.connMu.Unlock()
}
f.fileMu.Lock()
if f.file != nil {
_, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded)
@@ -1191,8 +1226,18 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
}
f.fileMu.Unlock()
f.connMu.Lock()
conn.Chunk.Downloaded += int64(n)
conn.Downloaded += int64(n)
// Check if size limit reached after updating counters
if !f.meta.Res.Range && f.meta.Res.Size > 0 && conn.Downloaded >= f.meta.Res.Size {
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
f.connMu.Unlock()
}
if err != nil {
if err == io.EOF {
@@ -1232,6 +1277,14 @@ func (f *Fetcher) runConnectionFallback(conn *connection) {
f.connMu.Lock()
conn.State = connConnecting
// For non-range downloads, reset chunk state since server will send from beginning.
// Without Range support, each new request restarts the entire transfer.
if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.End = 0
conn.Chunk.Downloaded = 0
conn.Downloaded = 0
}
f.connMu.Unlock()
err := func() error {
@@ -1276,6 +1329,17 @@ func (f *Fetcher) runConnectionFallback(conn *connection) {
n, err := reader.Read(buf)
if n > 0 {
// For non-range downloads with known size, enforce size limit
if !f.meta.Res.Range && f.meta.Res.Size > 0 {
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
return nil
}
if remaining < int64(n) {
n = int(remaining)
}
}
f.fileMu.Lock()
if f.file != nil {
_, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded)
@@ -1475,17 +1539,31 @@ func (f *Fetcher) onDownloadComplete() {
// Check if all chunks are complete (no remaining bytes)
allChunksComplete := true
for _, conn := range f.connections {
if conn.Chunk != nil && conn.Chunk.remain() > 0 && !conn.Completed && conn.State != connCompleted {
// 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
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
// 403 is server connection limit, check if other connections will complete this chunk
continue
if !conn.Completed && conn.State != connCompleted {
if f.meta.Res.Range {
// For range downloads, use chunk remain() to check completion
if conn.Chunk != nil && conn.Chunk.remain() > 0 {
// 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
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
continue
}
}
allChunksComplete = false
break
}
} else {
// For non-range downloads, completion is determined solely by EOF (conn.Completed).
// chunk.remain() is unreliable for non-range chunks (Begin=0, End=0).
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
continue
}
}
allChunksComplete = false
break
}
allChunksComplete = false
break
}
}
+52
View File
@@ -307,6 +307,58 @@ func TestFetcher_DownloadChunked(t *testing.T) {
downloadNormal(listener, 2, t)
}
// TestFetcher_DownloadNoRangePauseResume targets a specific bug where pausing
// and resuming a download from a server that doesn't return Content-Length
// (and doesn't support Range) causes the downloaded file to grow beyond the
// actual file size. Root cause: on resume the server resends from byte 0, but
// the old code wrote at the previously accumulated offset, appending a second
// copy instead of overwriting. The fix resets the write offset on each non-range
// retry so data is always written from the beginning.
func TestFetcher_DownloadNoRangePauseResume(t *testing.T) {
listener := test.StartTestCustomServer()
defer listener.Close()
fetcher := downloadReady(listener, 1, t)
err := fetcher.Start()
if err != nil {
t.Fatal(err)
}
// Let it download some data
time.Sleep(time.Millisecond * 50)
if err := fetcher.Pause(); err != nil {
t.Fatal(err)
}
time.Sleep(time.Millisecond * 50)
if err := fetcher.Start(); err != nil {
t.Fatal(err)
}
err = fetcher.Wait()
if err != nil {
t.Fatal(err)
}
// The critical assertion: the downloaded file must not be larger than
// the source file. Before the fix, each resume appended extra data,
// causing the file to exceed the actual size (the bug shown in the issue).
downloadInfo, err := os.Stat(test.DownloadFile)
if err != nil {
t.Fatal(err)
}
sourceInfo, err := os.Stat(test.BuildFile)
if err != nil {
t.Fatal(err)
}
if downloadInfo.Size() > sourceInfo.Size() {
t.Errorf("Downloaded file size %d exceeds source file size %d", downloadInfo.Size(), sourceInfo.Size())
}
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
if want != got {
t.Errorf("Download() got = %v, want %v", got, want)
}
}
func TestFetcher_DownloadPost(t *testing.T) {
listener := test.StartTestPostServer()
defer listener.Close()
+2
View File
@@ -28,6 +28,7 @@ android {
ndkVersion flutter.ndkVersion
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
@@ -83,4 +84,5 @@ flutter {
dependencies {
implementation(name: 'libgopeed', ext: 'aar')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
}
+3 -3
View File
@@ -200,10 +200,10 @@ Future<void> putConfig(DownloaderConfig config) async {
return _parse(() => _client.dio.put("api/v1/config", data: config), null);
}
Future<void> installExtension(InstallExtension installExtension) async {
return _parse(
Future<String> installExtension(InstallExtension installExtension) async {
return _parse<String>(
() => _client.dio.post("api/v1/extensions", data: installExtension),
null);
(data) => data as String);
}
Future<List<Extension>> getExtensions() async {
+55
View File
@@ -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!);
}
}
@@ -89,6 +89,7 @@ class ExtraConfig {
bool defaultBtClient;
bool notifyWhenNewVersion;
bool autoStartTasks;
bool desktopNotification;
List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt();
@@ -102,6 +103,7 @@ class ExtraConfig {
this.defaultBtClient = true,
this.notifyWhenNewVersion = true,
this.autoStartTasks = false,
this.desktopNotification = true,
this.downloadCategories = const [],
});
@@ -19,8 +19,7 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
..webhook =
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
..script =
ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
..autoTorrent = AutoTorrentConfig.fromJson(
json['autoTorrent'] as Map<String, dynamic>?)
..archive =
@@ -91,6 +90,7 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
desktopNotification: json['desktopNotification'] as bool? ?? true,
downloadCategories: (json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
@@ -109,6 +109,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
'defaultBtClient': instance.defaultBtClient,
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
'autoStartTasks': instance.autoStartTasks,
'desktopNotification': instance.desktopNotification,
'downloadCategories':
instance.downloadCategories.map((e) => e.toJson()).toList(),
'bt': instance.bt.toJson(),
@@ -156,8 +157,7 @@ Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
'urls': instance.urls,
};
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) =>
ScriptConfig(
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) => ScriptConfig(
enable: json['enable'] as bool? ?? false,
paths:
(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,
}
@@ -17,6 +17,7 @@ import 'package:uri_to_file/uri_to_file.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart';
import '../../../../api/model/downloader_config.dart';
@@ -38,6 +39,7 @@ import '../../../../util/util.dart';
import '../../../routes/app_pages.dart';
import '../../../rpc/rpc.dart';
import '../../redirect/views/redirect_view.dart';
import '../../../services/notification_service.dart';
const unixSocketPath = 'gopeed.sock';
@@ -96,6 +98,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
_initWindows().onError((error, stackTrace) =>
logger.w("initWindows error", error, stackTrace));
if (Util.isDesktop()) {
Get.put(NotificationService());
}
_initTray().onError(
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
@@ -1,38 +1,278 @@
import 'package:get/get.dart';
import 'package:gopeed/api/api.dart';
import 'dart:async';
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/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 {
final extensions = <Extension>[].obs;
static const manualInstallBusyKey = '__manual_install__';
final installedExtensions = <Extension>[].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;
var _devModeCount = 0;
/// Flag to prevent handling pending install multiple times
bool pendingInstallHandled = false;
UnmodifiableMapView<String, Extension> get installedMap =>
UnmodifiableMapView(
{for (final ext in installedExtensions) ext.identity: ext});
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();
}
return storeExtensions
.map((ext) =>
ExtensionListItem(installed: installedMap[ext.id], store: ext))
.toList();
}
@override
void onInit() async {
Future<void> onInit() async {
super.onInit();
await load();
checkUpdate();
await loadInitialData();
}
Future<void> load() async {
extensions.value = await getExtensions();
Future<void> loadInitialData() async {
await Future.wait([loadInstalled(refreshUpdates: true), refreshStore()]);
}
Future<void> checkUpdate() async {
for (final ext in extensions) {
final resp = await upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
updateFlags[ext.identity] = resp.newVersion;
Future<void> loadInstalled({bool refreshUpdates = false}) async {
loadingInstalled.value = true;
try {
installedExtensions.value = await getExtensions();
if (refreshUpdates) {
await checkUpdate();
}
} finally {
loadingInstalled.value = false;
}
}
// Try to open dev mode when install button is clicked 5 times in 2 seconds
Future<void> checkUpdate() async {
final nextFlags = <String, String>{};
for (final ext in installedExtensions) {
try {
final resp = await upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
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;
}
}
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() {
if (_devModeCount == 0) {
Future.delayed(const Duration(seconds: 2), () {
@@ -46,4 +286,76 @@ class ExtensionController extends GetxController {
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,27 @@ 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
final httpConfig = downloaderCfg.value.protocolConfig.http;
final buildHttpUa =
@@ -1592,6 +1613,7 @@ class SettingView extends GetView<SettingController> {
buildBrowserExtension(),
buildAutoStartup(),
buildMenubarMode(),
buildDesktopNotification(),
]),
)),
Text('archives'.tr),
@@ -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,
);
}
}
+221
View File
@@ -0,0 +1,221 @@
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',
'desktopNotification': 'Notificacions d\'escriptori',
'notificationTaskDone': 'Tasca completada',
'notificationTaskError': 'Error de tasca',
},
};
+3
View File
@@ -164,5 +164,8 @@ const deDE = {
'categoryPath': 'Kategoriepfad',
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
'selectCategory': 'Kategorie auswählen',
'desktopNotification': 'Desktop-Benachrichtigungen',
'notificationTaskDone': 'Aufgabe abgeschlossen',
'notificationTaskError': 'Aufgabenfehler',
},
};
+24
View File
@@ -107,6 +107,27 @@ const enUS = {
'extensionAlreadyLatest': 'It\'s already the latest version',
'extensionFind': 'Find 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',
'clearHistory': 'Clear History',
'noHistoryFound': 'No History Found',
@@ -193,5 +214,8 @@ const enUS = {
'Task "@name" is waiting for URL update. Do you want to update it with the new URL?',
'pendingUpdateYes': 'Update Task',
'pendingUpdateNo': 'Create New Task',
'desktopNotification': 'Desktop Notifications',
'notificationTaskDone': 'Task completed',
'notificationTaskError': 'Task error',
},
};
+3
View File
@@ -142,5 +142,8 @@ const esES = {
'categoryPath': 'Ruta de categoría',
'builtInCategory': 'La categoría incorporada no se puede eliminar',
'selectCategory': 'Seleccionar categoría',
'desktopNotification': 'Notificaciones de escritorio',
'notificationTaskDone': 'Tarea completada',
'notificationTaskError': 'Error de tarea',
},
};
+3
View File
@@ -93,5 +93,8 @@ const faIR = {
'launchAtStartup': 'راه‌اندازی در استارت‌آپ',
'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو',
'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو',
'desktopNotification': 'اعلان‌های دسکتاپ',
'notificationTaskDone': 'وظیفه تکمیل شد',
'notificationTaskError': 'خطای وظیفه',
}
};
+3
View File
@@ -135,5 +135,8 @@ const frFR = {
'categoryPath': 'Chemin de la catégorie',
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
'selectCategory': 'Sélectionner une catégorie',
'desktopNotification': 'Notifications de bureau',
'notificationTaskDone': 'Tâche terminée',
'notificationTaskError': 'Erreur de tâche',
},
};
+3
View File
@@ -156,5 +156,8 @@ const huHU = {
'categoryPath': 'Kategória útvonala',
'builtInCategory': 'A beépített kategória nem törölhető',
'selectCategory': 'Kategória kiválasztása',
'desktopNotification': 'Asztali értesítések',
'notificationTaskDone': 'Feladat befejezve',
'notificationTaskError': 'Feladat hiba',
},
};
+3
View File
@@ -142,5 +142,8 @@ const idID = {
'categoryPath': 'Path Kategori',
'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
'selectCategory': 'Pilih Kategori',
'desktopNotification': 'Notifikasi Desktop',
'notificationTaskDone': 'Tugas selesai',
'notificationTaskError': 'Tugas error',
},
};
+3
View File
@@ -130,5 +130,8 @@ const itIT = {
'categoryPath': 'Percorso categoria',
'builtInCategory': 'La categoria predefinita non può essere eliminata',
'selectCategory': 'Seleziona categoria',
'desktopNotification': 'Notifiche Desktop',
'notificationTaskDone': 'Attività completata',
'notificationTaskError': 'Errore attività',
},
};
+3
View File
@@ -95,5 +95,8 @@ const jaJP = {
'launchAtStartup': '起動時に起動',
'runAsMenubarApp': 'メニューバーアプリとして実行',
'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行',
'desktopNotification': 'デスクトップ通知',
'notificationTaskDone': 'タスク完了',
'notificationTaskError': 'タスクエラー',
}
};
+3
View File
@@ -127,5 +127,8 @@ const plPL = {
'categoryPath': 'Ścieżka kategorii',
'builtInCategory': 'Wbudowanej kategorii nie można usunąć',
'selectCategory': 'Wybierz kategorię',
'desktopNotification': 'Powiadomienia na pulpicie',
'notificationTaskDone': 'Zadanie zakończone',
'notificationTaskError': 'Błąd zadania',
},
};
+3
View File
@@ -153,5 +153,8 @@ const ptBR = {
'categoryPath': 'Caminho da Categoria',
'builtInCategory': 'Categoria integrada não pode ser excluída',
'selectCategory': 'Selecionar Categoria',
'desktopNotification': 'Notificações na área de trabalho',
'notificationTaskDone': 'Tarefa concluída',
'notificationTaskError': 'Erro na tarefa',
},
};
+3
View File
@@ -152,5 +152,8 @@ const ruRU = {
'categoryPath': 'Путь категории',
'builtInCategory': 'Встроенную категорию нельзя удалить',
'selectCategory': 'Выбрать категорию',
'desktopNotification': 'Уведомления',
'notificationTaskDone': 'Задача завершена',
'notificationTaskError': 'Ошибка задачи',
}
};
+3
View File
@@ -129,5 +129,8 @@ const taTA = {
'categoryPath': 'வகை பாதை',
'builtInCategory': 'உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது',
'selectCategory': 'வகையைத் தேர்ந்தெடுக்கவும்',
'desktopNotification': 'கணினி அறிவிப்புகள்',
'notificationTaskDone': 'பணி முடிந்தது',
'notificationTaskError': 'பணி பிழை',
},
};
+116 -51
View File
@@ -4,29 +4,28 @@ const trTR = {
'error': 'Hata',
'tip': 'İpucu',
'confirm': 'Onayla',
'confirmDelete': 'Silmek istediğinizden emin misiniz?',
'confirmDelete': 'Silmek istediğinize emin misiniz?',
'cancel': 'İptal',
'on': 'Açık',
'off': 'Kapalı',
'selectAll': 'Tümünü Seç',
'select': 'Seç',
'task': 'Görevler',
'task': 'İşlemler',
'downloading': 'İndiriliyor',
'downloaded': 'İndirildi',
'setting': 'Ayarlar',
'donate': 'Bağış Yap',
'exit': 'Çıkış',
'create': 'Görev Oluştur',
'directDownload': 'Doğrudan İndirme',
'create': 'İşlem Oluştur',
'directDownload': 'Direkt İndir',
'advancedOptions': 'Gelişmiş Seçenekler',
'followSettings': 'Ayarları Takip Et',
'downloadLink': 'İndirme Bağlantısı',
'downloadLinkValid': 'Lütfen indirme bağlantısını girin',
'downloadLinkHit': 'Lütfen her satıra bir indirme bağlantısı girin@append',
'downloadLinkHitDesktop':
', veya doğrudan torrent dosyasını buraya sürükleyin',
'downloadLinkHit': 'Lütfen her satıra bir tane indirme bağlantısını girin@append',
'downloadLinkHitDesktop': ', veya torrent dosyasını olarak buraya sürükleyin',
'download': 'İndir',
'noFileSelected': 'Devam etmek için en az bir dosya seçin.',
'noFileSelected': 'Devam etmek için lütfen en az bir dosya seçin.',
'noStoragePermission': 'Depolama izni gerekli',
'selectFile': 'Dosya Seç',
'rename': 'Yeniden Adlandır',
@@ -34,15 +33,16 @@ const trTR = {
'advanced': 'Gelişmiş',
'general': 'Genel',
'downloadDir': 'İndirme Dizini',
'downloadDirValid': 'Lütfen bir indirme dizini seçin',
'downloadDirValid': 'Lütfen indirme dizinini seçin',
'connections': 'Bağlantılar',
'useServerCtime': 'Dosya oluşturma için sunucu zamanını kullan',
'maxRunning': 'Maksimum Çalışan Görev',
'defaultDirectDownload': 'Varsayılan olarak doğrudan indirmeyi seç',
'autoTorrentEnable': '.torrent dosyalarından otomatik BT görevi oluştur',
'useServerCtime': 'Dosya oluşturma tarihi için sunucu zamanını kullan',
'maxRunning': 'Maksimum Çalışan İşlem',
'defaultDirectDownload': 'Varsayılan olarak direkt indirmeyi işaretle',
'autoStartTasks': 'Başlangıçta tamamlanmamış işlemleri otomatik başlat',
'autoTorrentEnable': '.torrent dosyalarından otomatik BitTorrent işlemleri oluştur',
'autoTorrentDeleteAfterDownload':
'BT görevi oluşturulduktan sonra .torrent dosyasını sil',
'autoDeleteMissingFileTasks': 'Eksik dosya görevlerini otomatik sil',
'BitTorrent işlemi oluşturulduktan sonra .torrent dosyasını sil',
'autoDeleteMissingFileTasks': 'Eksik dosya işlemlerini otomatik sil',
'items': '@count öğe',
'subscribeTracker': 'Tracker Aboneliği',
'subscribeFail':
@@ -51,81 +51,118 @@ const trTR = {
'updateDaily': 'Günlük güncelle',
'lastUpdate': 'Son güncelleme: @time',
'addTracker': 'Tracker Ekle',
'addTrackerHit':
'Lütfen tracker sunucu URL’sini girin, her satıra bir tane',
'addTrackerHit': 'Lütfen her satıra bir tane olacak şekilde tracker sunucu adresini girin',
'ui': 'Arayüz',
'theme': 'Tema',
'themeSystem': 'Sistem',
'themeLight': 'Açık',
'themeDark': 'Koyu',
'locale': 'Dil',
'notifyWhenNewVersion': 'Güncellemeleri bildir',
'analyticsEnabled': 'İstatistikleri Yükle',
'analyticsEnabledDesc': 'Uygulamayı geliştirmemize yardımcı olmak için anonim kullanım verilerini paylaşın',
'about': 'Hakkında',
'homepage': 'Ana Sayfa',
'version': 'Sürüm',
'protocol': 'Protokol',
'port': 'Bağlantı Noktası',
'port': 'Port',
'apiToken': 'API Anahtarı',
'notSet': 'AYARLANMADI',
'set': 'AYARLA',
'portInUse': 'Bağlantı noktası [@port] kullanımda, lütfen değiştirin',
'portInUse': 'Port [@port] kullanımda, lütfen portu değiştirin',
'effectAfterRestart': 'Yeniden başlatmadan sonra etkili olacak',
'developer': 'Geliştirici',
'logDirectory': 'Günlük Dizini',
'webhook': 'Webhook',
'webhookEnable': 'Webhook\'u Etkinleştir',
'webhookDesc': 'İşlemler tamamlandığında veya başarısız olduğunda HTTP POST bildirimleri gönder',
'webhookUrlHint': 'Webhook URL\'sini girin',
'webhookTest': 'Test',
'webhookTestSuccess': 'Webhook testi başarılı',
'webhookTestFail': 'Webhook testi başarısız',
'script': 'Betik',
'scriptEnable': 'Betiği Etkinleştir',
'scriptDesc': 'İndirmeler başarıyla tamamlandığında özel betikleri çalıştır',
'scriptPathHint': 'Betik dosya yolunu girin (örn. /path/to/script.sh)',
'urlInvalid': 'Lütfen geçerli bir HTTP veya HTTPS URL\'si girin',
'required': 'Bu alan zorunludur',
'show': 'Göster',
'continue': 'Devam Et',
'pause': 'Duraklat',
'startAll': 'Tümünü Başlat',
'pauseAll': 'Tümünü Duraklat',
'deleteTask': '@count görevi sil',
'deleteTask': '@count işlemi sil',
'deleteTaskTip': 'İndirilen dosyaları sakla',
'delete': 'Sil',
'newVersionTitle': 'Yeni sürüm bulundu @version',
'add': 'Ekle',
'edit': 'Düzenle',
'newVersionTitle': 'Yeni sürüm @version keşfedildi',
'newVersionUpdate': 'Şimdi Güncelle',
'newVersionLater': 'Daha Sonra',
'extensions': 'Eklentiler',
'extensionInstallUrl': 'Kurulum URL’si',
'extensionInstallSuccess': 'Başarıyla yüklendi',
'extensionInstallUrl': 'Kurulum URL\'si',
'extensionInstallSuccess': 'Başarıyla kuruldu',
'extensionUpdateSuccess': 'Başarıyla güncellendi',
'extensionDelete': 'Eklentiyi Sil',
'extensionAlreadyLatest': 'Zaten en son sürüm',
'extensionFind': 'Eklentileri Bul',
'extensionFind': 'Eklenti Bul',
'extensionDevelop': 'Eklenti Geliştir',
'extensionStoreTitle': 'Eklenti Mağazası',
'extensionStoreSection': 'Mağaza Eklentileri',
'extensionInstalledSection': 'Yüklü Eklentiler',
'extensionInstalledEmpty': 'Henüz yüklü eklenti yok',
'extensionStoreEmpty': 'Eşleşen eklenti bulunamadı',
'extensionSearchHint': 'Ada, başlığa, açıklamaya veya yazara göre ara',
'extensionSortStars': 'En Çok Yıldız Alanlar',
'extensionSortInstalls': 'En Çok Yüklenenler',
'extensionSortUpdated': 'Son Güncellenenler',
'extensionSortToggle': 'Sıralama yönünü değiştir',
'extensionLoadMore': 'Daha Fazla Yükle',
'extensionNoMore': 'Daha fazla eklenti yok',
'extensionInstalled': 'Yüklü',
'extensionCanUpdate': 'Güncelleme Mevcut',
'extensionInstall': 'Yükle',
'extensionLoadLocal': 'Yerel Klasörden Yükle',
'extensionFilterAll': 'Tümü',
'extensionFilterMarket': 'Market',
'extensionFilterInstalled': 'Yüklü',
'extensionManualInstall': 'Manuel Yükleme',
'extensionInstallTools': 'Yükleme Seçenekleri',
'history': 'Geçmiş',
'clearHistory': 'Geçmişi Temizle',
'noHistoryFound': 'Geçmiş Bulunamadı',
'serviceTitle': 'İndirme Servisi',
'serviceText': 'Çalışıyor',
'network': 'Ağ',
'proxy': 'Vekil Sunucu',
'noProxy': 'Vekil Sunucu Yok',
'systemProxy': 'Sistem Vekil Sunucusu',
'customProxy': 'Özel Vekil Sunucu',
'proxy': 'Proxy',
'noProxy': 'Proxy Yok',
'systemProxy': 'Sistem Proxy\'si',
'customProxy': 'Özel Proxy',
'server': 'Sunucu',
'username': 'Kullanıcı Adı',
'password': 'Şifre',
'thanks': 'Teşekkürler',
'thanksDesc':
'Gopeed topluluğunun inşasında ve geliştirilmesinde katkıda bulunan herkese teşekkürler!',
'Gopeed topluluğunu oluşturmaya ve geliştirmeye yardımcı olan herkese teşekkürler!',
'browserExtension': 'Tarayıcı Eklentisi',
'launchAtStartup': 'Başlangıçta Çalıştır',
'launchAtStartup': 'Başlangıçta çalıştır',
'runAsMenubarApp': 'Menü çubuğu uygulaması olarak çalıştır',
'runAsMenubarAppDesc':
'Dock simgesini gizle ve yalnızca menü çubuğunda çalıştır',
'seedConfig': 'Seed Ayarları',
'seedKeep': 'Manuel olarak durdurulana kadar seed etmeye devam et',
'runAsMenubarAppDesc': 'Dock simgesini gizle ve sadece menü çubuğunda çalıştır',
'seedConfig': 'Seed Yapılandırması',
'seedKeep': 'Manuel olarak durdurulana kadar seed\'e devam et',
'seedRatio': 'Seed oranı',
'seedTime': 'Seed süresi (dakika)',
'setAsDefaultBtClient': 'Varsayılan BT istemcisi olarak ayarla',
'taskDetail': 'Görev Detayı',
'taskName': 'Görev Adı',
'taskUrl': 'Görev URL’si',
'setAsDefaultBtClient': 'Varsayılan BitTorrent istemcisi olarak ayarla',
'taskDetail': 'İşlem Detayı',
'taskName': 'İşlem Adı',
'taskUrl': 'İşlem URL\'si',
'downloadPath': 'İndirme Yolu',
'skipVerifyCert': 'Sertifika Doğrulamayı Atla',
'skipVerifyCert': 'Sertifika Doğrulamasını Atla',
'archives': 'Arşivler',
'autoExtract': 'Arşivleri otomatik çıkart',
'archivePassword': 'Arşiv şifresi',
'archivePasswordHint': 'Şifre yoksa boş bırakın',
'deleteAfterExtract': 'Çıkarmadan sonra arşivi sil',
'autoExtract': 'Arşivleri Otomatik Çıkar',
'archivePassword': 'Arşiv Şifresi',
'archivePasswordHint': 'Şifre korumalı değilse boş bırakın',
'deleteAfterExtract': 'Çıkardıktan sonra arşivi sil',
'extracting': 'Çıkarılıyor',
'extractDone': 'Çıkarma tamamlandı',
'extractError': 'Çıkarma başarısız',
@@ -133,15 +170,23 @@ const trTR = {
'name': 'Ad',
'size': 'Boyut',
'unknown': 'Bilinmeyen',
'fileSelectedCount': 'Dosya: ',
'fileSelectedCount': 'Dosyalar: ',
'fileSelectedSize': 'Boyut: ',
'httpHeaderName': 'Başlık Adı',
'httpHeaderValue': 'Başlık Değeri',
'insertPlaceholder': 'Yer tutucu ekle',
'placeholderYear': 'Geçerli yıl',
'placeholderMonth': 'Geçerli ay (01-12)',
'placeholderDay': 'Geçerli gün (01-31)',
'placeholderDate': 'Tam tarih (YYYY-AA-GG)',
'httpHeader': 'Üstbilgi (Header)',
'httpHeaderName': 'Üstbilgi Adı',
'httpHeaderValue': 'Üstbilgi Değeri',
'login': 'Giriş Yap',
'username_required': 'Lütfen kullanıcı adınızı girin',
'password_required': 'Lütfen şifrenizi girin',
'login_success': 'Giriş başarılı',
'login_failed': 'Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin',
'login_failed_network':
'Giriş başarısız, lütfen ağ bağlantınızı kontrol edin',
'insertPlaceholder': 'Yer Tutucu Ekle',
'placeholderYear': 'Mevcut yıl',
'placeholderMonth': 'Mevcut ay (01-12)',
'placeholderDay': 'Mevcut gün (01-31)',
'placeholderDate': 'Tam tarih (YYYY-MM-DD)',
'example': 'örn. @value',
'downloadCategories': 'İndirme Kategorileri',
'categoryMusic': 'Müzik',
@@ -152,5 +197,25 @@ const trTR = {
'categoryPath': 'Kategori Yolu',
'builtInCategory': 'Yerleşik kategori silinemez',
'selectCategory': 'Kategori Seç',
'githubMirror': 'GitHub Yansısı',
'githubMirrorEnable': 'GitHub Yansısını Etkinleştir',
'githubMirrorDesc': 'GitHub içerik indirmelerini hızlandırmak için yansıları kullanın',
'githubMirrorType': 'Yansı Türü',
'githubMirrorUrl': 'Yansı URL\'si',
'githubMirrorUrlHint': 'Yansı URL\'sini girin',
'updateUrl': 'Güncelleme URL\'si',
'updateUrlManual': 'Manuel Güncelleme',
'updateUrlListen': 'Güncellemeyi İzle',
'updateUrlListeningTip': 'URL güncellemesi bekleniyor',
'updateUrlCancelListen': 'İzlemeyi İptal Et',
'updateUrlDialogHint': 'Yeni indirme URL\'sini girin',
'pendingUpdateFound': 'Bekleyen Güncelleme İşlemi Bulundu',
'pendingUpdateConfirm':
'"@name" işlemi URL güncellemesi bekliyor. Yeni URL ile güncellemek istiyor musunuz?',
'pendingUpdateYes': 'İşlemi Güncelle',
'pendingUpdateNo': 'Yeni İşlem Oluştur',
'desktopNotification': 'Masaüstü Bildirimleri',
'notificationTaskDone': 'Görev tamamlandı',
'notificationTaskError': 'Görev hatası',
},
};
+3
View File
@@ -152,5 +152,8 @@ const ukUA = {
'categoryPath': 'Шлях категорії',
'builtInCategory': 'Вбудовану категорію неможливо видалити',
'selectCategory': 'Вибрати категорію',
'desktopNotification': 'Сповіщення',
'notificationTaskDone': 'Завдання завершено',
'notificationTaskError': 'Помилка завдання',
},
};
+3
View File
@@ -122,5 +122,8 @@ const viVN = {
'launchAtStartup': 'Khởi động cùng hệ thống',
'runAsMenubarApp': 'Chạy như ứng dụng thanh menu',
'runAsMenubarAppDesc': 'Ẩn biểu tượng Dock và chỉ chạy trong thanh menu',
'desktopNotification': 'Thông báo trên màn hình',
'notificationTaskDone': 'Nhiệm vụ hoàn thành',
'notificationTaskError': 'Lỗi nhiệm vụ',
},
};
+24
View File
@@ -105,6 +105,27 @@ const zhCN = {
'extensionAlreadyLatest': '已经是最新版本',
'extensionFind': '获取扩展',
'extensionDevelop': '开发扩展',
'extensionStoreTitle': '扩展商店',
'extensionStoreSection': '在线扩展',
'extensionInstalledSection': '已安装扩展',
'extensionInstalledEmpty': '暂未安装扩展',
'extensionStoreEmpty': '没有匹配的扩展',
'extensionSearchHint': '按名称、标题、描述或作者搜索',
'extensionSortStars': '最多 Star',
'extensionSortInstalls': '最多安装',
'extensionSortUpdated': '最近更新',
'extensionSortToggle': '切换排序方向',
'extensionLoadMore': '加载更多',
'extensionNoMore': '已经到底了',
'extensionInstalled': '已安装',
'extensionCanUpdate': '可更新',
'extensionInstall': '安装',
'extensionLoadLocal': '安装本地目录',
'extensionFilterAll': '全部',
'extensionFilterMarket': '市场',
'extensionFilterInstalled': '已安装',
'extensionManualInstall': '手动安装',
'extensionInstallTools': '安装方式',
'history': '历史记录',
'clearHistory': '清空历史记录',
'noHistoryFound': '暂无历史记录',
@@ -188,5 +209,8 @@ const zhCN = {
'pendingUpdateConfirm': '任务 "@name" 正在等待更新地址,是否使用新地址更新该任务?',
'pendingUpdateYes': '更新任务',
'pendingUpdateNo': '创建新任务',
'desktopNotification': '桌面通知',
'notificationTaskDone': '任务完成',
'notificationTaskError': '任务失败',
}
};
+3
View File
@@ -147,5 +147,8 @@ const zhTW = {
'categoryPath': '分類路徑',
'builtInCategory': '內建分類無法刪除',
'selectCategory': '選擇分類',
'desktopNotification': '桌面通知',
'notificationTaskDone': '任務完成',
'notificationTaskError': '任務失敗',
}
};
+3 -1
View File
@@ -18,6 +18,7 @@ import 'langs/es_es.dart';
import 'langs/uk_ua.dart';
import 'langs/hu_hu.dart';
import 'langs/pt_br.dart';
import 'langs/ca_es.dart';
final messages = _Messages();
@@ -42,6 +43,7 @@ class _Messages extends Translations {
...ukUA,
...huHU,
...ptBR,
...deDE
...deDE,
...caES
};
}
@@ -14,10 +14,14 @@ const _hostName = 'com.gopeed.gopeed';
const _chromeExtensionId = 'mijpgljlfcapndmchhjffkpckknofcnd';
const _edgeExtensionId = 'dkajnckekendchdleoaenoophcobooce';
const _firefoxExtensionId = '{c5d69a8f-2ed0-46a7-afa4-b3a00dc58088}';
const _debugExtensionIds = [
'gjddllnejledbfaeondocjpejpamclkk',
'goaohdfiokcjapgonhofgljfccoccief'
];
List<String> get _debugExtensionIds {
final envValue = Platform.environment['GOPEED_DEBUG_EXTENSION_IDS'] ?? '';
if (envValue.isNotEmpty) {
return envValue.split(',').map((id) => id.trim()).toList();
}
return [];
}
// Windows NativeMessagingHosts registry constants
const _chromeNativeHostsKey = r'Software\Google\Chrome\NativeMessagingHosts';
+2 -2
View File
@@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../api/api.dart';
import '../api/gopeed_site_api.dart';
import '../app/views/outlined_button_loading.dart';
import 'arch/arch.dart';
import 'github_mirror.dart';
@@ -70,8 +71,7 @@ Future<VersionInfo?> checkUpdate() async {
"https://api.github.com/repos/GopeedLab/gopeed/releases/latest"))
.data;
} catch (e) {
releaseDataStr =
(await proxyRequest("https://gopeed.com/api/release")).data;
releaseDataStr = jsonEncode(await GopeedSiteApi.instance.getRelease());
}
if (releaseDataStr == null) {
return null;
+72 -8
View File
@@ -157,10 +157,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checkable_treeview:
dependency: "direct main"
description:
@@ -265,6 +265,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.6"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
desktop_drop:
dependency: "direct main"
description:
@@ -406,11 +414,51 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
url: "https://pub.dev"
source: hosted
version: "20.1.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_markdown_plus:
dependency: "direct main"
description:
name: flutter_markdown_plus
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -686,22 +734,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
menu_base:
dependency: transitive
description:
@@ -1208,10 +1264,18 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.9"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
timing:
dependency: transitive
description:
+3 -1
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.9.1+1
version: 1.9.2+1
environment:
sdk: ">=3.0.0 <4.0.0"
@@ -81,7 +81,9 @@ dependencies:
url: https://github.com/hui-z/flutter_install_plugin.git
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
flutter_svg: ^2.0.17
flutter_markdown_plus: ^1.0.3
dart_ipc: ^1.0.1
flutter_local_notifications: 20.1.0
dependency_overrides:
permission_handler_windows:
git: