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:
Levi committed 2026-09-17 09:49:20 +08:00
1 parent 21702d5d46
commit 6f4635e2eb
24 files changed
+377 -10

No files matched your search

@@ -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 {