614 lines
18 KiB
Dart
614 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:ffi' show Pointer;
|
|
import 'dart:io';
|
|
import 'dart:isolate';
|
|
|
|
import 'package:animations/animations.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:dynamic_color/dynamic_color.dart';
|
|
import 'package:fl_clash/common/theme.dart';
|
|
import 'package:fl_clash/core/core.dart';
|
|
import 'package:fl_clash/enum/enum.dart';
|
|
import 'package:fl_clash/l10n/l10n.dart';
|
|
import 'package:fl_clash/plugins/service.dart';
|
|
import 'package:fl_clash/widgets/dialog.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_js/flutter_js.dart';
|
|
import 'package:material_color_utilities/palettes/core_palette.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:path/path.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import 'common/common.dart';
|
|
import 'controller.dart';
|
|
import 'models/models.dart';
|
|
|
|
typedef UpdateTasks = List<FutureOr Function()>;
|
|
|
|
class GlobalState {
|
|
static GlobalState? _instance;
|
|
Map<CacheTag, FixedMap<String, double>> computeHeightMapCache = {};
|
|
Timer? timer;
|
|
Timer? groupsUpdateTimer;
|
|
late Config config;
|
|
late AppState appState;
|
|
bool isPre = true;
|
|
String? coreSHA256;
|
|
late PackageInfo packageInfo;
|
|
Function? updateCurrentDelayDebounce;
|
|
late Measure measure;
|
|
late CommonTheme theme;
|
|
late Color accentColor;
|
|
CorePalette? corePalette;
|
|
DateTime? startTime;
|
|
UpdateTasks tasks = [];
|
|
final navigatorKey = GlobalKey<NavigatorState>();
|
|
AppController? _appController;
|
|
bool isInit = false;
|
|
bool isUserDisconnected = false;
|
|
bool isService = false;
|
|
|
|
bool get isStart => startTime != null && startTime!.isBeforeNow;
|
|
|
|
AppController get appController => _appController!;
|
|
|
|
set appController(AppController appController) {
|
|
_appController = appController;
|
|
isInit = true;
|
|
}
|
|
|
|
GlobalState._internal();
|
|
|
|
factory GlobalState() {
|
|
_instance ??= GlobalState._internal();
|
|
return _instance!;
|
|
}
|
|
|
|
Future<void> initApp(int version) async {
|
|
coreSHA256 = const String.fromEnvironment('CORE_SHA256');
|
|
isPre = const String.fromEnvironment('APP_ENV') != 'stable';
|
|
appState = AppState(
|
|
brightness: WidgetsBinding.instance.platformDispatcher.platformBrightness,
|
|
version: version,
|
|
viewSize: Size.zero,
|
|
requests: FixedList(maxLength),
|
|
logs: FixedList(maxLength),
|
|
traffics: FixedList(30),
|
|
totalTraffic: Traffic(),
|
|
systemUiOverlayStyle: const SystemUiOverlayStyle(),
|
|
);
|
|
await _initDynamicColor();
|
|
await init();
|
|
await window?.init(version);
|
|
_shakingStore();
|
|
}
|
|
|
|
Future<void> _shakingStore() async {
|
|
final profileIds = config.profiles.map((item) => item.id);
|
|
final providersRootPath = await appPath.getProvidersRootPath();
|
|
final profilesRootPath = await appPath.profilesPath;
|
|
Isolate.run(() async {
|
|
final profilesDir = Directory(profilesRootPath);
|
|
final providersDir = Directory(providersRootPath);
|
|
final List<FileSystemEntity> entities = [];
|
|
if (await profilesDir.exists()) {
|
|
entities.addAll(
|
|
profilesDir.listSync().where(
|
|
(item) => !item.path.contains('providers'),
|
|
),
|
|
);
|
|
}
|
|
if (await providersDir.exists()) {
|
|
entities.addAll(providersDir.listSync());
|
|
}
|
|
final deleteFutures = entities.map((entity) async {
|
|
if (!profileIds.contains(basenameWithoutExtension(entity.path))) {
|
|
final res = await coreController.deleteFile(entity.path);
|
|
if (res.isNotEmpty) {
|
|
throw res;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
await Future.wait(deleteFutures);
|
|
});
|
|
}
|
|
|
|
Future<void> _initDynamicColor() async {
|
|
try {
|
|
corePalette = await DynamicColorPlugin.getCorePalette();
|
|
accentColor =
|
|
await DynamicColorPlugin.getAccentColor() ??
|
|
Color(defaultPrimaryColor);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> init() async {
|
|
packageInfo = await PackageInfo.fromPlatform();
|
|
config =
|
|
await preferences.getConfig() ?? Config(themeProps: defaultThemeProps);
|
|
await globalState.migrateOldData(config);
|
|
await AppLocalizations.load(
|
|
utils.getLocaleForString(config.appSetting.locale) ??
|
|
WidgetsBinding.instance.platformDispatcher.locale,
|
|
);
|
|
}
|
|
|
|
String get ua => config.patchClashConfig.globalUa ?? packageInfo.ua;
|
|
|
|
Future<void> startUpdateTasks([UpdateTasks? tasks]) async {
|
|
if (timer != null && timer!.isActive == true) return;
|
|
if (tasks != null) {
|
|
this.tasks = tasks;
|
|
}
|
|
if (this.tasks.isEmpty) {
|
|
return;
|
|
}
|
|
await executorUpdateTask();
|
|
timer = Timer(const Duration(seconds: 1), () async {
|
|
startUpdateTasks();
|
|
});
|
|
}
|
|
|
|
Future<void> executorUpdateTask() async {
|
|
for (final task in tasks) {
|
|
await task();
|
|
}
|
|
timer = null;
|
|
}
|
|
|
|
void stopUpdateTasks() {
|
|
if (timer == null || timer?.isActive == false) return;
|
|
timer?.cancel();
|
|
timer = null;
|
|
}
|
|
|
|
Future<void> handleStart([UpdateTasks? tasks]) async {
|
|
startTime ??= DateTime.now();
|
|
await coreController.startListener();
|
|
await service?.start();
|
|
startUpdateTasks(tasks);
|
|
}
|
|
|
|
Future updateStartTime() async {
|
|
startTime = await service?.getRunTime();
|
|
}
|
|
|
|
Future handleStop() async {
|
|
startTime = null;
|
|
await coreController.stopListener();
|
|
await service?.stop();
|
|
stopUpdateTasks();
|
|
}
|
|
|
|
Future<bool?> showMessage({
|
|
required InlineSpan message,
|
|
BuildContext? context,
|
|
String? title,
|
|
String? confirmText,
|
|
bool cancelable = true,
|
|
bool? dismissible,
|
|
}) async {
|
|
return await showCommonDialog<bool>(
|
|
context: context,
|
|
dismissible: dismissible,
|
|
child: Builder(
|
|
builder: (context) {
|
|
return CommonDialog(
|
|
title: title ?? appLocalizations.tip,
|
|
actions: [
|
|
if (cancelable)
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop(false);
|
|
},
|
|
child: Text(appLocalizations.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop(true);
|
|
},
|
|
child: Text(confirmText ?? appLocalizations.confirm),
|
|
),
|
|
],
|
|
child: Container(
|
|
width: 300,
|
|
constraints: const BoxConstraints(maxHeight: 200),
|
|
child: SingleChildScrollView(
|
|
child: SelectableText.rich(
|
|
TextSpan(
|
|
style: Theme.of(context).textTheme.labelLarge,
|
|
children: [message],
|
|
),
|
|
style: const TextStyle(overflow: TextOverflow.visible),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
VpnOptions getVpnOptions() {
|
|
final vpnProps = config.vpnProps;
|
|
final networkProps = config.networkProps;
|
|
final port = config.patchClashConfig.mixedPort;
|
|
return VpnOptions(
|
|
stack: config.patchClashConfig.tun.stack.name,
|
|
enable: vpnProps.enable,
|
|
systemProxy: vpnProps.systemProxy,
|
|
port: port,
|
|
ipv6: vpnProps.ipv6,
|
|
dnsHijacking: vpnProps.dnsHijacking,
|
|
accessControl: vpnProps.accessControl,
|
|
allowBypass: vpnProps.allowBypass,
|
|
bypassDomain: networkProps.bypassDomain,
|
|
);
|
|
}
|
|
|
|
Future<T?> showCommonDialog<T>({
|
|
required Widget child,
|
|
BuildContext? context,
|
|
bool? dismissible,
|
|
}) async {
|
|
return await showModal<T>(
|
|
useRootNavigator: false,
|
|
context: context ?? globalState.navigatorKey.currentContext!,
|
|
configuration: FadeScaleTransitionConfiguration(
|
|
barrierColor: Colors.black38,
|
|
barrierDismissible: dismissible ?? true,
|
|
),
|
|
builder: (_) => child,
|
|
filter: commonFilter,
|
|
);
|
|
}
|
|
|
|
void showNotifier(String text) {
|
|
if (text.isEmpty) {
|
|
return;
|
|
}
|
|
navigatorKey.currentContext?.showNotifier(text);
|
|
}
|
|
|
|
Future<void> openUrl(String url) async {
|
|
final res = await showMessage(
|
|
message: TextSpan(text: url),
|
|
title: appLocalizations.externalLink,
|
|
confirmText: appLocalizations.go,
|
|
);
|
|
if (res != true) {
|
|
return;
|
|
}
|
|
launchUrl(Uri.parse(url));
|
|
}
|
|
|
|
Future<void> migrateOldData(Config config) async {
|
|
final clashConfig = await preferences.getClashConfig();
|
|
if (clashConfig != null) {
|
|
config = config.copyWith(patchClashConfig: clashConfig);
|
|
preferences.clearClashConfig();
|
|
preferences.saveConfig(config);
|
|
}
|
|
}
|
|
|
|
Future<SetupParams> getSetupParams() async {
|
|
final params = SetupParams(
|
|
selectedMap: config.currentProfile?.selectedMap ?? {},
|
|
testUrl: config.appSetting.testUrl,
|
|
);
|
|
return params;
|
|
}
|
|
|
|
Future<void> genConfigFile(ClashConfig pathConfig) async {
|
|
final configFilePath = await appPath.configFilePath;
|
|
final config = await patchRawConfig(patchConfig: pathConfig);
|
|
final res = await Isolate.run<String>(() async {
|
|
try {
|
|
final res = json.encode(config);
|
|
final file = File(configFilePath);
|
|
if (!await file.exists()) {
|
|
await file.create(recursive: true);
|
|
}
|
|
await file.writeAsString(res);
|
|
return '';
|
|
} catch (e) {
|
|
return e.toString();
|
|
}
|
|
});
|
|
if (res.isNotEmpty) {
|
|
throw res;
|
|
}
|
|
}
|
|
|
|
Future<void> genValidateFile(String path, String data) async {
|
|
final res = await Isolate.run<String>(() async {
|
|
try {
|
|
final file = File(path);
|
|
if (!await file.exists()) {
|
|
await file.create(recursive: true);
|
|
}
|
|
await file.writeAsString(data);
|
|
return '';
|
|
} catch (e) {
|
|
return e.toString();
|
|
}
|
|
});
|
|
if (res.isNotEmpty) {
|
|
throw res;
|
|
}
|
|
}
|
|
|
|
Future<void> genValidateFileFormBytes(String path, Uint8List bytes) async {
|
|
final res = await Isolate.run<String>(() async {
|
|
try {
|
|
final file = File(path);
|
|
if (!await file.exists()) {
|
|
await file.create(recursive: true);
|
|
}
|
|
await file.writeAsBytes(bytes);
|
|
return '';
|
|
} catch (e) {
|
|
return e.toString();
|
|
}
|
|
});
|
|
if (res.isNotEmpty) {
|
|
throw res;
|
|
}
|
|
}
|
|
|
|
AndroidState getAndroidState() {
|
|
return AndroidState(
|
|
currentProfileName: config.currentProfile?.label ?? '',
|
|
onlyStatisticsProxy: config.appSetting.onlyStatisticsProxy,
|
|
stopText: appLocalizations.stop,
|
|
crashlytics: config.appSetting.crashlytics,
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> patchRawConfig({
|
|
required ClashConfig patchConfig,
|
|
}) async {
|
|
final profile = config.currentProfile;
|
|
if (profile == null) {
|
|
return {};
|
|
}
|
|
final profileId = profile.id;
|
|
final configMap = await getProfileConfig(profileId);
|
|
final rawConfig = await handleEvaluate(configMap);
|
|
final realPatchConfig = patchConfig.copyWith(
|
|
tun: patchConfig.tun.getRealTun(config.networkProps.routeMode),
|
|
);
|
|
rawConfig['external-controller'] = realPatchConfig.externalController.value;
|
|
rawConfig['external-ui'] = '';
|
|
rawConfig['interface-name'] = '';
|
|
rawConfig['external-ui-url'] = '';
|
|
rawConfig['tcp-concurrent'] = realPatchConfig.tcpConcurrent;
|
|
rawConfig['unified-delay'] = realPatchConfig.unifiedDelay;
|
|
rawConfig['ipv6'] = realPatchConfig.ipv6;
|
|
rawConfig['log-level'] = realPatchConfig.logLevel.name;
|
|
rawConfig['port'] = 0;
|
|
rawConfig['socks-port'] = 0;
|
|
rawConfig['keep-alive-interval'] = realPatchConfig.keepAliveInterval;
|
|
rawConfig['mixed-port'] = realPatchConfig.mixedPort;
|
|
rawConfig['port'] = realPatchConfig.port;
|
|
rawConfig['socks-port'] = realPatchConfig.socksPort;
|
|
rawConfig['redir-port'] = realPatchConfig.redirPort;
|
|
rawConfig['tproxy-port'] = realPatchConfig.tproxyPort;
|
|
rawConfig['find-process-mode'] = realPatchConfig.findProcessMode.name;
|
|
rawConfig['allow-lan'] = realPatchConfig.allowLan;
|
|
rawConfig['mode'] = realPatchConfig.mode.name;
|
|
if (rawConfig['tun'] == null) {
|
|
rawConfig['tun'] = {};
|
|
}
|
|
rawConfig['tun']['enable'] = realPatchConfig.tun.enable;
|
|
rawConfig['tun']['device'] = realPatchConfig.tun.device;
|
|
rawConfig['tun']['dns-hijack'] = realPatchConfig.tun.dnsHijack;
|
|
rawConfig['tun']['stack'] = realPatchConfig.tun.stack.name;
|
|
rawConfig['tun']['route-address'] = realPatchConfig.tun.routeAddress;
|
|
rawConfig['tun']['auto-route'] = realPatchConfig.tun.autoRoute;
|
|
rawConfig['geodata-loader'] = realPatchConfig.geodataLoader.name;
|
|
if (rawConfig['sniffer']?['sniff'] != null) {
|
|
for (final value in (rawConfig['sniffer']?['sniff'] as Map).values) {
|
|
if (value['ports'] != null && value['ports'] is List) {
|
|
value['ports'] =
|
|
value['ports']?.map((item) => item.toString()).toList() ?? [];
|
|
}
|
|
}
|
|
}
|
|
if (rawConfig['profile'] == null) {
|
|
rawConfig['profile'] = {};
|
|
}
|
|
if (rawConfig['proxy-providers'] != null) {
|
|
final proxyProviders = rawConfig['proxy-providers'] as Map;
|
|
for (final key in proxyProviders.keys) {
|
|
final proxyProvider = proxyProviders[key];
|
|
if (proxyProvider['type'] != 'http') {
|
|
continue;
|
|
}
|
|
if (proxyProvider['url'] != null) {
|
|
proxyProvider['path'] = await appPath.getProvidersFilePath(
|
|
profile.id,
|
|
'proxies',
|
|
proxyProvider['url'],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (rawConfig['rule-providers'] != null) {
|
|
final ruleProviders = rawConfig['rule-providers'] as Map;
|
|
for (final key in ruleProviders.keys) {
|
|
final ruleProvider = ruleProviders[key];
|
|
if (ruleProvider['type'] != 'http') {
|
|
continue;
|
|
}
|
|
if (ruleProvider['url'] != null) {
|
|
ruleProvider['path'] = await appPath.getProvidersFilePath(
|
|
profile.id,
|
|
'rules',
|
|
ruleProvider['url'],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
rawConfig['profile']['store-selected'] = false;
|
|
rawConfig['geox-url'] = realPatchConfig.geoXUrl.toJson();
|
|
rawConfig['global-ua'] = realPatchConfig.globalUa;
|
|
if (rawConfig['hosts'] == null) {
|
|
rawConfig['hosts'] = {};
|
|
}
|
|
for (final host in realPatchConfig.hosts.entries) {
|
|
rawConfig['hosts'][host.key] = host.value.splitByMultipleSeparators;
|
|
}
|
|
if (rawConfig['dns'] == null) {
|
|
rawConfig['dns'] = {};
|
|
}
|
|
final isEnableDns = rawConfig['dns']['enable'] == true;
|
|
final overrideDns = globalState.config.overrideDns;
|
|
if (overrideDns || !isEnableDns) {
|
|
final dns = switch (!isEnableDns) {
|
|
true => realPatchConfig.dns.copyWith(
|
|
nameserver: [...realPatchConfig.dns.nameserver, 'system://'],
|
|
),
|
|
false => realPatchConfig.dns,
|
|
};
|
|
rawConfig['dns'] = dns.toJson();
|
|
rawConfig['dns']['nameserver-policy'] = {};
|
|
for (final entry in dns.nameserverPolicy.entries) {
|
|
rawConfig['dns']['nameserver-policy'][entry.key] =
|
|
entry.value.splitByMultipleSeparators;
|
|
}
|
|
}
|
|
List rules = [];
|
|
if (rawConfig['rules'] != null) {
|
|
rules = rawConfig['rules'];
|
|
}
|
|
rawConfig.remove('rules');
|
|
|
|
final overrideData = profile.overrideData;
|
|
if (overrideData.enable && config.scriptProps.currentScript == null) {
|
|
if (overrideData.rule.type == OverrideRuleType.override) {
|
|
rules = overrideData.runningRule;
|
|
} else {
|
|
rules = [...overrideData.runningRule, ...rules];
|
|
}
|
|
}
|
|
rawConfig['rule'] = rules;
|
|
return rawConfig;
|
|
}
|
|
|
|
Future<Map<String, dynamic>> getProfileConfig(String profileId) async {
|
|
final configMap = await coreController.getConfig(profileId);
|
|
configMap['rules'] = configMap['rule'];
|
|
configMap.remove('rule');
|
|
return configMap;
|
|
}
|
|
|
|
Future<Map<String, dynamic>> handleEvaluate(
|
|
Map<String, dynamic> config,
|
|
) async {
|
|
final currentScript = globalState.config.scriptProps.currentScript;
|
|
if (currentScript == null) {
|
|
return config;
|
|
}
|
|
if (config['proxy-providers'] == null) {
|
|
config['proxy-providers'] = {};
|
|
}
|
|
final configJs = json.encode(config);
|
|
final runtime = getJavascriptRuntime();
|
|
final res = await runtime.evaluateAsync('''
|
|
${currentScript.content}
|
|
main($configJs)
|
|
''');
|
|
if (res.isError) {
|
|
throw res.stringResult;
|
|
}
|
|
final value = switch (res.rawResult is Pointer) {
|
|
true => runtime.convertValue<Map<String, dynamic>>(res),
|
|
false => Map<String, dynamic>.from(res.rawResult),
|
|
};
|
|
return value ?? config;
|
|
}
|
|
}
|
|
|
|
final globalState = GlobalState();
|
|
|
|
class DetectionState {
|
|
static DetectionState? _instance;
|
|
bool? _preIsStart;
|
|
Timer? _setTimeoutTimer;
|
|
CancelToken? cancelToken;
|
|
|
|
final state = ValueNotifier<NetworkDetectionState>(
|
|
const NetworkDetectionState(isLoading: true, ipInfo: null),
|
|
);
|
|
|
|
DetectionState._internal();
|
|
|
|
factory DetectionState() {
|
|
_instance ??= DetectionState._internal();
|
|
return _instance!;
|
|
}
|
|
|
|
void startCheck() {
|
|
debouncer.call(
|
|
FunctionTag.checkIp,
|
|
_checkIp,
|
|
duration: Duration(milliseconds: 1200),
|
|
);
|
|
}
|
|
|
|
void tryStartCheck() {
|
|
if (state.value.isLoading == false && state.value.ipInfo == null) {
|
|
startCheck();
|
|
}
|
|
}
|
|
|
|
Future<void> _checkIp() async {
|
|
final appState = globalState.appState;
|
|
final isInit = appState.isInit;
|
|
if (!isInit) return;
|
|
final isStart = appState.runTime != null;
|
|
if (_preIsStart == false &&
|
|
_preIsStart == isStart &&
|
|
state.value.ipInfo != null) {
|
|
return;
|
|
}
|
|
_clearSetTimeoutTimer();
|
|
state.value = state.value.copyWith(isLoading: true, ipInfo: null);
|
|
_preIsStart = isStart;
|
|
if (cancelToken != null) {
|
|
cancelToken!.cancel();
|
|
cancelToken = null;
|
|
}
|
|
cancelToken = CancelToken();
|
|
final res = await request.checkIp(cancelToken: cancelToken);
|
|
if (res.isError) {
|
|
state.value = state.value.copyWith(isLoading: true, ipInfo: null);
|
|
return;
|
|
}
|
|
final ipInfo = res.data;
|
|
if (ipInfo != null) {
|
|
state.value = state.value.copyWith(isLoading: false, ipInfo: ipInfo);
|
|
return;
|
|
}
|
|
_clearSetTimeoutTimer();
|
|
_setTimeoutTimer = Timer(const Duration(milliseconds: 300), () {
|
|
state.value = state.value.copyWith(isLoading: false, ipInfo: null);
|
|
});
|
|
}
|
|
|
|
void _clearSetTimeoutTimer() {
|
|
if (_setTimeoutTimer != null) {
|
|
_setTimeoutTimer?.cancel();
|
|
_setTimeoutTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
final detectionState = DetectionState();
|