feat(desktop): implement native desktop notifications for task completion and errors (#1293)

This commit is contained in:
Locon213 authored and GitHub committed 2026-03-01 11:12:40 +08:00
1 parent 98bd3305b7
commit bc5c818eae
26 files changed
+285 -4

No files matched your search

@@ -17,6 +17,7 @@ import 'package:uri_to_file/uri_to_file.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart';
import '../../../../api/model/downloader_config.dart';
@@ -38,6 +39,7 @@ import '../../../../util/util.dart';
import '../../../routes/app_pages.dart';
import '../../../rpc/rpc.dart';
import '../../redirect/views/redirect_view.dart';
import '../../../services/notification_service.dart';
const unixSocketPath = 'gopeed.sock';
@@ -96,6 +98,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
_initWindows().onError((error, stackTrace) =>
logger.w("initWindows error", error, stackTrace));
if (Util.isDesktop()) {
Get.put(NotificationService());
}
_initTray().onError(
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
@@ -498,6 +498,27 @@ class SettingView extends GetView<SettingController> {
);
});
final buildDesktopNotification = !Util.isDesktop()
? () => null
: _buildConfigItem('desktopNotification', () {
return appController.downloaderConfig.value.extra.desktopNotification
? 'on'.tr
: 'off'.tr;
}, (Key key) {
return Container(
alignment: Alignment.centerLeft,
child: Switch(
value: appController.downloaderConfig.value.extra.desktopNotification,
onChanged: (bool value) async {
appController.downloaderConfig.update((val) {
val!.extra.desktopNotification = value;
});
await debounceSave();
},
),
);
});
// http config items start
final httpConfig = downloaderCfg.value.protocolConfig.http;
final buildHttpUa =
@@ -1592,6 +1613,7 @@ class SettingView extends GetView<SettingController> {
buildBrowserExtension(),
buildAutoStartup(),
buildMenubarMode(),
buildDesktopNotification(),
]),
)),
Text('archives'.tr),
@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart';
import '../../api/api.dart';
import '../../api/model/task.dart';
import '../../util/util.dart';
import '../modules/app/controllers/app_controller.dart';
class NotificationService extends GetxService {
Timer? _timer;
final Map<String, Status> _previousStatus = {};
final AppController appController = Get.find<AppController>();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
@override
void onInit() {
super.onInit();
_initNotifications();
_startPolling();
}
Future<void> _initNotifications() async {
const DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false);
final LinuxInitializationSettings initializationSettingsLinux =
LinuxInitializationSettings(
defaultActionName: 'Open notification',
defaultIcon: AssetsLinuxIcon('assets/icon/icon.png'),
);
String? windowsIconPath;
try {
if (Util.isWindows()) {
final byteData = await rootBundle.load('assets/icon/icon.ico');
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/notification_icon.ico');
await file.writeAsBytes(byteData.buffer
.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));
windowsIconPath = file.path;
}
} catch (e) {
// Ignore
}
final WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Gopeed',
appUserModelId: 'com.gopeed.gopeed',
guid: '3c1bf3f4-3d91-4eaa-a33f-8705e71cf1ce', // unique guid
iconPath: windowsIconPath,
);
final InitializationSettings initializationSettings =
InitializationSettings(
macOS: initializationSettingsDarwin,
linux: initializationSettingsLinux,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
);
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
void _startPolling() {
_timer = Timer.periodic(const Duration(seconds: 2), (timer) async {
try {
final config = appController.downloaderConfig.value;
if (!config.extra.desktopNotification) {
return;
}
final tasks = await getTasks([
Status.ready,
Status.running,
Status.pause,
Status.wait,
Status.error,
Status.done,
]);
for (var task in tasks) {
final prevStatus = _previousStatus[task.id];
final currentStatus = task.status;
if (prevStatus != null && prevStatus != currentStatus) {
if (currentStatus == Status.done) {
_showNotification(
title: 'notificationTaskDone'.tr,
body: task.name,
);
} else if (currentStatus == Status.error) {
_showNotification(
title: 'notificationTaskError'.tr,
body: task.name,
);
}
}
_previousStatus[task.id] = currentStatus;
}
// Clean up deleted tasks from map
final currentTaskIds = tasks.map((t) => t.id).toSet();
_previousStatus
.removeWhere((id, status) => !currentTaskIds.contains(id));
} catch (e) {
// Ignored
}
});
}
int _notificationId = 0;
Future<void> _showNotification(
{required String title, required String body}) async {
const NotificationDetails notificationDetails = NotificationDetails(
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
windows: WindowsNotificationDetails(),
);
await flutterLocalNotificationsPlugin.show(
id: _notificationId++,
title: title,
body: body,
notificationDetails: notificationDetails,
);
}
}