mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 15:49:49 +00:00
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.
This commit is contained in:
1 parent
21702d5d46
commit
6f4635e2eb
24 files changed
+377
-10
No files matched your search
@@ -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<AppNotificationC
|
||||
AppNotificationController.new,
|
||||
);
|
||||
|
||||
const _openFileAction = 'open_file';
|
||||
const _openFolderAction = 'open_folder';
|
||||
|
||||
// macOS resolves buttons through pre-registered notification categories, one
|
||||
// per action combination a terminal task event can offer.
|
||||
const _categoryDoneSingleFile = 'taskDoneSingleFile';
|
||||
const _categoryDoneFolder = 'taskDoneFolder';
|
||||
const _categoryTaskError = 'taskTaskError';
|
||||
|
||||
/// A notification button resolved for one task event.
|
||||
///
|
||||
/// [id] is one of [_openFileAction]/[_openFolderAction]; Windows carries
|
||||
/// [path] inside the action arguments, macOS and Linux deliver it through the
|
||||
/// notification payload, so the click callback can act without looking the
|
||||
/// task up again (it may already be gone by the time the user clicks).
|
||||
class NotificationActionSpec {
|
||||
const NotificationActionSpec({required this.id, required this.path});
|
||||
|
||||
final String id;
|
||||
final String path;
|
||||
|
||||
String encode() => jsonEncode({'action': id, 'path': path});
|
||||
|
||||
static NotificationActionSpec? decode(String raw) {
|
||||
if (raw.isEmpty) return null;
|
||||
try {
|
||||
final data = jsonDecode(raw);
|
||||
if (data is! Map<String, dynamic>) 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<AppNotificationState> {
|
||||
Future<void> _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<AppNotificationState> {
|
||||
|
||||
await _plugin.initialize(
|
||||
settings: InitializationSettings(macOS: darwin, linux: linux, windows: windows),
|
||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,19 +159,142 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
||||
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<void> _showNotification({required String title, required String body}) async {
|
||||
const details = NotificationDetails(
|
||||
macOS: DarwinNotificationDetails(),
|
||||
linux: LinuxNotificationDetails(),
|
||||
windows: WindowsNotificationDetails(),
|
||||
Future<void> _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<void> handleNotificationResponse(NotificationResponse response) => _onNotificationResponse(response);
|
||||
|
||||
Future<void> _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<String, dynamic> ? 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<api_task.Task?> 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<bool> openTaskFile(String filePath) => FileExplorer.open(filePath);
|
||||
|
||||
@visibleForTesting
|
||||
Future<bool> revealTaskFolder(String filePath) => FileExplorer.reveal(filePath);
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> bringWindowToFront() async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "رجوع",
|
||||
"open": "فتح",
|
||||
"openFile": "فتح الملف",
|
||||
"openFolder": "فتح المجلد",
|
||||
"share": "مشاركة",
|
||||
"copy": "نسخ",
|
||||
"copied": "مُزَوَّل",
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
"back": "Geri",
|
||||
"open": "Aç",
|
||||
"openFile": "Faylı Aç",
|
||||
"openFolder": "Qovluğu Aç",
|
||||
"share": "Paylaş",
|
||||
"copy": "Kopyala",
|
||||
"copied": "Kopyalandı",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Enrere",
|
||||
"open": "Obre",
|
||||
"openFile": "Obre el fitxer",
|
||||
"openFolder": "Obre la carpeta",
|
||||
"share": "Comparteix",
|
||||
"copy": "Copia",
|
||||
"copied": "Copiat",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Zurück",
|
||||
"open": "Offen",
|
||||
"openFile": "Datei öffnen",
|
||||
"openFolder": "Ordner öffnen",
|
||||
"share": "Teilen",
|
||||
"copy": "Kopie",
|
||||
"copied": "Kopiert",
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"back": "Back",
|
||||
"open": "Open",
|
||||
"openFile": "Open File",
|
||||
"openFolder": "Open Folder",
|
||||
"share": "Share",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Volver",
|
||||
"open": "Abrir",
|
||||
"openFile": "Abrir archivo",
|
||||
"openFolder": "Abrir carpeta",
|
||||
"share": "Compartir",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "بازگشت",
|
||||
"open": "باز کردن",
|
||||
"openFile": "باز کردن فایل",
|
||||
"openFolder": "باز کردن پوشه",
|
||||
"share": "اشتراکگذاری",
|
||||
"copy": "کپی",
|
||||
"copied": "اخراج",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Retour",
|
||||
"open": "Ouvrir",
|
||||
"openFile": "Ouvrir le fichier",
|
||||
"openFolder": "Ouvrir le dossier",
|
||||
"share": "Partager",
|
||||
"copy": "Copier",
|
||||
"copied": "Copied",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Kembali",
|
||||
"open": "Buka",
|
||||
"openFile": "Buka Berkas",
|
||||
"openFolder": "Buka Folder",
|
||||
"share": "Bagikan",
|
||||
"copy": "Salin",
|
||||
"copied": "Disalin",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Indietro",
|
||||
"open": "Apri",
|
||||
"openFile": "Apri file",
|
||||
"openFolder": "Apri cartella",
|
||||
"share": "Condividi",
|
||||
"copy": "Copia",
|
||||
"copied": "- Si'",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "戻る",
|
||||
"open": "営業部",
|
||||
"openFile": "ファイルを開く",
|
||||
"openFolder": "フォルダを開く",
|
||||
"share": "共有",
|
||||
"copy": "コピー",
|
||||
"copied": "コピー",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "뒤로",
|
||||
"open": "열기",
|
||||
"openFile": "파일 열기",
|
||||
"openFolder": "폴더 열기",
|
||||
"share": "공유",
|
||||
"copy": "복사",
|
||||
"copied": "뚱 베어",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Wstecz",
|
||||
"open": "Otwórz",
|
||||
"openFile": "Otwórz plik",
|
||||
"openFolder": "Otwórz folder",
|
||||
"share": "Udostępnij",
|
||||
"copy": "Kopiuj",
|
||||
"copied": "Kopiowane",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Voltar",
|
||||
"open": "Abrir",
|
||||
"openFile": "Abrir ficheiro",
|
||||
"openFolder": "Abrir pasta",
|
||||
"share": "Partilhar",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Назад",
|
||||
"open": "Открыть",
|
||||
"openFile": "Открыть файл",
|
||||
"openFolder": "Открыть папку",
|
||||
"share": "Поделиться",
|
||||
"copy": "Копия",
|
||||
"copied": "Копирован",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "பின்செல்",
|
||||
"open": "திற",
|
||||
"openFile": "கோப்பைத் திற",
|
||||
"openFolder": "கோப்புறையைத் திற",
|
||||
"share": "பகிர்",
|
||||
"copy": "நகலெடு",
|
||||
"copied": "நகலெடுக்கப்பட்டது",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Geri",
|
||||
"open": "Aç",
|
||||
"openFile": "Dosyayı Aç",
|
||||
"openFolder": "Klasörü Aç",
|
||||
"share": "Paylaş",
|
||||
"copy": "Kopyala",
|
||||
"copied": "Kopyalandı",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "Назад",
|
||||
"open": "Відкрито",
|
||||
"openFile": "Відкрити файл",
|
||||
"openFolder": "Відкрити папку",
|
||||
"share": "Поділитися",
|
||||
"copy": "Список",
|
||||
"copied": "Панчохи",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "返回",
|
||||
"open": "打开",
|
||||
"openFile": "打开文件",
|
||||
"openFolder": "打开文件夹",
|
||||
"share": "分享",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"back": "返回",
|
||||
"open": "開啟",
|
||||
"openFile": "開啟檔案",
|
||||
"openFolder": "開啟資料夾",
|
||||
"share": "分享",
|
||||
"copy": "複製",
|
||||
"copied": "已複製",
|
||||
|
||||
@@ -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<dynamic, dynamic> initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map;
|
||||
|
||||
List<Map<dynamic, dynamic>> 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<void>.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<void>.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<dynamic>;
|
||||
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<Map>();
|
||||
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<void>.delayed(Duration.zero);
|
||||
|
||||
final notifications = shownNotifications();
|
||||
expect(notifications, hasLength(1));
|
||||
final platformSpecifics = notifications[0]['platformSpecifics'] as Map<dynamic, dynamic>;
|
||||
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<void>.delayed(Duration.zero);
|
||||
|
||||
final platformSpecifics = shownNotifications()[0]['platformSpecifics'] as Map<dynamic, dynamic>;
|
||||
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<void>.delayed(Duration.zero);
|
||||
|
||||
final platformSpecifics = shownNotifications()[0]['platformSpecifics'] as Map<dynamic, dynamic>;
|
||||
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<TaskEvent> taskEvents;
|
||||
|
||||
final tasksById = {
|
||||
'single': _task('single', folder: false),
|
||||
'folder': _task('folder', folder: true),
|
||||
};
|
||||
final openedFiles = <String>[];
|
||||
final revealedFolders = <String>[];
|
||||
var frontedWindow = false;
|
||||
|
||||
@override
|
||||
Future<api_task.Task?> findTask(String taskId) async => tasksById[taskId];
|
||||
|
||||
@override
|
||||
Future<bool> openTaskFile(String filePath) async {
|
||||
openedFiles.add(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> revealTaskFolder(String filePath) async {
|
||||
revealedFolders.add(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> bringWindowToFront() async {
|
||||
frontedWindow = true;
|
||||
}
|
||||
}
|
||||
|
||||
class _TestRuntimeController extends AppRuntimeController {
|
||||
|
||||
Reference in new issue
Block a user