From 7ce4fb7f0ebebd7c5dc28656b39d857a8dbfb539 Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 25 Sep 2026 19:08:20 +0800 Subject: [PATCH 1/9] fix(ui): smooth task progress bar updates (#1532) --- .../widgets/task_progress_bar.dart | 117 ++++++++++-------- 1 file changed, 63 insertions(+), 54 deletions(-) diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_progress_bar.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_progress_bar.dart index ac139965..b0cd1b55 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_progress_bar.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_progress_bar.dart @@ -1,3 +1,4 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart' show kDefaultDuration; import 'package:flutter/widgets.dart'; class TaskProgressBar extends StatefulWidget { @@ -74,68 +75,76 @@ class _TaskProgressBarState extends State with SingleTickerProv color: widget.trackColor, child: LayoutBuilder( builder: (context, constraints) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - if (widget.indeterminate) { - final segmentWidth = constraints.maxWidth * 0.4; - final left = lerpDouble(-segmentWidth, constraints.maxWidth, _controller.value); + final progress = (widget.value ?? 0).clamp(0.0, 1.0); + return TweenAnimationBuilder( + tween: Tween(begin: progress, end: progress), + duration: MediaQuery.disableAnimationsOf(context) || widget.indeterminate + ? Duration.zero + : kDefaultDuration, + curve: Curves.easeInOut, + builder: (context, animatedProgress, child) => AnimatedBuilder( + animation: _controller, + builder: (context, child) { + if (widget.indeterminate) { + final segmentWidth = constraints.maxWidth * 0.4; + final left = lerpDouble(-segmentWidth, constraints.maxWidth, _controller.value); + + return Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: left, + width: segmentWidth, + top: 0, + bottom: 0, + child: _ProgressFill( + color: widget.fillColor, + shimmer: widget.shimmer, + highlightStartColor: widget.highlightStartColor, + highlightEndColor: widget.highlightEndColor, + bandLeft: lerpDouble(-segmentWidth, segmentWidth * 2, _controller.value), + bandWidth: segmentWidth * 0.5, + ), + ), + ], + ); + } + + final fillWidth = constraints.maxWidth * animatedProgress; + final bandWidth = fillWidth == 0 ? 0.0 : (fillWidth * 0.35).clamp(16.0, 40.0); + final travelDistance = fillWidth + bandWidth * 2; + final durationMs = (travelDistance / _shimmerPixelsPerSecond * 1000) + .clamp(_shimmerMinDurationMs, _shimmerMaxDurationMs) + .round(); + + if (widget.shimmer && widget.value != null && fillWidth > 0) { + final duration = Duration(milliseconds: durationMs); + if (_controller.duration != duration) { + _controller.duration = duration; + } + } return Stack( fit: StackFit.expand, children: [ - Positioned( - left: left, - width: segmentWidth, - top: 0, - bottom: 0, - child: _ProgressFill( - color: widget.fillColor, - shimmer: widget.shimmer, - highlightStartColor: widget.highlightStartColor, - highlightEndColor: widget.highlightEndColor, - bandLeft: lerpDouble(-segmentWidth, segmentWidth * 2, _controller.value), - bandWidth: segmentWidth * 0.5, + Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: fillWidth, + child: _ProgressFill( + color: widget.fillColor, + shimmer: widget.shimmer, + highlightStartColor: widget.highlightStartColor, + highlightEndColor: widget.highlightEndColor, + bandLeft: lerpDouble(-bandWidth, fillWidth + bandWidth, _controller.value), + bandWidth: bandWidth, + ), ), ), ], ); - } - - final fillWidth = constraints.maxWidth * ((widget.value ?? 0).clamp(0, 1)); - final bandWidth = fillWidth == 0 ? 0.0 : (fillWidth * 0.35).clamp(16.0, 40.0); - final travelDistance = fillWidth + bandWidth * 2; - final durationMs = (travelDistance / _shimmerPixelsPerSecond * 1000) - .clamp(_shimmerMinDurationMs, _shimmerMaxDurationMs) - .round(); - - if (widget.shimmer && widget.value != null && fillWidth > 0) { - final duration = Duration(milliseconds: durationMs); - if (_controller.duration != duration) { - _controller.duration = duration; - } - } - - return Stack( - fit: StackFit.expand, - children: [ - Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: fillWidth, - child: _ProgressFill( - color: widget.fillColor, - shimmer: widget.shimmer, - highlightStartColor: widget.highlightStartColor, - highlightEndColor: widget.highlightEndColor, - bandLeft: lerpDouble(-bandWidth, fillWidth + bandWidth, _controller.value), - bandWidth: bandWidth, - ), - ), - ), - ], - ); - }, + }, + ), ); }, ), From 224b4880871f8d7a87c16a3d4daca31546f0ca80 Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 25 Sep 2026 19:15:31 +0800 Subject: [PATCH 2/9] feat(task): record and display task completion time (#1536) --- pkg/download/model.go | 10 +++++ ui/flutter/lib/api/model/task.dart | 2 + ui/flutter/lib/api/model/task.g.dart | 2 + .../features/tasks/domain/task_record.dart | 3 ++ .../presentation/widgets/task_drawer.dart | 8 ++++ ui/flutter/lib/l10n/app_ar.arb | 1 + ui/flutter/lib/l10n/app_az.arb | 1 + ui/flutter/lib/l10n/app_ca.arb | 1 + ui/flutter/lib/l10n/app_de.arb | 1 + ui/flutter/lib/l10n/app_en.arb | 1 + ui/flutter/lib/l10n/app_es.arb | 1 + ui/flutter/lib/l10n/app_fa.arb | 1 + ui/flutter/lib/l10n/app_fr.arb | 1 + ui/flutter/lib/l10n/app_hu.arb | 1 + ui/flutter/lib/l10n/app_id.arb | 1 + ui/flutter/lib/l10n/app_it.arb | 1 + ui/flutter/lib/l10n/app_ja.arb | 1 + ui/flutter/lib/l10n/app_ko.arb | 1 + ui/flutter/lib/l10n/app_pl.arb | 1 + ui/flutter/lib/l10n/app_pt.arb | 1 + ui/flutter/lib/l10n/app_ru.arb | 1 + ui/flutter/lib/l10n/app_ta.arb | 1 + ui/flutter/lib/l10n/app_tr.arb | 1 + ui/flutter/lib/l10n/app_uk.arb | 1 + ui/flutter/lib/l10n/app_vi.arb | 1 + ui/flutter/lib/l10n/app_zh.arb | 1 + ui/flutter/lib/l10n/app_zh_TW.arb | 1 + ui/flutter/macos/Podfile.lock | 43 ++++++++++--------- .../tasks/domain/task_record_test.dart | 10 ++++- ui/flutter/test/widget_test.dart | 36 +++++++++++++++- 30 files changed, 113 insertions(+), 23 deletions(-) diff --git a/pkg/download/model.go b/pkg/download/model.go index c838cf31..bf479a0b 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -32,6 +32,10 @@ type Task struct { Progress *Progress `json:"progress"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` + // DoneAt is the time when the task completed; nil if it has never + // finished. A done task never leaves the done state, so the value is + // written once and kept. + DoneAt *time.Time `json:"doneAt"` producer production.Source producerReceived int64 @@ -134,6 +138,12 @@ func (t *Task) MarshalJSON() ([]byte, error) { func (t *Task) updateStatus(status base.Status) { t.UpdatedAt = time.Now() t.Status = status + // A done task never leaves the done state, so there is no branch to clear + // DoneAt on other transitions. + if status == base.DownloadStatusDone { + doneAt := time.Now() + t.DoneAt = &doneAt + } } func (t *Task) clone() *Task { diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index fd08e781..e72c6aa2 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -35,6 +35,7 @@ class Task { Progress progress; DateTime createdAt; DateTime updatedAt; + DateTime? doneAt; Task({ required this.id, @@ -45,6 +46,7 @@ class Task { required this.progress, required this.createdAt, required this.updatedAt, + this.doneAt, }); factory Task.fromJson(Map json) => _$TaskFromJson(json); diff --git a/ui/flutter/lib/api/model/task.g.dart b/ui/flutter/lib/api/model/task.g.dart index f127beee..d394703f 100644 --- a/ui/flutter/lib/api/model/task.g.dart +++ b/ui/flutter/lib/api/model/task.g.dart @@ -15,6 +15,7 @@ Task _$TaskFromJson(Map json) => Task( progress: Progress.fromJson(json['progress'] as Map), createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String), + doneAt: json['doneAt'] == null ? null : DateTime.parse(json['doneAt'] as String), )..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']); Map _$TaskToJson(Task instance) => { @@ -27,6 +28,7 @@ Map _$TaskToJson(Task instance) => { 'progress': instance.progress.toJson(), 'createdAt': instance.createdAt.toIso8601String(), 'updatedAt': instance.updatedAt.toIso8601String(), + 'doneAt': ?instance.doneAt?.toIso8601String(), }; const _$StatusEnumMap = { diff --git a/ui/flutter/lib/features/tasks/domain/task_record.dart b/ui/flutter/lib/features/tasks/domain/task_record.dart index bd066ddb..85a10cb1 100644 --- a/ui/flutter/lib/features/tasks/domain/task_record.dart +++ b/ui/flutter/lib/features/tasks/domain/task_record.dart @@ -76,6 +76,7 @@ class TaskRecord { this.remaining, this.downloadDuration, this.createdAt, + this.doneAt, this.progress, this.error, this.completedLabel, @@ -97,6 +98,7 @@ class TaskRecord { final String? remaining; final Duration? downloadDuration; final DateTime? createdAt; + final DateTime? doneAt; final double? progress; final String url; final String storagePath; @@ -145,6 +147,7 @@ class TaskRecord { remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes), downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null, createdAt: task.createdAt, + doneAt: task.doneAt, waiting: task.status == api_task.Status.wait, progress: progress, url: displayUrl, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index 49de9689..c668ff9b 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -319,6 +319,14 @@ class _TaskInfoTab extends StatelessWidget { _InfoBlock(label: context.l10n.remaining, value: remaining), if (task.createdAt case final createdAt?) _InfoBlock(label: context.l10n.createdAt, value: _formatTaskDateTime(createdAt)), + if (task.status == TaskStatus.completed) + _InfoBlock( + label: context.l10n.doneAt, + value: switch (task.doneAt) { + final doneAt? => _formatTaskDateTime(doneAt), + _ => '—', + }, + ), Container( key: const ValueKey('task-details-info-divider'), height: 1, diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index a450ab56..f25ea37b 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -246,6 +246,7 @@ "refresh": "تحديث", "status": "الحالة", "createdAt": "وقت الإنشاء", + "doneAt": "وقت الإكتمال", "speed": "السرعة", "uploadSpeed": "سرعة التشغيل", "remaining": "البقايا", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index d4f297d5..4a74c52f 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -260,6 +260,7 @@ "refresh": "Yenilə", "status": "Vəziyyət", "createdAt": "Yaradılıb", + "doneAt": "Tamamlanıb", "speed": "Sürət", "uploadSpeed": "Yükləmə sürəti", "remaining": "Qalıb", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 86185834..a1ddc641 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -246,6 +246,7 @@ "refresh": "Refresca", "status": "Estat", "createdAt": "Data de creació", + "doneAt": "Data de finalització", "speed": "Velocitat", "uploadSpeed": "Velocitat de pujada", "remaining": "Restant", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index c0c97ad8..81f3fc7f 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -246,6 +246,7 @@ "refresh": "Aktualisieren", "status": "-Status", "createdAt": "Erstellungszeit", + "doneAt": "Abgeschlossen am", "speed": "Geschwindigkeit", "uploadSpeed": "Upload-Geschwindigkeit", "remaining": "Verbleibend", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index a241bbfd..883bed0c 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -268,6 +268,7 @@ "refresh": "Refresh", "status": "Status", "createdAt": "Created", + "doneAt": "Completed at", "speed": "Speed", "uploadSpeed": "Upload speed", "remaining": "Remaining", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 3beff20c..002c8597 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -246,6 +246,7 @@ "refresh": "Actualizar", "status": "Situación", "createdAt": "Fecha de creación", + "doneAt": "Fecha de finalización", "speed": "Velocidad", "uploadSpeed": "Velocidad de carga", "remaining": "Permaneciendo", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 08664277..59a17a59 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -246,6 +246,7 @@ "refresh": "تازه‌سازی", "status": "وضعیت", "createdAt": "زمان ایجاد", + "doneAt": "زمان تکمیل", "speed": "سرعت", "uploadSpeed": "سرعت آپلود", "remaining": "باقی مانده", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 4c667ae7..66274439 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -246,6 +246,7 @@ "refresh": "Rafraîchir", "status": "État", "createdAt": "Date de création", + "doneAt": "Terminé le", "speed": "Vitesse", "uploadSpeed": "Vitesse de chargement", "remaining": "Reste", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 7330fe85..d0fe3092 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -246,6 +246,7 @@ "refresh": "Frissítés", "status": "állapot", "createdAt": "Létrehozás ideje", + "doneAt": "Befejezés ideje", "speed": "sebesség", "uploadSpeed": "Feltöltési sebesség", "remaining": "Maradt", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index c28ee0a7..9ad8eddd 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -246,6 +246,7 @@ "refresh": "Segarkan", "status": "Status", "createdAt": "Waktu dibuat", + "doneAt": "Waktu selesai", "speed": "Kecepatan", "uploadSpeed": "Kecepatan pemutakhiran", "remaining": "Sisa", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 0ae8fc65..0500ce26 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -246,6 +246,7 @@ "refresh": "Aggiorna", "status": "Stato", "createdAt": "Data di creazione", + "doneAt": "Data di completamento", "speed": "Velocità", "uploadSpeed": "Velocità di carico", "remaining": "Rimanere", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index d85cb2bc..2544161c 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -246,6 +246,7 @@ "refresh": "リフレッシュ", "status": "ステータス", "createdAt": "作成日時", + "doneAt": "完了日時", "speed": "スピード", "uploadSpeed": "アップロード速度", "remaining": "メンテナンス", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 051a42f4..504b25b0 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -246,6 +246,7 @@ "refresh": "의붓기", "status": "상태", "createdAt": "생성 시간", + "doneAt": "완료 시간", "speed": "속도", "uploadSpeed": "연결 속도", "remaining": "의논하기", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index bf86c584..4d512aa4 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -246,6 +246,7 @@ "refresh": "Odśwież", "status": "Stan", "createdAt": "Czas utworzenia", + "doneAt": "Czas ukończenia", "speed": "Prędkość", "uploadSpeed": "Prędkość wysyłania", "remaining": "Pozostały", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index b7f581e4..f93da09e 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -246,6 +246,7 @@ "refresh": "Actualizar", "status": "Estado", "createdAt": "Data de criação", + "doneAt": "Data de conclusão", "speed": "Velocidade", "uploadSpeed": "Velocidade de envio", "remaining": "Restante", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index aea0b6f5..57035b2b 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -246,6 +246,7 @@ "refresh": "освежиться", "status": "Статус", "createdAt": "Время создания", + "doneAt": "Время завершения", "speed": "Скорость", "uploadSpeed": "Скорость загрузки", "remaining": "оставаться", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index a967ea8c..04193bd1 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -246,6 +246,7 @@ "refresh": "புதுப்பி", "status": "நிலை", "createdAt": "உருவாக்கிய நேரம்", + "doneAt": "முடிந்த நேரம்", "speed": "வேகம்", "uploadSpeed": "வேகம்", "remaining": "மீதம்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 89be13ea..7c01725b 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -246,6 +246,7 @@ "refresh": "Yenile", "status": "Durum durumu", "createdAt": "Oluşturulma zamanı", + "doneAt": "Tamamlanma zamanı", "speed": "Hız", "uploadSpeed": "Yük hızı", "remaining": "Kalanlar", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index d32d2701..65fc23a9 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -246,6 +246,7 @@ "refresh": "Реверс", "status": "Контакты", "createdAt": "Час створення", + "doneAt": "Час завершення", "speed": "ОДЯГ", "uploadSpeed": "Швидкість завантаження", "remaining": "Ременування", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index db3e0c37..8cbd8393 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -246,6 +246,7 @@ "refresh": "Cập nhật", "status": "Trạng thái", "createdAt": "Thời gian tạo", + "doneAt": "Thời gian hoàn thành", "speed": "Tốc độ", "uploadSpeed": "Tốc độ", "remaining": "Ở lại", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 0269904d..fed70a8d 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -246,6 +246,7 @@ "refresh": "刷新", "status": "状态", "createdAt": "创建时间", + "doneAt": "完成时间", "speed": "速度", "uploadSpeed": "上传速度", "remaining": "剩余时间", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 5ad3f7f7..9d8a4b94 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -246,6 +246,7 @@ "refresh": "重新整理", "status": "狀態", "createdAt": "建立時間", + "doneAt": "完成時間", "speed": "速度", "uploadSpeed": "上傳速度", "remaining": "剩餘時間", diff --git a/ui/flutter/macos/Podfile.lock b/ui/flutter/macos/Podfile.lock index 25728b41..ab2d2f29 100644 --- a/ui/flutter/macos/Podfile.lock +++ b/ui/flutter/macos/Podfile.lock @@ -7,7 +7,8 @@ PODS: - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - - file_picker (0.0.1): + - file_picker_darwin (1.0.0): + - Flutter - FlutterMacOS - flutter_inappwebview_macos (0.0.1): - FlutterMacOS @@ -41,7 +42,7 @@ DEPENDENCIES: - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) - desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - file_picker_darwin (from `Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) @@ -68,8 +69,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos - file_picker: - :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + file_picker_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin flutter_inappwebview_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_local_notifications: @@ -96,25 +97,25 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f - desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 - desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a - device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 - file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a - flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d - flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 + app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486 + device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 + file_picker_darwin: 27e043831d68462d54f1c40e724b1f53c9d1e8be + flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b + flutter_local_notifications: 14e285ca39907db50704f7f46c9ab7a526bd7ead FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - open_dir_macos: 79810d7921d777f8732bf6358b3a0839a8fad9b5 + open_dir_macos: 9649018f88e71eb9fb8aa3d95caec30a73c08c5b OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933 - share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - tray_manager: a104b5c81b578d83f3c3d0f40a997c8b10810166 - uri_content: 5f98ff12ded3f2e1681b689828d747bafb03ba5f - url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd - window_manager: b729e31d38fb04905235df9ea896128991cad99e + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + screen_retriever_macos: 038d3f7b34a779b06f536842003e41e8ec1fb4b7 + share_plus: 1fa619de8392a4398bfaf176d441853922614e89 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + tray_manager: c2a678ee19e2a094f0c2d03660ee2c3e81cea8ab + uri_content: e002074bcf6fbf905a3909603f9a2b1b490fce67 + url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce + window_manager: e25faf20d88283a0d46e7b1a759d07261ca27575 PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 -COCOAPODS: 1.17.0 +COCOAPODS: 1.15.2 diff --git a/ui/flutter/test/features/tasks/domain/task_record_test.dart b/ui/flutter/test/features/tasks/domain/task_record_test.dart index 17a8e4be..eba26e4f 100644 --- a/ui/flutter/test/features/tasks/domain/task_record_test.dart +++ b/ui/flutter/test/features/tasks/domain/task_record_test.dart @@ -4,7 +4,7 @@ import 'package:gopeed/core/icons/gopeed_icons.dart'; import 'package:gopeed/features/tasks/domain/task_record.dart'; void main() { - api.Task buildTask(List selection, int size) => api.Task.fromJson({ + api.Task buildTask(List selection, int size, {String? doneAt}) => api.Task.fromJson({ 'id': 'bt-selection', 'name': 'bundle', 'protocol': 'bt', @@ -12,6 +12,7 @@ void main() { 'uploading': false, 'createdAt': '2026-01-01T00:00:00Z', 'updatedAt': '2026-01-01T00:00:00Z', + 'doneAt': doneAt, 'meta': { 'req': {'url': 'magnet:?xt=urn:btih:test'}, 'opts': {'path': '/downloads', 'selectFiles': selection}, @@ -43,6 +44,13 @@ void main() { expect(record.totalBytes, 60); }); + test('completion time maps from api and stays null for legacy tasks', () { + final completed = TaskRecord.fromApi(buildTask([], 60, doneAt: '2026-01-02T03:04:05Z')); + expect(completed.doneAt, DateTime.parse('2026-01-02T03:04:05Z')); + final legacy = TaskRecord.fromApi(buildTask([], 60)); + expect(legacy.doneAt, isNull); + }); + test('file names preserve hash and question mark characters for type detection', () { expect( taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'), diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 7269398e..c9427a11 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -2649,7 +2649,7 @@ void main() { await tester.tap(find.byKey(const ValueKey('settings-language-select'))); await tester.pumpAndSettle(); expect(find.text('English'), findsOneWidget); - expect(SettingsLanguageSelect.supportedValues, hasLength(21)); + expect(SettingsLanguageSelect.supportedValues, hasLength(22)); expect(SettingsLanguageSelect.supportedValues, containsAll(['zh', 'zh_TW', 'pt'])); expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN'))); expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR'))); @@ -4127,6 +4127,7 @@ void main() { remaining: '1 minute remaining', downloadDuration: const Duration(hours: 1, minutes: 2, seconds: 3), createdAt: DateTime(2026, 8, 27, 12, 34, 56), + doneAt: DateTime(2026, 8, 28, 1, 2, 3), ); await tester.pumpWidget( @@ -4197,7 +4198,14 @@ void main() { expect(find.text('50 B / 100 B'), findsNothing); expect(find.text('Created'), findsOneWidget); expect(find.text('2026-08-27 12:34:56'), findsOneWidget); + expect(find.text('Completed at'), findsOneWidget); + expect(find.text('2026-08-28 01:02:03'), findsOneWidget); + expect( + tester.getBottomLeft(find.text('2026-08-27 12:34:56')).dy, + lessThan(tester.getTopLeft(find.text('Completed at')).dy), + ); final infoDivider = find.byKey(const ValueKey('task-details-info-divider')); + expect(tester.getBottomLeft(find.text('Completed at')).dy, lessThan(tester.getTopLeft(infoDivider).dy)); expect(tester.getBottomLeft(find.text('Created')).dy, lessThan(tester.getTopLeft(infoDivider).dy)); expect(tester.getBottomLeft(infoDivider).dy, lessThan(tester.getTopLeft(find.text('Download Link')).dy)); expect(find.text('1 minute remaining'), findsNothing); @@ -4259,6 +4267,30 @@ void main() { expect(find.text('—'), findsOneWidget); }); + testWidgets('completed task details show a dash when no done time is recorded', (WidgetTester tester) async { + await _setTestSize(tester, const Size(900, 700)); + final task = _taskRecord( + id: 'done-details', + name: 'done.zip', + status: TaskStatus.completed, + downloadDuration: const Duration(hours: 1, minutes: 2, seconds: 3), + ); + + await tester.pumpWidget( + ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: TaskDetailsView(task: task, mobile: false, onOpenStorage: () {}, onUpdateUrl: (_) async {}), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Completed at'), findsOneWidget); + expect(find.text('—'), findsOneWidget); + }); + testWidgets('failed task details omit the unavailable error reason', (WidgetTester tester) async { await _setTestSize(tester, const Size(900, 700)); final task = TaskRecord( @@ -6164,6 +6196,7 @@ TaskRecord _taskRecord({ String? remaining, Duration? downloadDuration, DateTime? createdAt, + DateTime? doneAt, bool isFolder = false, Map requestHeaders = const {}, }) { @@ -6184,6 +6217,7 @@ TaskRecord _taskRecord({ remaining: remaining, downloadDuration: downloadDuration, createdAt: createdAt, + doneAt: doneAt, requestHeaders: requestHeaders, ); } From ba84a04a434bc65bebddb2fac2f9b8cec68f8c99 Mon Sep 17 00:00:00 2001 From: WuFeng Date: Thu, 8 Oct 2026 16:31:16 +0800 Subject: [PATCH 3/9] fix(settings): use Save as the primary label on settings form dialogs (#1571) --- .../presentation/widgets/download_categories_setting.dart | 2 +- .../settings/presentation/widgets/settings_list_editor.dart | 4 ++-- ui/flutter/test/widget_test.dart | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart index 8c3c6cf6..dd0a40cb 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart @@ -201,7 +201,7 @@ Future showDownloadCategoryDialog( onPressed: () => shad.closeOverlay(dialogContext), child: Text(dialogContext.l10n.cancel), ), - AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.confirm)), + AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.save)), ], ); }, diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart index 58b325ae..d64cf360 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart @@ -234,7 +234,7 @@ Future showTextSettingDialog( } shad.closeOverlay(dialogContext, controller.text.trim()); }, - child: Text(dialogContext.l10n.confirm), + child: Text(dialogContext.l10n.save), ), ], ); @@ -321,7 +321,7 @@ Future showGithubMirrorDialog(BuildContext context, {GithubM } shad.closeOverlay(dialogContext, GithubMirrorDraft(type: selectedType, url: value)); }, - child: Text(dialogContext.l10n.confirm), + child: Text(dialogContext.l10n.save), ), ], ); diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index c9427a11..3365a275 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -2480,7 +2480,7 @@ void main() { expect(find.text('%date% — Full date (YYYY-MM-DD)'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('path-placeholder-%date%'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Confirm')); + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); expect(find.text('归档'), findsOneWidget); From fe2f45e40dc0165cfb4880b163698d1bce17cb08 Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 9 Oct 2026 19:39:05 +0800 Subject: [PATCH 4/9] fix: preserve explicit Cookie headers in extension requests --- pkg/download/engine/fetch_integration_test.go | 89 +++++++++++++++++++ pkg/download/engine/inject/fetch/fetch.js | 5 +- pkg/download/engine/testdata/wpt/UPSTREAM.md | 9 +- pkg/download/engine/wpt_fetch_test.go | 14 ++- 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/pkg/download/engine/fetch_integration_test.go b/pkg/download/engine/fetch_integration_test.go index dbe40afa..85ffe16b 100644 --- a/pkg/download/engine/fetch_integration_test.go +++ b/pkg/download/engine/fetch_integration_test.go @@ -105,6 +105,95 @@ func TestXMLHttpRequestUsesFetch(t *testing.T) { } } +func TestExtensionRequestsSendExplicitCookie(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + _, _ = w.Write([]byte(request.Header.Get("Cookie"))) + })) + t.Cleanup(server.Close) + + type requestTest struct { + name string + script string + } + tests := []requestTest{ + {"fetch record", `const response = await fetch(url, {headers: {Cookie: cookie}}); return response.text();`}, + {"fetch Headers", `const response = await fetch(url, {headers: new Headers({cookie})}); return response.text();`}, + {"fetch pairs", `const response = await fetch(url, {headers: [["cOoKiE", cookie]]}); return response.text();`}, + {"fetch Request clone", `const request = new Request(url, {headers: {Cookie: cookie}}); const response = await fetch(request.clone()); return response.text();`}, + {"fetch Request set", `const request = new Request(url); request.headers.set("Cookie", cookie); const response = await fetch(request); return response.text();`}, + {"fetch Request append", `const request = new Request(url); request.headers.append("Cookie", cookie); const response = await fetch(request); return response.text();`}, + {"fetch no-cors mutations", `const request = new Request(url, {mode: "no-cors"}); request.headers.set("Cookie", "removed=1"); request.headers.delete("COOKIE"); if (request.headers.has("cookie")) throw new Error("Cookie was not deleted"); request.headers.append("Cookie", cookie); const response = await fetch(request.clone()); return response.text();`}, + } + for _, mode := range []string{"cors", "same-origin", "no-cors"} { + for _, credentials := range []string{"omit", "same-origin", "include"} { + tests = append(tests, requestTest{ + name: "fetch " + mode + " " + credentials, + script: fmt.Sprintf(`const response = await fetch(url, {headers: {Cookie: cookie}, mode: %q, credentials: %q}); return response.text();`, mode, credentials), + }) + } + } + for _, withCredentials := range []bool{false, true} { + tests = append(tests, requestTest{ + name: fmt.Sprintf("XHR withCredentials=%t", withCredentials), + script: fmt.Sprintf(`return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.withCredentials = %t; + xhr.setRequestHeader("cOoKiE", cookie); + xhr.onload = () => resolve(xhr.responseText); + xhr.onerror = () => reject(new Error("XHR failed")); + xhr.send(); + });`, withCredentials), + }) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + const cookie = "sid=manual; token=provided" + value, err := runtime.RunString(fmt.Sprintf(`(async () => { + const url = %q; + const cookie = %q; + %s + })()`, server.URL, cookie, test.script)) + if err != nil { + t.Fatal(err) + } + if value != cookie { + t.Fatalf("server received Cookie %q, want %q", value, cookie) + } + }) + } +} + +func TestFetchExplicitCookieWithCookieJar(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/seed" { + http.SetCookie(w, &http.Cookie{Name: "stored", Value: "jar", Path: "/"}) + } + _, _ = w.Write([]byte(request.Header.Get("Cookie"))) + })) + t.Cleanup(server.Close) + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + value, err := runtime.RunString(fmt.Sprintf(`(async () => { + const url = %q; + await (await fetch(url + "/seed")).text(); + const automatic = await (await fetch(url)).text(); + const combined = await (await fetch(url, {headers: {Cookie: "manual=provided"}})).text(); + const omitted = await (await fetch(url, {credentials: "omit", headers: {Cookie: "manual=provided"}})).text(); + const empty = await (await fetch(url, {credentials: "omit"})).text(); + return JSON.stringify({automatic, combined, omitted, empty}); + })()`, server.URL)) + if err != nil { + t.Fatal(err) + } + const want = `{"automatic":"stored=jar","combined":"manual=provided; stored=jar","omitted":"manual=provided","empty":""}` + if value != want { + t.Fatalf("unexpected CookieJar result: got %#v want %s", value, want) + } +} + func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodPost { diff --git a/pkg/download/engine/inject/fetch/fetch.js b/pkg/download/engine/inject/fetch/fetch.js index b49ef59f..100fd7d2 100644 --- a/pkg/download/engine/inject/fetch/fetch.js +++ b/pkg/download/engine/inject/fetch/fetch.js @@ -195,6 +195,9 @@ function blockedByGuard(headers, name, value) { if (headers._guard === "immutable") throw new TypeError("Headers are immutable"); + // Extensions are privileged HTTP clients and may supply their own cookies, + // including in no-cors mode. Keep browser guards for other header names. + if (name === "cookie" && (headers._guard === "request" || headers._guard === "request-no-cors")) return false; if (headers._guard === "request") return forbiddenRequestHeader(name, value); if (headers._guard === "request-no-cors") { return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value); @@ -244,7 +247,7 @@ delete(name) { name = normalizeHeaderName(name); if (this._guard === "immutable") throw new TypeError("Headers are immutable"); - if (this._guard === "request-no-cors") { + if (this._guard === "request-no-cors" && name !== "cookie") { if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return; this._values.delete(name); return; diff --git a/pkg/download/engine/testdata/wpt/UPSTREAM.md b/pkg/download/engine/testdata/wpt/UPSTREAM.md index e858ecb9..2e14b990 100644 --- a/pkg/download/engine/testdata/wpt/UPSTREAM.md +++ b/pkg/download/engine/testdata/wpt/UPSTREAM.md @@ -19,10 +19,17 @@ a claim that Gopeed is a browser. Browser-only suites that require a document, CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers, browser HTTP cache, navigation, browser authentication UI, or WPT's multi-origin server infrastructure are intentionally excluded. Gopeed also -keeps one extension-specific behavior: `redirect: "manual"` exposes the HTTP +keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP redirect response so download extensions can inspect `Location`, instead of returning a browser `opaqueredirect` response. +Extensions may also supply an explicit `Cookie` request header, including in +`no-cors` mode. The runner excludes the single upstream assertion that treats +`Cookie` as an invalid request header; Gopeed integration tests instead verify +that fetch and XMLHttpRequest send it to the server. `credentials: "omit"` +disables automatic CookieJar cookies but preserves an explicit header. Other +request header guards remain covered by the unchanged upstream tests. + `wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only normalize a final newline. diff --git a/pkg/download/engine/wpt_fetch_test.go b/pkg/download/engine/wpt_fetch_test.go index 86111d10..f929f598 100644 --- a/pkg/download/engine/wpt_fetch_test.go +++ b/pkg/download/engine/wpt_fetch_test.go @@ -110,7 +110,19 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) { } runtime := engine.NewEngine(nil) t.Cleanup(runtime.Close) - value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + string(source) + "\n__wptFinish();") + adaptation := "" + if name == "testdata/wpt/fetch/api/request/request-headers.any.js" { + // Gopeed extensions intentionally allow explicit Cookie request headers. + // Keep the upstream file unchanged and exclude only its browser restriction. + adaptation = ` + const upstreamTest = globalThis.test; + globalThis.test = function (callback, name) { + if (name === 'Adding invalid request header "Cookie: KO"') return; + upstreamTest(callback, name); + }; + ` + } + value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + adaptation + "\n" + string(source) + "\n__wptFinish();") if err != nil { t.Fatal(err) } From 342c55fbd60af50b8d84aae1f548c205da45e997 Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 9 Oct 2026 19:59:06 +0800 Subject: [PATCH 5/9] fix: use server-side fetch header permissions for extensions --- pkg/download/engine/extension_headers_test.go | 222 ++++++++++++++++++ pkg/download/engine/fetch_integration_test.go | 10 + pkg/download/engine/inject/fetch/fetch.js | 78 ++---- pkg/download/engine/inject/fetch/module.go | 17 ++ pkg/download/engine/testdata/wpt/UPSTREAM.md | 31 ++- pkg/download/engine/wpt_fetch_test.go | 34 ++- 6 files changed, 303 insertions(+), 89 deletions(-) create mode 100644 pkg/download/engine/extension_headers_test.go diff --git a/pkg/download/engine/extension_headers_test.go b/pkg/download/engine/extension_headers_test.go new file mode 100644 index 00000000..2713fec5 --- /dev/null +++ b/pkg/download/engine/extension_headers_test.go @@ -0,0 +1,222 @@ +package engine_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GopeedLab/gopeed/pkg/download/engine" +) + +// Exercise server-side header permissions, including formerly forbidden names, +// download authentication, metadata and non-safelisted no-cors values. +var extensionRequestHeaders = [][2]string{ + {"Accept-Charset", "utf-8"}, {"Accept-Encoding", "identity"}, + {"Access-Control-Request-Headers", "authorization, x-download"}, + {"Access-Control-Request-Method", "POST"}, {"Connection", "close"}, + {"Connection", "keep-alive"}, + {"Content-Length", "4"}, {"Cookie", "sid=manual"}, {"Cookie2", "legacy=1"}, + {"Date", "Fri, 09 Oct 2026 00:00:00 GMT"}, {"DNT", "1"}, + {"Host", "download.example:1234"}, {"Origin", "https://download.example"}, + {"Permissions-Policy", "camera=()"}, {"Referer", "https://download.example/file"}, + {"TE", "trailers"}, {"Trailer", "X-Checksum"}, {"Set-Cookie", "sid=request"}, + {"Set-Cookie2", "legacy=request"}, {"Via", "1.1 proxy"}, + {"Proxy-Authorization", "Basic dGVzdA=="}, {"Proxy-", "custom"}, + {"Sec-Fetch-Site", "same-origin"}, {"Sec-Fetch-Mode", "custom"}, + {"Sec-CH-UA", `"Download client"`}, {"Sec-", "custom"}, + {"X-HTTP-Method", "TRACE"}, {"X-HTTP-Method-Override", "CONNECT"}, + {"X-Method-Override", "TRACK"}, {"Authorization", "Bearer token"}, + {"User-Agent", "Gopeed extension"}, {"Content-Type", "application/json"}, + {"Range", "bytes=0-1,3-4"}, {"X-Download-Token", "custom"}, + {"Accept-Language", strings.Repeat("x", 129)}, +} + +func TestExtensionRequestsSendNodeHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + body, err := io.ReadAll(request.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name := request.URL.Query().Get("header") + value := request.Header.Get(name) + if name == "Host" { + value = request.Host + } + _ = json.NewEncoder(w).Encode([]string{value, string(body)}) + })) + t.Cleanup(server.Close) + clients := []struct{ name, script string }{ + {"fetch record", `const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}}); return (await response.text()).trim();`}, + {"fetch no-cors Headers", `const response = await fetch(url, {method: "POST", body: "test", mode: "no-cors", headers: new Headers([[name.toLowerCase(), value]])}); return (await response.text()).trim();`}, + {"fetch Request clone", `const request = new Request(url, {method: "POST", body: "test", mode: "same-origin", headers: [[name.toUpperCase(), value]]}); const response = await fetch(request.clone()); return (await response.text()).trim();`}, + } + for _, credentials := range []bool{false, true} { + clients = append(clients, struct{ name, script string }{ + fmt.Sprintf("XHR credentials=%t", credentials), + fmt.Sprintf(`return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url); + xhr.withCredentials = %t; + xhr.setRequestHeader(name.toLowerCase(), value); + xhr.onload = () => resolve(xhr.responseText.trim()); + xhr.onerror = () => reject(new Error("XHR failed")); + xhr.send("test"); + });`, credentials), + }) + } + for _, header := range extensionRequestHeaders { + for _, client := range clients { + t.Run(header[0]+"/"+client.name, func(t *testing.T) { + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + value, err := runtime.RunString(fmt.Sprintf(`(async () => { + const name = %q, value = %q; + const url = %q + "?header=" + encodeURIComponent(name); + %s + })()`, header[0], header[1], server.URL, client.script)) + if err != nil { + t.Fatal(err) + } + want, _ := json.Marshal([]string{header[1], "test"}) + if value != string(want) { + t.Fatalf("server received %#v, want %s", value, want) + } + }) + } + } +} + +func TestExtensionHeaderMutationsMatchNode(t *testing.T) { + headers := append([][2]string(nil), extensionRequestHeaders...) + // Node stores these in Headers/Request, but rejects them during fetch. + headers = append(headers, [2]string{"Expect", "100-continue"}, [2]string{"Keep-Alive", "timeout=5"}, [2]string{"Transfer-Encoding", "chunked"}, [2]string{"Upgrade", "websocket"}) + data, _ := json.Marshal(headers) + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + _, err := runtime.RunString(fmt.Sprintf(`(() => { + for (const mode of ["cors", "same-origin", "no-cors"]) { + for (const [name, value] of %s) { + const request = new Request("http://example.com", {mode, headers: {[name]: value}}); + const headers = request.headers; + if (headers.get(name) !== value) throw new Error("Filtered init " + name + " in " + mode); + headers.delete(name.toUpperCase()); + if (headers.has(name)) throw new Error("Cannot delete " + name); + headers.append(name, value); + if (headers.get(name) !== value) throw new Error("Cannot append " + name); + headers.set(name, value); + if (request.clone().headers.get(name) !== value) throw new Error("Cannot clone " + name); + } + } + return true; + })()`, data)) + if err != nil { + t.Fatal(err) + } +} + +func TestExtensionRequestsIgnoreUnsupportedTransportHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + body, err := io.ReadAll(request.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + value := request.Header.Get(request.URL.Query().Get("header")) + _ = json.NewEncoder(w).Encode([]string{value, string(body)}) + })) + t.Cleanup(server.Close) + for _, header := range [][2]string{ + {"Expect", "100-continue"}, {"Keep-Alive", "timeout=5"}, + {"Transfer-Encoding", "chunked"}, {"Upgrade", "websocket"}, + {"Connection", "upgrade"}, {"Content-Length", "3"}, + {"Content-Length", "5"}, {"Content-Length", "invalid"}, + {"Content-Length", "-1"}, {"Content-Length", ""}, + } { + for _, xhr := range []bool{false, true} { + t.Run(fmt.Sprintf("%s=%s/XHR=%t", header[0], header[1], xhr), func(t *testing.T) { + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + value, err := runtime.RunString(fmt.Sprintf(`(async () => { + const name = %q, value = %q; + const url = %q + "?header=" + encodeURIComponent(name); + if (%t) return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url); + xhr.setRequestHeader(name, value); + xhr.onload = () => resolve(xhr.responseText.trim()); + xhr.onerror = () => reject(new Error("XHR failed")); + xhr.send("test"); + }); + const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}}); + return (await response.text()).trim(); + })()`, header[0], header[1], server.URL, xhr)) + if err != nil { + t.Fatal(err) + } + wireValue := "" + if header[0] == "Content-Length" { + wireValue = "4" + } + want, _ := json.Marshal([]string{wireValue, "test"}) + if value != string(want) { + t.Fatalf("server received %#v, want %s", value, want) + } + }) + } + } +} + +func TestExtensionResponseExposesSetCookie(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Add("Set-Cookie", "sid=one; HttpOnly") + w.Header().Add("Set-Cookie", "token=two") + w.Header().Set("Set-Cookie2", "legacy=three") + _, _ = w.Write([]byte("ok")) + })) + t.Cleanup(server.Close) + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + value, err := runtime.RunString(fmt.Sprintf(`(async () => { + const response = await fetch(%q); + const clone = response.clone(); + await response.text(); await clone.text(); + let immutable = false; + try { response.headers.set("Set-Cookie", "changed=1"); } catch (error) { immutable = error instanceof TypeError; } + const synthetic = new Response(null, {headers: {"Set-Cookie": "initial=1"}}); + synthetic.headers.append("Set-Cookie", "appended=2"); + return JSON.stringify({cookies: clone.headers.getSetCookie(), legacy: response.headers.get("set-cookie2"), immutable, synthetic: synthetic.headers.getSetCookie()}); + })()`, server.URL)) + if err != nil { + t.Fatal(err) + } + const want = `{"cookies":["sid=one; HttpOnly","token=two"],"legacy":"legacy=three","immutable":true,"synthetic":["initial=1","appended=2"]}` + if value != want { + t.Fatalf("unexpected response headers: got %#v want %s", value, want) + } +} + +func TestExtensionHeadersRejectInvalidSyntax(t *testing.T) { + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + _, err := runtime.RunString(`(() => { + for (const [name, value] of [["Bad Header", "ok"], ["", "ok"], ["X:Header", "ok"], + ["X-Test", "a\nb"], ["X-Test", "a\rb"], ["X-Test", "\0"], ["X-Test", "\u0100"]]) { + for (const create of [() => new Headers([[name, value]]), + () => new Request("http://example.com", {mode: "no-cors", headers: {[name]: value}}), + () => { const xhr = new XMLHttpRequest(); xhr.open("GET", "http://example.com"); xhr.setRequestHeader(name, value); }]) { + let rejected = false; + try { create(); } catch (error) { rejected = error instanceof TypeError; } + if (!rejected) throw new Error("Accepted invalid HTTP header syntax"); + } + } + return true; + })()`) + if err != nil { + t.Fatal(err) + } +} diff --git a/pkg/download/engine/fetch_integration_test.go b/pkg/download/engine/fetch_integration_test.go index 85ffe16b..2cce292f 100644 --- a/pkg/download/engine/fetch_integration_test.go +++ b/pkg/download/engine/fetch_integration_test.go @@ -119,6 +119,7 @@ func TestExtensionRequestsSendExplicitCookie(t *testing.T) { {"fetch record", `const response = await fetch(url, {headers: {Cookie: cookie}}); return response.text();`}, {"fetch Headers", `const response = await fetch(url, {headers: new Headers({cookie})}); return response.text();`}, {"fetch pairs", `const response = await fetch(url, {headers: [["cOoKiE", cookie]]}); return response.text();`}, + {"fetch repeated Cookie", `const headers = new Headers([["Cookie", "sid=manual"], ["cookie", "token=provided"]]); if (headers.get("cookie") !== cookie) throw new Error("Incorrect Cookie separator"); const response = await fetch(url, {headers}); return response.text();`}, {"fetch Request clone", `const request = new Request(url, {headers: {Cookie: cookie}}); const response = await fetch(request.clone()); return response.text();`}, {"fetch Request set", `const request = new Request(url); request.headers.set("Cookie", cookie); const response = await fetch(request); return response.text();`}, {"fetch Request append", `const request = new Request(url); request.headers.append("Cookie", cookie); const response = await fetch(request); return response.text();`}, @@ -146,6 +147,15 @@ func TestExtensionRequestsSendExplicitCookie(t *testing.T) { });`, withCredentials), }) } + tests = append(tests, requestTest{"XHR repeated Cookie", `return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.setRequestHeader("Cookie", "sid=manual"); + xhr.setRequestHeader("cookie", "token=provided"); + xhr.onload = () => resolve(xhr.responseText); + xhr.onerror = () => reject(new Error("XHR failed")); + xhr.send(); + });`}) for _, test := range tests { t.Run(test.name, func(t *testing.T) { runtime := engine.NewEngine(nil) diff --git a/pkg/download/engine/inject/fetch/fetch.js b/pkg/download/engine/inject/fetch/fetch.js index 100fd7d2..22d3120c 100644 --- a/pkg/download/engine/inject/fetch/fetch.js +++ b/pkg/download/engine/inject/fetch/fetch.js @@ -122,7 +122,7 @@ if (name === "set-cookie") { for (const value of values) result.push([name, value]); } else { - result.push([name, values.join(", ")]); + result.push([name, values.join(name === "cookie" ? "; " : ", ")]); } } return result; @@ -158,52 +158,10 @@ return iterator; } - function forbiddenRequestHeader(name, value) { - if (name.startsWith("proxy-") || name.startsWith("sec-")) return true; - if ([ - "accept-charset", "accept-encoding", "access-control-request-headers", - "access-control-request-method", "connection", "content-length", "cookie", - "cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin", - "permissions-policy", "referer", "te", "trailer", "transfer-encoding", - "set-cookie", "upgrade", "via", - ].includes(name)) return true; - if (["x-http-method", "x-http-method-override", "x-method-override"].includes(name)) { - return String(value).split(",").some((method) => { - method = method.trim().toUpperCase(); - return method === "CONNECT" || method === "TRACE" || method === "TRACK"; - }); - } - return false; - } - - function noCORSSafelistedRequestHeader(name, value) { - if (value.length > 128) return false; - const hasUnsafeByte = /[\x00-\x08\x0A-\x1F\x7F"():<>?@\[\\\]{}]/.test(value); - if (name === "accept") return !hasUnsafeByte; - if (name === "accept-language" || name === "content-language") { - return /^[0-9A-Za-z *,\-.;=]*$/.test(value); - } - if (name === "content-type") { - if (hasUnsafeByte) return false; - const essence = value.split(";", 1)[0].trim().toLowerCase(); - return essence === "application/x-www-form-urlencoded" || - essence === "multipart/form-data" || essence === "text/plain"; - } - if (name === "range") return /^bytes=[0-9]+-[0-9]*$/.test(value); - return false; - } - - function blockedByGuard(headers, name, value) { + function assertMutableHeaders(headers) { + // Match Node/Undici: HTTP syntax and immutability still apply, but browser + // forbidden-header and no-cors safelist rules do not apply to extensions. if (headers._guard === "immutable") throw new TypeError("Headers are immutable"); - // Extensions are privileged HTTP clients and may supply their own cookies, - // including in no-cors mode. Keep browser guards for other header names. - if (name === "cookie" && (headers._guard === "request" || headers._guard === "request-no-cors")) return false; - if (headers._guard === "request") return forbiddenRequestHeader(name, value); - if (headers._guard === "request-no-cors") { - return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value); - } - if (headers._guard === "response") return name === "set-cookie" || name === "set-cookie2"; - return false; } class Headers { @@ -238,27 +196,21 @@ name = normalizeHeaderName(name); value = normalizeHeaderValue(value); const values = this._values.get(name); - const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value; - if (blockedByGuard(this, name, combinedValue)) return; + assertMutableHeaders(this); if (values) values.push(value); else this._values.set(name, [value]); } delete(name) { - name = normalizeHeaderName(name); - if (this._guard === "immutable") throw new TypeError("Headers are immutable"); - if (this._guard === "request-no-cors" && name !== "cookie") { - if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return; - this._values.delete(name); - return; - } - if (blockedByGuard(this, name, "")) return; + name = normalizeHeaderName(name); + assertMutableHeaders(this); this._values.delete(name); } get(name) { - const values = this._values.get(normalizeHeaderName(name)); - return values ? values.join(", ") : null; + name = normalizeHeaderName(name); + const values = this._values.get(name); + return values ? values.join(name === "cookie" ? "; " : ", ") : null; } getSetCookie() { @@ -271,10 +223,10 @@ } set(name, value) { - name = normalizeHeaderName(name); - value = normalizeHeaderValue(value); - if (blockedByGuard(this, name, value)) return; - this._values.set(name, [value]); + name = normalizeHeaderName(name); + value = normalizeHeaderValue(value); + assertMutableHeaders(this); + this._values.set(name, [value]); } keys() { return createHeadersIterator(this, "key"); } @@ -487,7 +439,7 @@ } this._duplex = "half"; const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined); - this._headers = new Headers(headerInit, this._mode === "no-cors" ? "request-no-cors" : "request"); + this._headers = new Headers(headerInit, "request"); let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null); if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body); const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream; diff --git a/pkg/download/engine/inject/fetch/module.go b/pkg/download/engine/inject/fetch/module.go index 49ccf8cb..4032d8f9 100644 --- a/pkg/download/engine/inject/fetch/module.go +++ b/pkg/download/engine/inject/fetch/module.go @@ -302,6 +302,23 @@ func (r *registry) Open(fingerprint string, proxyHandler func(*http.Request) (*u return nil, err } for _, header := range metadata.Headers { + // Ignore transport controls unsupported by Node/Undici rather than + // applying a browser forbidden-header list or failing the whole request. + switch strings.ToLower(header[0]) { + case "expect", "keep-alive", "transfer-encoding", "upgrade", "content-length": + // Content-Length is generated from the buffered request body below. + continue + case "connection": + if value := strings.ToLower(header[1]); value != "close" && value != "keep-alive" { + continue + } + case "trailer": + // Fetch supplies a declaration, not a Go Request.Trailer payload. + // Keep its lower-case wire name so Go's writer does not omit it as + // the canonical Trailer field it normally generates itself. + httpRequest.Header["trailer"] = append(httpRequest.Header["trailer"], header[1]) + continue + } httpRequest.Header.Add(header[0], header[1]) } if host := httpRequest.Header.Get("Host"); host != "" { diff --git a/pkg/download/engine/testdata/wpt/UPSTREAM.md b/pkg/download/engine/testdata/wpt/UPSTREAM.md index 2e14b990..d5906ec3 100644 --- a/pkg/download/engine/testdata/wpt/UPSTREAM.md +++ b/pkg/download/engine/testdata/wpt/UPSTREAM.md @@ -9,7 +9,7 @@ The files under `fetch/` are copied from the official Gopeed executes applicable worker-style Fetch API tests at the extension JavaScript boundary. The checked-in runner currently executes unchanged WPT -tests for Headers, no-CORS header guards, Request construction and body +tests for Headers syntax and iteration, Request construction and body consumption, ReadableStream bodies, keepalive, Request errors, Response construction and body consumption, immutable network response headers, and the Response static constructors. @@ -23,12 +23,29 @@ keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP redirect response so download extensions can inspect `Location`, instead of returning a browser `opaqueredirect` response. -Extensions may also supply an explicit `Cookie` request header, including in -`no-cors` mode. The runner excludes the single upstream assertion that treats -`Cookie` as an invalid request header; Gopeed integration tests instead verify -that fetch and XMLHttpRequest send it to the server. `credentials: "omit"` -disables automatic CookieJar cookies but preserves an explicit header. Other -request header guards remain covered by the unchanged upstream tests. +Header permissions follow [Node/Undici's server-side Fetch API](https://github.com/nodejs/undici/blob/main/README.md#forbidden-and-safelisted-header-names). Gopeed does not +apply browser forbidden request/response header lists or no-CORS safelists. +Extensions may supply authentication, referrer, origin, host, proxy and fetch +metadata headers, and read `Set-Cookie` response headers. Network response +headers remain immutable; invalid header syntax still raises an error. +Repeated `Cookie` values are combined with `; `, as in Node/Undici. + +The runner excludes the browser-only `headers-no-cors.any.js` and +`headers-forbidden-override.any.js` suites, the filtering assertions within +`request-headers.any.js`, and the forbidden-response-cookie assertion within +`header-setcookie.any.js`. Gopeed's `extension_headers_test.go` replaces those +restrictions with integration tests that check headers actually received by +an HTTP server through fetch and XMLHttpRequest, plus header mutations, +cloning and response cookies. The upstream test files remain unchanged. + +Gopeed deliberately ignores transport controls that Node/Undici rejects: +`Expect`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, and `Connection` values +other than `close` or `keep-alive`. `Content-Length` is generated from the +actual request body, ignoring a supplied length. Explicit `Host` and +`Sec-Fetch-Mode` values are preserved rather than replaced by defaults. +These transport behaviors are also covered by integration tests. +`credentials: "omit"` disables automatic CookieJar cookies but preserves +explicit headers; Gopeed retains its extension CookieJar support. `wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only diff --git a/pkg/download/engine/wpt_fetch_test.go b/pkg/download/engine/wpt_fetch_test.go index f929f598..7bd8b9bc 100644 --- a/pkg/download/engine/wpt_fetch_test.go +++ b/pkg/download/engine/wpt_fetch_test.go @@ -20,9 +20,6 @@ var wptFetchUtils string //go:embed testdata/wpt/fetch/api/request/request-error.js var wptRequestError string -//go:embed testdata/wpt/fetch/api/cors/resources/not-cors-safelisted.json -var wptNoCORSSafelistedHeaders []byte - //go:embed testdata/wpt/fetch/api/headers/*.any.js var wptHeaders embed.FS @@ -30,31 +27,19 @@ var wptHeaders embed.FS var wptRequestResponse embed.FS func TestWPTHeaders(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { - response.Header().Set("Content-Type", "application/json") - _, _ = response.Write(wptNoCORSSafelistedHeaders) - })) - t.Cleanup(server.Close) - tests := []string{ "header-setcookie.any.js", "headers-basic.any.js", "headers-casing.any.js", "headers-combine.any.js", "headers-errors.any.js", - "headers-forbidden-override.any.js", "headers-normalize.any.js", - "headers-no-cors.any.js", "headers-record.any.js", "headers-structure.any.js", } for _, name := range tests { t.Run(name, func(t *testing.T) { - setup := "" - if name == "headers-no-cors.any.js" { - setup = fmt.Sprintf("globalThis.location = new URL(%q);", server.URL+"/fetch/api/headers/test.any.js") - } - runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), setup) + runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "") }) } } @@ -112,12 +97,23 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) { t.Cleanup(runtime.Close) adaptation := "" if name == "testdata/wpt/fetch/api/request/request-headers.any.js" { - // Gopeed extensions intentionally allow explicit Cookie request headers. - // Keep the upstream file unchanged and exclude only its browser restriction. + // Node-style header permissions are covered by extension integration tests. + // Keep upstream tests unchanged and exclude browser-only restrictions. adaptation = ` const upstreamTest = globalThis.test; globalThis.test = function (callback, name) { - if (name === 'Adding invalid request header "Cookie: KO"') return; + if (name.startsWith('Adding invalid request header ') || + name.startsWith('Adding invalid no-cors request header ') || + name.startsWith('Check that request constructor is filtering headers ') || + name.startsWith('Check that no-cors request constructor is filtering headers ')) return; + upstreamTest(callback, name); + }; + ` + } else if name == "testdata/wpt/fetch/api/headers/header-setcookie.any.js" { + adaptation = ` + const upstreamTest = globalThis.test; + globalThis.test = function (callback, name) { + if (name === 'Set-Cookie is a forbidden response header') return; upstreamTest(callback, name); }; ` From b6ce7756fb1e1b0e8b3e3e9ce5155d650b4369c7 Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 9 Oct 2026 20:49:14 +0800 Subject: [PATCH 6/9] fix: keep macOS traffic lights within the navigation rail --- .../macos/Runner/MainFlutterWindow.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/ui/flutter/macos/Runner/MainFlutterWindow.swift b/ui/flutter/macos/Runner/MainFlutterWindow.swift index 718f4c04..b7064845 100644 --- a/ui/flutter/macos/Runner/MainFlutterWindow.swift +++ b/ui/flutter/macos/Runner/MainFlutterWindow.swift @@ -4,6 +4,10 @@ import desktop_multi_window import window_manager class MainFlutterWindow: NSWindow { + // Keep this in sync with AppDesignTokens.railWidth in Flutter. + private static let navigationRailWidth: CGFloat = 68 + private static let trafficLightGap: CGFloat = 6 + override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -16,10 +20,53 @@ class MainFlutterWindow: NSWindow { } super.awakeFromNib() + + for name in [NSWindow.didResizeNotification, NSWindow.didBecomeKeyNotification, + NSWindow.didExitFullScreenNotification, + NSWindow.didChangeBackingPropertiesNotification] { + NotificationCenter.default.addObserver( + self, selector: #selector(refreshTrafficLightLayout(_:)), name: name, object: self) + } + layoutIfNeeded() + } + + override func layoutIfNeeded() { + super.layoutIfNeeded() + // Let AppKit position controls in its own full-screen title bar. + guard !styleMask.contains(.fullScreen) else { return } + + let buttons = [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] + .compactMap { standardWindowButton($0) } + guard buttons.count == 3 else { return } + + let width = buttons.reduce(CGFloat.zero) { $0 + $1.frame.width } + + Self.trafficLightGap * CGFloat(buttons.count - 1) + var x = (Self.navigationRailWidth - width) / 2 + guard x >= 0 else { return } + + // Keep native sizes, vertical positions and button behavior intact. + for button in buttons { + var origin = button.frame.origin + origin.x = x + if button.frame.origin != origin { + button.setFrameOrigin(origin) + } + x += button.frame.width + Self.trafficLightGap + } + } + + @objc private func refreshTrafficLightLayout(_ notification: Notification) { + // AppKit can reset button frames while finishing a resize or style change. + DispatchQueue.main.async { [weak self] in self?.layoutIfNeeded() } + } + + deinit { + NotificationCenter.default.removeObserver(self) } override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) { super.order(place, relativeTo: otherWin) hiddenWindowAtLaunch() + layoutIfNeeded() } } From 1589d4cbe7f864730a569f7d10b36882e8296d9a Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 9 Oct 2026 20:58:24 +0800 Subject: [PATCH 7/9] fix: update macOS traffic lights through window events --- .../macos/Runner/MainFlutterWindow.swift | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/ui/flutter/macos/Runner/MainFlutterWindow.swift b/ui/flutter/macos/Runner/MainFlutterWindow.swift index b7064845..9aa22332 100644 --- a/ui/flutter/macos/Runner/MainFlutterWindow.swift +++ b/ui/flutter/macos/Runner/MainFlutterWindow.swift @@ -7,6 +7,8 @@ class MainFlutterWindow: NSWindow { // Keep this in sync with AppDesignTokens.railWidth in Flutter. private static let navigationRailWidth: CGFloat = 68 private static let trafficLightGap: CGFloat = 6 + private var trafficLightLayoutScheduled = false + private var fullScreenTransitionInProgress = false override func awakeFromNib() { let flutterViewController = FlutterViewController() @@ -22,16 +24,22 @@ class MainFlutterWindow: NSWindow { super.awakeFromNib() for name in [NSWindow.didResizeNotification, NSWindow.didBecomeKeyNotification, - NSWindow.didExitFullScreenNotification, NSWindow.didChangeBackingPropertiesNotification] { NotificationCenter.default.addObserver( self, selector: #selector(refreshTrafficLightLayout(_:)), name: name, object: self) } - layoutIfNeeded() + for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willExitFullScreenNotification] { + NotificationCenter.default.addObserver( + self, selector: #selector(beginFullScreenTransition(_:)), name: name, object: self) + } + for name in [NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] { + NotificationCenter.default.addObserver( + self, selector: #selector(endFullScreenTransition(_:)), name: name, object: self) + } + scheduleTrafficLightLayout() } - override func layoutIfNeeded() { - super.layoutIfNeeded() + private func positionTrafficLights() { // Let AppKit position controls in its own full-screen title bar. guard !styleMask.contains(.fullScreen) else { return } @@ -56,8 +64,30 @@ class MainFlutterWindow: NSWindow { } @objc private func refreshTrafficLightLayout(_ notification: Notification) { + scheduleTrafficLightLayout() + } + + @objc private func beginFullScreenTransition(_ notification: Notification) { + fullScreenTransitionInProgress = true + } + + @objc private func endFullScreenTransition(_ notification: Notification) { + fullScreenTransitionInProgress = false + scheduleTrafficLightLayout() + } + + private func scheduleTrafficLightLayout() { + guard !trafficLightLayoutScheduled else { return } + trafficLightLayoutScheduled = true // AppKit can reset button frames while finishing a resize or style change. - DispatchQueue.main.async { [weak self] in self?.layoutIfNeeded() } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.trafficLightLayoutScheduled = false + guard !self.fullScreenTransitionInProgress, !self.styleMask.contains(.fullScreen) else { return } + // Calling this is supported; overriding AppKit's layout entry point is not. + self.layoutIfNeeded() + self.positionTrafficLights() + } } deinit { @@ -67,6 +97,8 @@ class MainFlutterWindow: NSWindow { override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) { super.order(place, relativeTo: otherWin) hiddenWindowAtLaunch() - layoutIfNeeded() + if place == .above { + scheduleTrafficLightLayout() + } } } From 0d08004c499fdfb541821cc555e54c7050f06b7b Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 9 Oct 2026 21:13:55 +0800 Subject: [PATCH 8/9] revert: remove macOS traffic light adjustments --- .../macos/Runner/MainFlutterWindow.swift | 79 ------------------- 1 file changed, 79 deletions(-) diff --git a/ui/flutter/macos/Runner/MainFlutterWindow.swift b/ui/flutter/macos/Runner/MainFlutterWindow.swift index 9aa22332..718f4c04 100644 --- a/ui/flutter/macos/Runner/MainFlutterWindow.swift +++ b/ui/flutter/macos/Runner/MainFlutterWindow.swift @@ -4,12 +4,6 @@ import desktop_multi_window import window_manager class MainFlutterWindow: NSWindow { - // Keep this in sync with AppDesignTokens.railWidth in Flutter. - private static let navigationRailWidth: CGFloat = 68 - private static let trafficLightGap: CGFloat = 6 - private var trafficLightLayoutScheduled = false - private var fullScreenTransitionInProgress = false - override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -22,83 +16,10 @@ class MainFlutterWindow: NSWindow { } super.awakeFromNib() - - for name in [NSWindow.didResizeNotification, NSWindow.didBecomeKeyNotification, - NSWindow.didChangeBackingPropertiesNotification] { - NotificationCenter.default.addObserver( - self, selector: #selector(refreshTrafficLightLayout(_:)), name: name, object: self) - } - for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willExitFullScreenNotification] { - NotificationCenter.default.addObserver( - self, selector: #selector(beginFullScreenTransition(_:)), name: name, object: self) - } - for name in [NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] { - NotificationCenter.default.addObserver( - self, selector: #selector(endFullScreenTransition(_:)), name: name, object: self) - } - scheduleTrafficLightLayout() - } - - private func positionTrafficLights() { - // Let AppKit position controls in its own full-screen title bar. - guard !styleMask.contains(.fullScreen) else { return } - - let buttons = [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] - .compactMap { standardWindowButton($0) } - guard buttons.count == 3 else { return } - - let width = buttons.reduce(CGFloat.zero) { $0 + $1.frame.width } - + Self.trafficLightGap * CGFloat(buttons.count - 1) - var x = (Self.navigationRailWidth - width) / 2 - guard x >= 0 else { return } - - // Keep native sizes, vertical positions and button behavior intact. - for button in buttons { - var origin = button.frame.origin - origin.x = x - if button.frame.origin != origin { - button.setFrameOrigin(origin) - } - x += button.frame.width + Self.trafficLightGap - } - } - - @objc private func refreshTrafficLightLayout(_ notification: Notification) { - scheduleTrafficLightLayout() - } - - @objc private func beginFullScreenTransition(_ notification: Notification) { - fullScreenTransitionInProgress = true - } - - @objc private func endFullScreenTransition(_ notification: Notification) { - fullScreenTransitionInProgress = false - scheduleTrafficLightLayout() - } - - private func scheduleTrafficLightLayout() { - guard !trafficLightLayoutScheduled else { return } - trafficLightLayoutScheduled = true - // AppKit can reset button frames while finishing a resize or style change. - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.trafficLightLayoutScheduled = false - guard !self.fullScreenTransitionInProgress, !self.styleMask.contains(.fullScreen) else { return } - // Calling this is supported; overriding AppKit's layout entry point is not. - self.layoutIfNeeded() - self.positionTrafficLights() - } - } - - deinit { - NotificationCenter.default.removeObserver(self) } override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) { super.order(place, relativeTo: otherWin) hiddenWindowAtLaunch() - if place == .above { - scheduleTrafficLightLayout() - } } } From b3f462bb7da2cab4851847241529da12f131f745 Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 9 Oct 2026 21:15:51 +0800 Subject: [PATCH 9/9] test(http): synchronize ignored Range response coverage --- internal/protocol/http/fetcher_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index 16d8403c..0077f4a0 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "strings" + "sync" "sync/atomic" "testing" "time" @@ -853,22 +854,34 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) { resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024) replacement := bytes.Repeat([]byte("replacement"), 24*1024) var rangeRequests atomic.Int32 + rangeStarted := make(chan struct{}) + releaseResolve := sync.OnceFunc(func() { close(rangeStarted) }) server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) if r.Header.Get(base.HttpHeaderRange) == "" { w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload))) w.WriteHeader(gohttp.StatusOK) + w.(gohttp.Flusher).Flush() + // Keep Resolve prefetch incomplete until the Range request starts. + // Otherwise Start may reuse the completed body and skip this scenario. + select { + case <-rangeStarted: + case <-r.Context().Done(): + return + } _, _ = w.Write(resolvePayload) return } rangeRequests.Add(1) + releaseResolve() w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement))) w.WriteHeader(gohttp.StatusOK) _, _ = w.Write(replacement) })) defer server.Close() + defer releaseResolve() f := buildFetcher() if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{