Files
FlClash/lib/common/tray.dart
T

282 lines
7.7 KiB
Dart
Raw Normal View History

2024-12-03 21:47:12 +08:00
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
2026-02-02 10:14:13 +08:00
import 'package:fl_clash/providers/providers.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:tray/tray.dart';
2024-12-03 21:47:12 +08:00
import 'app_localizations.dart';
import 'l10n_labels.dart';
import 'app_ports.dart';
2024-12-03 21:47:12 +08:00
import 'constant.dart';
import 'keyboard.dart';
import 'provider_reader.dart';
2025-06-07 01:48:34 +08:00
import 'system.dart';
2024-12-03 21:47:12 +08:00
import 'window.dart';
class AppTray implements TrayPort {
static AppTray? _instance;
final bool isMacOS;
final bool isWindows;
2025-12-16 11:23:09 +08:00
bool _isShutDown = false;
2025-12-16 11:23:09 +08:00
AppTray._internal({required this.isMacOS, required this.isWindows});
factory AppTray() {
_instance ??= AppTray._internal(
isMacOS: system.isMacOS,
isWindows: system.isWindows,
);
2025-12-16 11:23:09 +08:00
return _instance!;
}
@visibleForTesting
factory AppTray.forPlatform({
required bool isMacOS,
required bool isWindows,
}) {
return AppTray._internal(isMacOS: isMacOS, isWindows: isWindows);
2025-10-14 15:13:52 +08:00
}
String get _trayIconSuffix {
return isWindows ? 'ico' : 'png';
2025-12-16 11:23:09 +08:00
}
String get _trayIconDir {
if (isWindows) {
return 'assets/images/tray/windows';
}
return isMacOS ? 'assets/images/tray/macos' : 'assets/images/tray/unix';
2025-10-14 15:13:52 +08:00
}
String getTrayIcon({
required bool isStart,
required bool tunEnable,
required bool safeMode,
}) {
final status = switch ((safeMode, isMacOS || !isStart, tunEnable)) {
(true, _, _) => 4,
(false, true, _) => 1,
(false, false, false) => 2,
(false, false, true) => 3,
};
return '$_trayIconDir/status_$status.$_trayIconSuffix';
2024-12-03 21:47:12 +08:00
}
@override
Future<void> shutdown() async {
_isShutDown = true;
// Removing a status item makes macOS forget where the user dragged it in
// the menu bar; one that goes away with the process keeps its place.
if (isMacOS) {
return;
}
await Tray.instance.hide();
}
@override
2025-06-07 01:48:34 +08:00
Future<void> update({
2025-02-09 18:39:38 +08:00
required TrayState trayState,
2025-12-16 11:23:09 +08:00
required Traffic traffic,
required ProviderReader read,
2024-12-03 21:47:12 +08:00
}) async {
if (_isShutDown) {
2024-12-03 21:47:12 +08:00
return;
}
await Tray.instance.show(
TraySpec(
icon: TrayIcon.asset(
getTrayIcon(
isStart: trayState.isStart && !trayState.suspended,
tunEnable: trayState.tunEnable,
safeMode: trayState.safeMode,
),
isTemplate: isMacOS,
size: isMacOS ? 18 : 16,
),
toolTip: trayState.safeMode
? currentAppLocalizations.safeModeAppTitle(appName)
: appName,
menu: _buildMenu(trayState: trayState, read: read),
),
);
await updateTitle(showTrayTitle: trayState.showTrayTitle, traffic: traffic);
}
Future<void> updateTitle({
required bool showTrayTitle,
required Traffic traffic,
}) async {
if (_isShutDown || !isMacOS) {
return;
2024-12-03 21:47:12 +08:00
}
await Tray.instance.setTitle(showTrayTitle ? traffic.trayTitle : '');
}
List<TrayMenuItem> _buildMenu({
required TrayState trayState,
required ProviderReader read,
}) {
final commonAction = read(commonActionProvider.notifier);
final systemAction = read(systemActionProvider.notifier);
final setupAction = read(setupActionProvider.notifier);
2026-02-02 10:14:13 +08:00
final appLocalizations = currentAppLocalizations;
String? shortcut(HotAction action) => _shortcut(trayState, action);
final showItem = TrayMenuAction(
label: appLocalizations.show,
detail: shortcut(HotAction.view),
onSelected: () {
window?.show();
},
);
final exitItem = TrayMenuAction(
label: appLocalizations.exit,
detail: shortcut(HotAction.exit),
onSelected: () {
systemAction.handleExit();
},
);
return [
showItem,
TrayMenuCheckbox(
label: trayState.isStart
? appLocalizations.stop
: appLocalizations.start,
checked: false,
detail: shortcut(HotAction.start),
onSelected: commonAction.toggleRunning,
),
if (isMacOS)
TrayMenuCheckbox(
label: appLocalizations.speedStatistics,
checked: trayState.showTrayTitle,
onSelected: commonAction.updateSpeedStatistics,
),
const TrayMenuSeparator(),
for (final mode in Mode.values)
TrayMenuCheckbox(
label: mode.label,
checked: mode == trayState.mode,
detail: shortcut(switch (mode) {
Mode.rule => HotAction.ruleMode,
Mode.global => HotAction.globalMode,
Mode.direct => HotAction.directMode,
}),
onSelected: () {
2026-02-02 10:14:13 +08:00
setupAction.changeMode(mode);
2024-12-03 21:47:12 +08:00
},
),
const TrayMenuSeparator(),
if (isMacOS) ..._buildGroupMenu(trayState: trayState, read: read),
if (trayState.isStart) ...[
TrayMenuCheckbox(
2024-12-03 21:47:12 +08:00
label: appLocalizations.tun,
2025-02-09 18:39:38 +08:00
checked: trayState.tunEnable,
detail: shortcut(HotAction.tun),
onSelected: systemAction.updateTun,
2024-12-03 21:47:12 +08:00
),
TrayMenuCheckbox(
2024-12-03 21:47:12 +08:00
label: appLocalizations.systemProxy,
2025-02-09 18:39:38 +08:00
checked: trayState.systemProxy,
detail: shortcut(HotAction.proxy),
onSelected: systemAction.updateSystemProxy,
2024-12-03 21:47:12 +08:00
),
const TrayMenuSeparator(),
],
TrayMenuCheckbox(
label: appLocalizations.autoLaunch,
checked: trayState.autoLaunch,
onSelected: systemAction.updateAutoLaunch,
),
TrayMenuAction(
label: appLocalizations.copyEnvVar,
detail: shortcut(HotAction.copyEnv),
onSelected: systemAction.copyProxyEnv,
),
const TrayMenuSeparator(),
exitItem,
];
2024-12-03 21:47:12 +08:00
}
String? _shortcut(TrayState trayState, HotAction action) {
final hotKey = trayState.hotKeys[action];
final key = hotKey?.key;
if (hotKey == null || key == null) {
return null;
}
return ShortcutLabels(
isMacOS: isMacOS,
isWindows: isWindows,
).text(hotKey.modifiers, key);
}
String? _delayText(int? delay) {
if (delay == null) {
return null;
}
return delay > 0
? '$delay'
: delayFailureText(delay, currentAppLocalizations);
}
List<TrayMenuItem> _buildGroupMenu({
required TrayState trayState,
required ProviderReader read,
}) {
if (trayState.groups.isEmpty) {
return const [];
2025-10-14 15:13:52 +08:00
}
final delays = read(trayDelaysProvider);
final proxiesAction = read(proxiesActionProvider.notifier);
return [
for (final group in trayState.groups)
_buildGroupSubmenu(
group,
selectedName: read(selectedProxyNameProvider(group.name)),
delays: delays[group.name] ?? const {},
onSelected: (proxyName) {
proxiesAction.changeProxy(
groupName: group.name,
proxyName: proxyName,
);
},
),
TrayMenuAction(
label: HotAction.delayTest.label,
detail: _shortcut(trayState, HotAction.delayTest),
onSelected: () {
proxiesAction.delayTestGroups(trayState.groups);
},
),
const TrayMenuSeparator(),
];
2025-02-09 18:39:38 +08:00
}
TrayMenuSubmenu _buildGroupSubmenu(
Group group, {
required String? selectedName,
required Map<String, int> delays,
required void Function(String proxyName) onSelected,
}) {
return TrayMenuSubmenu(
label: group.name,
detail: _delayText(delays[selectedName]),
items: [
for (final proxy in group.all)
TrayMenuCheckbox(
label: proxy.name,
checked: selectedName == proxy.name,
detail: _delayText(delays[proxy.name]),
onSelected: () {
onSelected(proxy.name);
},
),
],
);
2024-12-03 21:47:12 +08:00
}
}
final appTray = system.isDesktop ? AppTray() : null;