From 6f4635e2eb83e38295f299037db3efeedaed3822 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 17 Sep 2026 09:49:20 +0800 Subject: [PATCH] feat(flutter): add action buttons to desktop download notifications Terminal task notifications now carry actionable buttons and respond to clicks instead of being inert: - Done notifications offer Open File and Open Folder for single-file tasks, and Open Folder only for folder tasks (e.g. multi-file torrents), following the same rule as the task details page. Error notifications offer Open Folder only, since the output is rarely usable. - Clicking the notification body brings the app window back to the front (show + focus), covering the close-to-tray case. - The click target is snapshotted into the notification (Windows: action arguments; macOS/Linux: notification payload) so buttons keep working after the task record is deleted, and a missing file is logged instead of surfacing an OS error dialog. - macOS resolves buttons through three pre-registered notification categories (done single-file / done folder / error); Windows uses the actions API of the pinned notification plugin fork; Linux uses freedesktop notification actions. - New openFolder label localized in all 22 ARB catalogs. Cold-start activation is intentionally out of scope: when the process is not running, OS activation opens the main window without dispatching the button action. --- .../app_notification_controller.dart | 192 +++++++++++++++++- 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 + .../app_notification_controller_test.dart | 173 +++++++++++++++- 24 files changed, 377 insertions(+), 10 deletions(-) diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index cf8619cb..83e29017 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -1,13 +1,19 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as path; +import 'package:window_manager/window_manager.dart'; +import '../../api/model/task.dart' as api_task; +import '../../core/capabilities/app_capabilities.dart'; import '../../core/common/task_event.dart'; import '../../core/libgopeed_boot.dart'; +import '../../core/utils/file_explorer.dart'; +import '../../features/tasks/domain/task_record.dart'; import '../../l10n/l10n.dart'; import '../../util/log_util.dart'; import '../../util/util.dart'; @@ -17,6 +23,45 @@ final appNotificationControllerProvider = AsyncNotifierProvider jsonEncode({'action': id, 'path': path}); + + static NotificationActionSpec? decode(String raw) { + if (raw.isEmpty) return null; + try { + final data = jsonDecode(raw); + if (data is! Map) return null; + final id = data['action']; + final targetPath = data['path']; + if (id is! String || targetPath is! String) return null; + if (id != _openFileAction && id != _openFolderAction) return null; + return NotificationActionSpec(id: id, path: targetPath); + } catch (_) { + return null; + } + } +} + class AppNotificationState { const AppNotificationState({this.started = false}); @@ -51,10 +96,17 @@ class AppNotificationController extends AsyncNotifier { Future _initNotifications(AppLocalizations locale, {required bool requestPermissions}) async { // The runtime is watched, so enabling notifications in settings also // requests authorization. macOS remembers previously granted/denied access. + final openFileAction = DarwinNotificationAction.plain(_openFileAction, locale.openFile); + final openFolderAction = DarwinNotificationAction.plain(_openFolderAction, locale.openFolder); final darwin = DarwinInitializationSettings( requestAlertPermission: requestPermissions, requestBadgePermission: false, requestSoundPermission: requestPermissions, + notificationCategories: [ + DarwinNotificationCategory(_categoryDoneSingleFile, actions: [openFileAction, openFolderAction]), + DarwinNotificationCategory(_categoryDoneFolder, actions: [openFolderAction]), + DarwinNotificationCategory(_categoryTaskError, actions: [openFolderAction]), + ], ); final linux = LinuxInitializationSettings( defaultActionName: locale.open, @@ -95,6 +147,7 @@ class AppNotificationController extends AsyncNotifier { await _plugin.initialize( settings: InitializationSettings(macOS: darwin, linux: linux, windows: windows), + onDidReceiveNotificationResponse: _onNotificationResponse, ); } @@ -106,19 +159,142 @@ class AppNotificationController extends AsyncNotifier { final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); switch (event.type) { case TaskEventType.done: - await _showNotification(title: locale.notificationTaskDone, body: event.name); + await _showNotification( + locale: locale, + title: locale.notificationTaskDone, + body: event.name, + taskId: event.taskId, + allowOpenFile: true, + ); case TaskEventType.error: - await _showNotification(title: locale.notificationTaskError, body: event.name); + await _showNotification( + locale: locale, + title: locale.notificationTaskError, + body: event.name, + taskId: event.taskId, + // A failed download rarely has a usable output file to open. + allowOpenFile: false, + ); } }); } - Future _showNotification({required String title, required String body}) async { - const details = NotificationDetails( - macOS: DarwinNotificationDetails(), - linux: LinuxNotificationDetails(), - windows: WindowsNotificationDetails(), + Future _showNotification({ + required AppLocalizations locale, + required String title, + required String body, + required String taskId, + required bool allowOpenFile, + }) async { + final target = await resolveTaskTarget(taskId, allowOpenFile: allowOpenFile); + final payload = target == null ? '' : jsonEncode({'path': target.path}); + final details = NotificationDetails( + macOS: DarwinNotificationDetails( + categoryIdentifier: switch (target) { + null => null, + _ when target.canOpenFile => _categoryDoneSingleFile, + _ when allowOpenFile => _categoryDoneFolder, + _ => _categoryTaskError, + }, + ), + linux: LinuxNotificationDetails( + actions: [ + if (target != null && target.canOpenFile) + LinuxNotificationAction(key: _openFileAction, label: locale.openFile), + if (target != null) LinuxNotificationAction(key: _openFolderAction, label: locale.openFolder), + ], + ), + windows: WindowsNotificationDetails( + actions: [ + if (target != null && target.canOpenFile) + WindowsAction(content: locale.openFile, arguments: NotificationActionSpec(id: _openFileAction, path: target.path).encode()), + if (target != null) + WindowsAction(content: locale.openFolder, arguments: NotificationActionSpec(id: _openFolderAction, path: target.path).encode()), + ], + ), ); - await _plugin.show(id: _notificationId++, title: title, body: body, notificationDetails: details); + await _plugin.show(id: _notificationId++, title: title, body: body, notificationDetails: details, payload: payload); + } + + /// Resolves the download target a terminal task event points at, following + /// the same rule as the task details page: only single-file tasks offer + /// "open file", folder tasks (e.g. multi-file torrents) can only reveal the + /// folder. Returns null when the task is already gone. + @visibleForTesting + Future<({String path, bool canOpenFile})?> resolveTaskTarget( + String taskId, { + required bool allowOpenFile, + }) async { + final apiTask = await findTask(taskId); + if (apiTask == null) return null; + final record = TaskRecord.fromApi(apiTask); + return (path: record.storagePath, canOpenFile: allowOpenFile && !record.isFolder); + } + + @visibleForTesting + Future handleNotificationResponse(NotificationResponse response) => _onNotificationResponse(response); + + Future _onNotificationResponse(NotificationResponse response) async { + // Windows actions carry the full spec in their arguments. + var spec = NotificationActionSpec.decode(response.actionId ?? ''); + if (spec == null) { + // macOS/Linux actions are stable keys; the path arrives in the payload. + final id = response.actionId ?? ''; + if (id == _openFileAction || id == _openFolderAction) { + try { + final data = jsonDecode(response.payload ?? ''); + final targetPath = data is Map ? data['path'] : null; + if (targetPath is String) { + spec = NotificationActionSpec(id: id, path: targetPath); + } + } catch (_) { + // Malformed payload: fall through to the plain-click behavior. + } + } + } + if (spec == null) { + // Plain click on the notification body: bring the app window back. + await bringWindowToFront(); + return; + } + if (!await File(spec.path).exists()) { + logger.w('notification target no longer exists: ${spec.path}'); + return; + } + switch (spec.id) { + case _openFileAction: + if (!await openTaskFile(spec.path)) { + logger.w('failed to open file from notification: ${spec.path}'); + } + case _openFolderAction: + if (!await revealTaskFolder(spec.path)) { + logger.w('failed to reveal folder from notification: ${spec.path}'); + } + } + } + + @visibleForTesting + Future findTask(String taskId) async { + try { + final tasks = await ref.read(gopeedServiceProvider).getTasks(api_task.Status.values); + for (final task in tasks) { + if (task.id == taskId) return task; + } + } catch (error, stackTrace) { + logger.w('look up task for notification failed', error, stackTrace); + } + return null; + } + + @visibleForTesting + Future openTaskFile(String filePath) => FileExplorer.open(filePath); + + @visibleForTesting + Future revealTaskFolder(String filePath) => FileExplorer.reveal(filePath); + + @visibleForTesting + Future bringWindowToFront() async { + await windowManager.show(); + await windowManager.focus(); } } diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 947af41b..a450ab56 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -239,6 +239,7 @@ "back": "رجوع", "open": "فتح", "openFile": "فتح الملف", + "openFolder": "فتح المجلد", "share": "مشاركة", "copy": "نسخ", "copied": "مُزَوَّل", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 39c351b9..d4f297d5 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -253,6 +253,7 @@ "back": "Geri", "open": "Aç", "openFile": "Faylı Aç", + "openFolder": "Qovluğu Aç", "share": "Paylaş", "copy": "Kopyala", "copied": "Kopyalandı", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index fe733610..86185834 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -239,6 +239,7 @@ "back": "Enrere", "open": "Obre", "openFile": "Obre el fitxer", + "openFolder": "Obre la carpeta", "share": "Comparteix", "copy": "Copia", "copied": "Copiat", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 058a4407..c0c97ad8 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -239,6 +239,7 @@ "back": "Zurück", "open": "Offen", "openFile": "Datei öffnen", + "openFolder": "Ordner öffnen", "share": "Teilen", "copy": "Kopie", "copied": "Kopiert", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 9af2806f..a241bbfd 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -261,6 +261,7 @@ "back": "Back", "open": "Open", "openFile": "Open File", + "openFolder": "Open Folder", "share": "Share", "copy": "Copy", "copied": "Copied", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 96a65113..3beff20c 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -239,6 +239,7 @@ "back": "Volver", "open": "Abrir", "openFile": "Abrir archivo", + "openFolder": "Abrir carpeta", "share": "Compartir", "copy": "Copiar", "copied": "Copiado", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index a7cd2144..08664277 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -239,6 +239,7 @@ "back": "بازگشت", "open": "باز کردن", "openFile": "باز کردن فایل", + "openFolder": "باز کردن پوشه", "share": "اشتراک‌گذاری", "copy": "کپی", "copied": "اخراج", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 9f269b01..4c667ae7 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -239,6 +239,7 @@ "back": "Retour", "open": "Ouvrir", "openFile": "Ouvrir le fichier", + "openFolder": "Ouvrir le dossier", "share": "Partager", "copy": "Copier", "copied": "Copied", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index db44d5a7..7330fe85 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -239,6 +239,7 @@ "back": "Vissza", "open": "Nyitva", "openFile": "Fájl megnyitása", + "openFolder": "Mappa megnyitása", "share": "Megosztás", "copy": "másolat", "copied": "Másolva", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 87a36a65..c28ee0a7 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -239,6 +239,7 @@ "back": "Kembali", "open": "Buka", "openFile": "Buka Berkas", + "openFolder": "Buka Folder", "share": "Bagikan", "copy": "Salin", "copied": "Disalin", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index b04fd493..0ae8fc65 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -239,6 +239,7 @@ "back": "Indietro", "open": "Apri", "openFile": "Apri file", + "openFolder": "Apri cartella", "share": "Condividi", "copy": "Copia", "copied": "- Si'", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 009a7bea..d85cb2bc 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -239,6 +239,7 @@ "back": "戻る", "open": "営業部", "openFile": "ファイルを開く", + "openFolder": "フォルダを開く", "share": "共有", "copy": "コピー", "copied": "コピー", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 8f89366e..051a42f4 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -239,6 +239,7 @@ "back": "뒤로", "open": "열기", "openFile": "파일 열기", + "openFolder": "폴더 열기", "share": "공유", "copy": "복사", "copied": "뚱 베어", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index a6b9b031..bf86c584 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -239,6 +239,7 @@ "back": "Wstecz", "open": "Otwórz", "openFile": "Otwórz plik", + "openFolder": "Otwórz folder", "share": "Udostępnij", "copy": "Kopiuj", "copied": "Kopiowane", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 0fb06371..b7f581e4 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -239,6 +239,7 @@ "back": "Voltar", "open": "Abrir", "openFile": "Abrir ficheiro", + "openFolder": "Abrir pasta", "share": "Partilhar", "copy": "Copiar", "copied": "Copiado", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index b8ebe8e9..aea0b6f5 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -239,6 +239,7 @@ "back": "Назад", "open": "Открыть", "openFile": "Открыть файл", + "openFolder": "Открыть папку", "share": "Поделиться", "copy": "Копия", "copied": "Копирован", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 97e6b3c9..a967ea8c 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -239,6 +239,7 @@ "back": "பின்செல்", "open": "திற", "openFile": "கோப்பைத் திற", + "openFolder": "கோப்புறையைத் திற", "share": "பகிர்", "copy": "நகலெடு", "copied": "நகலெடுக்கப்பட்டது", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 127d7a0c..89be13ea 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -239,6 +239,7 @@ "back": "Geri", "open": "Aç", "openFile": "Dosyayı Aç", + "openFolder": "Klasörü Aç", "share": "Paylaş", "copy": "Kopyala", "copied": "Kopyalandı", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 24e20393..d32d2701 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -239,6 +239,7 @@ "back": "Назад", "open": "Відкрито", "openFile": "Відкрити файл", + "openFolder": "Відкрити папку", "share": "Поділитися", "copy": "Список", "copied": "Панчохи", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index aa3e7a9c..db3e0c37 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -239,6 +239,7 @@ "back": "Quay lại", "open": "Mở", "openFile": "Mở tệp", + "openFolder": "Mở thư mục", "share": "Chia sẻ", "copy": "Chép", "copied": "Co dãn", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index e199a7f7..e8dd21c0 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -239,6 +239,7 @@ "back": "返回", "open": "打开", "openFile": "打开文件", + "openFolder": "打开文件夹", "share": "分享", "copy": "复制", "copied": "已复制", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 1925dd0f..84f755a3 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -239,6 +239,7 @@ "back": "返回", "open": "開啟", "openFile": "開啟檔案", + "openFolder": "開啟資料夾", "share": "分享", "copy": "複製", "copied": "已複製", diff --git a/ui/flutter/test/app/application/app_notification_controller_test.dart b/ui/flutter/test/app/application/app_notification_controller_test.dart index a74c60d4..4fbfa5b0 100644 --- a/ui/flutter/test/app/application/app_notification_controller_test.dart +++ b/ui/flutter/test/app/application/app_notification_controller_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -6,6 +7,11 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/api/model/meta.dart'; +import 'package:gopeed/api/model/options.dart'; +import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/api/model/resource.dart'; +import 'package:gopeed/api/model/task.dart' as api_task; import 'package:gopeed/app/application/app_notification_controller.dart'; import 'package:gopeed/app/application/app_runtime_controller.dart'; import 'package:gopeed/core/common/api_server_state.dart'; @@ -52,6 +58,9 @@ void main() { Map initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map; + List> shownNotifications() => + calls.where((call) => call.method == 'show').map((call) => call.arguments as Map).toList(); + test('macOS requests alerts and sound when download notifications are enabled at startup', () async { await start(enabled: true); @@ -101,9 +110,9 @@ void main() { events.add(done); events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip')); await Future.delayed(Duration.zero); - final notifications = calls.where((call) => call.method == 'show').toList(); + final notifications = shownNotifications(); expect(notifications, hasLength(2)); - expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']); + expect(notifications.map((call) => call['body']), ['archive.zip', 'failed.zip']); runtime.setNotificationsEnabled(false); await container.pump(); @@ -112,6 +121,138 @@ void main() { await Future.delayed(Duration.zero); expect(calls.where((call) => call.method == 'show'), hasLength(2)); }); + + test('macOS registers action categories for every terminal event combination', () async { + await start(enabled: true); + + final categories = initialization()['notificationCategories'] as List; + final identifiers = categories.map((category) => (category as Map)['identifier']).toList(); + expect(identifiers, containsAll(['taskDoneSingleFile', 'taskDoneFolder', 'taskTaskError'])); + final singleFile = categories.firstWhere((c) => c['identifier'] == 'taskDoneSingleFile') as Map; + final singleActions = (singleFile['actions'] as List).cast(); + expect(singleActions.map((action) => action['identifier']), ['open_file', 'open_folder']); + expect(singleActions.map((action) => action['title']), ['Open File', 'Open Folder']); + }); + + test('a done single-file task resolves an open-file target', () async { + final container = await start(enabled: true); + final notifier = container.read(appNotificationControllerProvider.notifier) as _TestNotificationController; + + final target = await notifier.resolveTaskTarget('single', allowOpenFile: true); + + expect(target?.path, '/downloads/archive.zip'); + expect(target?.canOpenFile, isTrue); + }); + + test('a folder task never resolves an open-file target', () async { + final container = await start(enabled: true); + final notifier = container.read(appNotificationControllerProvider.notifier) as _TestNotificationController; + + final target = await notifier.resolveTaskTarget('folder', allowOpenFile: true); + + expect(target?.path, '/downloads/torrent-bundle'); + expect(target?.canOpenFile, isFalse); + }); + + test('an errored task never resolves an open-file target', () async { + final container = await start(enabled: true); + final notifier = container.read(appNotificationControllerProvider.notifier) as _TestNotificationController; + + final target = await notifier.resolveTaskTarget('single', allowOpenFile: false); + + expect(target?.canOpenFile, isFalse); + }); + + test('a done single-file task notification selects the single-file category and payload path', () async { + await start(enabled: true); + events.add(const TaskEvent(type: TaskEventType.done, taskId: 'single', name: 'archive.zip')); + await Future.delayed(Duration.zero); + + final notifications = shownNotifications(); + expect(notifications, hasLength(1)); + final platformSpecifics = notifications[0]['platformSpecifics'] as Map; + expect(platformSpecifics['categoryIdentifier'], 'taskDoneSingleFile'); + expect(notifications[0]['payload'], '{"path":"/downloads/archive.zip"}'); + }); + + test('a done folder task notification selects the folder category', () async { + await start(enabled: true); + events.add(const TaskEvent(type: TaskEventType.done, taskId: 'folder', name: 'torrent-bundle')); + await Future.delayed(Duration.zero); + + final platformSpecifics = shownNotifications()[0]['platformSpecifics'] as Map; + expect(platformSpecifics['categoryIdentifier'], 'taskDoneFolder'); + }); + + test('an errored task notification selects the error category', () async { + await start(enabled: true); + events.add(const TaskEvent(type: TaskEventType.error, taskId: 'single', name: 'archive.zip')); + await Future.delayed(Duration.zero); + + final platformSpecifics = shownNotifications()[0]['platformSpecifics'] as Map; + expect(platformSpecifics['categoryIdentifier'], 'taskTaskError'); + }); + + test('action responses route to file and folder handlers by encoded spec', () async { + final container = await start(enabled: true); + final notifier = container.read(appNotificationControllerProvider.notifier) as _TestNotificationController; + final existingPath = '${Directory.systemTemp.path}/gopeed-notification-test.zip'; + addTearDown(() => File(existingPath).deleteSync()); + File(existingPath).writeAsStringSync('data'); + + // Windows style: the action arguments carry the full spec. + await notifier.handleNotificationResponse( + NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotificationAction, + actionId: NotificationActionSpec(id: 'open_file', path: existingPath).encode(), + ), + ); + expect(notifier.openedFiles, [existingPath]); + expect(notifier.revealedFolders, isEmpty); + expect(notifier.frontedWindow, isFalse); + + // macOS/Linux style: stable action key plus the notification payload path. + await notifier.handleNotificationResponse( + NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotificationAction, + actionId: 'open_folder', + payload: '{"path":"$existingPath"}', + ), + ); + expect(notifier.revealedFolders, [existingPath]); + expect(notifier.frontedWindow, isFalse); + }); + + test('a plain notification body click brings the app window to the front', () async { + final container = await start(enabled: true); + final notifier = container.read(appNotificationControllerProvider.notifier) as _TestNotificationController; + + await notifier.handleNotificationResponse( + const NotificationResponse(notificationResponseType: NotificationResponseType.selectedNotification), + ); + + expect(notifier.frontedWindow, isTrue); + expect(notifier.openedFiles, isEmpty); + expect(notifier.revealedFolders, isEmpty); + }); +} + +api_task.Task _task(String id, {required bool folder}) { + final task = api_task.Task( + id: id, + name: folder ? 'torrent-bundle' : 'archive.zip', + meta: Meta( + req: Request(url: 'https://example.com/archive.zip'), + opts: Options(path: '/downloads'), + ), + status: api_task.Status.done, + uploading: false, + progress: api_task.Progress(used: 0, speed: 0, downloaded: 1, uploadSpeed: 0, uploaded: 0), + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + task.meta.res = Resource(name: folder ? 'torrent-bundle' : '', files: const []); + return task; } class _TestNotificationController extends AppNotificationController { @@ -119,6 +260,34 @@ class _TestNotificationController extends AppNotificationController { @override final Stream taskEvents; + + final tasksById = { + 'single': _task('single', folder: false), + 'folder': _task('folder', folder: true), + }; + final openedFiles = []; + final revealedFolders = []; + var frontedWindow = false; + + @override + Future findTask(String taskId) async => tasksById[taskId]; + + @override + Future openTaskFile(String filePath) async { + openedFiles.add(filePath); + return true; + } + + @override + Future revealTaskFolder(String filePath) async { + revealedFolders.add(filePath); + return true; + } + + @override + Future bringWindowToFront() async { + frontedWindow = true; + } } class _TestRuntimeController extends AppRuntimeController {