From 392ee3613747838263ba48c8c48d2c67b6f7ae6b Mon Sep 17 00:00:00 2001 From: Lykin Date: Tue, 10 Nov 2026 15:00:15 +0800 Subject: [PATCH 1/7] fix(web): wait for the saved analytics preference (#1656) --- .../app/application/app_runtime_controller.dart | 6 +++++- .../app/application/downloader_config_init.dart | 2 +- .../application/downloader_config_init_test.dart | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart index 0b551b2d..68230cee 100644 --- a/ui/flutter/lib/app/application/app_runtime_controller.dart +++ b/ui/flutter/lib/app/application/app_runtime_controller.dart @@ -53,6 +53,7 @@ class AppRuntimeState { class AppRuntimeController extends AsyncNotifier { static StartConfig? _defaultStartConfig; Future _preferenceWrites = Future.value(); + bool _analyticsStarted = false; @override Future build() async { @@ -73,6 +74,7 @@ class AppRuntimeController extends AsyncNotifier { startConfig ..apiEnable = true ..mcpEnable = config.api.mcpEnable; + await _initAnalytics(config); } else { startConfig ..apiEnable = config.api.enable @@ -253,7 +255,8 @@ class AppRuntimeController extends AsyncNotifier { } Future _initAnalytics(DownloaderConfig config) async { - if (!AnalyticsConfig.isConfigured || !config.extra.analyticsEnabled) return; + if (_analyticsStarted || !AnalyticsConfig.isConfigured || !config.extra.analyticsEnabled) return; + _analyticsStarted = true; try { final generatedClientId = await Analytics.instance.init(fallbackClientId: config.extra.deviceId); if (generatedClientId != null) { @@ -262,6 +265,7 @@ class AppRuntimeController extends AsyncNotifier { } unawaited(Analytics.instance.logAppOpen()); } catch (error, stackTrace) { + _analyticsStarted = false; logger.w('GA4 init failed', error, stackTrace); } } diff --git a/ui/flutter/lib/app/application/downloader_config_init.dart b/ui/flutter/lib/app/application/downloader_config_init.dart index dfe82c16..acc8d1ac 100644 --- a/ui/flutter/lib/app/application/downloader_config_init.dart +++ b/ui/flutter/lib/app/application/downloader_config_init.dart @@ -19,7 +19,7 @@ Future loadDownloaderConfig({ config = await load(); } catch (error, stackTrace) { onLoadError?.call(error, stackTrace); - config = DownloaderConfig(); + config = DownloaderConfig()..extra.analyticsEnabled = false; loaded = false; } final changed = await initDownloaderConfig(config, defaultDownloadDir: defaultDownloadDir); diff --git a/ui/flutter/test/app/application/downloader_config_init_test.dart b/ui/flutter/test/app/application/downloader_config_init_test.dart index 704a0aed..f91cdb97 100644 --- a/ui/flutter/test/app/application/downloader_config_init_test.dart +++ b/ui/flutter/test/app/application/downloader_config_init_test.dart @@ -32,8 +32,22 @@ void main() { // The in-memory fallback still gets usable defaults. expect(config.downloadDir, '/downloads'); expect(config.categories, hasLength(4)); + expect(config.extra.analyticsEnabled, isFalse); }); + for (final analyticsEnabled in [false, true]) { + test('preserves the saved analytics preference ($analyticsEnabled)', () async { + final stored = configWith()..extra.analyticsEnabled = analyticsEnabled; + final config = await loadDownloaderConfig( + load: () async => stored, + save: (_) async {}, + defaultDownloadDir: () async => '/downloads', + ); + + expect(config.extra.analyticsEnabled, analyticsEnabled); + }); + } + test('skips persist when init changed nothing', () async { var saved = 0; await loadDownloaderConfig( From 89dfbe4c45a79234cf20d388821825d3a05a6872 Mon Sep 17 00:00:00 2001 From: Lykin Date: Wed, 11 Nov 2026 09:37:31 +0800 Subject: [PATCH 2/7] fix(download): separate torrent progress from transfer rates (#1654) Co-authored-by: xunguangzlj-cloud <293371022+xunguangzlj-cloud@users.noreply.github.com> --- internal/fetcher/fetcher.go | 4 ++ internal/protocol/bt/download_test.go | 76 +++++++++++++++++++++++++++ internal/protocol/bt/fetcher.go | 73 +++++++++++++++++++++---- internal/protocol/bt/fixture_test.go | 7 ++- pkg/download/downloader.go | 23 +++++--- pkg/download/model.go | 67 ++++++++++++++--------- pkg/download/model_test.go | 41 ++++++++++++++- 7 files changed, 247 insertions(+), 44 deletions(-) diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index 50319a13..94653f78 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -46,6 +46,10 @@ type Uploader interface { WaitUpload() error } +type DownloadCounter interface { + ReceivedBytes() int64 +} + // FetcherMeta defines the meta information of a fetcher. type FetcherMeta struct { Req *base.Request `json:"req"` diff --git a/internal/protocol/bt/download_test.go b/internal/protocol/bt/download_test.go index 3f4453a9..c4c0cc85 100644 --- a/internal/protocol/bt/download_test.go +++ b/internal/protocol/bt/download_test.go @@ -290,6 +290,82 @@ func TestBTDownloadSelectedFiles(t *testing.T) { if f.Meta().Res.Size != 48*1024 { t.Fatal("selected resource size is incorrect") } + if downloaded := f.Progress().TotalDownloaded(); downloaded != 48*1024 || f.ReceivedBytes() <= downloaded { + t.Fatalf("selected progress = %d, received traffic = %d; want 48 KiB of progress and traffic across the shared piece", downloaded, f.ReceivedBytes()) + } +} + +func TestBTProgressOnlyCountsVerifiedFileBytes(t *testing.T) { + const fileSize = 56 * 1024 + fixture := makeBTFixtureWithPieceSize(t, map[string][]byte{ + "a.bin": btPayload(fileSize, "verified-a"), + "b.bin": btPayload(fileSize, "verified-b"), + }, 16*1024, false, true, 64*1024) + f := btDownloadFetcher(t, nil) + btResolve(t, f, fixture.Magnet) + dir := t.TempDir() + f.Meta().Opts = &base.Options{Path: dir} + btStart(t, f) + deadline := time.Now().Add(8 * time.Second) + for f.torrent.Files()[0].BytesCompleted() == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + btPause(t, f) + if f.torrent.Files()[0].BytesCompleted() == 0 || f.torrent.Piece(0).State().Complete { + t.Fatal("expected some received bytes in a piece that is still incomplete") + } + if f.ReceivedBytes() == 0 { + t.Fatal("received traffic must be visible before a piece is verified") + } + if progress := f.Progress(); len(progress) != 2 || progress[0] != 0 { + t.Errorf("progress before the first file's piece is verified = %v, want zero for the first file", progress) + } + fixture.Limiter.SetLimit(rate.Inf) + btStart(t, f) + btAwaitDone(t, f) + fixture.AssertFiles(t, dir) + progress := f.Progress() + if len(progress) != 2 || progress[0] != fileSize || progress[1] != fileSize { + t.Fatalf("verified progress at completion = %v, want %d for each file", progress, fileSize) + } +} + +func TestBTUploadedBytesSamplesLiveTraffic(t *testing.T) { + payload := btPayload(48*1024, "upload-counter") + fixture := newBTFixture(t, map[string][]byte{"": payload}, 0) + const saved = 1024 + f := &Fetcher{ + torrent: fixture.Torrent, + data: &fetcherData{SeedBytes: saved}, + seedBaseBytes: saved, + } + f.torrentReady.Store(true) + f.torrentUpload.Store(true) + + cfg := torrent.TestingConfig(t) + cfg.DataDir = t.TempDir() + cfg.DisableTrackers = true + cfg.DisableUTP = true + cfg.DisableIPv6 = true + peer, err := torrent.NewClient(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { peer.Close() }) + download, _, err := peer.AddTorrentSpec(torrent.TorrentSpecFromMetaInfo(fixture.MetaInfo)) + if err != nil { + t.Fatal(err) + } + download.AddPeers([]torrent.PeerInfo{{Addr: fixture.Seeder.ListenAddrs()[0]}}) + download.DownloadAll() + select { + case <-download.Complete().On(): + case <-time.After(10 * time.Second): + t.Fatal("loopback peer did not complete") + } + if got, want := f.UploadedBytes(), int64(saved+len(payload)); got != want { + t.Fatalf("uploaded bytes = %d, want %d including the saved bytes", got, want) + } } func TestBTPauseAndResume(t *testing.T) { diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 1e4cb92f..900c3336 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "strings" "sync" "sync/atomic" @@ -42,9 +43,10 @@ type Fetcher struct { ctl *controller.Controller config *config - torrent *torrent.Torrent - meta *fetcher.FetcherMeta - data *fetcherData + torrent *torrent.Torrent + meta *fetcher.FetcherMeta + data *fetcherData + seedBaseBytes int64 torrentReady atomic.Bool torrentUpload atomic.Bool @@ -463,14 +465,46 @@ func (f *Fetcher) Progress() fetcher.Progress { if !f.torrentReady.Load() { return append(fetcher.Progress(nil), f.data.Progress...) } + pieceSize := f.torrent.Info().PieceLength + var offset int64 + var verified []verifiedByteRange + for _, run := range f.torrent.PieceStateRuns() { + end := offset + int64(run.Length)*pieceSize + if pieceVerified(run.PieceState) { + verified = append(verified, verifiedByteRange{start: offset, end: end}) + } + offset = end + } + files := f.torrent.Files() for i := range f.data.Progress { selectIndex := f.meta.Opts.SelectFiles[i] - file := f.torrent.Files()[selectIndex] - f.data.Progress[i] = file.BytesCompleted() + file := files[selectIndex] + f.data.Progress[i] = verifiedFileBytes(verified, file.Offset(), file.Length()) } return append(fetcher.Progress(nil), f.data.Progress...) } +type verifiedByteRange struct { + start, end int64 +} + +func verifiedFileBytes(verified []verifiedByteRange, offset, length int64) int64 { + first := sort.Search(len(verified), func(i int) bool { return verified[i].end > offset }) + fileEnd := offset + length + var completed int64 + for _, run := range verified[first:] { + if run.start >= fileEnd { + break + } + completed += min(fileEnd, run.end) - max(offset, run.start) + } + return completed +} + +func pieceVerified(state torrent.PieceState) bool { + return state.Ok && state.Complete && !state.Hashing && !state.QueuedForHash +} + func (f *Fetcher) Wait() error { f.mu.Lock() var closed <-chan struct{} @@ -587,7 +621,7 @@ func (f *Fetcher) isDoneLocked() bool { file := f.torrent.Files()[index] for i := file.BeginPieceIndex(); i < file.EndPieceIndex(); i++ { state := f.torrent.Piece(i).State() - if !state.Ok || !state.Complete || state.Hashing || state.QueuedForHash { + if !pieceVerified(state) { return false } } @@ -685,11 +719,13 @@ func (f *Fetcher) Upload() (err error) { } func (f *Fetcher) doUpload(fromUpload bool) { + f.mu.Lock() if !f.torrentUpload.CompareAndSwap(false, true) { + f.mu.Unlock() return } - f.mu.Lock() - initialBytes, initialTime := f.data.SeedBytes, f.data.SeedTime + f.seedBaseBytes = f.data.SeedBytes + initialTime := f.data.SeedTime torrentClosed := f.torrent.Closed() f.mu.Unlock() var doneTime int64 @@ -707,8 +743,7 @@ func (f *Fetcher) doUpload(fromUpload bool) { f.mu.Unlock() continue } - stats := f.torrentStats() - f.data.SeedBytes = initialBytes + stats.BytesWrittenData.Int64() + f.refreshSeedBytesLocked() if !fromUpload && !f.isDoneLocked() { f.mu.Unlock() continue @@ -741,9 +776,27 @@ func (f *Fetcher) torrentStats() torrent.TorrentStats { func (f *Fetcher) UploadedBytes() int64 { f.mu.Lock() defer f.mu.Unlock() + if f.torrentReady.Load() && f.torrentUpload.Load() { + f.refreshSeedBytesLocked() + } return f.data.SeedBytes } +func (f *Fetcher) refreshSeedBytesLocked() { + stats := f.torrentStats() + f.data.SeedBytes = max(f.data.SeedBytes, f.seedBaseBytes+stats.BytesWrittenData.Int64()) +} + +func (f *Fetcher) ReceivedBytes() int64 { + f.mu.Lock() + defer f.mu.Unlock() + if !f.torrentReady.Load() { + return 0 + } + stats := f.torrentStats() + return stats.BytesReadUsefulData.Int64() +} + func (f *Fetcher) WaitUpload() (err error) { <-f.uploadDoneCh return nil diff --git a/internal/protocol/bt/fixture_test.go b/internal/protocol/bt/fixture_test.go index a8b1fe18..dac925a4 100644 --- a/internal/protocol/bt/fixture_test.go +++ b/internal/protocol/bt/fixture_test.go @@ -46,6 +46,11 @@ func newBTHybridFixture(t testing.TB, payload []byte) *btFixture { } func makeBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int, v2, private bool) *btFixture { + t.Helper() + return makeBTFixtureWithPieceSize(t, files, bytesPerSecond, v2, private, 16*1024) +} + +func makeBTFixtureWithPieceSize(t testing.TB, files map[string][]byte, bytesPerSecond int, v2, private bool, pieceSize int64) *btFixture { t.Helper() source := t.TempDir() name := "payload.bin" @@ -64,7 +69,7 @@ func makeBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int, v2 } expected[filepath.Join(name, filepath.FromSlash(path))] = append([]byte(nil), data...) } - info := metainfo.Info{PieceLength: 16 * 1024} + info := metainfo.Info{PieceLength: pieceSize} if err := info.BuildFromFilePath(root); err != nil { t.Fatal(err) } diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index e7da2caf..3f841157 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -345,7 +345,12 @@ func (d *Downloader) Setup() error { } current := task.fetcher.Progress().TotalDownloaded() - delta := current - task.Progress.Downloaded + delta := max(0, current-task.Progress.Downloaded) + if counter, ok := task.fetcher.(fetcher.DownloadCounter); ok && task.Status == base.DownloadStatusRunning { + received := counter.ReceivedBytes() + delta = received - task.receivedBytes + task.receivedBytes = received + } if task.Meta != nil && task.Meta.Req != nil { if producer := d.blob.Production(task.Meta.Req.URL); producer != nil { downloaded, received := producer.Progress() @@ -353,19 +358,20 @@ func (d *Downloader) Setup() error { task.producer = producer task.producerReceived = 0 task.speedArr = nil + task.downloadSampledAt = time.Time{} } current = downloaded delta = max(0, received-task.producerReceived) task.producerReceived = received } } - tick := float64(d.cfg.RefreshInterval) / 1000 downloadDataChanged := false uploading := task.Uploading if task.Status == base.DownloadStatusRunning { downloadDataChanged = current != task.Progress.Downloaded task.Progress.Used = task.timer.Used() - task.Progress.Speed = task.updateSpeed(max(0, delta), tick) + elapsed := sampleSeconds(&task.downloadSampledAt, time.Now()) + task.Progress.Speed = task.updateSpeed(delta, elapsed) task.Progress.Downloaded = current } @@ -374,7 +380,8 @@ func (d *Downloader) Setup() error { uploader := task.fetcher.(fetcher.Uploader) currentUploaded := uploader.UploadedBytes() uploadDataChanged = currentUploaded != task.Progress.Uploaded - task.Progress.UploadSpeed = task.updateUploadSpeed(currentUploaded-task.Progress.Uploaded, tick) + elapsed := sampleSeconds(&task.uploadSampledAt, time.Now()) + task.Progress.UploadSpeed = task.updateUploadSpeed(currentUploaded-task.Progress.Uploaded, elapsed) task.Progress.Uploaded = currentUploaded } task.statusLock.Unlock() @@ -2453,6 +2460,8 @@ func (d *Downloader) doStart(task *Task) (err error) { return nil } task.Progress.Speed = 0 + task.speedArr = nil + task.downloadSampledAt = time.Time{} task.timer.Start() task.statusLock.Unlock() if err := task.fetcher.Start(); err != nil { @@ -3061,8 +3070,10 @@ func initTask(task *Task) { task.lock = &sync.Mutex{} task.statsLock = &sync.Mutex{} task.blobRefLock = &sync.Mutex{} - task.speedArr = make([]int64, 0) - task.uploadSpeedArr = make([]int64, 0) + task.speedArr = nil + task.uploadSpeedArr = nil + task.downloadSampledAt = time.Time{} + task.uploadSampledAt = time.Time{} } func (d *Downloader) releaseBlobTask(task *Task) { diff --git a/pkg/download/model.go b/pkg/download/model.go index 766a7a69..b08a34d3 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -75,20 +75,23 @@ type Task struct { producer production.Source producerReceived int64 - fetcherManager fetcher.FetcherManager - fetcher fetcher.Fetcher - timer *util.Timer - statusLock *sync.Mutex - lock *sync.Mutex - statsLock *sync.Mutex - statsLoaded bool - stats *fetcher.Stats - statsSnapshot json.RawMessage - blobRefLock *sync.Mutex - blobURL string - runGeneration uint64 - speedArr []int64 - uploadSpeedArr []int64 + fetcherManager fetcher.FetcherManager + fetcher fetcher.Fetcher + timer *util.Timer + statusLock *sync.Mutex + lock *sync.Mutex + statsLock *sync.Mutex + statsLoaded bool + stats *fetcher.Stats + statsSnapshot json.RawMessage + blobRefLock *sync.Mutex + blobURL string + runGeneration uint64 + speedArr []speedSample + uploadSpeedArr []speedSample + downloadSampledAt time.Time + uploadSampledAt time.Time + receivedBytes int64 } // TaskRuntimeStatus is the lightweight, frequently refreshed view of a task. @@ -195,7 +198,21 @@ func (t *Task) updateUploadSpeed(downloaded int64, usedTime float64) int64 { return calcSpeed(&t.uploadSpeedArr, downloaded, usedTime) } -func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 { +type speedSample struct { + bytes int64 + seconds float64 +} + +func sampleSeconds(lastSample *time.Time, now time.Time) float64 { + previous := *lastSample + *lastSample = now + if previous.IsZero() { + return 0 + } + return now.Sub(previous).Seconds() +} + +func calcSpeed(speedArr *[]speedSample, downloaded int64, usedTime float64) int64 { if usedTime <= 0 { return 0 } @@ -204,18 +221,18 @@ func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 { return 0 } - *speedArr = append(*speedArr, downloaded) - // Record last 5 seconds of download speed to calculate the average speed - if len(*speedArr) > int(5.0/usedTime) { - *speedArr = (*speedArr)[1:] - } - + *speedArr = append(*speedArr, speedSample{bytes: downloaded, seconds: usedTime}) var total int64 - for _, v := range *speedArr { - total += v + var elapsed float64 + first := len(*speedArr) + for first > 0 && elapsed < 5 { + first-- + sample := (*speedArr)[first] + total += sample.bytes + elapsed += sample.seconds } - - return int64(float64(total) / float64(len(*speedArr)) / usedTime) + *speedArr = (*speedArr)[first:] + return int64(float64(total) / elapsed) } type TaskFilter struct { diff --git a/pkg/download/model_test.go b/pkg/download/model_test.go index 98da0cb4..3826465f 100644 --- a/pkg/download/model_test.go +++ b/pkg/download/model_test.go @@ -1,9 +1,32 @@ package download -import "testing" +import ( + "testing" + "time" +) + +func TestTransferSpeedWithUnevenSamples(t *testing.T) { + var task Task + for _, sample := range []struct { + bytes int64 + seconds float64 + }{ + {1000, 1}, + {2000, 2}, + {4000, 4}, + {6000, 6}, + } { + if got := task.updateSpeed(sample.bytes, sample.seconds); got != 1000 { + t.Errorf("download speed after %v seconds = %d, want 1000", sample.seconds, got) + } + if got := task.updateUploadSpeed(sample.bytes, sample.seconds); got != 1000 { + t.Errorf("upload speed after %v seconds = %d, want 1000", sample.seconds, got) + } + } +} func TestCalcSpeedResetOnRollback(t *testing.T) { - speedArr := []int64{1024, 2048, 4096} + speedArr := []speedSample{{1024, 1}, {2048, 1}, {4096, 1}} if got := calcSpeed(&speedArr, -512, 1); got != 0 { t.Fatalf("calcSpeed() = %d, want 0 after rollback", got) @@ -16,3 +39,17 @@ func TestCalcSpeedResetOnRollback(t *testing.T) { t.Fatalf("calcSpeed() = %d, want 1024 after reset", got) } } + +func TestSampleSecondsUsesThePreviousSuccessfulSample(t *testing.T) { + start := time.Now() + var last time.Time + if got := sampleSeconds(&last, start); got != 0 { + t.Fatalf("first sample elapsed = %v, want 0", got) + } + if got := sampleSeconds(&last, start.Add(1500*time.Millisecond)); got != 1.5 { + t.Fatalf("elapsed after a delayed sample = %v, want 1.5", got) + } + if got := sampleSeconds(&last, start.Add(2*time.Second)); got != 0.5 { + t.Fatalf("elapsed after the next sample = %v, want 0.5", got) + } +} From 6cd95507fd1c8b47e51ac0cd23b910c73c6173af Mon Sep 17 00:00:00 2001 From: Chandra ObulaReddy <53801679+ChandraChaan@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:53:13 +0530 Subject: [PATCH 3/7] fix(http): decode HTML entities in filenames in one pass (#1651) unescapeHTMLEntities applied its replacements while ranging over a map, so the order changed from run to run. An escaped ampersand such as "a&lt;b.txt" came out as "a<b.txt" or, when & was replaced first, as the double-decoded "a", + """, "\"", + "'", "'", + "'", "'", +) + // unescapeHTMLEntities unescapes common HTML entities in filenames // This handles cases where servers HTML-encode special characters like & to & func unescapeHTMLEntities(s string) string { - // Common HTML entities that might appear in filenames - replacements := map[string]string{ - "&": "&", - "<": "<", - ">": ">", - """: "\"", - "'": "'", - "'": "'", - } - - result := s - for entity, char := range replacements { - result = strings.ReplaceAll(result, entity, char) - } - return result + return htmlEntityReplacer.Replace(s) } // tryDecodeGBK attempts to decode string as GBK encoding From 6f7fd056ef1ac2b2a76e20bc41f75e17a6de385d Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 14 Nov 2026 11:28:39 +0800 Subject: [PATCH 4/7] fix(windows): release FFmpeg memory and capture native crashes (#1668) --- .github/workflows/test.yml | 3 + pkg/download/crash_log_test.go | 26 ++++ pkg/download/downloader.go | 5 +- pkg/download/engine/ffmpeg/README.md | 9 ++ pkg/download/engine/ffmpeg/memory.go | 32 +++++ pkg/download/engine/ffmpeg/memory_other.go | 7 ++ pkg/download/engine/ffmpeg/memory_test.go | 88 +++++++++++++ pkg/download/engine/ffmpeg/memory_windows.go | 70 +++++++++++ .../engine/ffmpeg/memory_windows_test.go | 54 ++++++++ pkg/download/engine/ffmpeg/runtime.go | 4 +- ui/flutter/windows/runner/CMakeLists.txt | 2 + .../windows/runner/CRASH_DIAGNOSTICS.md | 31 +++++ ui/flutter/windows/runner/crash_handler.cpp | 119 ++++++++++++++++++ ui/flutter/windows/runner/crash_handler.h | 12 ++ ui/flutter/windows/runner/flutter_window.cpp | 4 +- ui/flutter/windows/runner/main.cpp | 7 +- ui/flutter/windows/runner/win32_window.cpp | 30 +++-- 17 files changed, 486 insertions(+), 17 deletions(-) create mode 100644 pkg/download/crash_log_test.go create mode 100644 pkg/download/engine/ffmpeg/memory.go create mode 100644 pkg/download/engine/ffmpeg/memory_other.go create mode 100644 pkg/download/engine/ffmpeg/memory_test.go create mode 100644 pkg/download/engine/ffmpeg/memory_windows.go create mode 100644 pkg/download/engine/ffmpeg/memory_windows_test.go create mode 100644 ui/flutter/windows/runner/CRASH_DIAGNOSTICS.md create mode 100644 ui/flutter/windows/runner/crash_handler.cpp create mode 100644 ui/flutter/windows/runner/crash_handler.h diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 467044a6..9cdfc95e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -134,6 +134,9 @@ jobs: if: runner.os == 'macOS' with: flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Test Windows FFmpeg memory and stream lifecycle + if: runner.os == 'Windows' + run: go test -v -count=1 -timeout 180s ./pkg/download/engine/ffmpeg ./pkg/download/engine/inject/stream ./pkg/download/engine - name: Install Linux WebView dependencies if: runner.os == 'Linux' run: | diff --git a/pkg/download/crash_log_test.go b/pkg/download/crash_log_test.go new file mode 100644 index 00000000..a7594163 --- /dev/null +++ b/pkg/download/crash_log_test.go @@ -0,0 +1,26 @@ +package download + +import ( + "os" + "path/filepath" + "runtime/debug" + "testing" +) + +func TestLogPanicPreservesPreviousCrash(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "crash.log") + const previous = "previous crash traceback\n" + if err := os.WriteFile(path, []byte(previous), 0600); err != nil { + t.Fatal(err) + } + logPanic(dir) + t.Cleanup(func() { _ = debug.SetCrashOutput(nil, debug.CrashOptions{}) }) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != previous { + t.Fatalf("previous crash was overwritten: %q", data) + } +} diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 3f841157..94ecbc4f 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -2659,11 +2659,12 @@ func logPanic(logDir string) { if err := util.CreateDirIfNotExist(logDir); err != nil { return } - f, err := os.Create(filepath.Join(logDir, "crash.log")) + f, err := os.OpenFile(filepath.Join(logDir, "crash.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return } - debug.SetCrashOutput(f, debug.CrashOptions{}) + defer f.Close() // SetCrashOutput duplicates the descriptor. + _ = debug.SetCrashOutput(f, debug.CrashOptions{}) } func (d *Downloader) assignFetcherManager(task *Task) error { diff --git a/pkg/download/engine/ffmpeg/README.md b/pkg/download/engine/ffmpeg/README.md index 6c759699..348b7885 100644 --- a/pkg/download/engine/ffmpeg/README.md +++ b/pkg/download/engine/ffmpeg/README.md @@ -174,3 +174,12 @@ compilation memory, host buffers and JS allocations are additional to the linear-memory limit. Playback support depends on the selected codecs, not just the `.mp4` suffix. Platform execution and performance must be tested on target devices, especially mobile/32-bit devices. + +On 64-bit Windows, instance linear memory uses a reserved virtual address range with +pages committed as it grows. Module completion, errors and cancellation release +this range directly through `VirtualFree`, rather than waiting for Go's garbage +collector to reclaim large buffers. Growing an instance preserves its address +without copying the previous buffer. The 512 MiB limit is unchanged; reservation +does not commit that entire amount. Shared compilation remains resident after +the first merge, so process memory is not expected to return exactly to the +pre-FFmpeg baseline. This does not diagnose unrelated idle memory growth. diff --git a/pkg/download/engine/ffmpeg/memory.go b/pkg/download/engine/ffmpeg/memory.go new file mode 100644 index 00000000..57dee379 --- /dev/null +++ b/pkg/download/engine/ffmpeg/memory.go @@ -0,0 +1,32 @@ +package ffmpeg + +import ( + "context" + "sync" + + "github.com/tetratelabs/wazero/experimental" +) + +// The runtime does not always invoke Free when a module's start function traps. +// Keep host ownership until execution returns, including failed instantiation. +func withOwnedMemory(ctx context.Context, allocator experimental.MemoryAllocator) (context.Context, func()) { + var memories []*ownedLinearMemory + ctx = experimental.WithMemoryAllocator(ctx, experimental.MemoryAllocatorFunc( + func(cap, max uint64) experimental.LinearMemory { + memory := &ownedLinearMemory{LinearMemory: allocator.Allocate(cap, max)} + memories = append(memories, memory) + return memory + })) + return ctx, func() { + for _, memory := range memories { + memory.Free() + } + } +} + +type ownedLinearMemory struct { + experimental.LinearMemory + once sync.Once +} + +func (m *ownedLinearMemory) Free() { m.once.Do(m.LinearMemory.Free) } diff --git a/pkg/download/engine/ffmpeg/memory_other.go b/pkg/download/engine/ffmpeg/memory_other.go new file mode 100644 index 00000000..34e94757 --- /dev/null +++ b/pkg/download/engine/ffmpeg/memory_other.go @@ -0,0 +1,7 @@ +//go:build !windows || (!amd64 && !arm64) + +package ffmpeg + +import "context" + +func withLinearMemory(ctx context.Context) (context.Context, func()) { return ctx, func() {} } diff --git a/pkg/download/engine/ffmpeg/memory_test.go b/pkg/download/engine/ffmpeg/memory_test.go new file mode 100644 index 00000000..f1b5cc16 --- /dev/null +++ b/pkg/download/engine/ffmpeg/memory_test.go @@ -0,0 +1,88 @@ +package ffmpeg + +import ( + "context" + "encoding/hex" + "testing" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/experimental" +) + +// Verify the pinned runtime's allocator lifecycle on every development host: +// host-owned buffers must not be released before execution ends, or retained +// after failed instantiation, traps or cancellation. +func TestLinearMemoryModuleLifecycle(t *testing.T) { + testLinearMemoryModuleLifecycle(t, func(_, _ uint64) experimental.LinearMemory { + return &testLinearMemory{} + }) +} + +type testLinearMemory struct{ buffer []byte } + +func (m *testLinearMemory) Reallocate(size uint64) []byte { + m.buffer = append(m.buffer, make([]byte, int(size)-len(m.buffer))...) + return m.buffer +} +func (m *testLinearMemory) Free() { m.buffer = nil } + +type trackedLinearMemory struct { + experimental.LinearMemory + freeCount int +} + +func (m *trackedLinearMemory) Free() { + m.freeCount++ + m.LinearMemory.Free() +} + +func testLinearMemoryModuleLifecycle(t *testing.T, allocate experimental.MemoryAllocatorFunc) { + t.Helper() + for _, tt := range []struct { + name string + // A module with one memory (min=1/max=2 pages), optionally a start + // function that traps or loops until its context is cancelled. + wasm string + wantErr bool + }{ + {"close", "0061736d01000000050401010102", false}, + {"trap", "0061736d01000000010401600000030201000504010101020801000a05010300000b", true}, + {"cancel", "0061736d01000000010401600000030201000504010101020801000a0901070003400c000b0b", true}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var memory *trackedLinearMemory + ctx, release := withOwnedMemory(ctx, experimental.MemoryAllocatorFunc( + func(_, max uint64) experimental.LinearMemory { + memory = &trackedLinearMemory{LinearMemory: allocate(0, max)} + return memory + })) + defer release() + rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)) + defer rt.Close(context.Background()) + binary, err := hex.DecodeString(tt.wasm) + if err != nil { + t.Fatal(err) + } + module, err := rt.Instantiate(ctx, binary) + if (err != nil) != tt.wantErr { + t.Fatalf("instantiate: %v", err) + } + if !tt.wantErr { + if memory == nil || memory.freeCount != 0 { + t.Fatal("module did not allocate native memory") + } + if err := module.Close(ctx); err != nil { + t.Fatal(err) + } + } + release() + release() + if memory == nil || memory.freeCount != 1 { + t.Fatal("host did not release memory exactly once") + } + }) + } +} diff --git a/pkg/download/engine/ffmpeg/memory_windows.go b/pkg/download/engine/ffmpeg/memory_windows.go new file mode 100644 index 00000000..7218414d --- /dev/null +++ b/pkg/download/engine/ffmpeg/memory_windows.go @@ -0,0 +1,70 @@ +//go:build windows && (amd64 || arm64) + +package ffmpeg + +import ( + "context" + "fmt" + "unsafe" + + "github.com/tetratelabs/wazero/experimental" + "golang.org/x/sys/windows" +) + +// Keep large, short-lived WASM buffers out of the Go heap. Closing a module +// releases its committed pages immediately, without waiting for GC/scavenging. +func withLinearMemory(ctx context.Context) (context.Context, func()) { + return withOwnedMemory(ctx, experimental.MemoryAllocatorFunc( + func(_, max uint64) experimental.LinearMemory { + return &windowsLinearMemory{max: max} + })) +} + +// Reserve the address range once, committing zero-filled pages on demand. This +// avoids both copying the previous buffer and temporarily retaining two large +// Go allocations when memory.grow runs. The backing address never changes. +type windowsLinearMemory struct { + address uintptr + max uint64 + size uint64 + freed bool +} + +func (m *windowsLinearMemory) Reallocate(size uint64) []byte { + if m.freed || size > m.max || m.max > uint64(^uint(0)>>1) { + return nil + } + if size == 0 { + return []byte{} + } + if m.address == 0 { + address, err := windows.VirtualAlloc(0, uintptr(m.max), windows.MEM_RESERVE, windows.PAGE_READWRITE) + if err != nil { + panic(fmt.Errorf("ffmpeg: reserve WASM memory: %w", err)) + } + m.address = address + } + if size > m.size { + _, err := windows.VirtualAlloc(m.address, uintptr(size), windows.MEM_COMMIT, windows.PAGE_READWRITE) + if err != nil { + if m.size == 0 { + // Instantiation cannot proceed without the minimum memory. Wazero + // has not taken ownership yet, so release the reservation here. + m.Free() + panic(fmt.Errorf("ffmpeg: commit WASM memory: %w", err)) + } + return nil // memory.grow reports failure to the guest. + } + m.size = size + } + return unsafe.Slice((*byte)(unsafe.Pointer(m.address)), int(size)) +} + +func (m *windowsLinearMemory) Free() { + if m.address != 0 { + _ = windows.VirtualFree(m.address, 0, windows.MEM_RELEASE) + m.address = 0 + } + m.size = 0 + m.freed = true +} diff --git a/pkg/download/engine/ffmpeg/memory_windows_test.go b/pkg/download/engine/ffmpeg/memory_windows_test.go new file mode 100644 index 00000000..fd3e80a3 --- /dev/null +++ b/pkg/download/engine/ffmpeg/memory_windows_test.go @@ -0,0 +1,54 @@ +//go:build windows && (amd64 || arm64) + +package ffmpeg + +import ( + "testing" + "unsafe" + + "github.com/tetratelabs/wazero/experimental" + "golang.org/x/sys/windows" +) + +func TestWindowsLinearMemoryGrowthAndRelease(t *testing.T) { + const page = 65536 + m := &windowsLinearMemory{max: 4 * page} + defer m.Free() + first := m.Reallocate(page) + if len(first) != page { + t.Fatalf("initial allocation: %d", len(first)) + } + first[0], first[page-1] = 17, 29 + address := m.address + grown := m.Reallocate(3 * page) + if unsafe.SliceData(first) != unsafe.SliceData(grown) || grown[0] != 17 || grown[page-1] != 29 { + t.Fatal("growing memory moved or lost existing bytes") + } + for _, b := range grown[page:] { + if b != 0 { + t.Fatal("newly committed memory is not zero-filled") + } + } + if m.Reallocate(5*page) != nil || m.address != address || m.size != 3*page { + t.Fatal("failed growth changed the existing allocation") + } + m.Free() + m.Free() + if m.address != 0 || m.Reallocate(page) != nil { + t.Fatal("freed memory can still be used") + } + var info windows.MemoryBasicInformation + if err := windows.VirtualQuery(address, &info, unsafe.Sizeof(info)); err != nil { + t.Fatal(err) + } + const memFree = 0x10000 // MEMORY_BASIC_INFORMATION.State: MEM_FREE. + if info.State != memFree { + t.Fatalf("pages remain reserved after Free: state=%x", info.State) + } +} + +func TestWindowsLinearMemoryModuleLifecycle(t *testing.T) { + testLinearMemoryModuleLifecycle(t, func(_, max uint64) experimental.LinearMemory { + return &windowsLinearMemory{max: max} + }) +} diff --git a/pkg/download/engine/ffmpeg/runtime.go b/pkg/download/engine/ffmpeg/runtime.go index 5b60cc36..5762b39b 100644 --- a/pkg/download/engine/ffmpeg/runtime.go +++ b/pkg/download/engine/ffmpeg/runtime.go @@ -151,7 +151,9 @@ func RunAcquired(ctx context.Context, video, audio Input, out io.Writer, format files := &inputFS{files: map[string]Input{"video": video, "audio": audio}} fsconfig := wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(files, "/inputs") logs := &tailLog{} - rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{ + runCtx, freeMemory := withLinearMemory(ctx) + defer freeMemory() + rc, err := wasm.Run(runCtx, shared.runtime, shared.module, wasm.Args{ Name: "ffmpeg", Args: args, Stdout: out, Stderr: logs, Config: func(c wazero.ModuleConfig) wazero.ModuleConfig { return c.WithFSConfig(fsconfig) }, }) diff --git a/ui/flutter/windows/runner/CMakeLists.txt b/ui/flutter/windows/runner/CMakeLists.txt index 394917c0..1a9a19c9 100644 --- a/ui/flutter/windows/runner/CMakeLists.txt +++ b/ui/flutter/windows/runner/CMakeLists.txt @@ -7,6 +7,7 @@ project(runner LANGUAGES CXX) # # Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} WIN32 + "crash_handler.cpp" "flutter_window.cpp" "main.cpp" "utils.cpp" @@ -34,6 +35,7 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_link_libraries(${BINARY_NAME} PRIVATE "dbghelp.lib") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") # Run the Flutter tool portions of the build. This must not be removed. diff --git a/ui/flutter/windows/runner/CRASH_DIAGNOSTICS.md b/ui/flutter/windows/runner/CRASH_DIAGNOSTICS.md new file mode 100644 index 00000000..42d45ec8 --- /dev/null +++ b/ui/flutter/windows/runner/CRASH_DIAGNOSTICS.md @@ -0,0 +1,31 @@ +# Windows native crash diagnostics + +The runner installs diagnostics before starting Flutter and plugins. Files are +written beside the executable under `storage/logs`, matching Windows app storage: + +- `native-crash.log`: UTC timestamp, app version, process/thread, exception code, + faulting module/address/offset, and minidump success or the Windows error code. +- `native-crash.dmp`: the most recent native crash, including thread stacks, + loaded modules and unloaded module information. +- `crash.log`: Go runtime crash output, kept separately from native diagnostics. + +Logs are appended and existing dumps survive normal restarts. A subsequent native +crash replaces the previous dump. Dump generation is best effort, particularly +for stack exhaustion, heap corruption or unavailable disk space. Process kills +and fail-fast termination can bypass exception handlers. + +The top-level unhandled filter covers native exceptions on process threads. An +SEH boundary inside the runner's window callback records the original escaping exception +before Windows wraps a window callback failure as `0xC000041D`. A vectored handler +also observes that fatal callback status, since it can bypass the unhandled +filter. All handlers continue exception processing; they do not attempt recovery +or log ordinary handled first-chance exceptions. + +On Windows, verify a release build by exercising tray clicks, Explorer restart, +system font changes and child-window open/close. In a disposable test build, +raise an unhandled access violation inside a window callback and on a worker +thread, then raise `0xC000041D`; check that the log and dump are written and +survive relaunch. A debugger may intercept these before application handlers. +Open the dump with WinDbg and matching runner/Flutter/plugin PDBs to locate the +faulting stack. Minidumps can contain application data; inspect them before +sharing. diff --git a/ui/flutter/windows/runner/crash_handler.cpp b/ui/flutter/windows/runner/crash_handler.cpp new file mode 100644 index 00000000..a6048449 --- /dev/null +++ b/ui/flutter/windows/runner/crash_handler.cpp @@ -0,0 +1,119 @@ +#include "crash_handler.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +HANDLE crash_log = INVALID_HANDLE_VALUE; +std::wstring dump_path; +volatile LONG recording = 0; +LPTOP_LEVEL_EXCEPTION_FILTER previous_filter = nullptr; + +void WriteLog(const char* text) { + if (crash_log == INVALID_HANDLE_VALUE) return; + DWORD written = 0; + WriteFile(crash_log, text, static_cast(strlen(text)), &written, + nullptr); + FlushFileBuffers(crash_log); +} + +LONG WINAPI UnhandledException(EXCEPTION_POINTERS* exception) { + RecordNativeCrash(exception); + return previous_filter ? previous_filter(exception) + : EXCEPTION_CONTINUE_SEARCH; +} + +LONG CALLBACK FatalCallbackException(EXCEPTION_POINTERS* exception) { + // Windows can terminate on this callback exception without invoking the + // process's unhandled filter. Ignore ordinary first-chance exceptions (Go, + // Dart and plugins may handle those) and observe only this fatal status. + if (exception->ExceptionRecord->ExceptionCode == 0xC000041D) { + RecordNativeCrash(exception); + } + return EXCEPTION_CONTINUE_SEARCH; +} + +} // namespace + +LONG RecordNativeCrash(EXCEPTION_POINTERS* exception) { + if (!exception || InterlockedCompareExchange(&recording, 1, 0) != 0) { + return EXCEPTION_CONTINUE_SEARCH; + } + + SYSTEMTIME time; + GetSystemTime(&time); + const auto* record = exception->ExceptionRecord; + HMODULE module = nullptr; + wchar_t module_path[MAX_PATH] = L"unknown"; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(record->ExceptionAddress), + &module); + if (module) GetModuleFileNameW(module, module_path, MAX_PATH); + + char line[2048] = {}; + StringCchPrintfA( + line, ARRAYSIZE(line), + "\r\n%04u-%02u-%02uT%02u:%02u:%02uZ Gopeed %s pid=%lu tid=%lu " + "exception=0x%08lX address=%p module=%ls offset=0x%llX\r\n", + time.wYear, time.wMonth, time.wDay, time.wHour, time.wMinute, + time.wSecond, FLUTTER_VERSION, GetCurrentProcessId(), GetCurrentThreadId(), + record->ExceptionCode, record->ExceptionAddress, module_path, + static_cast( + reinterpret_cast(record->ExceptionAddress) - + reinterpret_cast(module))); + WriteLog(line); + + HANDLE dump = CreateFileW(dump_path.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (dump != INVALID_HANDLE_VALUE) { + MINIDUMP_EXCEPTION_INFORMATION info = {}; + info.ThreadId = GetCurrentThreadId(); + info.ExceptionPointers = exception; + info.ClientPointers = FALSE; + const BOOL ok = MiniDumpWriteDump( + GetCurrentProcess(), GetCurrentProcessId(), dump, + static_cast(MiniDumpNormal | MiniDumpWithThreadInfo | + MiniDumpWithUnloadedModules), + &info, nullptr, nullptr); + const DWORD error = ok ? ERROR_SUCCESS : GetLastError(); + FlushFileBuffers(dump); + CloseHandle(dump); + StringCchPrintfA(line, ARRAYSIZE(line), "minidump success=%d error=%lu\r\n", + ok, error); + WriteLog(line); + } else { + StringCchPrintfA(line, ARRAYSIZE(line), "minidump open error=%lu\r\n", + GetLastError()); + WriteLog(line); + } + + // Do not swallow an exception or resume a potentially corrupted process. + return EXCEPTION_CONTINUE_SEARCH; +} + +void InstallCrashHandler() { + // Match Dart's Windows storage path independently of the working directory. + wchar_t executable[32768] = {}; + const DWORD length = GetModuleFileNameW(nullptr, executable, ARRAYSIZE(executable)); + if (length == 0 || length >= ARRAYSIZE(executable)) return; + const auto directory = std::filesystem::path(executable).parent_path() / + L"storage" / L"logs"; + std::error_code error; + std::filesystem::create_directories(directory, error); + if (error) return; + + dump_path = (directory / L"native-crash.dmp").wstring(); + crash_log = CreateFileW((directory / L"native-crash.log").c_str(), + FILE_APPEND_DATA, FILE_SHARE_READ, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + previous_filter = SetUnhandledExceptionFilter(UnhandledException); + AddVectoredExceptionHandler(1, FatalCallbackException); +} diff --git a/ui/flutter/windows/runner/crash_handler.h b/ui/flutter/windows/runner/crash_handler.h new file mode 100644 index 00000000..fece59e6 --- /dev/null +++ b/ui/flutter/windows/runner/crash_handler.h @@ -0,0 +1,12 @@ +#ifndef RUNNER_CRASH_HANDLER_H_ +#define RUNNER_CRASH_HANDLER_H_ + +#include + +// Installs best-effort native diagnostics before Flutter or plugins start. +void InstallCrashHandler(); + +// Records the exception and continues normal Windows exception processing. +LONG RecordNativeCrash(EXCEPTION_POINTERS* exception); + +#endif // RUNNER_CRASH_HANDLER_H_ diff --git a/ui/flutter/windows/runner/flutter_window.cpp b/ui/flutter/windows/runner/flutter_window.cpp index 5f4b6e27..1df494aa 100644 --- a/ui/flutter/windows/runner/flutter_window.cpp +++ b/ui/flutter/windows/runner/flutter_window.cpp @@ -63,7 +63,9 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, switch (message) { case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); + if (flutter_controller_ && flutter_controller_->engine()) { + flutter_controller_->engine()->ReloadSystemFonts(); + } break; } diff --git a/ui/flutter/windows/runner/main.cpp b/ui/flutter/windows/runner/main.cpp index 74a39e96..281d2fd3 100644 --- a/ui/flutter/windows/runner/main.cpp +++ b/ui/flutter/windows/runner/main.cpp @@ -5,6 +5,7 @@ #include #include "app_links/app_links_plugin_c_api.h" +#include "crash_handler.h" #include "flutter_window.h" #include "flutter/generated_plugin_registrant.h" #include "utils.h" @@ -43,6 +44,7 @@ bool IsMultiWindowLaunch(const std::vector& arguments) { int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { + InstallCrashHandler(); // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { @@ -77,11 +79,12 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, window.SetQuitOnClose(true); ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { + BOOL message_result; + while ((message_result = ::GetMessage(&msg, nullptr, 0, 0)) > 0) { ::TranslateMessage(&msg); ::DispatchMessage(&msg); } ::CoUninitialize(); - return EXIT_SUCCESS; + return message_result == -1 ? EXIT_FAILURE : static_cast(msg.wParam); } diff --git a/ui/flutter/windows/runner/win32_window.cpp b/ui/flutter/windows/runner/win32_window.cpp index 60608d0f..b2f72605 100644 --- a/ui/flutter/windows/runner/win32_window.cpp +++ b/ui/flutter/windows/runner/win32_window.cpp @@ -3,6 +3,7 @@ #include #include +#include "crash_handler.h" #include "resource.h" namespace { @@ -158,19 +159,26 @@ LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); + // Capture inside the callback, before user32 wraps or suppresses an escaping + // exception. Keep C++ objects requiring unwinding out of this SEH boundary. + __try { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); + } __except (RecordNativeCrash(GetExceptionInformation())) { + // The filter always continues the exception search; this is unreachable. + return 0; } - - return DefWindowProc(window, message, wparam, lparam); } LRESULT From 0520fd2baf0e38885a188cf79fe1f4e863595e32 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 14 Nov 2026 11:29:07 +0800 Subject: [PATCH 5/7] fix(bt): upgrade torrent to v1.62.0 for WebTorrent announces (#1665) --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- Dockerfile | 2 +- go.mod | 36 +++++----- go.sum | 111 +++++++++++------------------ internal/protocol/bt/fetcher.go | 2 +- internal/protocol/bt/stats_test.go | 2 +- 8 files changed, 66 insertions(+), 93 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index af92a6b2..d9500a83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ on: default: "false" env: - GO_VERSION: "1.25.4" + GO_VERSION: "1.26.11" FLUTTER_VERSION: "3.41.6" GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b9d58a0..8e221f85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ on: - "Dockerfile" env: - GO_VERSION: "1.25.4" + GO_VERSION: "1.26.11" permissions: contents: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9cdfc95e..4350a02f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ on: workflow_dispatch: env: - GO_VERSION: "1.25.4" + GO_VERSION: "1.26.11" FLUTTER_VERSION: "3.41.6" jobs: diff --git a/Dockerfile b/Dockerfile index 0fbdc9be..3daad842 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.4-alpine3.22 AS go +FROM golang:1.26.11-alpine3.23 AS go WORKDIR /app COPY ./go.mod ./go.sum ./ RUN go mod download diff --git a/go.mod b/go.mod index b1c422ba..97e20967 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,12 @@ module github.com/GopeedLab/gopeed -go 1.25.4 +go 1.26.0 require ( codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914 - github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0 + github.com/RoaringBitmap/roaring/v2 v2.16.0 + github.com/anacrolix/torrent v1.62.0 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/bodgit/sevenzip v1.6.1 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 @@ -41,11 +42,13 @@ require ( require ( github.com/STARRY-S/zip v0.2.3 // indirect github.com/anacrolix/btree v0.1.1 // indirect - github.com/anacrolix/missinggo/v2 v2.10.0 // indirect + github.com/anacrolix/go-utp v0.0.0-20260908033909-9f1664acf866 // indirect + github.com/anacrolix/missinggo/v2 v2.11.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/felixge/fgprof v0.9.5 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -69,20 +72,19 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.22.0 // indirect + golang.org/x/sync v0.23.0 // indirect ) require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 github.com/ProtonMail/go-crypto v1.1.6 // indirect - github.com/RoaringBitmap/roaring v1.9.4 github.com/alecthomas/atomic v0.1.0-alpha2 // indirect - github.com/anacrolix/chansync v0.7.0 // indirect - github.com/anacrolix/dht/v2 v2.23.0 // indirect + github.com/anacrolix/chansync v0.8.0 // indirect + github.com/anacrolix/dht/v2 v2.23.1-0.20260525063928-ec3a9bd99456 // indirect github.com/anacrolix/envpprof v1.5.0 // indirect github.com/anacrolix/generics v0.2.0 - github.com/anacrolix/go-libutp v1.3.2 // indirect + github.com/anacrolix/go-libutp v1.5.2-0.20260908013213-836dd42cdde6 // indirect github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect github.com/anacrolix/missinggo v1.3.0 // indirect github.com/anacrolix/missinggo/perf v1.0.0 // indirect @@ -91,7 +93,6 @@ require ( github.com/anacrolix/stm v0.5.0 // indirect github.com/anacrolix/sync v0.6.0 // indirect github.com/anacrolix/upnp v0.1.4 // indirect - github.com/anacrolix/utp v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/immutable v0.4.3 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect @@ -108,14 +109,13 @@ require ( github.com/go-git/go-billy/v5 v5.9.2 // indirect github.com/go-llsqlite/adapter v0.2.0 // indirect github.com/go-llsqlite/crawshaw v0.6.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -153,13 +153,13 @@ require ( github.com/tidwall/btree v1.8.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.58.0 - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.60.0 + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 golang.org/x/time v0.15.0 gopkg.in/warnings.v0 v0.1.2 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index 543c0154..9edcd992 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQA github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI= github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= -github.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ= -github.com/RoaringBitmap/roaring v1.9.4/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90= +github.com/RoaringBitmap/roaring/v2 v2.16.0 h1:Kys1UNf49d5W8Tq3bpuAhIr/Z8/yPB+59CO8A6c/BbE= +github.com/RoaringBitmap/roaring/v2 v2.16.0/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= @@ -57,10 +57,10 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/anacrolix/btree v0.1.1 h1:igdFPLrt82L6qovzbEGSMkTeiwcU3EFIGl2K8XWocAc= github.com/anacrolix/btree v0.1.1/go.mod h1:KHWYRZuUULATjUGJC4dQDXx/BPOnWrJozGR6TndjOmc= -github.com/anacrolix/chansync v0.7.0 h1:wgwxbsJRmOqNjil4INpxHrDp4rlqQhECxR8/WBP4Et0= -github.com/anacrolix/chansync v0.7.0/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k= -github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY= -github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U= +github.com/anacrolix/chansync v0.8.0 h1:Z/YLVXRDMTy3jFfHKSwzCRVD88ldnFoM4THdWqLjNxM= +github.com/anacrolix/chansync v0.8.0/go.mod h1:RT3KIkmAanMPsiKC/HTBOrSlgB4TQ8DW8dxgDOnQGsg= +github.com/anacrolix/dht/v2 v2.23.1-0.20260525063928-ec3a9bd99456 h1:5ylkQM8XiZxJw1yhWlV8ICyzl4DNRbEPR4FNlYKpEgs= +github.com/anacrolix/dht/v2 v2.23.1-0.20260525063928-ec3a9bd99456/go.mod h1:0IpTSbqYnFcjmexGvnINUGq/kbx93S5cKIryH4oJL8U= github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4= @@ -69,18 +69,17 @@ github.com/anacrolix/envpprof v1.5.0/go.mod h1:OQPV3SNK6uVAlXL4slcZVXv+xLcE0ybWg github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8= github.com/anacrolix/generics v0.2.0 h1:gPwGOs14irokFN9kUP1i1A0Bn0FPT7/hWWD3hHKSKNw= github.com/anacrolix/generics v0.2.0/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc= -github.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M= -github.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA= +github.com/anacrolix/go-libutp v1.5.2-0.20260908013213-836dd42cdde6 h1:eWpL7T6AXAC/Q5ttYsviaWIBEg4eBpB6LH/LCST8pBc= +github.com/anacrolix/go-libutp v1.5.2-0.20260908013213-836dd42cdde6/go.mod h1:qJ59Ax77trj2F6OIydwSHtvMpPZSo/zZPPHosbIJQDk= +github.com/anacrolix/go-utp v0.0.0-20260908033909-9f1664acf866 h1:JMYl7Xp9ZQwUFOzij7Ng+hrhbOymWY2B32p+IeZ8J3M= +github.com/anacrolix/go-utp v0.0.0-20260908033909-9f1664acf866/go.mod h1:eCVeSbTCNP6qXMaRpjhCeiDd1Ao33c+qQuF+KtFQXzs= github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= -github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68= github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY= github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU= github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk= -github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM= github.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY= github.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM= -github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s= github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y= @@ -90,9 +89,8 @@ github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ= github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY= github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA= -github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY= -github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y= -github.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc= +github.com/anacrolix/missinggo/v2 v2.11.0 h1:9vYHTNHa5q8tLeDbA6XWFyYeKrxrhfw7GZe9KpS2Wnw= +github.com/anacrolix/missinggo/v2 v2.11.0/go.mod h1:gcZICf9xhHZEuLIaxXG1gRg4hpbL7lqgVrq5jsYtQU4= github.com/anacrolix/mmsg v1.1.1 h1:4ce/3I5kM7qSF6T5A8MOmDCfac3UqYlk5Bzh5XsWebM= github.com/anacrolix/mmsg v1.1.1/go.mod h1:lPCXEN1eDDQtKktdKEzdw+roswx6wWPpeXAl/WpWVDU= github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM= @@ -100,19 +98,15 @@ github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE4 github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg= github.com/anacrolix/stm v0.5.0 h1:9df1KBpttF0TzLgDq51Z+TEabZKMythqgx89f1FQJt8= github.com/anacrolix/stm v0.5.0/go.mod h1:MOwrSy+jCm8Y7HYfMAwPj7qWVu7XoVvjOiYwJmpeB/M= -github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk= -github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g= github.com/anacrolix/sync v0.6.0 h1:W8jXs9tlj/oHhvLys2Cu8ncDAw6NVjm413t0BHp3LVo= github.com/anacrolix/sync v0.6.0/go.mod h1:zigHZiBXkcjo9uNACGCpovT+wNKMbzjO1qN2+eehq8Y= github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8= -github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0 h1:lzjUG5O/Vzn4vodFZnrmVny08kMnujjmMLQw0X7cetI= -github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ= +github.com/anacrolix/torrent v1.62.0 h1:oWY/q8XdpkJaRd4QkSOaYWUXCmwR3PoLZEo5sneEPuA= +github.com/anacrolix/torrent v1.62.0/go.mod h1:gmJQGJUN/XDHLWSKeHTPEH9nAM2E7Mr5cI1NidmdvSU= github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U= github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic= -github.com/anacrolix/utp v0.2.0 h1:65Cdmr6q9WSw2KsM+rtJFu7rqDzLl2bdysf4KlNPcFI= -github.com/anacrolix/utp v0.2.0/go.mod h1:HGk4GYQw1O/3T1+yhqT/F6EcBd+AAwlo9dYErNy7mj8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -128,7 +122,6 @@ github.com/benbjohnson/immutable v0.4.3/go.mod h1:qJIKKSmdqz1tVzNtst1DZzvaqOU1on github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= @@ -159,6 +152,8 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -197,7 +192,6 @@ github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= @@ -229,12 +223,12 @@ github.com/go-llsqlite/crawshaw v0.6.0/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYz github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -308,8 +302,6 @@ github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -319,7 +311,6 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= -github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -355,8 +346,6 @@ github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -491,8 +480,6 @@ github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -539,7 +526,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -579,12 +565,12 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -600,9 +586,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -634,9 +619,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -658,12 +642,9 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -679,9 +660,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -716,18 +696,14 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -736,10 +712,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -771,9 +745,8 @@ golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 900c3336..2b0a401e 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -21,7 +21,7 @@ import ( "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/protocol/bt" "github.com/GopeedLab/gopeed/pkg/util" - "github.com/RoaringBitmap/roaring" + "github.com/RoaringBitmap/roaring/v2" "github.com/anacrolix/torrent" "github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/storage" diff --git a/internal/protocol/bt/stats_test.go b/internal/protocol/bt/stats_test.go index b6ac8681..8d54c827 100644 --- a/internal/protocol/bt/stats_test.go +++ b/internal/protocol/bt/stats_test.go @@ -6,7 +6,7 @@ import ( "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/pkg/base" protocolbt "github.com/GopeedLab/gopeed/pkg/protocol/bt" - "github.com/RoaringBitmap/roaring" + "github.com/RoaringBitmap/roaring/v2" "github.com/anacrolix/torrent" "github.com/anacrolix/torrent/storage" ) From 38c2f1e8c218cba091e21fb480fff3e605b3d92e Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 14 Nov 2026 11:30:33 +0800 Subject: [PATCH 6/7] refactor(ui): refine advanced options and settings feedback (#1661) --- .../application/settings_controller.dart | 21 ++++++- .../presentation/pages/settings_page.dart | 7 ++- .../pages/create_task_window_page.dart | 44 ++------------ .../widgets/create_task_advanced_toggle.dart | 49 +++++++++++++++ ui/flutter/lib/l10n/app_ar.arb | 2 +- ui/flutter/lib/l10n/app_az.arb | 2 +- ui/flutter/lib/l10n/app_ca.arb | 2 +- ui/flutter/lib/l10n/app_de.arb | 2 +- ui/flutter/lib/l10n/app_en.arb | 2 +- ui/flutter/lib/l10n/app_es.arb | 2 +- ui/flutter/lib/l10n/app_fa.arb | 2 +- ui/flutter/lib/l10n/app_fr.arb | 2 +- ui/flutter/lib/l10n/app_hu.arb | 2 +- ui/flutter/lib/l10n/app_id.arb | 2 +- ui/flutter/lib/l10n/app_it.arb | 2 +- ui/flutter/lib/l10n/app_ja.arb | 2 +- ui/flutter/lib/l10n/app_ko.arb | 2 +- ui/flutter/lib/l10n/app_pl.arb | 2 +- ui/flutter/lib/l10n/app_pt.arb | 2 +- ui/flutter/lib/l10n/app_ru.arb | 2 +- ui/flutter/lib/l10n/app_sk.arb | 2 +- ui/flutter/lib/l10n/app_ta.arb | 2 +- ui/flutter/lib/l10n/app_tr.arb | 2 +- ui/flutter/lib/l10n/app_uk.arb | 2 +- ui/flutter/lib/l10n/app_vi.arb | 2 +- ui/flutter/lib/l10n/app_zh.arb | 2 +- ui/flutter/lib/l10n/app_zh_TW.arb | 2 +- .../application/settings_controller_test.dart | 40 ++++++++++++- .../create_task_advanced_toggle_test.dart | 59 +++++++++++++++++++ ui/flutter/test/widget_test.dart | 45 +++++++++++++- 30 files changed, 243 insertions(+), 68 deletions(-) create mode 100644 ui/flutter/lib/features/tasks/presentation/widgets/create_task_advanced_toggle.dart create mode 100644 ui/flutter/test/features/tasks/presentation/create_task_advanced_toggle_test.dart diff --git a/ui/flutter/lib/features/settings/application/settings_controller.dart b/ui/flutter/lib/features/settings/application/settings_controller.dart index 311be7be..fdc33f19 100644 --- a/ui/flutter/lib/features/settings/application/settings_controller.dart +++ b/ui/flutter/lib/features/settings/application/settings_controller.dart @@ -19,6 +19,12 @@ class SettingsState { } } +class SettingsSaveResult { + const SettingsSaveResult({required this.btRestartRequired}); + + final bool btRestartRequired; +} + class SettingsController extends AsyncNotifier { Future _saveTail = Future.value(); int _saveRevision = 0; @@ -36,7 +42,7 @@ class SettingsController extends AsyncNotifier { state = AsyncValue.data(SettingsState(config: config)); } - Future save(DownloaderConfig config) { + Future save(DownloaderConfig config) { final snapshot = DownloaderConfig.fromJson(config.toJson()); final revision = ++_saveRevision; state = AsyncValue.data(SettingsState(config: config, saving: true)); @@ -47,10 +53,15 @@ class SettingsController extends AsyncNotifier { return result; } - Future _save(DownloaderConfig config, int revision) async { + Future _save(DownloaderConfig config, int revision) async { try { final service = ref.read(gopeedServiceProvider); final latest = await service.getConfig(); + final previousBt = ( + latest.protocolConfig.bt.listenPort, + latest.protocolConfig.bt.disableDht, + latest.protocolConfig.bt.disablePex, + ); config.extra ..windowState = latest.extra.windowState ..bookmarks = latest.extra.bookmarks @@ -64,6 +75,12 @@ class SettingsController extends AsyncNotifier { ref.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(saved); if (revision == _saveRevision) state = AsyncValue.data(SettingsState(config: saved)); } + final savedBt = ( + saved.protocolConfig.bt.listenPort, + saved.protocolConfig.bt.disableDht, + saved.protocolConfig.bt.disablePex, + ); + return SettingsSaveResult(btRestartRequired: previousBt != savedBt); } finally { if (ref.mounted && revision == _saveRevision && state.value?.saving == true) { state = AsyncValue.data(state.requireValue.copyWith(saving: false)); diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index c48a031e..0f90b6b7 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -625,7 +625,6 @@ class _SettingsPageState extends ConsumerState { ), SettingsItem( title: context.l10n.enableDht, - subtitle: context.l10n.restartRequired, child: shad.Switch( value: !config.protocolConfig.bt.disableDht, onChanged: (value) => _mutateConfig((next) => next.protocolConfig.bt.disableDht = !value), @@ -633,7 +632,6 @@ class _SettingsPageState extends ConsumerState { ), SettingsItem( title: context.l10n.enablePex, - subtitle: context.l10n.restartRequired, child: shad.Switch( value: !config.protocolConfig.bt.disablePex, onChanged: (value) => _mutateConfig((next) => next.protocolConfig.bt.disablePex = !value), @@ -1204,7 +1202,10 @@ class _SettingsPageState extends ConsumerState { final snapshot = DownloaderConfig.fromJson(config.toJson()); _loadedSignature = _signature(snapshot); try { - await ref.read(settingsControllerProvider.notifier).save(snapshot); + final result = await ref.read(settingsControllerProvider.notifier).save(snapshot); + if (mounted && result.btRestartRequired) { + _toast(context.l10n.restartRequired, type: AppToastType.info); + } return true; } catch (error) { if (mounted) _toast(error.toString()); diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index abc96c13..5ffb71a2 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -41,6 +41,7 @@ import '../../application/pending_download.dart'; import '../../application/task_list_navigation.dart'; import '../widgets/history_search_field.dart'; import '../widgets/create_task_action_buttons.dart'; +import '../widgets/create_task_advanced_toggle.dart'; import '../widgets/resolve_download_dialog.dart'; class CreateTaskWindowPage extends ConsumerStatefulWidget { @@ -560,20 +561,11 @@ class _CreateTaskWindowPageState extends ConsumerState { ), const SizedBox(height: 20), Align( - alignment: Alignment.centerLeft, - child: GestureDetector( - onTap: _toggleAdvanced, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.advanced, - style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w600), - ), - const SizedBox(width: 8), - _TripleChevronIcon(expanded: _showAdvanced, color: palette.textSecondary), - ], - ), + alignment: AlignmentDirectional.centerStart, + child: CreateTaskAdvancedToggle( + key: const ValueKey('create-task-advanced-toggle'), + expanded: _showAdvanced, + onPressed: _toggleAdvanced, ), ), AnimatedCrossFade( @@ -1933,30 +1925,6 @@ class _MiniInputAction extends StatelessWidget { } } -class _TripleChevronIcon extends StatelessWidget { - const _TripleChevronIcon({required this.expanded, required this.color}); - - final bool expanded; - final Color color; - - @override - Widget build(BuildContext context) { - final icon = expanded ? Icons.expand_less : Icons.expand_more; - return SizedBox( - width: 14, - height: 18, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned(top: 0, left: 0, child: Icon(icon, size: 14, color: color)), - Positioned(top: 4, left: 0, child: Icon(icon, size: 14, color: color)), - Positioned(top: 8, left: 0, child: Icon(icon, size: 14, color: color)), - ], - ), - ); - } -} - class _SegmentButton extends StatelessWidget { const _SegmentButton({required this.label, required this.active, required this.onTap}); diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/create_task_advanced_toggle.dart b/ui/flutter/lib/features/tasks/presentation/widgets/create_task_advanced_toggle.dart new file mode 100644 index 00000000..aa8e6112 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/create_task_advanced_toggle.dart @@ -0,0 +1,49 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; + +/// A quiet disclosure control with the same keyboard behavior as other buttons. +class CreateTaskAdvancedToggle extends StatelessWidget { + const CreateTaskAdvancedToggle({super.key, required this.expanded, required this.onPressed}); + + final bool expanded; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return Semantics( + expanded: expanded, + child: ButtonStyleOverride.inherit( + padding: (context, states, value) => const EdgeInsets.symmetric(vertical: AppDesignTokens.space12), + child: GhostButton( + onPressed: onPressed, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 20), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + context.l10n.advanced, + style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: AppDesignTokens.space8), + AnimatedRotation( + turns: expanded ? 0.5 : 0, + duration: reduceMotion ? Duration.zero : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + child: Icon(Icons.keyboard_arrow_down, size: 16, color: palette.textSecondary), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 6123f256..4b84f11e 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -208,7 +208,7 @@ "categoryName": "اسم الفئة", "categoryPath": "مسار الفئة", "autoCategorizeByExtension": "تصنيف تلقائي حسب نوع الملف", - "autoCategorizeByExtensionDescription": "توجيه التنزيلات إلى مجلد الفئة تلقائيًا حسب امتداد الملف عند الحفظ في مجلد التنزيل الافتراضي.", + "autoCategorizeByExtensionDescription": "توجيه التنزيلات إلى مجلد الفئة تلقائيًا حسب امتداد الملف عند الحفظ في مجلد التنزيل الافتراضي", "categoryExtensions": "امتدادات الملفات", "categoryExtensionsUsageHint": "افصل الامتدادات بفواصل أو مسافات ثم اضغط Enter.", "categoryExtensionsEmptyHint": "بدون الامتدادات، لن تُحفظ الملفات في هذا التصنيف تلقائيًا.", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 577b1937..5bbbec9b 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -215,7 +215,7 @@ "categoryName": "Kateqoriyanın Adı", "categoryPath": "Kateqoriyanın Yolu", "autoCategorizeByExtension": "Fayl tipinə görə avtomatik kateqoriyalaşdırma", - "autoCategorizeByExtensionDescription": "Standart yükləmə qovluğuna yadda saxlanarkən yükləmələr fayl uzantısına görə avtomatik olaraq kateqoriya qovluğuna yönləndirilir.", + "autoCategorizeByExtensionDescription": "Standart yükləmə qovluğuna yadda saxlanarkən yükləmələr fayl uzantısına görə avtomatik olaraq kateqoriya qovluğuna yönləndirilir", "categoryExtensions": "Fayl uzantıları", "categoryExtensionsUsageHint": "Uzantıları vergül və ya boşluqla ayırın, sonra Enter-a basın.", "categoryExtensionsEmptyHint": "Uzantı yoxdursa, fayllar bu kateqoriyaya avtomatik olaraq saxlanmır.", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 4e8fd62e..3cfb47fe 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -208,7 +208,7 @@ "categoryName": "Nom de la categoria", "categoryPath": "Ruta de la categoria", "autoCategorizeByExtension": "Categoritza automàticament segons el tipus de fitxer", - "autoCategorizeByExtensionDescription": "Les baixades es desen a la carpeta de categoria segons l'extensió del fitxer en utilitzar la carpeta de baixades predeterminada.", + "autoCategorizeByExtensionDescription": "Les baixades es desen a la carpeta de categoria segons l'extensió del fitxer en utilitzar la carpeta de baixades predeterminada", "categoryExtensions": "Extensions de fitxer", "categoryExtensionsUsageHint": "Separa les extensions amb comes o espais i prem Retorn.", "categoryExtensionsEmptyHint": "Sense extensions, els fitxers no es desaran aquí automàticament.", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 8e46d196..d05ccf6d 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -208,7 +208,7 @@ "categoryName": "Kategoriename", "categoryPath": "Kategoriepfad", "autoCategorizeByExtension": "Nach Dateityp automatisch kategorisieren", - "autoCategorizeByExtensionDescription": "Downloads werden beim Speichern im Standard-Download-Ordner anhand der Dateiendung automatisch einem Kategorieordner zugeordnet.", + "autoCategorizeByExtensionDescription": "Downloads werden beim Speichern im Standard-Download-Ordner anhand der Dateiendung automatisch einem Kategorieordner zugeordnet", "categoryExtensions": "Dateiendungen", "categoryExtensionsUsageHint": "Trenne Endungen mit Kommas oder Leerzeichen und drücke Enter.", "categoryExtensionsEmptyHint": "Ohne Endungen werden Dateien nicht automatisch dieser Kategorie zugeordnet.", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 3816a3fc..44ed0382 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -222,7 +222,7 @@ "categoryName": "Category Name", "categoryPath": "Category Path", "autoCategorizeByExtension": "Auto-categorize by file type", - "autoCategorizeByExtensionDescription": "Automatically route downloads into a category folder based on the file name when saving to the default download folder.", + "autoCategorizeByExtensionDescription": "Automatically route downloads into a category folder based on the file name when saving to the default download folder", "categoryExtensions": "Associated Extensions", "categoryExtensionsUsageHint": "Separate suffixes with commas or spaces, then press Enter.", "categoryExtensionsEmptyHint": "Without extensions, files won't be saved into this category automatically.", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index d2daef8c..5a1110b7 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -208,7 +208,7 @@ "categoryName": "Nombre de categoría", "categoryPath": "Ruta de categoría", "autoCategorizeByExtension": "Categorizar automáticamente por tipo de archivo", - "autoCategorizeByExtensionDescription": "Las descargas se guardan automáticamente en la carpeta de categoría según la extensión del archivo al usar la carpeta de descargas predeterminada.", + "autoCategorizeByExtensionDescription": "Las descargas se guardan automáticamente en la carpeta de categoría según la extensión del archivo al usar la carpeta de descargas predeterminada", "categoryExtensions": "Extensiones de archivo", "categoryExtensionsUsageHint": "Separa las extensiones con comas o espacios y pulsa Intro.", "categoryExtensionsEmptyHint": "Sin extensiones, los archivos no se guardarán automáticamente en esta categoría.", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 97478f13..3459113a 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -208,7 +208,7 @@ "categoryName": "نام دسته‌بندی", "categoryPath": "مسیر دسته‌بندی", "autoCategorizeByExtension": "دسته‌بندی خودکار بر اساس نوع فایل", - "autoCategorizeByExtensionDescription": "هنگام ذخیره در پوشه پیش‌فرض دانلود، دانلودها بر اساس پسوند فایل به پوشه دسته مربوطه منتقل می‌شوند.", + "autoCategorizeByExtensionDescription": "هنگام ذخیره در پوشه پیش‌فرض دانلود، دانلودها بر اساس پسوند فایل به پوشه دسته مربوطه منتقل می‌شوند", "categoryExtensions": "پسوندهای فایل", "categoryExtensionsUsageHint": "پسوندها را با ویرگول یا فاصله جدا کنید و Enter را بزنید.", "categoryExtensionsEmptyHint": "بدون پسوندها، فایل‌ها به‌صورت خودکار در این دسته ذخیره نمی‌شوند.", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 23566819..e3978284 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -208,7 +208,7 @@ "categoryName": "Nom de la catégorie", "categoryPath": "Chemin de la catégorie", "autoCategorizeByExtension": "Catégoriser automatiquement selon le type de fichier", - "autoCategorizeByExtensionDescription": "Les téléchargements sont placés dans un dossier de catégorie selon l'extension du fichier lors de l'enregistrement dans le dossier de téléchargement par défaut.", + "autoCategorizeByExtensionDescription": "Les téléchargements sont placés dans un dossier de catégorie selon l'extension du fichier lors de l'enregistrement dans le dossier de téléchargement par défaut", "categoryExtensions": "Extensions de fichier", "categoryExtensionsUsageHint": "Séparez les extensions par virgules ou espaces, puis appuyez sur Entrée.", "categoryExtensionsEmptyHint": "Sans extensions, les fichiers ne seront pas enregistrés ici automatiquement.", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index d2e3cbc2..2df11da3 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -208,7 +208,7 @@ "categoryName": "Kategória neve", "categoryPath": "Kategória útvonala", "autoCategorizeByExtension": "Automatikus kategorizálás fájltípus szerint", - "autoCategorizeByExtensionDescription": "Az alapértelmezett letöltési mappa használatakor a letöltések a fájl kiterjesztése alapján automatikusan a kategória mappájába kerülnek.", + "autoCategorizeByExtensionDescription": "Az alapértelmezett letöltési mappa használatakor a letöltések a fájl kiterjesztése alapján automatikusan a kategória mappájába kerülnek", "categoryExtensions": "Fájlkiterjesztések", "categoryExtensionsUsageHint": "A kiterjesztéseket vesszővel vagy szóközzel válaszd el, majd nyomd le az Enter billentyűt.", "categoryExtensionsEmptyHint": "Kiterjesztések nélkül a fájlok nem kerülnek automatikusan ebbe a kategóriába.", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index a2383e10..b264ee12 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -208,7 +208,7 @@ "categoryName": "Nama Kategori", "categoryPath": "Path Kategori", "autoCategorizeByExtension": "Kategorikan otomatis berdasarkan jenis file", - "autoCategorizeByExtensionDescription": "Unduhan dialihkan ke folder kategori berdasarkan ekstensi file saat disimpan ke folder unduhan bawaan.", + "autoCategorizeByExtensionDescription": "Unduhan dialihkan ke folder kategori berdasarkan ekstensi file saat disimpan ke folder unduhan bawaan", "categoryExtensions": "Ekstensi file", "categoryExtensionsUsageHint": "Pisahkan ekstensi dengan koma atau spasi, lalu tekan Enter.", "categoryExtensionsEmptyHint": "Tanpa ekstensi, file tidak akan otomatis disimpan ke kategori ini.", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 1a8df506..8f4afd36 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -208,7 +208,7 @@ "categoryName": "Nome categoria", "categoryPath": "Percorso categoria", "autoCategorizeByExtension": "Categorizza automaticamente per tipo di file", - "autoCategorizeByExtensionDescription": "Il download viene instradato automaticamente nella cartella della categoria in base all'estensione quando si salva nella cartella di download predefinita.", + "autoCategorizeByExtensionDescription": "Il download viene instradato automaticamente nella cartella della categoria in base all'estensione quando si salva nella cartella di download predefinita", "categoryExtensions": "Estensioni file", "categoryExtensionsUsageHint": "Separa le estensioni con virgole o spazi e premi Invio.", "categoryExtensionsEmptyHint": "Senza estensioni, i file non verranno salvati qui automaticamente.", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 2ffb0d45..68f60558 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -208,7 +208,7 @@ "categoryName": "カテゴリ名", "categoryPath": "カテゴリパス", "autoCategorizeByExtension": "ファイル種別で自動分類", - "autoCategorizeByExtensionDescription": "既定のダウンロードフォルダーに保存する際、拡張子に基づいて分類フォルダーへ自動的に振り分けます。", + "autoCategorizeByExtensionDescription": "既定のダウンロードフォルダーに保存する際、拡張子に基づいて分類フォルダーへ自動的に振り分けます", "categoryExtensions": "ファイル拡張子", "categoryExtensionsUsageHint": "カンマやスペースで拡張子を区切り、Enterで追加します。", "categoryExtensionsEmptyHint": "拡張子が未設定の場合、ファイルは自動的にこの分類へ保存されません。", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 1973cbd0..181a31a1 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -208,7 +208,7 @@ "categoryName": "카테고리 이름", "categoryPath": "카테고리 경로", "autoCategorizeByExtension": "파일 형식별 자동 분류", - "autoCategorizeByExtensionDescription": "기본 다운로드 폴더에 저장할 때 확장자에 따라 분류 폴더로 자동 이동합니다.", + "autoCategorizeByExtensionDescription": "기본 다운로드 폴더에 저장할 때 확장자에 따라 분류 폴더로 자동 이동합니다", "categoryExtensions": "파일 확장자", "categoryExtensionsUsageHint": "쉼표나 공백으로 확장자를 구분하고 Enter를 눌러 추가하세요.", "categoryExtensionsEmptyHint": "확장자가 없으면 파일이 이 분류로 자동 저장되지 않습니다.", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index d953dbaa..9d467855 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -208,7 +208,7 @@ "categoryName": "Nazwa kategorii", "categoryPath": "Ścieżka kategorii", "autoCategorizeByExtension": "Automatyczna kategorizacja według typu pliku", - "autoCategorizeByExtensionDescription": "Pobierane pliki są automatycznie zapisywane w folderze kategorii na podstawie rozszerzenia, gdy używany jest domyślny folder pobierania.", + "autoCategorizeByExtensionDescription": "Pobierane pliki są automatycznie zapisywane w folderze kategorii na podstawie rozszerzenia, gdy używany jest domyślny folder pobierania", "categoryExtensions": "Rozszerzenia plików", "categoryExtensionsUsageHint": "Rozdzielaj rozszerzenia przecinkami lub spacjami i naciskaj Enter.", "categoryExtensionsEmptyHint": "Bez rozszerzeń pliki nie będą automatycznie zapisywane w tej kategorii.", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 464e7960..2ee1a4f7 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -208,7 +208,7 @@ "categoryName": "Nome da Categoria", "categoryPath": "Caminho da Categoria", "autoCategorizeByExtension": "Categorizar automaticamente por tipo de arquivo", - "autoCategorizeByExtensionDescription": "Envia os downloads para a pasta da categoria com base na extensão ao salvar na pasta de download padrão.", + "autoCategorizeByExtensionDescription": "Envia os downloads para a pasta da categoria com base na extensão ao salvar na pasta de download padrão", "categoryExtensions": "Extensões de arquivo", "categoryExtensionsUsageHint": "Separe as extensões com vírgulas ou espaços e prima Enter.", "categoryExtensionsEmptyHint": "Sem extensões, os ficheiros não serão guardados aqui automaticamente.", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index fd9abe4a..4e6bd7cc 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -208,7 +208,7 @@ "categoryName": "Название категории", "categoryPath": "Путь категории", "autoCategorizeByExtension": "Автокатегоризация по типу файла", - "autoCategorizeByExtensionDescription": "Загрузки автоматически сохраняются в папку категории по расширению файла, если выбрана папка загрузки по умолчанию.", + "autoCategorizeByExtensionDescription": "Загрузки автоматически сохраняются в папку категории по расширению файла, если выбрана папка загрузки по умолчанию", "categoryExtensions": "Расширения файлов", "categoryExtensionsUsageHint": "Разделяйте расширения запятыми или пробелами и нажимайте Enter.", "categoryExtensionsEmptyHint": "Без расширений файлы не будут автоматически сохранены в эту категорию.", diff --git a/ui/flutter/lib/l10n/app_sk.arb b/ui/flutter/lib/l10n/app_sk.arb index afdf6695..4e9d3c16 100644 --- a/ui/flutter/lib/l10n/app_sk.arb +++ b/ui/flutter/lib/l10n/app_sk.arb @@ -222,7 +222,7 @@ "categoryName": "Názov kategórie", "categoryPath": "Cesta kategórie", "autoCategorizeByExtension": "Automatická kategorizácia podľa typu súboru", - "autoCategorizeByExtensionDescription": "Sťahovania sa pri ukladaní do predvoleného priečinka sťahovania automaticky presúvajú do priečinka kategórie podľa prípony súboru.", + "autoCategorizeByExtensionDescription": "Sťahovania sa pri ukladaní do predvoleného priečinka sťahovania automaticky presúvajú do priečinka kategórie podľa prípony súboru", "categoryExtensions": "Prípony súborov", "categoryExtensionsUsageHint": "Oddeľujte prípony čiarkami alebo medzerami a stlačte Enter.", "categoryExtensionsEmptyHint": "Bez prípon sa súbory automaticky neuložia do tejto kategórie.", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 44acf778..d2adc8b3 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -208,7 +208,7 @@ "categoryName": "வகை பெயர்", "categoryPath": "வகை பாதை", "autoCategorizeByExtension": "கோப்பு வகை வாரியாக தானாக வகைப்படுத்து", - "autoCategorizeByExtensionDescription": "இயல்புநிலை பதிவிறக்கக் கோப்புறையில் சேமிக்கும்போது, கோப்பு நீட்டிப்பின் அடிப்படையில் வகைக் கோப்புறைக்கு தானாக அனுப்பப்படும்.", + "autoCategorizeByExtensionDescription": "இயல்புநிலை பதிவிறக்கக் கோப்புறையில் சேமிக்கும்போது, கோப்பு நீட்டிப்பின் அடிப்படையில் வகைக் கோப்புறைக்கு தானாக அனுப்பப்படும்", "categoryExtensions": "கோப்பு நீட்டிப்புகள்", "categoryExtensionsUsageHint": "நீட்டிப்புகளை காற்புள்ளிகள் அல்லது இடைவெளிகளால் பிரித்து Enter அழுத்தவும்.", "categoryExtensionsEmptyHint": "நீட்டிப்புகள் இல்லாததால், கோப்புகள் இந்த வகைப்பாட்டில் தானாகச் சேமிக்கப்படாது.", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 7fe573c7..3d24bba7 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -208,7 +208,7 @@ "categoryName": "Kategori Adı", "categoryPath": "Kategori Yolu", "autoCategorizeByExtension": "Dosya tipine göre otomatik sınıflandır", - "autoCategorizeByExtensionDescription": "Varsayılan indirme klasörüne kaydedilirken dosya uzantısına göre ilgili klasöre otomatik yönlendirir.", + "autoCategorizeByExtensionDescription": "Varsayılan indirme klasörüne kaydedilirken dosya uzantısına göre ilgili klasöre otomatik yönlendirir", "categoryExtensions": "Dosya uzantıları", "categoryExtensionsUsageHint": "Sonekleri virgül veya boşlukla ayırıp Enter'a basın.", "categoryExtensionsEmptyHint": "Uzantı yoksa dosyalar bu kategoriye otomatik olarak kaydedilmez.", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 8dc51080..e41f7f83 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -208,7 +208,7 @@ "categoryName": "Назва категорії", "categoryPath": "Шлях категорії", "autoCategorizeByExtension": "Автокатегоризація за типом файлу", - "autoCategorizeByExtensionDescription": "Завантаження автоматично зберігаються в папку категорії за розширенням файлу, якщо вибрано папку завантаження за замовчуванням.", + "autoCategorizeByExtensionDescription": "Завантаження автоматично зберігаються в папку категорії за розширенням файлу, якщо вибрано папку завантаження за замовчуванням", "categoryExtensions": "Розширення файлів", "categoryExtensionsUsageHint": "Розділяйте розширення комами або пробілами та натискайте Enter.", "categoryExtensionsEmptyHint": "Без розширень файли не зберігатимуться в цю категорію автоматично.", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 91d7cf89..5baa7d08 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -208,7 +208,7 @@ "categoryName": "Tên danh mục", "categoryPath": "Đường dẫn danh mục", "autoCategorizeByExtension": "Tự động phân loại theo loại tệp", - "autoCategorizeByExtensionDescription": "Tải xuống sẽ được chuyển vào thư mục danh mục dựa trên phần mở rộng tệp khi lưu vào thư mục tải xuống mặc định.", + "autoCategorizeByExtensionDescription": "Tải xuống sẽ được chuyển vào thư mục danh mục dựa trên phần mở rộng tệp khi lưu vào thư mục tải xuống mặc định", "categoryExtensions": "Phần mở rộng tệp", "categoryExtensionsUsageHint": "Phân tách phần mở rộng bằng dấu phẩy hoặc khoảng trắng rồi nhấn Enter.", "categoryExtensionsEmptyHint": "Không có phần mở rộng, tệp sẽ không tự động được lưu vào danh mục này.", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 56eab1d9..9b0ea544 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -208,7 +208,7 @@ "categoryName": "分类名称", "categoryPath": "分类路径", "autoCategorizeByExtension": "按文件类型自动分类", - "autoCategorizeByExtensionDescription": "保存到默认下载目录时,根据文件后缀自动归入对应分类文件夹。", + "autoCategorizeByExtensionDescription": "保存到默认下载目录时,根据文件后缀自动归入对应分类文件夹", "categoryExtensions": "文件后缀关联", "categoryExtensionsUsageHint": "支持逗号/空格分隔后缀,按回车添加。", "categoryExtensionsEmptyHint": "未设置后缀,文件不会自动归入此分类。", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 0b36e96c..8eb05b56 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -208,7 +208,7 @@ "categoryName": "分類名稱", "categoryPath": "分類路徑", "autoCategorizeByExtension": "按檔案類型自動分類", - "autoCategorizeByExtensionDescription": "儲存至預設下載目錄時,根據檔案副檔名自動歸入對應分類資料夾。", + "autoCategorizeByExtensionDescription": "儲存至預設下載目錄時,根據檔案副檔名自動歸入對應分類資料夾", "categoryExtensions": "檔案副檔名", "categoryExtensionsUsageHint": "支援逗號/空白分隔後綴,按回車新增。", "categoryExtensionsEmptyHint": "未設定後綴,檔案不會自動歸入此分類。", diff --git a/ui/flutter/test/features/settings/application/settings_controller_test.dart b/ui/flutter/test/features/settings/application/settings_controller_test.dart index 0d6816f7..133160dc 100644 --- a/ui/flutter/test/features/settings/application/settings_controller_test.dart +++ b/ui/flutter/test/features/settings/application/settings_controller_test.dart @@ -99,6 +99,43 @@ void main() { expect(saved.saving, isFalse); }); + for (final field in ['port', 'DHT', 'PEX']) { + test('$field restart notice follows successful persisted changes only', () async { + var backend = DownloaderConfig(); + var failNextSave = false; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson())) + ..bind(GopeedMethods.putConfig, (config) { + if (failNextSave) { + failNextSave = false; + throw StateError('offline'); + } + backend = DownloaderConfig.fromJson(config.toJson()); + return const RpcUnit(); + }); + final container = _settingsContainer(registry); + addTearDown(container.dispose); + final initial = await container.read(settingsControllerProvider.future); + final controller = container.read(settingsControllerProvider.notifier); + final draft = DownloaderConfig.fromJson(initial.config.toJson()); + switch (field) { + case 'port': + draft.protocolConfig.bt.listenPort = 6881; + case 'DHT': + draft.protocolConfig.bt.disableDht = true; + case 'PEX': + draft.protocolConfig.bt.disablePex = true; + } + failNextSave = true; + await expectLater(controller.save(draft), throwsStateError); + expect((await controller.save(draft)).btRestartRequired, isTrue); + expect((await controller.save(draft)).btRestartRequired, isFalse); + draft.protocolConfig.bt.seedRatio = 2; + expect((await controller.save(draft)).btRestartRequired, isFalse); + expect((await controller.save(initial.config)).btRestartRequired, isTrue); + }); + } + test('rapid full config saves run in order and keep the latest user edits', () async { final backend = DownloaderConfig(); final entered = Completer(); @@ -127,7 +164,8 @@ void main() { final savingSecond = controller.save(second); expect(puts.length, 1); release.complete(); - await Future.wait([savingFirst, savingSecond]); + final results = await Future.wait([savingFirst, savingSecond]); + expect(results.map((result) => result.btRestartRequired), [true, false]); expect(puts, hasLength(2)); expect(puts[0].protocolConfig.bt.listenPort, 6881); expect(puts[1].protocolConfig.bt.listenPort, 6881); diff --git a/ui/flutter/test/features/tasks/presentation/create_task_advanced_toggle_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_advanced_toggle_test.dart new file mode 100644 index 00000000..34a5a5ee --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/create_task_advanced_toggle_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/create_task_advanced_toggle.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final width in [360.0, 768.0, 1024.0]) { + testWidgets('advanced disclosure supports keyboard and touch at width $width', (tester) async { + tester.view.physicalSize = Size(width, 768); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var expanded = false; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: StatefulBuilder( + builder: (context, setState) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: CreateTaskAdvancedToggle( + expanded: expanded, + onPressed: () => setState(() => expanded = !expanded), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(shad.GhostButton)).height, greaterThanOrEqualTo(44)); + expect( + find.byWidgetPredicate((widget) => widget is Semantics && widget.properties.expanded == false), + findsOneWidget, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(expanded, isTrue); + expect( + find.byWidgetPredicate((widget) => widget is Semantics && widget.properties.expanded == true), + findsOneWidget, + ); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(expanded, isFalse); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); + } +} diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 34bc10d9..882e391c 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -1636,6 +1636,46 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('BT settings show a restart message only after an affected auto-save', (tester) async { + await _setTestSize(tester, const Size(1024, 900)); + final controller = FakeSettingsController()..btRestartRequired = true; + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => controller), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + const message = 'This setting takes effect after restarting the app'; + expect(find.text(message), findsNothing); + final port = find.byKey(const ValueKey('bt-listen-port-input')); + await tester.ensureVisible(port); + await tester.pumpAndSettle(); + await tester.enterText(port, '6881'); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text(message), findsNothing); + await tester.pump(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text(message), findsOneWidget); + expect(tester.widget(find.byType(AppToastContent)).type, AppToastType.info); + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + controller.btRestartRequired = false; + final seedRatio = find.byKey(const ValueKey('bt-seed-ratio-input')); + await tester.ensureVisible(seedRatio); + await tester.enterText(seedRatio, '2'); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pumpAndSettle(); + expect(find.text(message), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('settings FTP connections save into the FTP protocol config', (WidgetTester tester) async { await _setTestSize(tester, const Size(1024, 900)); final settingsController = FakeSettingsController(); @@ -6181,12 +6221,15 @@ ApiServerState _testApiServerState({ } class FakeSettingsController extends SettingsController { + bool btRestartRequired = false; + @override Future build() async => SettingsState(config: DownloaderConfig()); @override - Future save(DownloaderConfig config) async { + Future save(DownloaderConfig config) async { state = AsyncValue.data(SettingsState(config: config)); + return SettingsSaveResult(btRestartRequired: btRestartRequired); } } From 506f6899923ba2f284ede9f6b350c018b719b9c4 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 14 Nov 2026 11:32:19 +0800 Subject: [PATCH 7/7] fix(extension): cancel stuck event handlers and unblock engine waiters (#1659) --- pkg/download/downloader.go | 4 +- pkg/download/engine/cancellation_test.go | 47 ++++++ pkg/download/engine/engine.go | 55 +++++++- pkg/download/extension.go | 19 ++- pkg/download/extension_cancellation_test.go | 149 ++++++++++++++++++++ pkg/download/extension_engine_session.go | 15 ++ pkg/download/model.go | 18 ++- 7 files changed, 290 insertions(+), 17 deletions(-) create mode 100644 pkg/download/engine/cancellation_test.go create mode 100644 pkg/download/extension_cancellation_test.go diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 94ecbc4f..a76f3b45 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -2412,7 +2412,9 @@ func (d *Downloader) doStart(task *Task) (err error) { return err } } - d.triggerOnStart(task) + if err := d.triggerOnStart(task); err != nil { + return err + } if !d.taskIsRunningGeneration(task, generation) { return nil } diff --git a/pkg/download/engine/cancellation_test.go b/pkg/download/engine/cancellation_test.go new file mode 100644 index 00000000..d75b2ed7 --- /dev/null +++ b/pkg/download/engine/cancellation_test.go @@ -0,0 +1,47 @@ +package engine + +import ( + "context" + "errors" + "github.com/dop251/goja" + "testing" + "time" +) + +func TestCloseUnblocksPendingPromise(t *testing.T) { + e := NewEngine(nil) + entered := make(chan struct{}) + _, err := e.runOnLoop(func(r *goja.Runtime) (goja.Value, error) { + return goja.Undefined(), r.Set("entered", func() { close(entered) }) + }) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { _, err := e.RunString(`entered(); new Promise(()=>{});`); done <- err }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("script did not enter") + } + e.Close() + select { + case err := <-done: + if err == nil { + t.Fatal("closed execution returned success") + } + case <-time.After(300 * time.Millisecond): + t.Fatal("Engine.Close left the Promise waiter blocked") + } +} + +func TestExecutionDeadlineInterruptsSynchronousScript(t *testing.T) { + e := NewEngine(nil) + defer e.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _, err := e.RunStringContext(ctx, `while (true) {}`) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("got %v, want deadline exceeded", err) + } +} diff --git a/pkg/download/engine/engine.go b/pkg/download/engine/engine.go index 3dc20084..1deb92ca 100644 --- a/pkg/download/engine/engine.go +++ b/pkg/download/engine/engine.go @@ -1,6 +1,7 @@ package engine import ( + "context" _ "embed" "errors" "fmt" @@ -26,6 +27,7 @@ var polyfillScript string type Engine struct { loop *eventloop.EventLoop + done chan struct{} closeOnce sync.Once cleanupMu sync.Mutex @@ -39,7 +41,13 @@ type JSFunction func(goja.FunctionCall) goja.Value // RunString executes the script and returns the go type value // if script result is promise, it will be resolved func (e *Engine) RunString(script string) (value any, err error) { - return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) { + return e.RunStringContext(context.Background(), script) +} + +// RunStringContext bounds an execution, including awaited promises. Cancelling +// an execution closes its engine so outstanding operations cannot outlive it. +func (e *Engine) RunStringContext(ctx context.Context, script string) (any, error) { + return e.runOnLoopContext(ctx, func(runtime *goja.Runtime) (goja.Value, error) { return runtime.RunString(script) }) } @@ -47,7 +55,11 @@ func (e *Engine) RunString(script string) (value any, err error) { // CallFunction calls the function and returns the go type value // if function result is promise, it will be resolved func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) { - return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) { + return e.CallFunctionContext(context.Background(), fn, args...) +} + +func (e *Engine) CallFunctionContext(ctx context.Context, fn any, args ...any) (value any, err error) { + return e.runOnLoopContext(ctx, func(runtime *goja.Runtime) (goja.Value, error) { var jsArgs []goja.Value for _, arg := range args { jsArgs = append(jsArgs, runtime.ToValue(arg)) @@ -69,6 +81,20 @@ func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) { } func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) { + return e.runOnLoopContext(context.Background(), fn) +} + +var ErrClosed = errors.New("engine closed") + +func (e *Engine) runOnLoopContext(ctx context.Context, fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + select { + case <-e.done: + return nil, ErrClosed + default: + } type result struct { value any err error @@ -132,15 +158,31 @@ func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) ( if !ok { return nil, errors.New("engine loop terminated") } - res := <-ch - if res.err != nil { - return nil, res.err + select { + case res := <-ch: + return res.value, res.err + case <-ctx.Done(): + // Interrupt synchronous JS too; a select alone only stops Promise waiting. + e.Close() + return nil, ctx.Err() + case <-e.done: + return nil, ErrClosed } - return res.value, nil } func (e *Engine) Close() { e.closeOnce.Do(func() { + close(e.done) + e.Runtime.Interrupt(ErrClosed) + // Wait for interrupted JS to leave the loop, then allow cleanup hooks + // to execute their JS cancellation callbacks before terminating it. + cleared := make(chan struct{}) + if e.loop.RunOnLoop(func(runtime *goja.Runtime) { + runtime.ClearInterrupt() + close(cleared) + }) { + <-cleared + } e.cleanupMu.Lock() cleanups := e.cleanups e.cleanups = nil @@ -177,6 +219,7 @@ func NewEngine(cfg *Config) *Engine { loop := eventloop.NewEventLoop() engine := &Engine{ loop: loop, + done: make(chan struct{}), } loop.Start() done := make(chan struct{}) diff --git a/pkg/download/extension.go b/pkg/download/extension.go index b160b0e4..94db2024 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -3,6 +3,7 @@ package download import ( "context" "encoding/json" + "errors" "fmt" "io" "net/url" @@ -327,8 +328,8 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er return } -func (d *Downloader) triggerOnStart(task *Task) { - doTrigger(d, +func (d *Downloader) triggerOnStart(task *Task) error { + return doTrigger(d, EventOnStart, task.Meta.Req, &OnStartContext{ @@ -413,6 +414,13 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c } engine, session := d.newExtensionEngine() defer session.CloseIfIdle() + eventCtx, cancelEvent := context.WithTimeout(d.backgroundCtx, d.cfg.ExtensionEventTimeout) + defer cancelEvent() + defer func() { + if eventCtx.Err() != nil { + session.Close() + } + }() gopeed.Runtime = &InstanceRuntime{ WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), } @@ -421,13 +429,13 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity()) return } - _, err = engine.RunString(string(scriptBuf)) + _, err = engine.RunStringContext(eventCtx, string(scriptBuf)) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity()) return } if fn, ok := gopeed.Events[event]; ok { - _, err = engine.CallFunction(fn, ctx) + _, err = engine.CallFunctionContext(eventCtx, fn, ctx) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", ext.buildIdentity(), event) return @@ -437,6 +445,9 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c } } }() + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } } } } diff --git a/pkg/download/extension_cancellation_test.go b/pkg/download/extension_cancellation_test.go new file mode 100644 index 00000000..362483cb --- /dev/null +++ b/pkg/download/extension_cancellation_test.go @@ -0,0 +1,149 @@ +package download + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/pkg/base" +) + +func installCancellationExtension(t *testing.T, d *Downloader, event, script string) { + t.Helper() + dir := t.TempDir() + manifest := fmt.Sprintf(`{"name":"cancel-%s","author":"test","title":"Cancel","version":"1.0.0","scripts":[{"event":%q,"match":{"urls":["*://example.com/*"]},"entry":"index.js"}]}`, event, event) + for name, body := range map[string]string{"manifest.json": manifest, "index.js": script} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + if _, err := d.InstallExtensionByFolder(dir, true); err != nil { + t.Fatal(err) + } +} +func newCancellationDownloader(t *testing.T) *Downloader { + t.Helper() + d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&generationTestManager{holdOpen: true}}}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + return d +} +func TestResolveExtensionStopsOnShutdown(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + defer once.Do(func() { close(release) }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(entered) + select { + case <-release: + case <-r.Context().Done(): + } + w.Write([]byte("ok")) + })) + defer server.Close() + d := newCancellationDownloader(t) + defer d.Close() + installCancellationExtension(t, d, "onResolve", fmt.Sprintf(`gopeed.events.onResolve(async ctx=>{await fetch(%q);});`, server.URL)) + done := make(chan error, 1) + go func() { _, err := d.Resolve(&base.Request{URL: "https://example.com/cancel"}); done <- err }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("handler did not enter") + } + if err := d.Close(); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf("got %v, want cancellation", err) + } + case <-time.After(300 * time.Millisecond): + once.Do(func() { close(release) }) + t.Fatal("shutdown did not cancel extension Resolve") + } +} +func TestOnErrorCancellationReleasesTaskAPIs(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + defer once.Do(func() { close(release) }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(entered) + select { + case <-release: + case <-r.Context().Done(): + } + w.Write([]byte("ok")) + })) + defer server.Close() + d := newCancellationDownloader(t) + defer d.Close() + parent, cancel := context.WithCancel(d.backgroundCtx) + d.backgroundCtx = parent + defer cancel() + installCancellationExtension(t, d, "onError", fmt.Sprintf(`gopeed.events.onError(async ctx=>{await fetch(%q);});`, server.URL)) + d.cfg.MaxRunning = 0 + id, err := d.CreateDirect(&base.Request{URL: "generation://example.com/task"}, &base.Options{Path: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + task := d.GetTask(id) + task.statusLock.Lock() + task.Status = base.DownloadStatusRunning + task.statusLock.Unlock() + handled := make(chan struct{}) + go func() { d.doOnError(task, errors.New("source failed")); close(handled) }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("onError did not enter") + } + status, stats := make(chan struct{}), make(chan struct{}) + go func() { d.RuntimeStatus(id); close(status) }() + go func() { d.Stats(id); close(stats) }() + cancel() + for name, ch := range map[string]<-chan struct{}{"onError": handled, "RuntimeStatus": status, "Stats": stats} { + select { + case <-ch: + case <-time.After(300 * time.Millisecond): + once.Do(func() { close(release) }) + <-handled + t.Fatalf("%s stayed blocked after cancellation", name) + } + } +} + +func TestExtensionEventDeadlineRejectsUnsettledResolve(t *testing.T) { + d := newCancellationDownloader(t) + defer d.Close() + d.cfg.ExtensionEventTimeout = 30 * time.Millisecond + installCancellationExtension(t, d, "onResolve", `gopeed.events.onResolve(async ctx=>{await new Promise(()=>{});});`) + _, err := d.Resolve(&base.Request{URL: "https://example.com/timeout"}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("got %v, want event deadline exceeded", err) + } +} +func TestExtensionStartDeadlinePreventsFetcherStart(t *testing.T) { + d := newCancellationDownloader(t) + defer d.Close() + d.cfg.ExtensionEventTimeout = 30 * time.Millisecond + installCancellationExtension(t, d, "onStart", `gopeed.events.onStart(async ctx=>{await new Promise(()=>{});});`) + id, err := d.CreateDirect(&base.Request{URL: "generation://example.com/timeout"}, &base.Options{Path: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, d, id, base.DownloadStatusError, 2*time.Second) + if starts := d.cfg.FetchManagers[0].(*generationTestManager).starts.Load(); starts != 0 { + t.Fatalf("timed out onStart still started fetcher %d times", starts) + } +} diff --git a/pkg/download/extension_engine_session.go b/pkg/download/extension_engine_session.go index ae617d7b..813aa3d6 100644 --- a/pkg/download/extension_engine_session.go +++ b/pkg/download/extension_engine_session.go @@ -59,6 +59,21 @@ func (s *engineSession) Release() { } } +// Close releases retained resources when the owning event is cancelled. +func (s *engineSession) Close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + s.mu.Unlock() + s.runClosers() + if s.engine != nil { + s.engine.Close() + } +} + func (s *engineSession) CloseIfIdle() { s.mu.Lock() shouldClose := s.refs == 0 && !s.closed diff --git a/pkg/download/model.go b/pkg/download/model.go index b08a34d3..60be919b 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -249,12 +249,15 @@ type DownloaderConfig struct { Controller *controller.Controller FetchManagers []fetcher.FetcherManager - RefreshInterval int // RefreshInterval time duration to refresh task progress(ms) - Storage Storage - StorageDir string - TempDir string - WhiteDownloadDirs []string - WebViewProvider enginewebview.Provider + // ExtensionEventTimeout bounds one script load and its awaited event handler. + // Zero uses one minute. Embedders may increase it for long-running handlers. + ExtensionEventTimeout time.Duration + RefreshInterval int // RefreshInterval time duration to refresh task progress(ms) + Storage Storage + StorageDir string + TempDir string + WhiteDownloadDirs []string + WebViewProvider enginewebview.Provider ProductionMode bool @@ -277,6 +280,9 @@ func (cfg *DownloaderConfig) Init() *DownloaderConfig { new(ed2k.FetcherManager), } } + if cfg.ExtensionEventTimeout <= 0 { + cfg.ExtensionEventTimeout = time.Minute + } if cfg.RefreshInterval == 0 { cfg.RefreshInterval = 350 }