Files
FlClash/test/common/tray_menu_test.dart
T
chen08209 94a25f4827 fix(desktop): fix the system proxy, tray and Helper on Windows and Linux
Changelog: Windows and Linux desktop fixes
2026-10-11 12:28:52 +08:00

434 lines
13 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/common/app_localizations.dart';
import 'package:fl_clash/common/app_ports.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/common/l10n_labels.dart';
import 'package:fl_clash/common/tray.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:riverpod/riverpod.dart';
import 'package:tray/tray.dart';
const _channel = MethodChannel('tray');
const _proxyGroup = Group(
name: 'Proxy',
type: GroupType.Selector,
all: [
Proxy(name: 'A', type: 'Direct'),
Proxy(name: 'B', type: 'Direct'),
Proxy(name: 'C', type: 'Direct'),
],
);
HotKeyAction _bind(HotAction action, PhysicalKeyboardKey key) {
return HotKeyAction(
action: action,
key: key.usbHidUsage,
modifiers: const {KeyboardModifier.control, KeyboardModifier.shift},
);
}
class _FakePathProvider extends PathProviderPlatform {
_FakePathProvider(this.root);
final String root;
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
TrayState _trayState({
bool isStart = false,
bool tunEnable = false,
bool systemProxy = false,
bool autoLaunch = false,
bool showTrayTitle = false,
bool safeMode = false,
Mode mode = Mode.rule,
List<Group> groups = const [],
Map<HotAction, HotKeyAction> hotKeys = const {},
}) {
return TrayState(
mode: mode,
port: 7890,
autoLaunch: autoLaunch,
systemProxy: systemProxy,
tunEnable: tunEnable,
isStart: isStart,
groups: groups,
selectedMap: const {},
showTrayTitle: showTrayTitle,
safeMode: safeMode,
hotKeys: hotKeys,
);
}
List<Map<Object?, Object?>> _items(MethodCall? call) {
final menu = (call?.arguments as Map?)?['menu'] as List?;
return menu?.cast<Map<Object?, Object?>>() ?? const [];
}
Map<Object?, Object?> _item(MethodCall? call, String label) {
return _items(call).firstWhere((item) => item['label'] == label);
}
List<String> _labels(MethodCall? call) {
return _items(call).map((item) => item['label']).whereType<String>().toList();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late List<MethodCall> calls;
late ProviderContainer container;
late AppTray tray;
late Directory root;
setUpAll(() async {
root = Directory.systemTemp.createTempSync('tray_menu_test');
PathProviderPlatform.instance = _FakePathProvider(root.path);
await AppLocalizations.load(const Locale('en'));
});
tearDownAll(() {
// The shared system temp dir is not exclusively ours; another suite running
// alongside this one can take the tree out from under the teardown, either
// before the check or between the check and the delete.
try {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
} on FileSystemException {
return;
}
});
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
calls = [];
tray = AppTray.forPlatform(isMacOS: true, isWindows: false);
container = ProviderContainer();
Tray.instance.resetForTesting();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_channel, (call) async {
calls.add(call);
return true;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_channel, null);
container.dispose();
debugDefaultTargetPlatformOverride = null;
});
MethodCall? showCall() {
for (final call in calls.reversed) {
if (call.method == 'show') {
return call;
}
}
return null;
}
Future<void> update(TrayState trayState, {AppTray? on}) {
return (on ?? tray).update(
trayState: trayState,
traffic: const Traffic(),
read: container.read,
);
}
test(
'SystemAction.updateTray builds the menu without reading itself',
() async {
globalState.container = container;
trayPort = tray;
addTearDown(() => trayPort = null);
await container.read(systemActionProvider.notifier).updateTray();
expect(showCall(), isNotNull);
expect(_labels(showCall()), contains(currentAppLocalizations.exit));
},
);
test('builds the stopped menu without the running-only toggles', () async {
await update(_trayState());
final labels = _labels(showCall());
final l10n = currentAppLocalizations;
expect(labels, contains(l10n.show));
expect(labels, contains(l10n.start));
expect(labels, contains(l10n.autoLaunch));
expect(labels, contains(l10n.copyEnvVar));
expect(labels, contains(l10n.exit));
expect(labels, isNot(contains(l10n.tun)));
expect(labels, isNot(contains(l10n.systemProxy)));
});
test('adds TUN and system proxy toggles once the core is running', () async {
await update(_trayState(isStart: true));
final labels = _labels(showCall());
final l10n = currentAppLocalizations;
expect(labels, contains(l10n.stop), reason: 'start flips to stop');
expect(labels, contains(l10n.tun));
expect(labels, contains(l10n.systemProxy));
});
group('safe mode', () {
test('keeps the full menu', () async {
await update(_trayState(isStart: true, safeMode: true));
final labels = _labels(showCall());
final l10n = currentAppLocalizations;
expect(labels, contains(l10n.stop));
expect(labels, contains(l10n.tun));
expect(labels, contains(l10n.systemProxy));
expect(labels, contains(l10n.copyEnvVar));
});
test('names the mode in the tooltip and ships the safe icon', () async {
await update(_trayState(isStart: true, safeMode: true));
final arguments = showCall()!.arguments as Map;
final l10n = currentAppLocalizations;
expect(arguments['toolTip'], '$appName (${l10n.safeMode})');
final reps = ((arguments['icon'] as Map)['reps'] as List).cast<Map>();
final base = reps.firstWhere((rep) => rep['scale'] == 1.0);
expect(
base['bytes'],
base64Encode(
File('assets/images/tray/macos/status_4.png').readAsBytesSync(),
),
);
});
});
test('offers every outbound mode as a menu entry', () async {
await update(_trayState(mode: Mode.global));
final checkedModes = _items(showCall())
.where((item) => item['checked'] == true)
.map((item) => item['label'])
.toList();
expect(checkedModes, contains(Intl.message(Mode.global.name)));
});
test('sends icon, tooltip and menu in a single show call', () async {
await update(_trayState(isStart: true, tunEnable: true));
final showCalls = calls.where((call) => call.method == 'show').toList();
expect(showCalls, hasLength(1));
final arguments = showCalls.single.arguments as Map;
expect(arguments['toolTip'], appName);
expect(arguments['menu'], isNotEmpty);
final reps = ((arguments['icon'] as Map)['reps'] as List).cast<Map>();
expect(reps.map((rep) => rep['scale']), containsAll([1.0, 2.0, 3.0, 4.0]));
expect(reps.map((rep) => rep['bytes']), everyElement(isNotEmpty));
expect((arguments['icon'] as Map)['isTemplate'], isTrue);
});
test('skips the platform call when the tray state is unchanged', () async {
await update(_trayState(isStart: true));
await update(_trayState(isStart: true));
expect(calls.where((call) => call.method == 'show'), hasLength(1));
});
test('menu item ids are stable across identical rebuilds', () async {
await update(_trayState());
final first = _items(showCall()).map((item) => item['id']).toList();
Tray.instance.resetForTesting();
calls.clear();
await update(_trayState());
final second = _items(showCall()).map((item) => item['id']).toList();
expect(second, first);
expect(first.first, 1024);
});
test('group submenus carry their proxies as nested items', () async {
await update(
_trayState(
groups: [
const Group(
name: 'Proxy',
type: GroupType.Selector,
all: [Proxy(name: 'A', type: 'Direct')],
),
],
),
);
final submenu = _items(
showCall(),
).firstWhere((item) => item['type'] == 'submenu');
expect(submenu['label'], 'Proxy');
final children = (submenu['items'] as List).cast<Map<Object?, Object?>>();
expect(children.map((item) => item['label']), contains('A'));
});
test('shows each bound hotkey dimmed beside its entry', () async {
await update(
_trayState(
isStart: true,
hotKeys: {
HotAction.view: _bind(HotAction.view, PhysicalKeyboardKey.keyV),
HotAction.globalMode: _bind(
HotAction.globalMode,
PhysicalKeyboardKey.keyG,
),
HotAction.tun: _bind(HotAction.tun, PhysicalKeyboardKey.keyT),
},
),
);
final l10n = currentAppLocalizations;
final call = showCall();
expect(_item(call, l10n.show)['detail'], '⌃⇧V');
expect(_item(call, Mode.global.label)['detail'], '⌃⇧G');
expect(_item(call, l10n.tun)['detail'], '⌃⇧T');
expect(_item(call, l10n.exit), isNot(contains('detail')));
expect(_item(call, Mode.rule.label), isNot(contains('detail')));
});
test(
'group submenus show the delay of the selection and of each proxy',
() async {
container.dispose();
container = ProviderContainer(
overrides: [
selectedProxyNameProvider('Proxy').overrideWithValue('A'),
trayDelaysProvider.overrideWithValue({
'Proxy': {'A': 120, 'B': -1, 'C': -2},
}),
],
);
const group = Group(
name: 'Proxy',
type: GroupType.Selector,
all: [
Proxy(name: 'A', type: 'Direct'),
Proxy(name: 'B', type: 'Direct'),
Proxy(name: 'C', type: 'Direct'),
Proxy(name: 'D', type: 'Direct'),
],
);
await update(_trayState(groups: [group]));
final submenu = _item(showCall(), 'Proxy');
expect(submenu['detail'], '120');
final children = (submenu['items'] as List).cast<Map<Object?, Object?>>();
expect(children.map((item) => item['detail']), [
'120',
currentAppLocalizations.timeout,
currentAppLocalizations.delayFailed,
null,
]);
},
);
test('a delay test entry follows the groups', () async {
await update(
_trayState(
groups: [_proxyGroup],
hotKeys: {
HotAction.delayTest: _bind(
HotAction.delayTest,
PhysicalKeyboardKey.keyD,
),
},
),
);
final items = _items(showCall());
final index = items.indexWhere((item) => item['type'] == 'submenu');
expect(items[index + 1]['label'], HotAction.delayTest.label);
expect(items[index + 1]['detail'], '⌃⇧D');
});
group('a platform that is not macOS', () {
late AppTray windows;
setUp(() {
windows = AppTray.forPlatform(isMacOS: false, isWindows: true);
});
test('gets a plain icon, no group submenus and no speed toggle', () async {
await update(
_trayState(
isStart: true,
groups: [
const Group(
name: 'Proxy',
type: GroupType.Selector,
all: [Proxy(name: 'A', type: 'Direct')],
),
],
),
on: windows,
);
final arguments = showCall()!.arguments as Map;
expect((arguments['icon'] as Map)['isTemplate'], isFalse);
expect(
_items(showCall()).where((item) => item['type'] == 'submenu'),
isEmpty,
);
expect(
_labels(showCall()),
isNot(contains(currentAppLocalizations.speedStatistics)),
);
expect(_labels(showCall()), isNot(contains(HotAction.delayTest.label)));
});
test('joins modifiers with + in the hotkey hints', () async {
await update(
_trayState(
hotKeys: {
HotAction.view: _bind(HotAction.view, PhysicalKeyboardKey.keyV),
},
),
on: windows,
);
expect(
_item(showCall(), currentAppLocalizations.show)['detail'],
'Ctrl+Shift+V',
);
});
test('never pushes a tray title', () async {
await windows.updateTitle(showTrayTitle: true, traffic: const Traffic());
expect(calls.where((call) => call.method == 'setTitle'), isEmpty);
});
});
}