refactor(app): rebuild core client, providers, models, database, managers, and window handling
Rework the application layer end to end: core IPC client, providers, models, database, managers, and shared utilities, with riverpod 3.4.2 and freezed 4.0.1. Keep a rejected profile selected, push the empty config to Core when a profile fails to build, and fall back to the default activity icon for package icons. Add opt-in proxy authentication that injects credentials into the generated profile and the runtime config, force-clears profile-provided skip-auth-prefixes so loopback stays covered, sends the credentials with the app's own proxied requests, and withholds the Android VPN system proxy while credentials are set. HelperCoreLease treats an unreachable Helper as confirmation once the OS reports the Core pid gone, and the lifecycle reconcile retries a parked stop, so the Core comes back after the Helper service dies. Launch arguments seed LinkManager so a clash:// link raises a silently started window, the access control app list is fetched on every page entry, the desktop header matches a standard title bar height, and the tray icons ship as resolution-aware assets scoped per platform. The version moves to 0.8.97+2026090101. Rule.parse mirrors mihomo's ParseRulePayload: the type is case-insensitive, MATCH rules keep no content field, a two-field rule is payload without a target except for comma-payload types whose lone field is the target, params are matched whole, and the wildcard and REMATCH-NAME types the core accepts get their own actions instead of collapsing into DOMAIN. Window routes show, hide and toggle through one serialized queue and defers the macOS accessory activation switch until a second after the last regular switch, so hotkey bursts no longer leave stray Dock icons. The hotkey manager registers through rust_api instead of hotkey_manager, the window header restyles the caption buttons after the native Windows title bar, and the proxies tab keeps its tab controller alive while the empty state animates in. Bootstrap reads the Android dynamic palette from the plugin channel without the deprecated CorePalette, dynamic_color moves to its material_ui release, navigator_resizable 3.1.0 fixes an assertion when popping mid-transition, reorderable_grid goes, and the SDK floor rises to 3.10. Changelog: Rework the app layer and window handling, and add proxy authentication
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 1018 B |
|
After Width: | Height: | Size: 960 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -3,19 +3,52 @@ import 'dart:io';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/window.dart';
|
||||
import 'package:fl_clash/bootstrap.dart';
|
||||
import 'package:fl_clash/common/system_dns.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/hotkey_manager.dart';
|
||||
import 'package:fl_clash/manager/manager.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'pages/pages.dart';
|
||||
|
||||
Widget buildManagerStack({
|
||||
required bool isDesktop,
|
||||
required Future<void> Function(List<ConnectivityResult> results)
|
||||
onConnectivityChanged,
|
||||
required Widget child,
|
||||
}) {
|
||||
final platformApp = isDesktop
|
||||
? WindowHeaderContainer(child: child)
|
||||
: VpnManager(child: child);
|
||||
final state = AppStateManager(
|
||||
child: CoreManager(
|
||||
child: ConnectivityManager(
|
||||
onConnectivityChanged: onConnectivityChanged,
|
||||
child: platformApp,
|
||||
),
|
||||
),
|
||||
);
|
||||
final platformState = isDesktop
|
||||
? WindowManager(
|
||||
child: TrayManager(
|
||||
child: HotKeyManager(child: ProxyManager(child: state)),
|
||||
),
|
||||
)
|
||||
: AndroidManager(child: TileManager(child: state));
|
||||
return AppEnvManager(
|
||||
child: LocaleManager(
|
||||
child: StatusManager(child: ThemeManager(child: platformState)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class Application extends ConsumerStatefulWidget {
|
||||
const Application({super.key});
|
||||
|
||||
@@ -36,10 +69,7 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
},
|
||||
);
|
||||
|
||||
ColorScheme _getAppColorScheme({
|
||||
required Brightness brightness,
|
||||
int? primaryColor,
|
||||
}) {
|
||||
ColorScheme _getAppColorScheme({required Brightness brightness}) {
|
||||
return ref.read(genColorSchemeProvider(brightness));
|
||||
}
|
||||
|
||||
@@ -49,86 +79,66 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
SystemNavigator.setFrameworkHandlesBack(true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
|
||||
if (globalState.navigatorKey.currentContext != null) {
|
||||
await globalState.attach();
|
||||
await bootstrap.attach();
|
||||
} else {
|
||||
exit(0);
|
||||
}
|
||||
_autoUpdateProfilesTask();
|
||||
_initLink();
|
||||
app?.initShortcuts();
|
||||
unawaited(app?.initShortcuts());
|
||||
});
|
||||
}
|
||||
|
||||
void _initLink() {
|
||||
linkManager.initAppLinksListen((url) async {
|
||||
final res = await globalState.showMessage(
|
||||
unawaited(window?.show());
|
||||
final message = currentAppLocalizations.createProfileFromUrlTip(url);
|
||||
final parts = message.split(url);
|
||||
final res = await dialogs.showMessage(
|
||||
title: currentAppLocalizations.addProfile,
|
||||
message: TextSpan(
|
||||
children: [
|
||||
TextSpan(text: currentAppLocalizations.doYouWantToPass),
|
||||
TextSpan(text: parts.first),
|
||||
TextSpan(
|
||||
text: ' $url ',
|
||||
text: url,
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
TextSpan(text: currentAppLocalizations.createProfile),
|
||||
if (parts.length > 1) TextSpan(text: parts.last),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (res != true) return;
|
||||
ref.read(profilesActionProvider.notifier).addProfileFormURL(url);
|
||||
unawaited(
|
||||
ref.read(profilesActionProvider.notifier).addProfileFormURL(url),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _autoUpdateProfilesTask() {
|
||||
_autoUpdateProfilesTaskTimer = Timer(const Duration(minutes: 20), () async {
|
||||
await ref.read(profilesActionProvider.notifier).autoUpdateProfiles();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_autoUpdateProfilesTask();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildPlatformState({required Widget child}) {
|
||||
if (system.isDesktop) {
|
||||
return WindowManager(
|
||||
child: TrayManager(
|
||||
child: HotKeyManager(child: ProxyManager(child: child)),
|
||||
),
|
||||
);
|
||||
Future<void> _handleConnectivityChanged(
|
||||
List<ConnectivityResult> results,
|
||||
) async {
|
||||
commonPrint.log('connectivityChanged ${results.toString()}');
|
||||
unawaited(systemDnsCoordinator?.resync() ?? Future.value());
|
||||
unawaited(ref.read(systemActionProvider.notifier).updateLocalIp());
|
||||
final hasVpn = results.contains(ConnectivityResult.vpn);
|
||||
if (_preHasVpn == hasVpn) {
|
||||
ref.read(checkIpNumProvider.notifier).add();
|
||||
}
|
||||
return AndroidManager(child: TileManager(child: child));
|
||||
}
|
||||
|
||||
Widget _buildState({required Widget child}) {
|
||||
return AppStateManager(
|
||||
child: CoreManager(
|
||||
child: ConnectivityManager(
|
||||
onConnectivityChanged: (results) async {
|
||||
commonPrint.log('connectivityChanged ${results.toString()}');
|
||||
ref.read(systemActionProvider.notifier).updateLocalIp();
|
||||
final hasVpn = results.contains(ConnectivityResult.vpn);
|
||||
if (_preHasVpn == hasVpn) {
|
||||
ref.read(checkIpNumProvider.notifier).add();
|
||||
}
|
||||
_preHasVpn = hasVpn;
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlatformApp({required Widget child}) {
|
||||
if (system.isDesktop) {
|
||||
return WindowHeaderContainer(child: child);
|
||||
}
|
||||
return VpnManager(child: child);
|
||||
}
|
||||
|
||||
Widget _buildApp({required Widget child}) {
|
||||
return StatusManager(child: ThemeManager(child: child));
|
||||
_preHasVpn = hasVpn;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -145,40 +155,40 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
onNavigationNotification: (_) => true,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
],
|
||||
builder: (_, child) {
|
||||
return AppEnvManager(
|
||||
child: _buildApp(
|
||||
child: _buildPlatformState(
|
||||
child: _buildState(child: _buildPlatformApp(child: child!)),
|
||||
builder: (context, child) {
|
||||
// The bridge's legacy Theme swaps in its own default IconTheme color,
|
||||
// which material_ui IconButton.filled reads as custom and loses onPrimary.
|
||||
// ignore: deprecated_member_use
|
||||
return MaterialUiCompatibilityBridge(
|
||||
child: IconTheme(
|
||||
data: Theme.of(context).iconTheme,
|
||||
child: buildManagerStack(
|
||||
isDesktop: system.isDesktop,
|
||||
onConnectivityChanged: _handleConnectivityChanged,
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
scrollBehavior: BaseScrollBehavior(),
|
||||
scrollBehavior: const BaseScrollBehavior(),
|
||||
title: appName,
|
||||
locale: utils.getLocaleForString(locale),
|
||||
locale: getLocaleForString(locale),
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
themeMode: themeProps.themeMode,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
colorScheme: _getAppColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primaryColor: themeProps.primaryColor,
|
||||
),
|
||||
),
|
||||
colorScheme: _getAppColorScheme(brightness: Brightness.light),
|
||||
).withAppShapes,
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
colorScheme: _getAppColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primaryColor: themeProps.primaryColor,
|
||||
).toPureBlack(themeProps.pureBlack),
|
||||
),
|
||||
).withAppShapes,
|
||||
home: child!,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:fl_clash/common/boot_guard.dart';
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/launch.dart';
|
||||
import 'package:fl_clash/common/migration.dart';
|
||||
import 'package:fl_clash/common/permission.dart';
|
||||
import 'package:fl_clash/common/tray.dart';
|
||||
import 'package:fl_clash/common/window.dart';
|
||||
import 'package:fl_clash/database/database.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/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/navigation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:material_color_utilities/palettes/tonal_palette.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class Bootstrap {
|
||||
static Bootstrap? _instance;
|
||||
|
||||
Bootstrap._internal();
|
||||
|
||||
factory Bootstrap() {
|
||||
_instance ??= Bootstrap._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
BootDecision _bootDecision = const BootDecision();
|
||||
|
||||
Future<ProviderContainer> init(int version) async {
|
||||
globalState.appEnv = const String.fromEnvironment(
|
||||
'APP_ENV',
|
||||
defaultValue: 'pre',
|
||||
);
|
||||
windowPort = window;
|
||||
trayPort = appTray;
|
||||
navigationPort = navigation;
|
||||
final dynamicColor = await _initDynamicColor();
|
||||
return _initData(version, dynamicColor);
|
||||
}
|
||||
|
||||
Future<DynamicColorSeeds> _initDynamicColor() async {
|
||||
TonalPalette? primaryPalette;
|
||||
Color? accentColor;
|
||||
try {
|
||||
primaryPalette = await _getSystemPrimaryPalette();
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'Failed to get core palette: $error',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
try {
|
||||
accentColor = await DynamicColorPlugin.getAccentColor();
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'Failed to get accent color: $error',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
return (
|
||||
lightSeed: primaryPalette != null ? Color(primaryPalette.get(40)) : null,
|
||||
darkSeed: primaryPalette != null ? Color(primaryPalette.get(80)) : null,
|
||||
accentColor: accentColor ?? const Color(defaultPrimaryColor),
|
||||
);
|
||||
}
|
||||
|
||||
Future<TonalPalette?> _getSystemPrimaryPalette() async {
|
||||
final raw = await DynamicColorPlugin.channel.invokeMethod<List<dynamic>>(
|
||||
DynamicColorPlugin.methodName,
|
||||
);
|
||||
if (raw == null || raw.length < TonalPalette.commonSize) {
|
||||
return null;
|
||||
}
|
||||
return TonalPalette.fromList(
|
||||
raw.sublist(0, TonalPalette.commonSize).cast<int>(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProviderContainer> _initData(
|
||||
int version,
|
||||
DynamicColorSeeds dynamicColor,
|
||||
) async {
|
||||
globalState.packageInfo = await PackageInfo.fromPlatform();
|
||||
var config = await migration.run();
|
||||
_bootDecision = await bootGuard.evaluate(
|
||||
profileId: config.currentProfileId,
|
||||
crashlyticsEnabled: config.appSettingProps.crashlytics,
|
||||
);
|
||||
if (_bootDecision.recovery == BootRecovery.clearProfile) {
|
||||
config = config.copyWith(currentProfileId: null);
|
||||
await preferences.saveConfig(config);
|
||||
}
|
||||
final appState = AppState(
|
||||
brightness: WidgetsBinding.instance.platformDispatcher.platformBrightness,
|
||||
version: version,
|
||||
viewSize: Size.zero,
|
||||
requests: FixedList(maxRequestsLength),
|
||||
logs: FixedList(maxLogsLength),
|
||||
traffics: FixedList(trafficSampleLength),
|
||||
totalTraffic: const Traffic(),
|
||||
systemUiOverlayStyle: const SystemUiOverlayStyle(),
|
||||
);
|
||||
final appStateOverrides = buildAppStateOverrides(appState);
|
||||
final configOverrides = buildConfigOverrides(config);
|
||||
final container = ProviderContainer(
|
||||
overrides: [...appStateOverrides, ...configOverrides],
|
||||
);
|
||||
globalState.container = container;
|
||||
container
|
||||
.read(dynamicColorProvider.notifier)
|
||||
.seed(
|
||||
lightSeed: dynamicColor.lightSeed,
|
||||
darkSeed: dynamicColor.darkSeed,
|
||||
accentColor: dynamicColor.accentColor,
|
||||
);
|
||||
final profiles = await database.profilesDao.query().get();
|
||||
container.read(profilesProvider.notifier).setAndReorder(profiles);
|
||||
await AppLocalizations.load(
|
||||
getLocaleForString(config.appSettingProps.locale) ??
|
||||
WidgetsBinding.instance.platformDispatcher.locale,
|
||||
);
|
||||
await window?.init(version, config.windowProps);
|
||||
if (system.isAndroid) {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
Future<void> attach() async {
|
||||
if (globalState.isAttach == true) {
|
||||
return;
|
||||
}
|
||||
await _initApp();
|
||||
globalState.isAttach = true;
|
||||
}
|
||||
|
||||
ProviderContainer get _container => globalState.container;
|
||||
|
||||
Future<void> _initApp() async {
|
||||
unawaited(_container.read(systemActionProvider.notifier).updateTray());
|
||||
unawaited(
|
||||
_container.read(profilesActionProvider.notifier).autoUpdateProfiles(),
|
||||
);
|
||||
unawaited(_container.read(commonActionProvider.notifier).autoCheckUpdate());
|
||||
unawaited(
|
||||
autoLaunch?.updateStatus(_container.read(appSettingProvider).autoLaunch),
|
||||
);
|
||||
if (!_container.read(appSettingProvider).silentLaunch) {
|
||||
unawaited(window?.show());
|
||||
} else {
|
||||
unawaited(window?.hide());
|
||||
}
|
||||
await _handleFailedPreference();
|
||||
await _handlerDisclaimer();
|
||||
await _showCrashRecoveryTip();
|
||||
await _showCrashlyticsTip();
|
||||
await _container.read(coreActionProvider.notifier).startCore();
|
||||
if (!_bootDecision.isDegraded) {
|
||||
await _container.read(setupActionProvider.notifier).initStatus();
|
||||
}
|
||||
_container.read(initProvider.notifier).value = true;
|
||||
await bootGuard.markRunning();
|
||||
permissions.check(_container.read);
|
||||
}
|
||||
|
||||
Future<void> _showCrashRecoveryTip() async {
|
||||
switch (_bootDecision.recovery) {
|
||||
case BootRecovery.none:
|
||||
return;
|
||||
case BootRecovery.skipAutoSetup:
|
||||
await dialogs.showMessage(
|
||||
title: currentAppLocalizations.launchInterrupted,
|
||||
cancelable: false,
|
||||
dismissible: false,
|
||||
message: TextSpan(text: currentAppLocalizations.launchInterruptedTip),
|
||||
);
|
||||
case BootRecovery.clearProfile:
|
||||
await dialogs.showMessage(
|
||||
title: currentAppLocalizations.crashDetected,
|
||||
cancelable: false,
|
||||
dismissible: false,
|
||||
message: TextSpan(
|
||||
text: currentAppLocalizations.crashDetectedTip(_failedProfileLabel),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String get _failedProfileLabel {
|
||||
final profileId = _bootDecision.failedProfileId;
|
||||
if (profileId == null) {
|
||||
return '';
|
||||
}
|
||||
final profile = _container.read(profilesProvider).getProfile(profileId);
|
||||
return profile?.label.takeFirstValid(['$profileId']) ?? '$profileId';
|
||||
}
|
||||
|
||||
Future<void> _handleFailedPreference() async {
|
||||
if (await preferences.isInit) return;
|
||||
final res = await dialogs.showMessage(
|
||||
title: currentAppLocalizations.tip,
|
||||
message: TextSpan(text: currentAppLocalizations.cacheCorrupt),
|
||||
);
|
||||
if (res == true) {
|
||||
final file = File(await appPath.sharedPreferencesPath);
|
||||
await file.safeDelete();
|
||||
}
|
||||
// Saving here would rewrite the preferences file the user just chose to delete.
|
||||
await _container.read(systemActionProvider.notifier).handleExit(false);
|
||||
}
|
||||
|
||||
Future<void> _showCrashlyticsTip() async {
|
||||
if (!system.isAndroid) return;
|
||||
if (_container.read(
|
||||
appSettingProvider.select((state) => state.crashlyticsTip),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
await dialogs.showMessage(
|
||||
title: currentAppLocalizations.dataCollectionTip,
|
||||
cancelable: false,
|
||||
message: TextSpan(text: currentAppLocalizations.dataCollectionContent),
|
||||
);
|
||||
_container
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(crashlyticsTip: true));
|
||||
}
|
||||
|
||||
Future<void> _handlerDisclaimer() async {
|
||||
if (_container.read(
|
||||
appSettingProvider.select((state) => state.disclaimerAccepted),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final isDisclaimerAccepted = await dialogs.showDisclaimer();
|
||||
if (!isDisclaimerAccepted) {
|
||||
await _container.read(systemActionProvider.notifier).handleExit();
|
||||
}
|
||||
_container
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(disclaimerAccepted: true));
|
||||
}
|
||||
}
|
||||
|
||||
final bootstrap = Bootstrap();
|
||||
@@ -1,3 +1,66 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fl_clash/core/desktop/launch_policy.dart';
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
final currentAppLocalizations = AppLocalizations.current;
|
||||
|
||||
String? networkErrorMessage(Object error, AppLocalizations appLocalizations) {
|
||||
if (error case CoreMethodException(:final code)) {
|
||||
return switch (code) {
|
||||
'request_bad_response' => appLocalizations.networkException,
|
||||
'request_error' => appLocalizations.unknownNetworkError,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
if (error is DioException) {
|
||||
return error.type == DioExceptionType.badResponse
|
||||
? appLocalizations.networkException
|
||||
: appLocalizations.unknownNetworkError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? coreLaunchBlockedMessage(
|
||||
Object error,
|
||||
AppLocalizations appLocalizations,
|
||||
) {
|
||||
if (!isPolicyBlockedLaunch(error)) {
|
||||
return null;
|
||||
}
|
||||
return switch (smartAppControlStateReader()) {
|
||||
SmartAppControlState.on || SmartAppControlState.evaluation =>
|
||||
appLocalizations.coreBlockedBySmartAppControlTip,
|
||||
_ => appLocalizations.coreBlockedByPolicyTip(launchOsError(error)!),
|
||||
};
|
||||
}
|
||||
|
||||
String userFacingErrorMessage(Object error, AppLocalizations appLocalizations) {
|
||||
return networkErrorMessage(error, appLocalizations) ??
|
||||
coreLaunchBlockedMessage(error, appLocalizations) ??
|
||||
switch (error) {
|
||||
CoreMethodException(:final message) => message,
|
||||
_ => error.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
Locale? getLocaleForString(String? localString) {
|
||||
if (localString == null) return null;
|
||||
final localSplit = localString.split('_');
|
||||
if (localSplit.length == 1) {
|
||||
return Locale(localSplit[0]);
|
||||
}
|
||||
if (localSplit.length == 2) {
|
||||
return Locale(localSplit[0], localSplit[1]);
|
||||
}
|
||||
if (localSplit.length == 3) {
|
||||
return Locale.fromSubtags(
|
||||
languageCode: localSplit[0],
|
||||
scriptCode: localSplit[1],
|
||||
countryCode: localSplit[2],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:fl_clash/common/provider_reader.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
|
||||
abstract interface class WindowPort {
|
||||
Future<WindowProps?> captureNormalGeometry(WindowProps current);
|
||||
|
||||
Future<void> show();
|
||||
|
||||
Future<void> hide();
|
||||
|
||||
Future<void> toggle();
|
||||
|
||||
Future<void> close();
|
||||
|
||||
void forceExit();
|
||||
}
|
||||
|
||||
abstract interface class TrayPort {
|
||||
Future<void> shutdown();
|
||||
|
||||
Future<void> update({
|
||||
required TrayState trayState,
|
||||
required Traffic traffic,
|
||||
required ProviderReader read,
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class NavigationPort {
|
||||
List<NavigationItem> getItems({bool openLogs, bool hasProxies});
|
||||
}
|
||||
|
||||
WindowPort? windowPort;
|
||||
TrayPort? trayPort;
|
||||
NavigationPort? navigationPort;
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
extension ArchiveExt on Archive {
|
||||
void addDirectoryToArchive(String dirPath, String parentPath) {
|
||||
final dir = Directory(dirPath);
|
||||
if (!dir.existsSync()) {
|
||||
return;
|
||||
}
|
||||
final entities = dir.listSync(recursive: false);
|
||||
for (final entity in entities) {
|
||||
final relativePath = relative(entity.path, from: parentPath);
|
||||
if (entity is File) {
|
||||
final data = entity.readAsBytesSync();
|
||||
final archiveFile = ArchiveFile(relativePath, data.length, data);
|
||||
addFile(archiveFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/preferences.dart';
|
||||
import 'package:fl_clash/common/print.dart';
|
||||
import 'package:fl_clash/common/system.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
|
||||
class BootGuard {
|
||||
final bool _supported;
|
||||
final Future<BootRecord?> Function() _readRecord;
|
||||
final Future<void> Function(BootRecord record) _writeRecord;
|
||||
final Future<AppExitInfo?> Function() _readExitInfo;
|
||||
final Future<bool> Function() _readCrashReport;
|
||||
final int Function() _now;
|
||||
|
||||
BootDecision _decision = const BootDecision();
|
||||
|
||||
BootGuard({
|
||||
bool? supported,
|
||||
Future<BootRecord?> Function()? readRecord,
|
||||
Future<void> Function(BootRecord record)? writeRecord,
|
||||
Future<AppExitInfo?> Function()? readExitInfo,
|
||||
Future<bool> Function()? readCrashReport,
|
||||
int Function()? now,
|
||||
}) : _supported = supported ?? system.isAndroid,
|
||||
_readRecord = readRecord ?? preferences.getBootRecord,
|
||||
_writeRecord = writeRecord ?? preferences.saveBootRecord,
|
||||
_readExitInfo = readExitInfo ?? system.lastExitInfo,
|
||||
_readCrashReport = readCrashReport ?? system.didCrashOnPreviousExecution,
|
||||
_now = now ?? _currentMilliseconds;
|
||||
|
||||
static int _currentMilliseconds() => DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
BootDecision get decision => _decision;
|
||||
|
||||
Future<BootDecision> evaluate({
|
||||
required int? profileId,
|
||||
required bool crashlyticsEnabled,
|
||||
}) async {
|
||||
if (!_supported) {
|
||||
return _decision;
|
||||
}
|
||||
final record = await _readRecord();
|
||||
final exitInfo = await _readExitInfo();
|
||||
final crashReported = crashlyticsEnabled && await _readCrashReport();
|
||||
final decision = resolveBootDecision(
|
||||
record: record,
|
||||
exitInfo: exitInfo,
|
||||
crashReported: crashReported,
|
||||
);
|
||||
if (decision.isDegraded) {
|
||||
commonPrint.log(
|
||||
'Previous launch did not finish: $decision',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
await _writeRecord(
|
||||
BootRecord(
|
||||
stage: BootStage.starting,
|
||||
profileId: decision.recovery == BootRecovery.clearProfile
|
||||
? null
|
||||
: profileId,
|
||||
startedAt: _now(),
|
||||
failureCount: decision.failureCount,
|
||||
lastFailedProfileId:
|
||||
decision.failedProfileId ?? record?.lastFailedProfileId,
|
||||
handledExitAt: max(
|
||||
record?.handledExitAt ?? 0,
|
||||
exitInfo?.timestamp ?? 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
_decision = decision;
|
||||
return decision;
|
||||
}
|
||||
|
||||
Future<void> markRunning() async {
|
||||
if (!_supported) {
|
||||
return;
|
||||
}
|
||||
final record = await _readRecord();
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
await _writeRecord(
|
||||
BootRecord(
|
||||
stage: BootStage.running,
|
||||
profileId: record.profileId,
|
||||
startedAt: record.startedAt,
|
||||
failureCount: _decision.isDegraded ? record.failureCount : 0,
|
||||
lastFailedProfileId: record.lastFailedProfileId,
|
||||
handledExitAt: record.handledExitAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markClosed() async {
|
||||
if (!_supported) {
|
||||
return;
|
||||
}
|
||||
final record = await _readRecord();
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
await _writeRecord(
|
||||
BootRecord(
|
||||
profileId: record.profileId,
|
||||
startedAt: record.startedAt,
|
||||
lastFailedProfileId: record.lastFailedProfileId,
|
||||
handledExitAt: record.handledExitAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final bootGuard = BootGuard();
|
||||
@@ -0,0 +1,225 @@
|
||||
const crashRecoveryClearThreshold = 2;
|
||||
|
||||
enum AppExitReason {
|
||||
unknown,
|
||||
exitSelf,
|
||||
signaled,
|
||||
lowMemory,
|
||||
crash,
|
||||
crashNative,
|
||||
anr,
|
||||
initializationFailure,
|
||||
permissionChange,
|
||||
excessiveResourceUsage,
|
||||
userRequested,
|
||||
userStopped,
|
||||
dependencyDied,
|
||||
other,
|
||||
freezer,
|
||||
packageStateChange,
|
||||
packageUpdated;
|
||||
|
||||
static AppExitReason fromCode(Object? code) => switch (code) {
|
||||
1 => exitSelf,
|
||||
2 => signaled,
|
||||
3 => lowMemory,
|
||||
4 => crash,
|
||||
5 => crashNative,
|
||||
6 => anr,
|
||||
7 => initializationFailure,
|
||||
8 => permissionChange,
|
||||
9 => excessiveResourceUsage,
|
||||
10 => userRequested,
|
||||
11 => userStopped,
|
||||
12 => dependencyDied,
|
||||
13 => other,
|
||||
14 => freezer,
|
||||
15 => packageStateChange,
|
||||
16 => packageUpdated,
|
||||
_ => unknown,
|
||||
};
|
||||
|
||||
bool get isCrash => switch (this) {
|
||||
crash || crashNative || anr || initializationFailure => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
bool get isExternalStop => switch (this) {
|
||||
exitSelf ||
|
||||
signaled ||
|
||||
lowMemory ||
|
||||
permissionChange ||
|
||||
excessiveResourceUsage ||
|
||||
userRequested ||
|
||||
userStopped ||
|
||||
dependencyDied ||
|
||||
freezer ||
|
||||
packageStateChange ||
|
||||
packageUpdated => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
class AppExitInfo {
|
||||
final AppExitReason reason;
|
||||
final int timestamp;
|
||||
final String? description;
|
||||
|
||||
const AppExitInfo({
|
||||
required this.reason,
|
||||
required this.timestamp,
|
||||
this.description,
|
||||
});
|
||||
|
||||
static AppExitInfo? fromJson(Object? json) {
|
||||
if (json is! Map) {
|
||||
return null;
|
||||
}
|
||||
final timestamp = json['timestamp'];
|
||||
if (timestamp is! int) {
|
||||
return null;
|
||||
}
|
||||
final description = json['description'];
|
||||
return AppExitInfo(
|
||||
reason: AppExitReason.fromCode(json['reason']),
|
||||
timestamp: timestamp,
|
||||
description: description is String ? description : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppExitInfo(${reason.name}, $timestamp, ${description ?? '-'})';
|
||||
}
|
||||
|
||||
enum BootStage { starting, running }
|
||||
|
||||
class BootRecord {
|
||||
final BootStage? stage;
|
||||
final int? profileId;
|
||||
final int startedAt;
|
||||
final int failureCount;
|
||||
final int? lastFailedProfileId;
|
||||
final int handledExitAt;
|
||||
|
||||
const BootRecord({
|
||||
this.stage,
|
||||
this.profileId,
|
||||
this.startedAt = 0,
|
||||
this.failureCount = 0,
|
||||
this.lastFailedProfileId,
|
||||
this.handledExitAt = 0,
|
||||
});
|
||||
|
||||
static BootRecord? fromJson(Object? json) {
|
||||
if (json is! Map) {
|
||||
return null;
|
||||
}
|
||||
final stage = json['stage'];
|
||||
return BootRecord(
|
||||
stage: BootStage.values.where((value) => value.name == stage).firstOrNull,
|
||||
profileId: json['profileId'] is int ? json['profileId'] as int : null,
|
||||
startedAt: json['startedAt'] is int ? json['startedAt'] as int : 0,
|
||||
failureCount: json['failureCount'] is int
|
||||
? json['failureCount'] as int
|
||||
: 0,
|
||||
lastFailedProfileId: json['lastFailedProfileId'] is int
|
||||
? json['lastFailedProfileId'] as int
|
||||
: null,
|
||||
handledExitAt: json['handledExitAt'] is int
|
||||
? json['handledExitAt'] as int
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'stage': stage?.name,
|
||||
'profileId': profileId,
|
||||
'startedAt': startedAt,
|
||||
'failureCount': failureCount,
|
||||
'lastFailedProfileId': lastFailedProfileId,
|
||||
'handledExitAt': handledExitAt,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BootRecord &&
|
||||
other.stage == stage &&
|
||||
other.profileId == profileId &&
|
||||
other.startedAt == startedAt &&
|
||||
other.failureCount == failureCount &&
|
||||
other.lastFailedProfileId == lastFailedProfileId &&
|
||||
other.handledExitAt == handledExitAt;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
stage,
|
||||
profileId,
|
||||
startedAt,
|
||||
failureCount,
|
||||
lastFailedProfileId,
|
||||
handledExitAt,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BootRecord(${stage?.name ?? 'closed'}, profile: $profileId, '
|
||||
'failures: $failureCount)';
|
||||
}
|
||||
|
||||
enum BootRecovery { none, skipAutoSetup, clearProfile }
|
||||
|
||||
class BootDecision {
|
||||
final BootRecovery recovery;
|
||||
final int failureCount;
|
||||
final int? failedProfileId;
|
||||
final bool crashConfirmed;
|
||||
final AppExitReason? exitReason;
|
||||
|
||||
const BootDecision({
|
||||
this.recovery = BootRecovery.none,
|
||||
this.failureCount = 0,
|
||||
this.failedProfileId,
|
||||
this.crashConfirmed = false,
|
||||
this.exitReason,
|
||||
});
|
||||
|
||||
bool get isDegraded => recovery != BootRecovery.none;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BootDecision(${recovery.name}, failures: $failureCount, '
|
||||
'profile: $failedProfileId, confirmed: $crashConfirmed, '
|
||||
'exit: ${exitReason?.name ?? '-'})';
|
||||
}
|
||||
|
||||
BootDecision resolveBootDecision({
|
||||
required BootRecord? record,
|
||||
required AppExitInfo? exitInfo,
|
||||
required bool crashReported,
|
||||
}) {
|
||||
if (record == null || record.stage != BootStage.starting) {
|
||||
return const BootDecision();
|
||||
}
|
||||
final matchesLastRun =
|
||||
exitInfo != null &&
|
||||
exitInfo.timestamp > record.handledExitAt &&
|
||||
exitInfo.timestamp >= record.startedAt;
|
||||
final reason = matchesLastRun ? exitInfo.reason : null;
|
||||
if (reason != null && reason.isExternalStop) {
|
||||
return BootDecision(exitReason: reason);
|
||||
}
|
||||
final failureCount = record.failureCount + 1;
|
||||
final shouldClear =
|
||||
failureCount >= crashRecoveryClearThreshold && record.profileId != null;
|
||||
return BootDecision(
|
||||
recovery: shouldClear
|
||||
? BootRecovery.clearProfile
|
||||
: BootRecovery.skipAutoSetup,
|
||||
failureCount: failureCount,
|
||||
failedProfileId: record.profileId,
|
||||
crashConfirmed: (reason?.isCrash ?? false) || crashReported,
|
||||
exitReason: reason,
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,6 @@ extension CacheManagerExt on CacheManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
streamController.close();
|
||||
unawaited(streamController.close());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/changelog.dart';
|
||||
|
||||
import 'common.dart';
|
||||
|
||||
const releaseChangelogJsonMarker = '<!-- flclash:changelog:json';
|
||||
|
||||
const _releaseChangelogJsonEndMarker = '-->';
|
||||
|
||||
String changelogGroupTitle(
|
||||
AppLocalizations appLocalizations,
|
||||
ChangelogType type,
|
||||
) => switch (type) {
|
||||
ChangelogType.breaking => appLocalizations.changelogBreaking,
|
||||
ChangelogType.feat => appLocalizations.changelogFeatures,
|
||||
ChangelogType.fix => appLocalizations.changelogFixes,
|
||||
ChangelogType.perf => appLocalizations.changelogPerformance,
|
||||
ChangelogType.revert => appLocalizations.changelogReverts,
|
||||
ChangelogType.unknown => '',
|
||||
};
|
||||
|
||||
ChangelogVersion? parseReleaseChangelog(String? body) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
final begin = body.indexOf(releaseChangelogJsonMarker);
|
||||
if (begin < 0) {
|
||||
return null;
|
||||
}
|
||||
final start = begin + releaseChangelogJsonMarker.length;
|
||||
final end = body.indexOf(_releaseChangelogJsonEndMarker, start);
|
||||
if (end < 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final changelog = Changelog.fromJson(
|
||||
jsonDecode(body.substring(start, end)) as Map<String, dynamic>,
|
||||
);
|
||||
if (!changelog.isSupported) {
|
||||
commonPrint.log(
|
||||
'changelog schema ${changelog.schemaVersion} is not supported',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return changelog.versions.firstOrNull;
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'changelog decode failed ${compactError(error)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
extension ColorExtension on Color {
|
||||
Color get opacity80 {
|
||||
@@ -101,15 +101,6 @@ extension ColorExtension on Color {
|
||||
factor,
|
||||
)!;
|
||||
}
|
||||
|
||||
Color blendLighten(BuildContext context, {double factor = 0.1}) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
return Color.lerp(
|
||||
this,
|
||||
brightness == Brightness.dark ? Colors.black : Colors.white,
|
||||
factor,
|
||||
)!;
|
||||
}
|
||||
}
|
||||
|
||||
extension ColorSchemeExtension on ColorScheme {
|
||||
@@ -120,3 +111,10 @@ extension ColorSchemeExtension on ColorScheme {
|
||||
)
|
||||
: this;
|
||||
}
|
||||
|
||||
Color? getDelayColor(int? delay) {
|
||||
if (delay == null) return null;
|
||||
if (delay < 0) return Colors.red;
|
||||
if (delay < 600) return Colors.green;
|
||||
return const Color(0xFFC57F0A);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export 'app_localizations.dart';
|
||||
export 'app_ports.dart';
|
||||
export 'changelog.dart';
|
||||
export 'color.dart';
|
||||
export 'compute.dart';
|
||||
export 'constant.dart';
|
||||
export 'context.dart';
|
||||
export 'converter.dart';
|
||||
export 'datetime.dart';
|
||||
export 'dialog.dart';
|
||||
export 'exception.dart';
|
||||
export 'file.dart';
|
||||
export 'fixed.dart';
|
||||
export 'function.dart';
|
||||
@@ -16,32 +20,32 @@ export 'input_limits.dart';
|
||||
export 'iterable.dart';
|
||||
export 'javascript.dart';
|
||||
export 'keyboard.dart';
|
||||
export 'launch.dart';
|
||||
export 'l10n_labels.dart';
|
||||
export 'layout.dart';
|
||||
export 'link.dart';
|
||||
export 'lock.dart';
|
||||
export 'measure.dart';
|
||||
export 'mixin.dart';
|
||||
export 'navigation.dart';
|
||||
export 'navigator.dart';
|
||||
export 'network.dart';
|
||||
export 'num.dart';
|
||||
export 'package.dart';
|
||||
export 'path.dart';
|
||||
export 'permission.dart';
|
||||
export 'picker.dart';
|
||||
export 'preferences.dart';
|
||||
export 'print.dart';
|
||||
export 'protocol.dart';
|
||||
export 'provider_reader.dart';
|
||||
export 'proxy.dart';
|
||||
export 'render.dart';
|
||||
export 'request.dart';
|
||||
export 'scroll.dart';
|
||||
export 'shape.dart';
|
||||
export 'snowflake.dart';
|
||||
export 'string.dart';
|
||||
export 'system.dart';
|
||||
export 'task.dart';
|
||||
export 'task_pool.dart';
|
||||
export 'text.dart';
|
||||
export 'tray.dart';
|
||||
export 'utils.dart';
|
||||
export 'window.dart';
|
||||
export 'webdav.dart';
|
||||
export 'yaml.dart';
|
||||
|
||||
@@ -91,6 +91,10 @@ SelectedProxyState computeRealSelectedProxyState(
|
||||
);
|
||||
}
|
||||
|
||||
String delayTestKey(String testUrl, String proxyName) {
|
||||
return '$testUrl\u0000$proxyName';
|
||||
}
|
||||
|
||||
DelayState computeProxyDelayState({
|
||||
required String proxyName,
|
||||
required String testUrl,
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const appName = 'FlClash';
|
||||
const appHelperService = 'FlClashHelperService';
|
||||
@@ -19,6 +19,7 @@ const packageName = 'com.follow.clash';
|
||||
final unixSocketPath = '/tmp/FlClashSocket_${Random().nextInt(10000)}.sock';
|
||||
final windowsPipeName = '\\\\.\\pipe\\FlClashCore_${_randomPipeId()}';
|
||||
const helperPort = 47890;
|
||||
const helperSocketPath = '/run/flclash/helper.sock';
|
||||
const helperProtocolVersionHeader = 'x-flclash-helper-protocol';
|
||||
const helperProtocolVersion = '6';
|
||||
const maxTextScale = 1.4;
|
||||
@@ -47,13 +48,19 @@ String _randomPipeId() {
|
||||
|
||||
final defaultTextScaleFactor =
|
||||
WidgetsBinding.instance.platformDispatcher.textScaleFactor;
|
||||
const httpTimeoutDuration = Duration(milliseconds: 5000);
|
||||
|
||||
/// Keep at or below the Core's delay-test concurrency (`mBatch` in
|
||||
/// core/common.go). Surplus requests queue inside the Core behind a full wave
|
||||
/// of 5s timeouts, which no RPC timeout can cover.
|
||||
const maxConcurrentDelayTests = 50;
|
||||
const moreDuration = Duration(milliseconds: 100);
|
||||
/// How long the Core may spend on one delay test. It spends this twice in the
|
||||
/// worst case - once queueing for a slot, once on the probe itself - so the
|
||||
/// guard below has to outlast twice this value.
|
||||
const delayTestTimeoutDuration = Duration(seconds: 8);
|
||||
|
||||
const delayTestGuardDuration = Duration(seconds: 30);
|
||||
|
||||
const coreConnectionWaitDuration = Duration(seconds: 10);
|
||||
|
||||
/// Keep at or below the Core's delay-test concurrency (`delayTestConcurrency`
|
||||
/// in core/common.go).
|
||||
const maxConcurrentDelayTests = 16;
|
||||
const animateDuration = Duration(milliseconds: 100);
|
||||
const midDuration = Duration(milliseconds: 200);
|
||||
const commonDuration = Duration(milliseconds: 300);
|
||||
@@ -62,18 +69,22 @@ const MMDB = 'GEOIP.metadb';
|
||||
const ASN = 'ASN.mmdb';
|
||||
const GEOIP = 'GEOIP.dat';
|
||||
const GEOSITE = 'GEOSITE.dat';
|
||||
final double kHeaderHeight = system.isDesktop
|
||||
? !system.isMacOS
|
||||
? 40
|
||||
: 28
|
||||
: 0;
|
||||
final double kHeaderHeight = getWindowHeaderHeight(
|
||||
isDesktop: system.isDesktop,
|
||||
isMacOS: system.isMacOS,
|
||||
);
|
||||
const profilesDirectoryName = 'profiles';
|
||||
const providersDirectoryName = 'providers';
|
||||
const proxiesProviderDirectoryName = 'proxies';
|
||||
const rulesProviderDirectoryName = 'rules';
|
||||
const localhost = '127.0.0.1';
|
||||
const clashConfigKey = 'clash_config';
|
||||
const configKey = 'config';
|
||||
const systemDnsRecordKey = 'system_dns_record';
|
||||
const bootRecordKey = 'boot_record';
|
||||
const defaultSystemDnsFallback = '223.5.5.5';
|
||||
const double dialogCommonWidth = 300;
|
||||
const repository = 'chen08209/FlClash';
|
||||
const defaultExternalController = '127.0.0.1:9090';
|
||||
const maxMobileWidth = 600;
|
||||
const maxLaptopWidth = 840;
|
||||
const defaultTestUrl = 'https://www.gstatic.com/generate_204';
|
||||
@@ -83,37 +94,17 @@ final commonFilter = ImageFilter.blur(
|
||||
tileMode: TileMode.clamp,
|
||||
);
|
||||
|
||||
const listEquality = ListEquality();
|
||||
const navigationItemListEquality = ListEquality<NavigationItem>();
|
||||
const trackerInfoListEquality = ListEquality<TrackerInfo>();
|
||||
const stringListEquality = ListEquality<String>();
|
||||
const intListEquality = ListEquality<int>();
|
||||
const logListEquality = ListEquality<Log>();
|
||||
const groupListEquality = ListEquality<Group>();
|
||||
const ruleListEquality = ListEquality<Rule>();
|
||||
const scriptListEquality = ListEquality<Script>();
|
||||
const externalProviderListEquality = ListEquality<ExternalProvider>();
|
||||
const packageListEquality = ListEquality<Package>();
|
||||
const profileListEquality = ListEquality<Profile>();
|
||||
const proxyGroupsEquality = ListEquality<ProxyGroup>();
|
||||
const hotKeyActionListEquality = ListEquality<HotKeyAction>();
|
||||
const stringAndStringMapEquality = MapEquality<String, String>();
|
||||
const stringAndStringMapEntryListEquality =
|
||||
ListEquality<MapEntry<String, String>>();
|
||||
const stringAndStringMapEntryIterableEquality =
|
||||
IterableEquality<MapEntry<String, String>>();
|
||||
const stringAndObjectMapEntryIterableEquality =
|
||||
IterableEquality<MapEntry<String, Object?>>();
|
||||
const delayMapEquality = MapEquality<String, Map<String, int?>>();
|
||||
const stringSetEquality = SetEquality<String>();
|
||||
const keyboardModifierListEquality = SetEquality<KeyboardModifier>();
|
||||
|
||||
const viewModeColumnsMap = {
|
||||
ViewMode.mobile: [2, 1],
|
||||
ViewMode.laptop: [3, 2],
|
||||
ViewMode.desktop: [4, 3],
|
||||
};
|
||||
|
||||
const proxiesListStoreKey = PageStorageKey<String>('proxies_list');
|
||||
const toolsStoreKey = PageStorageKey<String>('tools');
|
||||
const profilesStoreKey = PageStorageKey<String>('profiles');
|
||||
@@ -125,11 +116,12 @@ double getWidgetHeight(num lines) {
|
||||
return max(lines * (80.ap + space) - space, 0);
|
||||
}
|
||||
|
||||
const maxLength = 1000;
|
||||
const maxLogsLength = 5000;
|
||||
const maxRequestsLength = 2000;
|
||||
const pausedMaxLogsLength = maxLogsLength * 2;
|
||||
const pausedMaxRequestsLength = maxRequestsLength * 2;
|
||||
|
||||
const mainIsolate = 'FlClashMainIsolate';
|
||||
|
||||
const serviceIsolate = 'FlClashServiceIsolate';
|
||||
const trafficSampleLength = 30;
|
||||
|
||||
const defaultPrimaryColors = [
|
||||
0xFF795548,
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/manager.dart';
|
||||
import 'package:fl_clash/manager/status_manager.dart';
|
||||
import 'package:fl_clash/models/state.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/scaffold.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
extension BuildContextExtension on BuildContext {
|
||||
CommonScaffoldState? get commonScaffoldState {
|
||||
return findAncestorStateOfType<CommonScaffoldState>();
|
||||
}
|
||||
|
||||
bool get isMobileView {
|
||||
return ProviderScope.containerOf(
|
||||
this,
|
||||
listen: false,
|
||||
).read(isMobileViewProvider);
|
||||
}
|
||||
|
||||
void safeNestedPop<T extends Object?>([T? result]) {
|
||||
final nestedPop = SheetProvider.of(this)?.nestedNavigatorPop;
|
||||
if (nestedPop != null) {
|
||||
@@ -30,15 +40,20 @@ extension BuildContextExtension on BuildContext {
|
||||
}
|
||||
}
|
||||
|
||||
void showNotifier(String text, {MessageActionState? actionState}) {
|
||||
void showNotifier(
|
||||
String text, {
|
||||
MessageLevel level = MessageLevel.info,
|
||||
MessageActionState? actionState,
|
||||
}) {
|
||||
return findAncestorStateOfType<StatusManagerState>()?.message(
|
||||
text,
|
||||
level: level,
|
||||
actionState: actionState,
|
||||
);
|
||||
}
|
||||
|
||||
void showSnackBar(String message, {SnackBarAction? action}) {
|
||||
final width = viewWidth;
|
||||
final width = MediaQuery.sizeOf(this).width;
|
||||
EdgeInsets margin;
|
||||
if (width < 600) {
|
||||
margin = const EdgeInsets.only(bottom: 16, right: 16, left: 16);
|
||||
@@ -56,16 +71,13 @@ extension BuildContextExtension on BuildContext {
|
||||
);
|
||||
}
|
||||
|
||||
Size get appSize {
|
||||
return MediaQuery.of(this).size;
|
||||
}
|
||||
|
||||
double get viewWidth {
|
||||
return appSize.width;
|
||||
}
|
||||
|
||||
ColorScheme get colorScheme => Theme.of(this).colorScheme;
|
||||
|
||||
bool get disableAnimations => MediaQuery.disableAnimationsOf(this);
|
||||
|
||||
Duration motionDuration(Duration duration) =>
|
||||
disableAnimations ? Duration.zero : duration;
|
||||
|
||||
TextTheme get textTheme => Theme.of(this).textTheme;
|
||||
|
||||
AppLocalizations get appLocalizations => AppLocalizations.of(this);
|
||||
@@ -89,21 +101,3 @@ extension BuildContextExtension on BuildContext {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class BackHandleInherited extends InheritedWidget {
|
||||
final Function handleBack;
|
||||
|
||||
const BackHandleInherited({
|
||||
super.key,
|
||||
required this.handleBack,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static BackHandleInherited? of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<BackHandleInherited>();
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(BackHandleInherited oldWidget) {
|
||||
return handleBack != oldWidget.handleBack;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
|
||||
import 'print.dart';
|
||||
|
||||
T decodeOrRestoreDefault<T>(
|
||||
String label,
|
||||
T Function() decode,
|
||||
T Function() restoreDefault,
|
||||
) {
|
||||
try {
|
||||
return decode();
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'Discarded damaged $label and restored defaults: ${compactError(error)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return restoreDefault();
|
||||
}
|
||||
}
|
||||
|
||||
class Uint8ListToListIntConverter extends Converter<Uint8List, List<int>> {
|
||||
@override
|
||||
List<int> convert(Uint8List input) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'context.dart';
|
||||
|
||||
@@ -16,6 +16,9 @@ extension DateTimeExtension on DateTime {
|
||||
|
||||
String getLastUpdateTimeDesc(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
if (year <= 1970) {
|
||||
return appLocalizations.unknown;
|
||||
}
|
||||
final currentDateTime = DateTime.now();
|
||||
final difference = currentDateTime.difference(this);
|
||||
final days = difference.inDays;
|
||||
@@ -53,3 +56,24 @@ extension DateTimeExtension on DateTime {
|
||||
return toString().substring(10, 19);
|
||||
}
|
||||
}
|
||||
|
||||
String getDateStringLast2(int value) {
|
||||
final valueRaw = '0$value';
|
||||
return valueRaw.substring(valueRaw.length - 2);
|
||||
}
|
||||
|
||||
String getTimeText(int? timeStamp) {
|
||||
if (timeStamp == null) {
|
||||
return '00:00:00';
|
||||
}
|
||||
final diff = timeStamp / 1000;
|
||||
final inHours = (diff / 3600).floor();
|
||||
if (inHours > 999) {
|
||||
return '999:59:59';
|
||||
}
|
||||
final inMinutes = (diff / 60 % 60).floor();
|
||||
final inSeconds = (diff % 60).floor();
|
||||
final hoursText = inHours.toString().padLeft(2, '0');
|
||||
|
||||
return '$hoursText:${getDateStringLast2(inMinutes)}:${getDateStringLast2(inSeconds)}';
|
||||
}
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:webdav_client/webdav_client.dart';
|
||||
|
||||
typedef DAVClientFactory = DAVClient Function(DAVProps props);
|
||||
|
||||
class DAVClient {
|
||||
late Client client;
|
||||
late DAVTransport client;
|
||||
late String fileName;
|
||||
|
||||
DAVClient(DAVProps dav) {
|
||||
client = newClient(dav.uri, user: dav.user, password: dav.password);
|
||||
client = DAVTransport(uri: dav.uri, user: dav.user, password: dav.password);
|
||||
fileName = dav.fileName;
|
||||
client.setHeaders({'accept-charset': 'utf-8', 'Content-Type': 'text/xml'});
|
||||
client.setConnectTimeout(8000);
|
||||
client.setSendTimeout(60000);
|
||||
client.setReceiveTimeout(60000);
|
||||
}
|
||||
|
||||
Future<bool> ping() async {
|
||||
try {
|
||||
await client.ping();
|
||||
await client.options('/');
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'dav ping error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -34,15 +35,15 @@ class DAVClient {
|
||||
String get backupFile => '$root/$fileName';
|
||||
|
||||
Future<bool> backup(String localFilePath) async {
|
||||
await client.mkdir(root);
|
||||
await client.writeFromFile(localFilePath, backupFile);
|
||||
await client.mkcol(root);
|
||||
await client.put(backupFile, await io.File(localFilePath).readAsBytes());
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> restore() async {
|
||||
await client.mkdir(root);
|
||||
final backupFilePath = await appPath.backupFilePath;
|
||||
await client.read2File(backupFile, backupFilePath);
|
||||
final bytes = await client.get(backupFile);
|
||||
await io.File(backupFilePath).safeWriteAsBytes(bytes);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class Dialogs {
|
||||
Dialogs._();
|
||||
|
||||
BuildContext get _context => rootNavigatorKey.currentContext!;
|
||||
|
||||
Future<T?> showCommonDialog<T>({
|
||||
required Widget child,
|
||||
BuildContext? context,
|
||||
bool? dismissible,
|
||||
bool filter = true,
|
||||
}) async {
|
||||
return showModal<T>(
|
||||
useRootNavigator: false,
|
||||
context: context ?? _context,
|
||||
configuration: FadeScaleTransitionConfiguration(
|
||||
barrierColor: Colors.black38,
|
||||
barrierDismissible: dismissible ?? true,
|
||||
),
|
||||
builder: (_) => child,
|
||||
filter: filter ? commonFilter : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showMessage({
|
||||
required InlineSpan message,
|
||||
BuildContext? context,
|
||||
String? title,
|
||||
String? confirmText,
|
||||
String? cancelText,
|
||||
bool cancelable = true,
|
||||
bool? dismissible,
|
||||
}) async {
|
||||
return showCommonDialog<bool>(
|
||||
context: context,
|
||||
dismissible: dismissible,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return CommonDialog(
|
||||
title: title ?? appLocalizations.tip,
|
||||
actions: [
|
||||
if (cancelable)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: Text(cancelText ?? 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showAllUpdatingMessagesDialog(
|
||||
List<UpdatingMessage> messages,
|
||||
) async {
|
||||
return showCommonDialog<bool>(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return CommonDialog(
|
||||
backgroundColor: context.colorScheme.surfaceContainerLow,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
title: appLocalizations.tip,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(appLocalizations.confirm),
|
||||
),
|
||||
],
|
||||
child: generateSectionV3(
|
||||
items: messages.map(
|
||||
(message) => _UpdatingMessageItem(message: message),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> showDisclaimer() async {
|
||||
return await showCommonDialog<bool>(
|
||||
dismissible: false,
|
||||
child: CommonDialog(
|
||||
title: currentAppLocalizations.disclaimer,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(_context).pop<bool>(false);
|
||||
},
|
||||
child: Text(currentAppLocalizations.exit),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(_context).pop<bool>(true);
|
||||
},
|
||||
child: Text(currentAppLocalizations.agree),
|
||||
),
|
||||
],
|
||||
child: Text(currentAppLocalizations.disclaimerDesc),
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
void showNotifier(
|
||||
String text, {
|
||||
MessageLevel level = MessageLevel.info,
|
||||
MessageActionState? actionState,
|
||||
}) {
|
||||
rootNavigatorKey.currentContext?.showNotifier(
|
||||
text,
|
||||
level: level,
|
||||
actionState: actionState,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openUrl(String url) async {
|
||||
final res = await showMessage(
|
||||
message: TextSpan(text: url),
|
||||
title: currentAppLocalizations.externalLink,
|
||||
confirmText: currentAppLocalizations.go,
|
||||
);
|
||||
if (res != true) {
|
||||
return;
|
||||
}
|
||||
unawaited(launchUrl(Uri.parse(url)));
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdatingMessageItem extends StatelessWidget {
|
||||
final UpdatingMessage message;
|
||||
|
||||
const _UpdatingMessageItem({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecorationListItem(
|
||||
minVerticalPadding: 12,
|
||||
title: TooltipText(
|
||||
text: Text(message.label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
subtitle: TooltipText(
|
||||
text: Text(
|
||||
message.message,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final dialogs = Dialogs._();
|
||||
@@ -0,0 +1,8 @@
|
||||
final class MessageException implements Exception {
|
||||
final String message;
|
||||
|
||||
const MessageException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -36,3 +36,11 @@ extension FileSystemEntityExt on FileSystemEntity {
|
||||
await delete(recursive: recursive);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> safeDeletePath(String path) async {
|
||||
final entity = switch (FileSystemEntity.typeSync(path)) {
|
||||
FileSystemEntityType.directory => Directory(path),
|
||||
_ => File(path),
|
||||
};
|
||||
await entity.safeDelete(recursive: true);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import 'iterable.dart';
|
||||
|
||||
typedef ValueCallback<T> = T Function();
|
||||
|
||||
class FixedList<T> {
|
||||
final int maxLength;
|
||||
final List<T> _list;
|
||||
|
||||
final int _revision;
|
||||
List<T>? _snapshot;
|
||||
|
||||
FixedList(this.maxLength, {List<T>? list})
|
||||
: _list = (list ?? [])..truncate(maxLength);
|
||||
: assert(maxLength > 0, 'FixedList without a cap grows without bound'),
|
||||
_list = (list ?? [])..truncate(maxLength),
|
||||
_revision = 0;
|
||||
|
||||
FixedList._(this.maxLength, this._list, this._revision);
|
||||
|
||||
int get revision => _revision;
|
||||
|
||||
void add(T item) {
|
||||
_list.add(item);
|
||||
_list.truncate(maxLength);
|
||||
_snapshot = null;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_list.clear();
|
||||
_snapshot = null;
|
||||
}
|
||||
|
||||
List<T> get list => List.unmodifiable(_list);
|
||||
FixedList<T> append(T item) {
|
||||
add(item);
|
||||
return FixedList._(maxLength, _list, _revision + 1);
|
||||
}
|
||||
|
||||
List<T> get list => _snapshot ??= List.unmodifiable(_list);
|
||||
|
||||
int get length => _list.length;
|
||||
|
||||
@@ -27,47 +41,27 @@ class FixedList<T> {
|
||||
FixedList<T> copyWith() {
|
||||
return FixedList(maxLength, list: List.of(_list));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is FixedList<T> &&
|
||||
identical(other._list, _list) &&
|
||||
other._revision == _revision;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(identityHashCode(_list), _revision);
|
||||
}
|
||||
|
||||
class FixedMap<K, V> {
|
||||
int maxLength;
|
||||
late Map<K, V> _map;
|
||||
|
||||
FixedMap(this.maxLength, {Map<K, V>? map}) {
|
||||
_map = map ?? {};
|
||||
/// Restores the head that [latest] trimmed off [current] so a paused view
|
||||
/// stays anchored; the result is capped at [limit] from the tail.
|
||||
List<T> retainTrimmedHead<T>(List<T> current, List<T> latest, int limit) {
|
||||
if (current.isEmpty || latest.isEmpty) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
V updateCacheValue(K key, ValueCallback<V> callback) {
|
||||
final realValue = _map.updateCacheValue(key, callback);
|
||||
_adjustMap();
|
||||
return realValue;
|
||||
final index = current.indexWhere((item) => identical(item, latest.first));
|
||||
if (index == 0) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_map.clear();
|
||||
}
|
||||
|
||||
void updateMaxLength(int size) {
|
||||
maxLength = size;
|
||||
_adjustMap();
|
||||
}
|
||||
|
||||
void updateMap(Map<K, V> map) {
|
||||
_map = map;
|
||||
_adjustMap();
|
||||
}
|
||||
|
||||
void _adjustMap() {
|
||||
if (_map.length > maxLength) {
|
||||
_map = Map.fromEntries(map.entries.toList()..truncate(maxLength));
|
||||
}
|
||||
}
|
||||
|
||||
V? get(K key) => _map[key];
|
||||
|
||||
bool containsKey(K key) => _map.containsKey(key);
|
||||
|
||||
int get length => _map.length;
|
||||
|
||||
Map<K, V> get map => Map.unmodifiable(_map);
|
||||
final head = index > 0 ? current.sublist(0, index) : current;
|
||||
return List.unmodifiable([...head, ...latest].take(limit));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
void _applyAndReport(dynamic tag, Function func, List<dynamic>? args) {
|
||||
void report(Object error, StackTrace stackTrace) {
|
||||
commonPrint.log(
|
||||
'Scheduled task $tag failed: ${compactError(error)}, $stackTrace',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final result = Function.apply(func, args);
|
||||
if (result is Future) {
|
||||
result.then<void>((_) {}, onError: report);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
report(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
class Debouncer {
|
||||
final Map<dynamic, Timer?> _operations = {};
|
||||
final Map<dynamic, Timer> _operations = {};
|
||||
final Map<dynamic, ({Function func, List<dynamic>? args})> _pending = {};
|
||||
|
||||
void call(
|
||||
dynamic tag,
|
||||
@@ -11,20 +32,31 @@ class Debouncer {
|
||||
List<dynamic>? args,
|
||||
Duration? duration,
|
||||
}) {
|
||||
final timer = _operations[tag];
|
||||
if (timer != null) {
|
||||
timer.cancel();
|
||||
}
|
||||
_operations.remove(tag)?.cancel();
|
||||
_pending[tag] = (func: func, args: args);
|
||||
_operations[tag] = Timer(duration ?? const Duration(milliseconds: 600), () {
|
||||
_operations[tag]?.cancel();
|
||||
_operations.remove(tag);
|
||||
Function.apply(func, args);
|
||||
final pending = _pending.remove(tag);
|
||||
if (pending == null) {
|
||||
return;
|
||||
}
|
||||
_applyAndReport(tag, pending.func, pending.args);
|
||||
});
|
||||
}
|
||||
|
||||
void flush(dynamic tag) {
|
||||
final timer = _operations.remove(tag);
|
||||
timer?.cancel();
|
||||
final pending = _pending.remove(tag);
|
||||
if (timer == null || pending == null) {
|
||||
return;
|
||||
}
|
||||
_applyAndReport(tag, pending.func, pending.args);
|
||||
}
|
||||
|
||||
void cancel(dynamic tag) {
|
||||
_operations[tag]?.cancel();
|
||||
_operations[tag] = null;
|
||||
_operations.remove(tag)?.cancel();
|
||||
_pending.remove(tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +77,7 @@ class SerialTaskScheduler {
|
||||
}
|
||||
|
||||
class Throttler {
|
||||
final Map<dynamic, Timer?> _operations = {};
|
||||
final Map<dynamic, Timer> _operations = {};
|
||||
|
||||
bool call(
|
||||
dynamic tag,
|
||||
@@ -54,20 +86,17 @@ class Throttler {
|
||||
Duration duration = const Duration(milliseconds: 600),
|
||||
bool fire = false,
|
||||
}) {
|
||||
final timer = _operations[tag];
|
||||
if (timer != null) {
|
||||
if (_operations.containsKey(tag)) {
|
||||
return true;
|
||||
}
|
||||
if (fire) {
|
||||
Function.apply(func, args);
|
||||
_applyAndReport(tag, func, args);
|
||||
_operations[tag] = Timer(duration, () {
|
||||
_operations[tag]?.cancel();
|
||||
_operations.remove(tag);
|
||||
});
|
||||
} else {
|
||||
_operations[tag] = Timer(duration, () {
|
||||
Function.apply(func, args);
|
||||
_operations[tag]?.cancel();
|
||||
_applyAndReport(tag, func, args);
|
||||
_operations.remove(tag);
|
||||
});
|
||||
}
|
||||
@@ -75,8 +104,7 @@ class Throttler {
|
||||
}
|
||||
|
||||
void cancel(dynamic tag) {
|
||||
_operations[tag]?.cancel();
|
||||
_operations[tag] = null;
|
||||
_operations.remove(tag)?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +123,25 @@ Future<T> retry<T>({
|
||||
}
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
throw 'retry error';
|
||||
throw TimeoutException('retry gave up after $maxAttempts attempts');
|
||||
}
|
||||
|
||||
final debouncer = Debouncer();
|
||||
|
||||
final throttler = Throttler();
|
||||
|
||||
FutureOr<T> handleWatch<T>({
|
||||
required Function function,
|
||||
required void Function() onStart,
|
||||
required void Function(T data, int elapsedMilliseconds) onEnd,
|
||||
}) async {
|
||||
if (kDebugMode && watchExecution) {
|
||||
onStart();
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final res = await function();
|
||||
stopwatch.stop();
|
||||
onEnd(res, stopwatch.elapsedMilliseconds);
|
||||
return res;
|
||||
}
|
||||
return await function();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,77 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class FlClashHttpOverrides extends HttpOverrides {
|
||||
static String handleFindProxy(Uri url) {
|
||||
final ProviderContainer _container;
|
||||
|
||||
FlClashHttpOverrides(this._container);
|
||||
|
||||
static String findProxyFor(ProviderContainer container, Uri url) {
|
||||
return findProxyForReader(container.read, url);
|
||||
}
|
||||
|
||||
static String findProxyForReader(ProviderReader read, Uri url) {
|
||||
if ([localhost].contains(url.host)) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
final ref = globalState.container;
|
||||
final isStart = ref.read(isStartProvider);
|
||||
final suspend = ref.read(suspendProvider);
|
||||
final isStart = read(isStartProvider);
|
||||
final suspend = read(suspendProvider);
|
||||
commonPrint.log('find $url proxy: $isStart');
|
||||
if (!isStart || suspend) return 'DIRECT';
|
||||
final mixedPort = ref.read(
|
||||
final mixedPort = read(
|
||||
patchClashConfigProvider.select((state) => state.mixedPort),
|
||||
);
|
||||
return 'PROXY localhost:$mixedPort';
|
||||
final authentication = read(
|
||||
networkSettingProvider.select((state) => state.authentication),
|
||||
);
|
||||
final credentials = authentication.credentials;
|
||||
final userInfo = credentials.isNotEmpty ? '${credentials.first}@' : '';
|
||||
return 'PROXY ${userInfo}localhost:$mixedPort';
|
||||
}
|
||||
|
||||
static bool allowBadCertificate(
|
||||
ProviderContainer container,
|
||||
X509Certificate certificate,
|
||||
String host,
|
||||
int port,
|
||||
) {
|
||||
return allowBadCertificateForReader(
|
||||
container.read,
|
||||
certificate,
|
||||
host,
|
||||
port,
|
||||
);
|
||||
}
|
||||
|
||||
static bool allowBadCertificateForReader(
|
||||
ProviderReader read,
|
||||
X509Certificate certificate,
|
||||
String host,
|
||||
int port,
|
||||
) {
|
||||
final checkCertificate = read(
|
||||
appSettingProvider.select((state) => state.checkCertificate),
|
||||
);
|
||||
commonPrint.log(
|
||||
'untrusted certificate for $host:$port issued by ${certificate.issuer}, '
|
||||
'check: $checkCertificate',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return !checkCertificate;
|
||||
}
|
||||
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
final client = super.createHttpClient(context);
|
||||
client.badCertificateCallback = (_, _, _) => true;
|
||||
client.findProxy = handleFindProxy;
|
||||
client.badCertificateCallback = (certificate, host, port) =>
|
||||
allowBadCertificate(_container, certificate, host, port);
|
||||
client.findProxy = (url) => findProxyFor(_container, url);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class IconsExt {
|
||||
static const IconData target = IconData(0xe900, fontFamily: 'Icons');
|
||||
|
||||
@@ -35,11 +35,6 @@ extension IterableExt<E> on Iterable<E> {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<E> takeLast({int count = 50}) {
|
||||
if (count <= 0) return Iterable.empty();
|
||||
return count >= length ? this : toList().skip(length - count);
|
||||
}
|
||||
}
|
||||
|
||||
extension ListExt<T> on List<T> {
|
||||
@@ -87,15 +82,6 @@ extension ListExt<T> on List<T> {
|
||||
return newList;
|
||||
}
|
||||
|
||||
List<T> safeSublist(int start, [int? end]) {
|
||||
if (start <= 0) return this;
|
||||
if (start > length) return [];
|
||||
if (end != null) {
|
||||
return sublist(start, end.clamp(start, length));
|
||||
}
|
||||
return sublist(start);
|
||||
}
|
||||
|
||||
T? safeGet(int index, {T? defaultValue}) {
|
||||
if (index < 0 || index >= length) {
|
||||
return defaultValue;
|
||||
@@ -129,32 +115,6 @@ extension SetExt<T> on Set<T> {
|
||||
}
|
||||
}
|
||||
|
||||
extension DoubleListExt on List<double> {
|
||||
int findInterval(num target) {
|
||||
if (isEmpty) return -1;
|
||||
if (target < first) return -1;
|
||||
if (target >= last) return length - 1;
|
||||
|
||||
int left = 0;
|
||||
int right = length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
final int mid = left + (right - left) ~/ 2;
|
||||
|
||||
if (mid == length - 1 ||
|
||||
(this[mid] <= target && target < this[mid + 1])) {
|
||||
return mid;
|
||||
} else if (target < this[mid]) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
extension MapExt<K, V> on Map<K, V> {
|
||||
V updateCacheValue(K key, V Function() callback) {
|
||||
if (this[key] == null) {
|
||||
@@ -162,14 +122,4 @@ extension MapExt<K, V> on Map<K, V> {
|
||||
}
|
||||
return this[key]!;
|
||||
}
|
||||
|
||||
Map<K, V> copyWitUpdate(K key, V? value) {
|
||||
final newMap = Map<K, V>.from(this);
|
||||
if (value == null) {
|
||||
newMap.remove(key);
|
||||
} else {
|
||||
newMap[key] = value;
|
||||
}
|
||||
return newMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi' as ffi;
|
||||
|
||||
import 'package:flutter_js/flutter_js.dart';
|
||||
import 'package:fl_clash/common/exception.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:rust_api/rust_api.dart';
|
||||
|
||||
typedef ScriptEvaluator =
|
||||
Future<String> Function({required String script, required String config});
|
||||
|
||||
@visibleForTesting
|
||||
ScriptEvaluator scriptEvaluator = evaluateScript;
|
||||
|
||||
Future<Map<String, dynamic>> handleEvaluate(
|
||||
String scriptContent,
|
||||
@@ -10,18 +17,20 @@ Future<Map<String, dynamic>> handleEvaluate(
|
||||
if (config['proxy-providers'] == null) {
|
||||
config['proxy-providers'] = {};
|
||||
}
|
||||
final configJs = json.encode(config);
|
||||
final runtime = getJavascriptRuntime();
|
||||
final res = await runtime.evaluateAsync('''
|
||||
$scriptContent
|
||||
main($configJs)
|
||||
''');
|
||||
if (res.isError) {
|
||||
throw res.stringResult;
|
||||
final String result;
|
||||
try {
|
||||
result = await scriptEvaluator(
|
||||
script: scriptContent,
|
||||
config: json.encode(config),
|
||||
);
|
||||
} catch (e) {
|
||||
throw MessageException(e.toString());
|
||||
}
|
||||
final value = switch (res.rawResult is ffi.Pointer) {
|
||||
true => runtime.convertValue<Map<String, dynamic>>(res),
|
||||
false => Map<String, dynamic>.from(res.rawResult),
|
||||
};
|
||||
return value ?? config;
|
||||
final decoded = json.decode(result);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const MessageException(
|
||||
'script did not return a configuration object',
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:uni_platform/uni_platform.dart';
|
||||
|
||||
import 'system.dart';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
final Map<PhysicalKeyboardKey, String> _knownKeyLabels =
|
||||
<PhysicalKeyboardKey, String>{
|
||||
PhysicalKeyboardKey.keyA: 'A',
|
||||
@@ -101,3 +103,8 @@ extension KeyboardKeyExt on KeyboardKey {
|
||||
return _knownKeyLabels[physicalKey] ?? physicalKey?.debugName ?? 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
SingleActivator controlSingleActivator(LogicalKeyboardKey trigger) {
|
||||
final control = system.isMacOS ? false : true;
|
||||
return SingleActivator(trigger, control: control, meta: !control);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
extension PageLabelL10n on PageLabel {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
PageLabel.dashboard => appLocalizations.dashboard,
|
||||
PageLabel.proxies => appLocalizations.proxies,
|
||||
PageLabel.profiles => appLocalizations.profiles,
|
||||
PageLabel.tools => appLocalizations.tools,
|
||||
PageLabel.logs => appLocalizations.logs,
|
||||
PageLabel.requests => appLocalizations.requests,
|
||||
PageLabel.resources => appLocalizations.resources,
|
||||
PageLabel.connections => appLocalizations.connections,
|
||||
};
|
||||
}
|
||||
|
||||
String? get description {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
PageLabel.logs => appLocalizations.logsDesc,
|
||||
PageLabel.requests => appLocalizations.requestsDesc,
|
||||
PageLabel.resources => appLocalizations.resourcesDesc,
|
||||
PageLabel.connections => appLocalizations.connectionsDesc,
|
||||
PageLabel.dashboard ||
|
||||
PageLabel.proxies ||
|
||||
PageLabel.profiles ||
|
||||
PageLabel.tools => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension ModeL10n on Mode {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
Mode.rule => appLocalizations.rule,
|
||||
Mode.global => appLocalizations.global,
|
||||
Mode.direct => appLocalizations.direct,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension ProxiesTypeL10n on ProxiesType {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
ProxiesType.tab => appLocalizations.tab,
|
||||
ProxiesType.list => appLocalizations.list,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension ProxyCardTypeL10n on ProxyCardType {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
ProxyCardType.expand => appLocalizations.expand,
|
||||
ProxyCardType.shrink => appLocalizations.shrink,
|
||||
ProxyCardType.min => appLocalizations.min,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension HotActionL10n on HotAction {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
HotAction.start => appLocalizations.actionStart,
|
||||
HotAction.view => appLocalizations.actionView,
|
||||
HotAction.mode => appLocalizations.actionMode,
|
||||
HotAction.proxy => appLocalizations.actionProxy,
|
||||
HotAction.tun => appLocalizations.actionTun,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension RouteModeL10n on RouteMode {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
RouteMode.bypassPrivate => appLocalizations.routeModeBypassPrivate,
|
||||
RouteMode.config => appLocalizations.routeModeConfig,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension RestoreStrategyL10n on RestoreStrategy {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
RestoreStrategy.compatible => appLocalizations.restoreStrategyCompatible,
|
||||
RestoreStrategy.override => appLocalizations.restoreStrategyOverride,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension DynamicSchemeVariantL10n on DynamicSchemeVariant {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
DynamicSchemeVariant.tonalSpot => appLocalizations.tonalSpotScheme,
|
||||
DynamicSchemeVariant.fidelity => appLocalizations.fidelityScheme,
|
||||
DynamicSchemeVariant.monochrome => appLocalizations.monochromeScheme,
|
||||
DynamicSchemeVariant.neutral => appLocalizations.neutralScheme,
|
||||
DynamicSchemeVariant.vibrant => appLocalizations.vibrantScheme,
|
||||
DynamicSchemeVariant.expressive => appLocalizations.expressiveScheme,
|
||||
DynamicSchemeVariant.content => appLocalizations.contentScheme,
|
||||
DynamicSchemeVariant.rainbow => appLocalizations.rainbowScheme,
|
||||
DynamicSchemeVariant.fruitSalad => appLocalizations.fruitSaladScheme,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension LocaleL10n on Locale {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (toString()) {
|
||||
'en' => appLocalizations.en,
|
||||
'ja' => appLocalizations.ja,
|
||||
'ru' => appLocalizations.ru,
|
||||
'zh_CN' => appLocalizations.zhCN,
|
||||
final code => code,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,7 @@ class AutoLaunch {
|
||||
static AutoLaunch? _instance;
|
||||
|
||||
AutoLaunch._internal() {
|
||||
launchAtStartup.setup(
|
||||
appName: appName,
|
||||
appPath: Platform.resolvedExecutable,
|
||||
);
|
||||
launcher.setup(appName: appName, appPath: Platform.resolvedExecutable);
|
||||
}
|
||||
|
||||
factory AutoLaunch() {
|
||||
@@ -22,16 +19,19 @@ class AutoLaunch {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static LaunchAtStartup launcher = launchAtStartup;
|
||||
|
||||
Future<bool> get isEnable async {
|
||||
return launchAtStartup.isEnabled();
|
||||
return launcher.isEnabled();
|
||||
}
|
||||
|
||||
Future<bool> enable() async {
|
||||
return launchAtStartup.enable();
|
||||
return launcher.enable();
|
||||
}
|
||||
|
||||
Future<bool> disable() async {
|
||||
return launchAtStartup.disable();
|
||||
return launcher.disable();
|
||||
}
|
||||
|
||||
Future<void> updateStatus(bool isAutoLaunch) async {
|
||||
@@ -40,9 +40,9 @@ class AutoLaunch {
|
||||
}
|
||||
if (await isEnable == isAutoLaunch) return;
|
||||
if (isAutoLaunch == true) {
|
||||
enable();
|
||||
unawaited(enable());
|
||||
} else {
|
||||
disable();
|
||||
unawaited(disable());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
|
||||
import 'constant.dart';
|
||||
|
||||
double getWindowHeaderHeight({required bool isDesktop, required bool isMacOS}) {
|
||||
if (!isDesktop) return 0;
|
||||
return isMacOS ? 28 : 32;
|
||||
}
|
||||
|
||||
/// Windows 11 draws its caption buttons 46 wide over a 32 tall title bar
|
||||
/// and renders every glyph as a 10x10 Segoe Fluent icon.
|
||||
const captionButtonWidth = 46.0;
|
||||
const captionGlyphSize = 10.0;
|
||||
|
||||
/// The pin is not a caption button: it keeps the round Material button in a
|
||||
/// square slot the height of the bar, with a regular icon size.
|
||||
const pinIconSize = 16.0;
|
||||
|
||||
Size getCaptionButtonSize(double headerHeight) =>
|
||||
Size(captionButtonWidth, headerHeight);
|
||||
|
||||
bool showsWindowHeader({
|
||||
required bool isDesktop,
|
||||
required bool isMacOS,
|
||||
required int version,
|
||||
required bool isMobileView,
|
||||
}) {
|
||||
if (!isDesktop) return false;
|
||||
return !(isMacOS && (version <= 10 || !isMobileView));
|
||||
}
|
||||
|
||||
ViewMode getViewMode(double viewWidth) {
|
||||
if (viewWidth <= maxMobileWidth) return ViewMode.mobile;
|
||||
if (viewWidth <= maxLaptopWidth) return ViewMode.laptop;
|
||||
return ViewMode.desktop;
|
||||
}
|
||||
|
||||
int getProxiesColumns(double viewWidth, ProxiesLayout proxiesLayout) {
|
||||
final columns = max((viewWidth / 250).ceil(), 2);
|
||||
return switch (proxiesLayout) {
|
||||
ProxiesLayout.tight => columns + 1,
|
||||
ProxiesLayout.standard => columns,
|
||||
ProxiesLayout.loose => columns - 1,
|
||||
};
|
||||
}
|
||||
|
||||
const profileItemMinWidth = 270.0;
|
||||
|
||||
int getProfilesColumns(
|
||||
double viewWidth, {
|
||||
double spacing = 0,
|
||||
double minItemWidth = profileItemMinWidth,
|
||||
}) {
|
||||
final columns = (viewWidth + spacing) / (minItemWidth + spacing);
|
||||
return max(columns.floor(), 1);
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'print.dart';
|
||||
import 'protocol.dart';
|
||||
|
||||
typedef InstallConfigCallBack = void Function(String url);
|
||||
|
||||
class LinkManager {
|
||||
static LinkManager? _instance;
|
||||
late AppLinks _appLinks;
|
||||
StreamSubscription? subscription;
|
||||
Uri? _pendingUri;
|
||||
|
||||
LinkManager._internal() {
|
||||
_appLinks = AppLinks();
|
||||
LinkManager._internal();
|
||||
|
||||
@visibleForTesting
|
||||
Stream<Uri> Function() uriLinkStream = () => AppLinks().uriLinkStream;
|
||||
|
||||
/// Linux argv: the gtk plugin hooks GApplication too late to see it.
|
||||
void seedInitialLink(List<String> args) {
|
||||
for (final arg in args) {
|
||||
final uri = Uri.tryParse(arg);
|
||||
if (uri != null && protocolSchemes.contains(uri.scheme)) {
|
||||
_pendingUri = uri;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initAppLinksListen(
|
||||
@@ -20,16 +34,25 @@ class LinkManager {
|
||||
) async {
|
||||
commonPrint.log('initAppLinksListen');
|
||||
destroy();
|
||||
subscription = _appLinks.uriLinkStream.listen((uri) {
|
||||
commonPrint.log('onAppLink: $uri');
|
||||
if (uri.host == 'install-config') {
|
||||
final parameters = uri.queryParameters;
|
||||
final url = parameters['url'];
|
||||
if (url != null) {
|
||||
installConfigCallBack(url);
|
||||
}
|
||||
}
|
||||
subscription = uriLinkStream().listen((uri) {
|
||||
_handle(uri, installConfigCallBack);
|
||||
});
|
||||
final pending = _pendingUri;
|
||||
_pendingUri = null;
|
||||
if (pending != null) {
|
||||
_handle(pending, installConfigCallBack);
|
||||
}
|
||||
}
|
||||
|
||||
void _handle(Uri uri, Function(String url) installConfigCallBack) {
|
||||
commonPrint.log('onAppLink: $uri');
|
||||
if (uri.host == 'install-config') {
|
||||
final parameters = uri.queryParameters;
|
||||
final url = parameters['url'];
|
||||
if (url != null) {
|
||||
installConfigCallBack(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void destroy() {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Keeps two processes off one data directory. Raising the running window is
|
||||
/// the platform's job: LaunchServices reopen, GApplication activation, or the
|
||||
/// app_links hand-off in the Windows runner.
|
||||
class SingleInstanceLock {
|
||||
static SingleInstanceLock? _instance;
|
||||
RandomAccessFile? _accessFile;
|
||||
@@ -13,18 +18,31 @@ class SingleInstanceLock {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Future<String> Function() resolvePath = () => appPath.lockFilePath;
|
||||
|
||||
Future<bool> acquire() async {
|
||||
try {
|
||||
final lockFilePath = await appPath.lockFilePath;
|
||||
final lockFilePath = await resolvePath();
|
||||
final lockFile = File(lockFilePath);
|
||||
await lockFile.create();
|
||||
_accessFile = await lockFile.open(mode: FileMode.write);
|
||||
await _accessFile?.lock();
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'single instance lock acquire failed ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> release() async {
|
||||
await _accessFile?.close();
|
||||
_accessFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
final singleInstanceLock = SingleInstanceLock();
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
typedef _TextMeasureKey = (
|
||||
String?,
|
||||
TextStyle?,
|
||||
TextStyle?,
|
||||
int?,
|
||||
TextDirection?,
|
||||
double?,
|
||||
);
|
||||
|
||||
class Measure {
|
||||
static const _textMeasureCacheLimit = 512;
|
||||
|
||||
final TextScaler _textScaler;
|
||||
final BuildContext context;
|
||||
final Map<String, dynamic> _measureMap;
|
||||
final Map<_TextMeasureKey, Size> _textSizeCache = {};
|
||||
final Map<_TextMeasureKey, bool> _textOverflowCache = {};
|
||||
|
||||
Measure.of(this.context, double textScaleFactor)
|
||||
: _measureMap = {},
|
||||
_textScaler = TextScaler.linear(textScaleFactor);
|
||||
|
||||
TextPainter computeText(Text text, {TextStyle? style, double? maxWidth}) {
|
||||
TextPainter _computeText(Text text, {TextStyle? style, double? maxWidth}) {
|
||||
return TextPainter(
|
||||
text: TextSpan(text: text.data, style: text.style ?? style),
|
||||
maxLines: text.maxLines,
|
||||
@@ -22,14 +34,65 @@ class Measure {
|
||||
)..layout(maxWidth: maxWidth ?? double.infinity);
|
||||
}
|
||||
|
||||
R _measuring<R>(
|
||||
Text text,
|
||||
TextStyle? style,
|
||||
double? maxWidth,
|
||||
R Function(TextPainter painter) read,
|
||||
) {
|
||||
final textPainter = _computeText(text, style: style, maxWidth: maxWidth);
|
||||
try {
|
||||
return read(textPainter);
|
||||
} finally {
|
||||
textPainter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
_TextMeasureKey _measureKey(Text text, TextStyle? style, double? maxWidth) {
|
||||
return (
|
||||
text.data,
|
||||
text.style,
|
||||
style,
|
||||
text.maxLines,
|
||||
text.textDirection,
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
|
||||
R _cached<R extends Object>(
|
||||
Map<_TextMeasureKey, R> cache,
|
||||
_TextMeasureKey key,
|
||||
R Function() compute,
|
||||
) {
|
||||
final cachedValue = cache[key];
|
||||
if (cachedValue != null) {
|
||||
return cachedValue;
|
||||
}
|
||||
if (cache.length >= _textMeasureCacheLimit) {
|
||||
cache.clear();
|
||||
}
|
||||
return cache[key] = compute();
|
||||
}
|
||||
|
||||
Size computeTextSize(Text text, {TextStyle? style, double? maxWidth}) {
|
||||
final textPainter = computeText(text, style: style, maxWidth: maxWidth);
|
||||
return textPainter.size;
|
||||
return _cached(
|
||||
_textSizeCache,
|
||||
_measureKey(text, style, maxWidth),
|
||||
() => _measuring(text, style, maxWidth, (painter) => painter.size),
|
||||
);
|
||||
}
|
||||
|
||||
bool computeTextIsOverflow(Text text, {TextStyle? style, double? maxWidth}) {
|
||||
final textPainter = computeText(text, style: style, maxWidth: maxWidth);
|
||||
return textPainter.didExceedMaxLines;
|
||||
return _cached(
|
||||
_textOverflowCache,
|
||||
_measureKey(text, style, maxWidth),
|
||||
() => _measuring(
|
||||
text,
|
||||
style,
|
||||
maxWidth,
|
||||
(painter) => painter.didExceedMaxLines,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double get bodyMediumHeight {
|
||||
|
||||
@@ -8,6 +8,10 @@ typedef MigrationTransform =
|
||||
Future<MigrationData> Function(Map<String, Object?> configMap);
|
||||
|
||||
abstract interface class MigrationStore {
|
||||
/// False when the backing store could not be opened at all, as opposed to a
|
||||
/// store that opened but holds nothing.
|
||||
Future<bool> get isAvailable;
|
||||
|
||||
Future<Map<String, Object?>?> getConfigMap();
|
||||
|
||||
Future<int> getVersion();
|
||||
@@ -26,6 +30,9 @@ abstract interface class MigrationStore {
|
||||
class _AppMigrationStore implements MigrationStore {
|
||||
const _AppMigrationStore();
|
||||
|
||||
@override
|
||||
Future<bool> get isAvailable => preferences.isInit;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>?> getConfigMap() => preferences.getConfigMap();
|
||||
|
||||
@@ -123,7 +130,12 @@ class Migration {
|
||||
config = Config.realFromJson(data.configMap);
|
||||
await _store.restore(data);
|
||||
if (!await _store.saveConfig(config)) {
|
||||
throw StateError('Failed to save migrated preferences');
|
||||
// An unopenable store is reported later by the corrupt-cache dialog,
|
||||
// which offers a reset; failing here would hide that path.
|
||||
if (await _store.isAvailable) {
|
||||
throw StateError('Failed to save migrated preferences');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
if (shouldClearClashConfig) {
|
||||
await _store.clearClashConfig();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import 'utils.dart';
|
||||
import 'snowflake.dart';
|
||||
|
||||
mixin AutoDisposeNotifierMixin<T> on AnyNotifier<T, T> {
|
||||
T get value => state;
|
||||
@@ -11,23 +11,6 @@ mixin AutoDisposeNotifierMixin<T> on AnyNotifier<T, T> {
|
||||
state = value;
|
||||
}
|
||||
|
||||
bool equals(T previous, T next) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(previous, next) {
|
||||
final res = !equals(previous, next)
|
||||
? super.updateShouldNotify(previous, next)
|
||||
: true;
|
||||
if (res) {
|
||||
onUpdate(next);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
void onUpdate(T value) {}
|
||||
|
||||
void update(T Function(T) builder) {
|
||||
final res = builder(value);
|
||||
if (res == value) {
|
||||
@@ -46,5 +29,5 @@ mixin AsyncNotifierMixin<T> on AnyNotifier<AsyncValue<T>, T> {
|
||||
}
|
||||
|
||||
mixin UniqueKeyStateMixin<T extends StatefulWidget> on State<T> {
|
||||
final key = utils.id;
|
||||
final key = uniqueId;
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/views/views.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Navigation {
|
||||
static Navigation? _instance;
|
||||
|
||||
List<NavigationItem> getItems({
|
||||
bool openLogs = false,
|
||||
bool hasProxies = false,
|
||||
}) {
|
||||
return [
|
||||
NavigationItem(
|
||||
keep: false,
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) =>
|
||||
const DashboardView(key: GlobalObjectKey(PageLabel.dashboard)),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.article),
|
||||
label: PageLabel.proxies,
|
||||
builder: (_) =>
|
||||
const ProxiesView(key: GlobalObjectKey(PageLabel.proxies)),
|
||||
modes: hasProxies
|
||||
? [NavigationItemMode.mobile, NavigationItemMode.desktop]
|
||||
: [],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.folder),
|
||||
label: PageLabel.profiles,
|
||||
builder: (_) =>
|
||||
const ProfilesView(key: GlobalObjectKey(PageLabel.profiles)),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.view_timeline),
|
||||
label: PageLabel.requests,
|
||||
builder: (_) =>
|
||||
const RequestsView(key: GlobalObjectKey(PageLabel.requests)),
|
||||
description: 'requestsDesc',
|
||||
modes: [NavigationItemMode.desktop, NavigationItemMode.more],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.ballot),
|
||||
label: PageLabel.connections,
|
||||
builder: (_) =>
|
||||
const ConnectionsView(key: GlobalObjectKey(PageLabel.connections)),
|
||||
description: 'connectionsDesc',
|
||||
modes: [NavigationItemMode.desktop, NavigationItemMode.more],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.storage),
|
||||
label: PageLabel.resources,
|
||||
description: 'resourcesDesc',
|
||||
builder: (_) =>
|
||||
const ResourcesView(key: GlobalObjectKey(PageLabel.resources)),
|
||||
modes: [NavigationItemMode.more],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.adb),
|
||||
label: PageLabel.logs,
|
||||
builder: (_) => const LogsView(key: GlobalObjectKey(PageLabel.logs)),
|
||||
description: 'logsDesc',
|
||||
modes: openLogs
|
||||
? [NavigationItemMode.desktop, NavigationItemMode.more]
|
||||
: [],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const ToolsView(key: GlobalObjectKey(PageLabel.tools)),
|
||||
modes: [NavigationItemMode.desktop, NavigationItemMode.mobile],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Navigation._internal();
|
||||
|
||||
factory Navigation() {
|
||||
_instance ??= Navigation._internal();
|
||||
return _instance!;
|
||||
}
|
||||
}
|
||||
|
||||
final navigation = Navigation();
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
class BaseNavigator {
|
||||
static Future<T?> push<T>(BuildContext context, Widget child) async {
|
||||
if (!globalState.container.read(isMobileViewProvider)) {
|
||||
if (!context.isMobileView) {
|
||||
return Navigator.of(
|
||||
context,
|
||||
).push<T>(CommonDesktopRoute(builder: (context) => child));
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
typedef NetworkInterfaceLister =
|
||||
Future<List<NetworkInterface>> Function({bool includeLoopback});
|
||||
|
||||
@visibleForTesting
|
||||
NetworkInterfaceLister listNetworkInterfaces =
|
||||
({bool includeLoopback = false}) =>
|
||||
NetworkInterface.list(includeLoopback: includeLoopback);
|
||||
|
||||
extension NetworkInterfaceExt on NetworkInterface {
|
||||
bool get isWifi {
|
||||
final nameLowCase = name.toLowerCase();
|
||||
@@ -23,3 +33,28 @@ extension InternetAddressExt on InternetAddress {
|
||||
return type == InternetAddressType.IPv4;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getLocalIpAddress() async {
|
||||
final List<NetworkInterface> interfaces =
|
||||
await listNetworkInterfaces(includeLoopback: false)
|
||||
..sort((a, b) {
|
||||
if (a.isWifi && !b.isWifi) return -1;
|
||||
if (!a.isWifi && b.isWifi) return 1;
|
||||
if (a.includesIPv4 && !b.includesIPv4) return -1;
|
||||
if (!a.includesIPv4 && b.includesIPv4) return 1;
|
||||
return 0;
|
||||
});
|
||||
for (final interface in interfaces) {
|
||||
final addresses = interface.addresses;
|
||||
if (addresses.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
addresses.sort((a, b) {
|
||||
if (a.isIPv4 && !b.isIPv4) return -1;
|
||||
if (!a.isIPv4 && b.isIPv4) return 1;
|
||||
return 0;
|
||||
});
|
||||
return addresses.first.address;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
extension NumExt on num {
|
||||
String fixed({int decimals = 2}) {
|
||||
@@ -57,7 +57,7 @@ extension NumExt on num {
|
||||
|
||||
extension DoubleExt on double {
|
||||
bool moreOrEqual(double value) {
|
||||
return this > value || (value - this).abs() < precisionErrorTolerance + 1;
|
||||
return this > value || (value - this).abs() < precisionErrorTolerance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,23 +69,4 @@ extension OffsetExt on Offset {
|
||||
double getMainAxisOffset(Axis direction) {
|
||||
return direction == Axis.vertical ? dy : dx;
|
||||
}
|
||||
|
||||
bool less(Offset offset) {
|
||||
if (dy < offset.dy) {
|
||||
return true;
|
||||
}
|
||||
if (dy == offset.dy && dx < offset.dx) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
extension RectExt on Rect {
|
||||
bool doRectIntersect(Rect rect) {
|
||||
return left < rect.right &&
|
||||
right > rect.left &&
|
||||
top < rect.bottom &&
|
||||
bottom > rect.top;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,3 +11,51 @@ extension PackageInfoExtension on PackageInfo {
|
||||
'Platform/${Platform.operatingSystem}',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
int compareVersions(String version1, String version2) {
|
||||
final List<String> v1 = version1.split('+')[0].split('.');
|
||||
final List<String> v2 = version2.split('+')[0].split('.');
|
||||
final int major1 = int.parse(v1[0]);
|
||||
final int major2 = int.parse(v2[0]);
|
||||
if (major1 != major2) {
|
||||
return major1.compareTo(major2);
|
||||
}
|
||||
final int minor1 = v1.length > 1 ? int.parse(v1[1]) : 0;
|
||||
final int minor2 = v2.length > 1 ? int.parse(v2[1]) : 0;
|
||||
if (minor1 != minor2) {
|
||||
return minor1.compareTo(minor2);
|
||||
}
|
||||
final int patch1 = v1.length > 2 ? int.parse(v1[2]) : 0;
|
||||
final int patch2 = v2.length > 2 ? int.parse(v2[2]) : 0;
|
||||
if (patch1 != patch2) {
|
||||
return patch1.compareTo(patch2);
|
||||
}
|
||||
final int build1 = version1.contains('+')
|
||||
? int.parse(version1.split('+')[1])
|
||||
: 0;
|
||||
final int build2 = version2.contains('+')
|
||||
? int.parse(version2.split('+')[1])
|
||||
: 0;
|
||||
return build1.compareTo(build2);
|
||||
}
|
||||
|
||||
const releaseNotesBeginMarker = '<!-- flclash:changelog:begin -->';
|
||||
const releaseNotesEndMarker = '<!-- flclash:changelog:end -->';
|
||||
|
||||
List<String> parseReleaseBody(String? body) {
|
||||
if (body == null) return [];
|
||||
final regex = RegExp(r'^[ \t]*-[ \t]+(.*)$', multiLine: true);
|
||||
return regex
|
||||
.allMatches(scopeReleaseNotes(body))
|
||||
.map((match) => match.group(1)?.trim() ?? '')
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String scopeReleaseNotes(String body) {
|
||||
final begin = body.indexOf(releaseNotesBeginMarker);
|
||||
if (begin < 0) return body;
|
||||
final start = begin + releaseNotesBeginMarker.length;
|
||||
final end = body.indexOf(releaseNotesEndMarker, start);
|
||||
return end < 0 ? body.substring(start) : body.substring(start, end);
|
||||
}
|
||||
|
||||
@@ -2,26 +2,43 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class AppPath {
|
||||
static AppPath? _instance;
|
||||
Completer<Directory> dataDir = Completer();
|
||||
late final Future<Directory?> _downloadDir = getDownloadsDirectory();
|
||||
late final Future<Directory?> _downloadDir = downloadDirectory();
|
||||
Completer<Directory> tempDir = Completer();
|
||||
Completer<Directory> cacheDir = Completer();
|
||||
late String appDirPath;
|
||||
|
||||
@visibleForTesting
|
||||
static Future<Directory> Function() supportDirectory =
|
||||
getApplicationSupportDirectory;
|
||||
|
||||
@visibleForTesting
|
||||
static Future<Directory> Function() temporaryDirectory =
|
||||
getTemporaryDirectory;
|
||||
|
||||
@visibleForTesting
|
||||
static Future<Directory> Function() cacheDirectory =
|
||||
getApplicationCacheDirectory;
|
||||
|
||||
@visibleForTesting
|
||||
static Future<Directory?> Function() downloadDirectory =
|
||||
getDownloadsDirectory;
|
||||
|
||||
AppPath._internal() {
|
||||
appDirPath = join(dirname(Platform.resolvedExecutable));
|
||||
getApplicationSupportDirectory().then((value) {
|
||||
supportDirectory().then((value) {
|
||||
dataDir.complete(value);
|
||||
});
|
||||
getTemporaryDirectory().then((value) {
|
||||
temporaryDirectory().then((value) {
|
||||
tempDir.complete(value);
|
||||
});
|
||||
getApplicationCacheDirectory().then((value) {
|
||||
cacheDirectory().then((value) {
|
||||
cacheDir.complete(value);
|
||||
});
|
||||
}
|
||||
@@ -75,7 +92,7 @@ class AppPath {
|
||||
|
||||
Future<String> get tempFilePath async {
|
||||
final mTempDir = await tempDir.future;
|
||||
return join(mTempDir.path, 'temp${utils.id}');
|
||||
return join(mTempDir.path, 'temp$uniqueId');
|
||||
}
|
||||
|
||||
Future<String> get lockFilePath async {
|
||||
@@ -88,11 +105,6 @@ class AppPath {
|
||||
return join(mHomeDirPath, 'config.yaml');
|
||||
}
|
||||
|
||||
Future<String> get sharedFilePath async {
|
||||
final mHomeDirPath = await homeDirPath;
|
||||
return join(mHomeDirPath, 'shared.json');
|
||||
}
|
||||
|
||||
Future<String> get sharedPreferencesPath async {
|
||||
final directory = await dataDir.future;
|
||||
return join(directory.path, 'shared_preferences.json');
|
||||
@@ -117,28 +129,27 @@ class AppPath {
|
||||
return join(path, '$fileName.js');
|
||||
}
|
||||
|
||||
Future<String> getIconsCacheDir() async {
|
||||
final directory = await cacheDir.future;
|
||||
return join(directory.path, 'icons');
|
||||
}
|
||||
|
||||
Future<String> getProvidersRootPath() async {
|
||||
final directory = await profilesPath;
|
||||
return join(directory, 'providers');
|
||||
return join(directory, providersDirectoryName);
|
||||
}
|
||||
|
||||
Future<String> getProvidersDirPath(String id) async {
|
||||
final directory = await profilesPath;
|
||||
return join(directory, 'providers', id);
|
||||
Future<String> getProviderDirPath(int profileId, String type) async {
|
||||
final directory = await getProvidersRootPath();
|
||||
return join(directory, profileId.toString(), type);
|
||||
}
|
||||
|
||||
Future<String> getProvidersFilePath(
|
||||
String id,
|
||||
String type,
|
||||
String url,
|
||||
) async {
|
||||
final directory = await profilesPath;
|
||||
return join(directory, 'providers', id, type, url.toMd5());
|
||||
Future<void> ensureProviderDirs(int profileId) async {
|
||||
for (final type in const [
|
||||
proxiesProviderDirectoryName,
|
||||
rulesProviderDirectoryName,
|
||||
]) {
|
||||
final directory = Directory(await getProviderDirPath(profileId, type));
|
||||
if (await directory.exists()) {
|
||||
continue;
|
||||
}
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> get tempPath async {
|
||||
@@ -148,3 +159,11 @@ class AppPath {
|
||||
}
|
||||
|
||||
final appPath = AppPath();
|
||||
|
||||
String getBackupFileName() {
|
||||
return '${appName}_backup_${DateTime.now().show}.zip';
|
||||
}
|
||||
|
||||
String get logFileName {
|
||||
return '${appName}_${DateTime.now().show}.log';
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:wifi_ssid/wifi_ssid_manager.dart';
|
||||
|
||||
@@ -45,26 +45,28 @@ class Permissions {
|
||||
final bool Function() _supportsLocationPermissions;
|
||||
|
||||
bool _isRequestingLocation = false;
|
||||
bool _autoRequestedLocation = false;
|
||||
bool _hadExcludeSSIDs = false;
|
||||
bool needWaitingBatteryOptimizationSettings = false;
|
||||
|
||||
void check() {
|
||||
checkLocationPermissions();
|
||||
checkBatteryOptimizationDisable();
|
||||
void check(ProviderReader read) {
|
||||
checkLocationPermissions(read);
|
||||
checkBatteryOptimizationDisable(read);
|
||||
}
|
||||
|
||||
Future<void> checkBatteryOptimizationDisable() async {
|
||||
await _checkBatteryOptimizationDisable();
|
||||
Future<void> checkBatteryOptimizationDisable(ProviderReader read) async {
|
||||
await _checkBatteryOptimizationDisable(read);
|
||||
}
|
||||
|
||||
Future<void> _checkBatteryOptimizationDisable() async {
|
||||
Future<void> _checkBatteryOptimizationDisable(ProviderReader read) async {
|
||||
const tag = LoadingTag.batteryOptimization;
|
||||
try {
|
||||
if (needWaitingBatteryOptimizationSettings) {
|
||||
globalState.container.read(loadingProvider(tag).notifier).value = true;
|
||||
read(loadingProvider(tag).notifier).value = true;
|
||||
}
|
||||
globalState.container
|
||||
.read(batteryOptimizationDisableProvider.notifier)
|
||||
.value = await retry<bool>(
|
||||
read(
|
||||
batteryOptimizationDisableProvider.notifier,
|
||||
).value = await retry<bool>(
|
||||
task: () async {
|
||||
return await app?.isBatteryOptimizationDisabled() ?? false;
|
||||
},
|
||||
@@ -73,37 +75,46 @@ class Permissions {
|
||||
maxAttempts: needWaitingBatteryOptimizationSettings ? 5 : 1,
|
||||
);
|
||||
} finally {
|
||||
globalState.container.read(loadingProvider(tag).notifier).value = false;
|
||||
read(loadingProvider(tag).notifier).value = false;
|
||||
needWaitingBatteryOptimizationSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkLocationPermissions() async {
|
||||
Future<void> checkLocationPermissions(ProviderReader read) async {
|
||||
if (!_supportsLocationPermissions()) {
|
||||
return;
|
||||
}
|
||||
final res = await WifiSsidManager.instance.checkPermission();
|
||||
final current = globalState.container.read(locationPermissionsProvider);
|
||||
final current = read(locationPermissionsProvider);
|
||||
if (res == WifiSsidPermission.granted ||
|
||||
current != WifiSsidPermission.permanentlyDenied) {
|
||||
globalState.container.read(locationPermissionsProvider.notifier).value =
|
||||
res;
|
||||
read(locationPermissionsProvider.notifier).value = res;
|
||||
}
|
||||
final needRequestPermission = globalState.container.read(
|
||||
final needRequestPermission = read(
|
||||
excludeSSIDsProvider.select((state) => state.isNotEmpty),
|
||||
);
|
||||
if (needRequestPermission && !_hadExcludeSSIDs) {
|
||||
_autoRequestedLocation = false;
|
||||
}
|
||||
_hadExcludeSSIDs = needRequestPermission;
|
||||
if (res == WifiSsidPermission.denied &&
|
||||
needRequestPermission &&
|
||||
!_autoRequestedLocation &&
|
||||
!_isRequestingLocation) {
|
||||
_isRequestingLocation = true;
|
||||
try {
|
||||
_isRequestingLocation = true;
|
||||
final res = await WifiSsidManager.instance.requestPermission();
|
||||
globalState.container.read(locationPermissionsProvider.notifier).value =
|
||||
res;
|
||||
_autoRequestedLocation = true;
|
||||
read(locationPermissionsProvider.notifier).value = res;
|
||||
if (res == WifiSsidPermission.granted) {
|
||||
final ssid = await WifiSsidManager.instance.getSsid();
|
||||
globalState.container.read(currentSSIDProvider.notifier).value = ssid;
|
||||
read(currentSSIDProvider.notifier).value = ssid;
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
commonPrint.log(
|
||||
'requestPermission error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
} finally {
|
||||
_isRequestingLocation = false;
|
||||
}
|
||||
|
||||
@@ -11,32 +11,32 @@ class Picker {
|
||||
return FilePicker.pickFile(initialDirectory: await appPath.downloadDirPath);
|
||||
}
|
||||
|
||||
Future<String?> saveFile(String fileName, Uint8List bytes) async {
|
||||
final path = await FilePicker.saveFile(
|
||||
Future<Uri?> saveFile(String fileName, Uint8List bytes) async {
|
||||
final uri = await FilePicker.saveFile(
|
||||
fileName: fileName,
|
||||
initialDirectory: await appPath.downloadDirPath,
|
||||
bytes: bytes,
|
||||
);
|
||||
if (!system.isAndroid && path != null) {
|
||||
final file = File(path);
|
||||
if (!system.isAndroid && uri != null && uri.scheme == 'file') {
|
||||
final file = File(uri.toFilePath());
|
||||
await file.safeWriteAsBytes(bytes);
|
||||
}
|
||||
return path;
|
||||
return uri;
|
||||
}
|
||||
|
||||
Future<String?> saveFileWithPath(String fileName, String localPath) async {
|
||||
Future<Uri?> saveFileWithPath(String fileName, String localPath) async {
|
||||
final localFile = File(localPath);
|
||||
if (!await localFile.exists()) {
|
||||
await localFile.create(recursive: true);
|
||||
}
|
||||
final bytes = await localFile.readAsBytes();
|
||||
final path = await FilePicker.saveFile(
|
||||
final uri = await FilePicker.saveFile(
|
||||
fileName: fileName,
|
||||
initialDirectory: await appPath.downloadDirPath,
|
||||
bytes: bytes,
|
||||
);
|
||||
await localFile.safeDelete();
|
||||
return path;
|
||||
return uri;
|
||||
}
|
||||
|
||||
Future<String?> pickerConfigQRCode() async {
|
||||
@@ -51,7 +51,7 @@ class Picker {
|
||||
);
|
||||
final result = capture?.barcodes.first.rawValue;
|
||||
if (result == null || !result.isUrl) {
|
||||
throw currentAppLocalizations.pleaseUploadValidQrcode;
|
||||
throw MessageException(currentAppLocalizations.pleaseUploadValidQrcode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/system_dns.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'constant.dart';
|
||||
|
||||
class Preferences {
|
||||
static Preferences? _instance;
|
||||
Completer<SharedPreferences?> sharedPreferencesCompleter = Completer();
|
||||
@@ -46,7 +48,11 @@ class Preferences {
|
||||
if (configString == null) return null;
|
||||
final Map<String, Object?>? configMap = json.decode(configString);
|
||||
return configMap;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'getConfigMap error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -57,7 +63,11 @@ class Preferences {
|
||||
final clashConfigString = preferences?.getString(clashConfigKey);
|
||||
if (clashConfigString == null) return null;
|
||||
return json.decode(clashConfigString);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'getClashConfigMap error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +77,11 @@ class Preferences {
|
||||
final preferences = await sharedPreferencesCompleter.future;
|
||||
await preferences?.remove(clashConfigKey);
|
||||
return;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'clearClashConfig error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +99,58 @@ class Preferences {
|
||||
return preferences?.setString(configKey, json.encode(config)) ?? false;
|
||||
}
|
||||
|
||||
Future<SystemDnsRecord?> getSystemDnsRecord() async {
|
||||
try {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
final raw = sharedPreferencesIns?.getString(systemDnsRecordKey);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
return SystemDnsRecord.fromJson(json.decode(raw));
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'getSystemDnsRecord error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveSystemDnsRecord(SystemDnsRecord record) async {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
await sharedPreferencesIns?.setString(
|
||||
systemDnsRecordKey,
|
||||
json.encode(record),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearSystemDnsRecord() async {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
await sharedPreferencesIns?.remove(systemDnsRecordKey);
|
||||
}
|
||||
|
||||
Future<BootRecord?> getBootRecord() async {
|
||||
try {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
final raw = sharedPreferencesIns?.getString(bootRecordKey);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
return BootRecord.fromJson(json.decode(raw));
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'getBootRecord error ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveBootRecord(BootRecord record) async {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
await sharedPreferencesIns?.setString(bootRecordKey, json.encode(record));
|
||||
}
|
||||
|
||||
Future<void> clearPreferences() async {
|
||||
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
|
||||
await sharedPreferencesIns?.clear();
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
String compactError(Object error) {
|
||||
if (error is DioException) {
|
||||
final statusCode = error.response?.statusCode;
|
||||
return statusCode != null
|
||||
? 'DioException(${error.type.name}, HTTP $statusCode)'
|
||||
: 'DioException(${error.type.name})';
|
||||
}
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
class CommonPrint {
|
||||
static CommonPrint? _instance;
|
||||
|
||||
@@ -2,6 +2,10 @@ import 'dart:io';
|
||||
|
||||
import 'package:win32_registry/win32_registry.dart';
|
||||
|
||||
import 'print.dart';
|
||||
|
||||
const protocolSchemes = ['clash', 'clashmeta', 'flclash'];
|
||||
|
||||
class ProtocolRegistrationPlan {
|
||||
final String scheme;
|
||||
final String executable;
|
||||
@@ -22,6 +26,51 @@ class ProtocolRegistrationPlan {
|
||||
String get command => '"$executable" "%1"';
|
||||
}
|
||||
|
||||
/// A user-level desktop entry that claims the schemes for the running binary,
|
||||
/// so an AppImage or a development build is reachable without a packaged
|
||||
/// .desktop file. Rewritten on every launch, like the Windows registry keys.
|
||||
class LinuxProtocolRegistrationPlan {
|
||||
final List<String> schemes;
|
||||
final String executable;
|
||||
final String applicationsDir;
|
||||
|
||||
const LinuxProtocolRegistrationPlan({
|
||||
required this.schemes,
|
||||
required this.executable,
|
||||
required this.applicationsDir,
|
||||
});
|
||||
|
||||
String get desktopId => 'flclash-url-handler.desktop';
|
||||
|
||||
String get desktopPath => '$applicationsDir/$desktopId';
|
||||
|
||||
List<String> get mimeTypes =>
|
||||
schemes.map((scheme) => 'x-scheme-handler/$scheme').toList();
|
||||
|
||||
String get exec => '"${_quoteExecArgument(executable)}" %u';
|
||||
|
||||
String get desktopEntry => [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=FlClash',
|
||||
'NoDisplay=true',
|
||||
'Exec=$exec',
|
||||
'MimeType=${mimeTypes.join(';')};',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
List<String> get xdgMimeArguments => ['default', desktopId, ...mimeTypes];
|
||||
|
||||
static String _quoteExecArgument(String value) {
|
||||
return value
|
||||
.replaceAll(r'\', r'\\')
|
||||
.replaceAll('"', r'\"')
|
||||
.replaceAll(r'$', r'\$')
|
||||
.replaceAll('`', r'\`')
|
||||
.replaceAll('%', '%%');
|
||||
}
|
||||
}
|
||||
|
||||
class Protocol {
|
||||
static Protocol? _instance;
|
||||
|
||||
@@ -53,6 +102,32 @@ class Protocol {
|
||||
regKey.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> registerLinux(List<String> schemes) async {
|
||||
final env = Platform.environment;
|
||||
final home = env['HOME'];
|
||||
if (home == null || home.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final dataHome = env['XDG_DATA_HOME'];
|
||||
final plan = LinuxProtocolRegistrationPlan(
|
||||
schemes: schemes,
|
||||
executable: env['APPIMAGE'] ?? Platform.resolvedExecutable,
|
||||
applicationsDir:
|
||||
'${dataHome?.isNotEmpty == true ? dataHome : '$home/.local/share'}/applications',
|
||||
);
|
||||
try {
|
||||
final file = File(plan.desktopPath);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(plan.desktopEntry);
|
||||
final result = await Process.run('xdg-mime', plan.xdgMimeArguments);
|
||||
if (result.exitCode != 0) {
|
||||
commonPrint.log('xdg-mime default failed: ${result.stderr}'.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
commonPrint.log('linux protocol registration failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final protocol = Protocol();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'package:riverpod/misc.dart' show ProviderListenable;
|
||||
|
||||
typedef ProviderReader = T Function<T>(ProviderListenable<T> provider);
|
||||
@@ -8,13 +8,18 @@ import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
class Request {
|
||||
late final Dio dio;
|
||||
late final Dio _clashDio;
|
||||
String? userAgent;
|
||||
|
||||
ProviderReader? _read;
|
||||
|
||||
void attach(ProviderReader read) {
|
||||
_read = read;
|
||||
}
|
||||
|
||||
Request() {
|
||||
dio = Dio(BaseOptions(headers: {'User-Agent': browserUa}));
|
||||
_clashDio = Dio();
|
||||
@@ -23,7 +28,11 @@ class Request {
|
||||
final client = HttpClient();
|
||||
client.findProxy = (Uri uri) {
|
||||
client.userAgent = globalState.ua;
|
||||
return FlClashHttpOverrides.handleFindProxy(uri);
|
||||
final read = _read;
|
||||
if (read == null) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
return FlClashHttpOverrides.findProxyForReader(read, uri);
|
||||
};
|
||||
return client;
|
||||
},
|
||||
@@ -37,36 +46,27 @@ class Request {
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
} catch (e) {
|
||||
commonPrint.log('getFileResponseForUrl error ${e.toString()}');
|
||||
if (e is DioException) {
|
||||
if (e.type == DioExceptionType.unknown) {
|
||||
throw currentAppLocalizations.unknownNetworkError;
|
||||
} else if (e.type == DioExceptionType.badResponse) {
|
||||
throw currentAppLocalizations.networkException;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
throw currentAppLocalizations.unknownNetworkError;
|
||||
commonPrint.log(
|
||||
'getFileResponseForUrl error ${compactError(e)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<String>> getTextResponseForUrl(String url) async {
|
||||
final response = await _clashDio.get<String>(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<MemoryImage?> getImage(String url) async {
|
||||
if (url.isEmpty) return null;
|
||||
final response = await dio.get<Uint8List>(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) return null;
|
||||
return MemoryImage(data);
|
||||
try {
|
||||
return await _clashDio.get<String>(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'getTextResponseForUrl error ${compactError(e)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> checkForUpdate() async {
|
||||
@@ -80,7 +80,7 @@ class Request {
|
||||
final remoteVersion = data['tag_name'];
|
||||
final version = globalState.packageInfo.version;
|
||||
final hasUpdate =
|
||||
utils.compareVersions(remoteVersion.replaceAll('v', ''), version) > 0;
|
||||
compareVersions(remoteVersion.replaceAll('v', ''), version) > 0;
|
||||
if (!hasUpdate) return null;
|
||||
return data;
|
||||
} catch (e) {
|
||||
@@ -117,25 +117,27 @@ class Request {
|
||||
options: Options(responseType: ResponseType.json),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
future
|
||||
.then((res) {
|
||||
if (res.statusCode == HttpStatus.ok && res.data != null) {
|
||||
completer.complete(Result.success(source.value(res.data!)));
|
||||
return;
|
||||
}
|
||||
commonPrint.log('checkIp data empty', logLevel: LogLevel.info);
|
||||
failureCount++;
|
||||
handleFailRes();
|
||||
})
|
||||
.catchError((e) {
|
||||
failureCount++;
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) {
|
||||
completer.complete(Result.error('cancelled'));
|
||||
return;
|
||||
}
|
||||
commonPrint.log('checkIp error $e', logLevel: LogLevel.warning);
|
||||
handleFailRes();
|
||||
});
|
||||
unawaited(
|
||||
future
|
||||
.then((res) {
|
||||
if (res.statusCode == HttpStatus.ok && res.data != null) {
|
||||
completer.complete(Result.success(source.value(res.data!)));
|
||||
return;
|
||||
}
|
||||
commonPrint.log('checkIp data empty', logLevel: LogLevel.info);
|
||||
failureCount++;
|
||||
handleFailRes();
|
||||
})
|
||||
.catchError((e) {
|
||||
failureCount++;
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) {
|
||||
completer.complete(Result.error('cancelled'));
|
||||
return;
|
||||
}
|
||||
commonPrint.log('checkIp error $e', logLevel: LogLevel.warning);
|
||||
handleFailRes();
|
||||
}),
|
||||
);
|
||||
return completer.future;
|
||||
});
|
||||
final res = await Future.any(futures);
|
||||
@@ -145,3 +147,25 @@ class Request {
|
||||
}
|
||||
|
||||
final request = Request();
|
||||
|
||||
String? getFileNameForDisposition(String? disposition) {
|
||||
if (disposition == null) return null;
|
||||
final parseValue = HeaderValue.parse(disposition);
|
||||
final parameters = parseValue.parameters;
|
||||
final fileNamePointKey = parameters.keys.firstWhere(
|
||||
(key) => key == 'filename*',
|
||||
orElse: () => '',
|
||||
);
|
||||
if (fileNamePointKey.isNotEmpty) {
|
||||
final res = parameters[fileNamePointKey]?.split("''") ?? [];
|
||||
if (res.length >= 2) {
|
||||
return Uri.decodeComponent(res[1]);
|
||||
}
|
||||
}
|
||||
final fileNameKey = parameters.keys.firstWhere(
|
||||
(key) => key == 'filename',
|
||||
orElse: () => '',
|
||||
);
|
||||
if (fileNameKey.isEmpty) return null;
|
||||
return parameters[fileNameKey];
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ import 'dart:ui';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/widgets/scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class BaseScrollBehavior extends MaterialScrollBehavior {
|
||||
const BaseScrollBehavior({this.scrollbarPadding = EdgeInsets.zero});
|
||||
|
||||
final EdgeInsets scrollbarPadding;
|
||||
|
||||
@override
|
||||
Set<PointerDeviceKind> get dragDevices => {
|
||||
PointerDeviceKind.touch,
|
||||
@@ -16,54 +20,54 @@ class BaseScrollBehavior extends MaterialScrollBehavior {
|
||||
PointerDeviceKind.unknown,
|
||||
};
|
||||
|
||||
bool showScrollbar(BuildContext context) {
|
||||
switch (getPlatform(context)) {
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.macOS:
|
||||
case TargetPlatform.windows:
|
||||
return true;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.iOS:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildScrollbar(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
ScrollableDetails details,
|
||||
) {
|
||||
switch (axisDirectionToAxis(details.direction)) {
|
||||
case Axis.horizontal:
|
||||
return child;
|
||||
case Axis.vertical:
|
||||
switch (getPlatform(context)) {
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.macOS:
|
||||
case TargetPlatform.windows:
|
||||
assert(details.controller != null);
|
||||
return CommonScrollBar(
|
||||
controller: details.controller,
|
||||
child: child,
|
||||
);
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.iOS:
|
||||
return child;
|
||||
}
|
||||
if (axisDirectionToAxis(details.direction) == Axis.horizontal ||
|
||||
!showScrollbar(context)) {
|
||||
return child;
|
||||
}
|
||||
return CommonScrollBar(
|
||||
controller: details.controller,
|
||||
padding: scrollbarPadding,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldNotify(covariant BaseScrollBehavior oldDelegate) {
|
||||
return oldDelegate.scrollbarPadding != scrollbarPadding;
|
||||
}
|
||||
}
|
||||
|
||||
class HiddenBarScrollBehavior extends BaseScrollBehavior {
|
||||
const HiddenBarScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildScrollbar(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
ScrollableDetails details,
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
bool showScrollbar(BuildContext context) => false;
|
||||
}
|
||||
|
||||
class ShowBarScrollBehavior extends BaseScrollBehavior {
|
||||
const ShowBarScrollBehavior({super.scrollbarPadding});
|
||||
|
||||
@override
|
||||
Widget buildScrollbar(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
ScrollableDetails details,
|
||||
) {
|
||||
return CommonScrollBar(controller: details.controller, child: child);
|
||||
}
|
||||
bool showScrollbar(BuildContext context) => true;
|
||||
}
|
||||
|
||||
class NextClampingScrollPhysics extends ClampingScrollPhysics {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show ClipOp, lerpDouble;
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
abstract final class AppCorner {
|
||||
static const double none = 0;
|
||||
static const double xs = 4;
|
||||
static const double sm = 8;
|
||||
static const double md = 16;
|
||||
static const double lg = 20;
|
||||
static const double xl = 24;
|
||||
static const double xxl = 28;
|
||||
static const double full = 1000;
|
||||
|
||||
static double fit(double shortestSide) {
|
||||
final limit = shortestSide / 3;
|
||||
if (limit >= xxl) return xxl;
|
||||
if (limit >= xl) return xl;
|
||||
if (limit >= lg) return lg;
|
||||
if (limit >= md) return md;
|
||||
if (limit >= sm) return sm;
|
||||
if (limit >= xs) return xs;
|
||||
return none;
|
||||
}
|
||||
}
|
||||
|
||||
abstract final class AppRadius {
|
||||
static const BorderRadius none = BorderRadius.zero;
|
||||
static const BorderRadius xs = BorderRadius.all(
|
||||
Radius.circular(AppCorner.xs),
|
||||
);
|
||||
static const BorderRadius sm = BorderRadius.all(
|
||||
Radius.circular(AppCorner.sm),
|
||||
);
|
||||
static const BorderRadius md = BorderRadius.all(
|
||||
Radius.circular(AppCorner.md),
|
||||
);
|
||||
static const BorderRadius lg = BorderRadius.all(
|
||||
Radius.circular(AppCorner.lg),
|
||||
);
|
||||
static const BorderRadius xl = BorderRadius.all(
|
||||
Radius.circular(AppCorner.xl),
|
||||
);
|
||||
static const BorderRadius xxl = BorderRadius.all(
|
||||
Radius.circular(AppCorner.xxl),
|
||||
);
|
||||
static const BorderRadius full = BorderRadius.all(
|
||||
Radius.circular(AppCorner.full),
|
||||
);
|
||||
|
||||
static BorderRadius all(double corner) => BorderRadius.circular(corner);
|
||||
|
||||
static BorderRadius top(double corner) =>
|
||||
BorderRadius.vertical(top: Radius.circular(corner));
|
||||
|
||||
static BorderRadius vertical({
|
||||
double top = AppCorner.none,
|
||||
double bottom = AppCorner.none,
|
||||
}) => BorderRadius.vertical(
|
||||
top: Radius.circular(top),
|
||||
bottom: Radius.circular(bottom),
|
||||
);
|
||||
}
|
||||
|
||||
abstract final class AppShape {
|
||||
static const RoundedSuperellipseBorder none = RoundedSuperellipseBorder();
|
||||
static const RoundedSuperellipseBorder xs = RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.xs,
|
||||
);
|
||||
static const RoundedSuperellipseBorder sm = RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.sm,
|
||||
);
|
||||
static const RoundedSuperellipseBorder md = RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.md,
|
||||
);
|
||||
static const RoundedSuperellipseBorder lg = RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.lg,
|
||||
);
|
||||
static const RoundedSuperellipseBorder xl = RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.xl,
|
||||
);
|
||||
static const RoundedSuperellipseBorder xxl = RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.xxl,
|
||||
);
|
||||
static const StadiumBorder full = StadiumBorder();
|
||||
static const CircleBorder circle = CircleBorder();
|
||||
static const AppInputBorder input = AppInputBorder();
|
||||
|
||||
static RoundedSuperellipseBorder all(double corner) =>
|
||||
RoundedSuperellipseBorder(borderRadius: AppRadius.all(corner));
|
||||
|
||||
static RoundedSuperellipseBorder top(double corner) =>
|
||||
RoundedSuperellipseBorder(borderRadius: AppRadius.top(corner));
|
||||
|
||||
static RoundedSuperellipseBorder vertical({
|
||||
double top = AppCorner.none,
|
||||
double bottom = AppCorner.none,
|
||||
}) => RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.vertical(top: top, bottom: bottom),
|
||||
);
|
||||
|
||||
static RoundedSuperellipseBorder of(BorderRadius borderRadius) =>
|
||||
RoundedSuperellipseBorder(borderRadius: borderRadius);
|
||||
}
|
||||
|
||||
class AppInputBorder extends InputBorder {
|
||||
const AppInputBorder({
|
||||
super.borderSide = const BorderSide(),
|
||||
this.borderRadius = AppRadius.md,
|
||||
this.gapPadding = 4.0,
|
||||
});
|
||||
|
||||
final BorderRadius borderRadius;
|
||||
final double gapPadding;
|
||||
|
||||
RoundedSuperellipseBorder get _shape =>
|
||||
RoundedSuperellipseBorder(borderRadius: borderRadius, side: borderSide);
|
||||
|
||||
@override
|
||||
bool get isOutline => true;
|
||||
|
||||
@override
|
||||
bool get preferPaintInterior => true;
|
||||
|
||||
@override
|
||||
EdgeInsetsGeometry get dimensions => EdgeInsets.all(borderSide.width);
|
||||
|
||||
@override
|
||||
AppInputBorder copyWith({
|
||||
BorderSide? borderSide,
|
||||
BorderRadius? borderRadius,
|
||||
double? gapPadding,
|
||||
}) {
|
||||
return AppInputBorder(
|
||||
borderSide: borderSide ?? this.borderSide,
|
||||
borderRadius: borderRadius ?? this.borderRadius,
|
||||
gapPadding: gapPadding ?? this.gapPadding,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AppInputBorder scale(double t) {
|
||||
return AppInputBorder(
|
||||
borderSide: borderSide.scale(t),
|
||||
borderRadius: borderRadius * t,
|
||||
gapPadding: gapPadding * t,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
|
||||
_shape.getInnerPath(rect, textDirection: textDirection);
|
||||
|
||||
@override
|
||||
Path getOuterPath(Rect rect, {TextDirection? textDirection}) =>
|
||||
_shape.getOuterPath(rect, textDirection: textDirection);
|
||||
|
||||
@override
|
||||
void paintInterior(
|
||||
Canvas canvas,
|
||||
Rect rect,
|
||||
Paint paint, {
|
||||
TextDirection? textDirection,
|
||||
}) => _shape.paintInterior(canvas, rect, paint, textDirection: textDirection);
|
||||
|
||||
@override
|
||||
ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
|
||||
if (a is AppInputBorder) {
|
||||
return AppInputBorder(
|
||||
borderSide: BorderSide.lerp(a.borderSide, borderSide, t),
|
||||
borderRadius: BorderRadius.lerp(a.borderRadius, borderRadius, t)!,
|
||||
gapPadding: a.gapPadding,
|
||||
);
|
||||
}
|
||||
return super.lerpFrom(a, t);
|
||||
}
|
||||
|
||||
@override
|
||||
ShapeBorder? lerpTo(ShapeBorder? b, double t) {
|
||||
if (b is AppInputBorder) {
|
||||
return AppInputBorder(
|
||||
borderSide: BorderSide.lerp(borderSide, b.borderSide, t),
|
||||
borderRadius: BorderRadius.lerp(borderRadius, b.borderRadius, t)!,
|
||||
gapPadding: b.gapPadding,
|
||||
);
|
||||
}
|
||||
return super.lerpTo(b, t);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(
|
||||
Canvas canvas,
|
||||
Rect rect, {
|
||||
double? gapStart,
|
||||
double gapExtent = 0.0,
|
||||
double gapPercentage = 0.0,
|
||||
TextDirection? textDirection,
|
||||
}) {
|
||||
final outline = rect.deflate(borderSide.width / 2);
|
||||
if (gapStart == null || gapExtent <= 0.0 || gapPercentage == 0.0) {
|
||||
_shape.paint(canvas, outline, textDirection: textDirection);
|
||||
return;
|
||||
}
|
||||
final extent = lerpDouble(
|
||||
0.0,
|
||||
gapExtent + gapPadding * 2.0,
|
||||
gapPercentage,
|
||||
)!;
|
||||
final start = switch (textDirection ?? TextDirection.ltr) {
|
||||
TextDirection.rtl => gapStart + gapPadding - extent,
|
||||
TextDirection.ltr => gapStart - gapPadding,
|
||||
};
|
||||
final radii = borderRadius.resolve(textDirection);
|
||||
final left = outline.left + math.max(0.0, start);
|
||||
final depth =
|
||||
math.max(radii.topLeft.y, radii.topRight.y) + borderSide.width;
|
||||
canvas
|
||||
..save()
|
||||
..clipRect(
|
||||
Rect.fromLTRB(
|
||||
left,
|
||||
outline.top - borderSide.width,
|
||||
left + extent,
|
||||
outline.top + depth,
|
||||
),
|
||||
clipOp: ClipOp.difference,
|
||||
);
|
||||
_shape.paint(canvas, outline, textDirection: textDirection);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return other is AppInputBorder &&
|
||||
other.borderSide == borderSide &&
|
||||
other.borderRadius == borderRadius &&
|
||||
other.gapPadding == gapPadding;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(borderSide, borderRadius, gapPadding);
|
||||
}
|
||||
|
||||
extension AppShapeThemeExt on ThemeData {
|
||||
ThemeData get withAppShapes => copyWith(
|
||||
cardTheme: cardTheme.copyWith(shape: AppShape.md),
|
||||
dialogTheme: dialogTheme.copyWith(shape: AppShape.xxl),
|
||||
bottomSheetTheme: bottomSheetTheme.copyWith(
|
||||
shape: AppShape.top(AppCorner.xxl),
|
||||
),
|
||||
snackBarTheme: snackBarTheme.copyWith(shape: AppShape.xs),
|
||||
chipTheme: chipTheme.copyWith(shape: AppShape.sm),
|
||||
popupMenuTheme: popupMenuTheme.copyWith(shape: AppShape.md),
|
||||
menuTheme: MenuThemeData(
|
||||
style: (menuTheme.style ?? const MenuStyle()).copyWith(
|
||||
shape: const WidgetStatePropertyAll(AppShape.md),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: inputDecorationTheme.copyWith(border: AppShape.input),
|
||||
floatingActionButtonTheme: floatingActionButtonTheme.copyWith(
|
||||
shape: AppShape.md,
|
||||
),
|
||||
navigationBarTheme: navigationBarTheme.copyWith(
|
||||
indicatorShape: AppShape.full,
|
||||
),
|
||||
navigationRailTheme: navigationRailTheme.copyWith(
|
||||
indicatorShape: AppShape.full,
|
||||
),
|
||||
progressIndicatorTheme: progressIndicatorTheme.copyWith(
|
||||
borderRadius: AppRadius.full,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
class Snowflake {
|
||||
static Snowflake? _instance;
|
||||
|
||||
@@ -62,3 +64,26 @@ class Snowflake {
|
||||
}
|
||||
|
||||
final snowflake = Snowflake();
|
||||
|
||||
String get uniqueId {
|
||||
final timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
final random = Random();
|
||||
final randomStr = String.fromCharCodes(
|
||||
List.generate(8, (_) => random.nextInt(26) + 97),
|
||||
);
|
||||
return '$timestamp$randomStr';
|
||||
}
|
||||
|
||||
String get uuidV4 {
|
||||
final Random random = Random();
|
||||
final bytes = List.generate(16, (_) => random.nextInt(256));
|
||||
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80;
|
||||
|
||||
final hex = bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}';
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
class Store<T> {
|
||||
late T _data;
|
||||
|
||||
Store(Stream stream, T defaultValue) {
|
||||
stream.listen((data) {
|
||||
_add(data);
|
||||
});
|
||||
_data = defaultValue;
|
||||
}
|
||||
|
||||
bool equals(T oldValue, T newValue) {
|
||||
return oldValue == newValue;
|
||||
}
|
||||
|
||||
void _add(T value) {
|
||||
if (!equals(_data, value)) {
|
||||
_streamController.add(value);
|
||||
_data = value;
|
||||
}
|
||||
}
|
||||
|
||||
final StreamController<T> _streamController = StreamController<T>.broadcast();
|
||||
|
||||
Stream<T> get stream => _streamController.stream;
|
||||
|
||||
T get value => _data;
|
||||
|
||||
set value(T value) {
|
||||
_add(value);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
extension StringExtension on String {
|
||||
bool get isUrl {
|
||||
@@ -56,6 +59,10 @@ extension StringExtension on String {
|
||||
try {
|
||||
return base64.decode(realValue);
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'invalid base64 data ${e.toString()}',
|
||||
logLevel: LogLevel.debug,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +86,7 @@ extension StringExtension on String {
|
||||
return md5.convert(bytes).toString();
|
||||
}
|
||||
|
||||
Future<T> commonToJSON<T>() async {
|
||||
Future<T> decodeJson<T>() async {
|
||||
const thresholdLimit = 51200;
|
||||
if (length < thresholdLimit) {
|
||||
return json.decode(this);
|
||||
@@ -108,3 +115,48 @@ extension StringNullExt on String? {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
String generateRandomString({int minLength = 10, int maxLength = 100}) {
|
||||
const latinChars =
|
||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
final random = Random();
|
||||
|
||||
final int length = minLength + random.nextInt(maxLength - minLength + 1);
|
||||
|
||||
String result = '';
|
||||
for (int i = 0; i < length; i++) {
|
||||
if (random.nextBool()) {
|
||||
result += String.fromCharCode(
|
||||
0x4E00 + random.nextInt(0x9FA5 - 0x4E00 + 1),
|
||||
);
|
||||
} else {
|
||||
result += latinChars[random.nextInt(latinChars.length)];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
String generateRandomSecret(int length) {
|
||||
const chars =
|
||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
final random = Random.secure();
|
||||
return String.fromCharCodes(
|
||||
Iterable.generate(
|
||||
length,
|
||||
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String getOverwriteLabel(String label) {
|
||||
final reg = RegExp(r'\((\d+)\)$');
|
||||
final matches = reg.allMatches(label);
|
||||
if (matches.isNotEmpty) {
|
||||
final match = matches.last;
|
||||
final number = int.parse(match[1] ?? '0') + 1;
|
||||
return '${label.substring(0, match.start)}($number)';
|
||||
} else {
|
||||
return '$label(1)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,25 @@ import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:fl_clash/common/boot_record.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/system_dns.dart';
|
||||
import 'package:fl_clash/core/desktop/helper_client.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/widgets/input.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
typedef ProcessRunner =
|
||||
Future<ProcessResult> Function(String executable, List<String> arguments);
|
||||
|
||||
class System {
|
||||
static System? _instance;
|
||||
bool _isTV = false;
|
||||
|
||||
@visibleForTesting
|
||||
ProcessRunner runProcess = Process.run;
|
||||
|
||||
System._internal();
|
||||
|
||||
factory System() {
|
||||
@@ -59,29 +65,107 @@ class System {
|
||||
return await app?.didCrashOnPreviousExecution() ?? false;
|
||||
}
|
||||
|
||||
Future<AppExitInfo?> lastExitInfo() async {
|
||||
if (!isAndroid) return null;
|
||||
return app?.getLastExitInfo();
|
||||
}
|
||||
|
||||
/// [corePath] is handed to `Process.run` as an argv entry, so it must stay
|
||||
/// verbatim. Shell-quoting or escaping it here reaches `stat` as part of the
|
||||
/// file name and turns every path containing a space into a miss.
|
||||
@visibleForTesting
|
||||
static List<String> statArguments(String corePath, {required bool isMacOS}) {
|
||||
return isMacOS
|
||||
? ['-f', '%Su:%Sg %Sp', corePath]
|
||||
: ['-c', '%U:%G %A', corePath];
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static bool isPrivilegedStatOutput(
|
||||
String output, {
|
||||
required String ownerPrefix,
|
||||
}) {
|
||||
final trimmed = output.trim();
|
||||
return trimmed.startsWith(ownerPrefix) && trimmed.contains('rws');
|
||||
}
|
||||
|
||||
/// A read-only nosuid mount at a path that changes every run: no elevation sticks.
|
||||
bool get isAppImage =>
|
||||
isLinux && Platform.environment.containsKey('APPIMAGE');
|
||||
|
||||
late final bool _hasSystemd = Directory('/run/systemd/system').existsSync();
|
||||
|
||||
bool get hasHelperService =>
|
||||
isWindows || (isLinux && !isAppImage && _hasSystemd);
|
||||
|
||||
Future<bool> checkIsAdmin() async {
|
||||
final corePath = appPath.corePath.replaceAll(' ', '\\\\ ');
|
||||
if (system.isWindows) {
|
||||
return await windowsHelperClient.readiness() ==
|
||||
WindowsHelperReadiness.ready;
|
||||
} else if (system.isMacOS) {
|
||||
final result = await Process.run('stat', ['-f', '%Su:%Sg %Sp', corePath]);
|
||||
final output = result.stdout.trim();
|
||||
if (output.startsWith('root:admin') && output.contains('rws')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} else if (Platform.isLinux) {
|
||||
final result = await Process.run('stat', ['-c', '%U:%G %A', corePath]);
|
||||
final output = result.stdout.trim();
|
||||
if (output.startsWith('root:') && output.contains('rws')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (hasHelperService) {
|
||||
return await helperClient.readiness() == HelperReadiness.ready;
|
||||
}
|
||||
if (system.isMacOS) {
|
||||
final result = await runProcess(
|
||||
'stat',
|
||||
statArguments(appPath.corePath, isMacOS: true),
|
||||
);
|
||||
return isPrivilegedStatOutput(
|
||||
result.stdout.toString(),
|
||||
ownerPrefix: 'root:admin',
|
||||
);
|
||||
}
|
||||
if (system.isLinux) {
|
||||
final result = await runProcess(
|
||||
'stat',
|
||||
statArguments(appPath.corePath, isMacOS: false),
|
||||
);
|
||||
return isPrivilegedStatOutput(
|
||||
result.stdout.toString(),
|
||||
ownerPrefix: 'root:',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static const _inheritedAclPermissions =
|
||||
'list,search,add_file,add_subdirectory,delete,delete_child,'
|
||||
'file_inherit,directory_inherit';
|
||||
|
||||
@visibleForTesting
|
||||
static List<String> aclArguments(String homeDirPath, String userName) {
|
||||
return [
|
||||
'-R',
|
||||
'+a',
|
||||
'user:$userName allow $_inheritedAclPermissions',
|
||||
homeDirPath,
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> grantHomeDirAccess(String homeDirPath) async {
|
||||
if (!isMacOS) {
|
||||
return;
|
||||
}
|
||||
final userName = Platform.environment['USER'];
|
||||
if (userName == null || userName.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final result = await runProcess(
|
||||
'chmod',
|
||||
aclArguments(homeDirPath, userName),
|
||||
);
|
||||
if (result.exitCode != 0) {
|
||||
commonPrint.log(
|
||||
'chmod +a exited with ${result.exitCode}: ${result.stderr.toString().trim()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'chmod +a failed: ${compactError(error)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _shellEscape(String value) {
|
||||
return "'${value.replaceAll("'", "'\\''")}'";
|
||||
}
|
||||
@@ -93,6 +177,17 @@ class System {
|
||||
if (system.isWindows) {
|
||||
return await windows?.registerService() ?? AuthorizeCode.error;
|
||||
}
|
||||
if (hasHelperService) {
|
||||
return await linux?.registerService() ?? AuthorizeCode.error;
|
||||
}
|
||||
if (isAppImage) {
|
||||
commonPrint.log(
|
||||
'TUN cannot be authorized inside an AppImage: '
|
||||
'the bundled Core is on a read-only nosuid mount',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
return AuthorizeCode.error;
|
||||
}
|
||||
final isAdmin = await checkIsAdmin();
|
||||
if (isAdmin) {
|
||||
return AuthorizeCode.none;
|
||||
@@ -105,32 +200,32 @@ class System {
|
||||
'-e',
|
||||
'do shell script "$shell" with administrator privileges',
|
||||
];
|
||||
final result = await Process.run('osascript', arguments);
|
||||
final result = await runProcess('osascript', arguments);
|
||||
if (result.exitCode != 0) {
|
||||
return AuthorizeCode.error;
|
||||
}
|
||||
return AuthorizeCode.success;
|
||||
} else if (Platform.isLinux) {
|
||||
final shell = Platform.environment['SHELL'] ?? 'bash';
|
||||
final password = await globalState.showCommonDialog<String>(
|
||||
child: InputDialog(
|
||||
obscureText: true,
|
||||
title: currentAppLocalizations.pleaseInputAdminPassword,
|
||||
value: '',
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.password),
|
||||
),
|
||||
);
|
||||
if (password == null || password.isEmpty) {
|
||||
} else if (system.isLinux) {
|
||||
final escapedCorePath = _shellEscape(appPath.corePath);
|
||||
final ProcessResult result;
|
||||
try {
|
||||
result = await runProcess('pkexec', [
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'chown root:root $escapedCorePath && chmod +sx $escapedCorePath',
|
||||
]);
|
||||
} on ProcessException catch (error) {
|
||||
commonPrint.log(
|
||||
'pkexec is unavailable: ${compactError(error)}',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
return AuthorizeCode.error;
|
||||
}
|
||||
final escapedPassword = _shellEscape(password);
|
||||
final escapedCorePath = _shellEscape(appPath.corePath);
|
||||
final arguments = [
|
||||
'-c',
|
||||
'echo $escapedPassword | sudo -S chown root:root $escapedCorePath && echo $escapedPassword | sudo -S chmod +sx $escapedCorePath',
|
||||
];
|
||||
final result = await Process.run(shell, arguments);
|
||||
if (result.exitCode != 0) {
|
||||
commonPrint.log(
|
||||
'pkexec refused to elevate the Core: ${result.exitCode}',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
return AuthorizeCode.error;
|
||||
}
|
||||
return AuthorizeCode.success;
|
||||
@@ -140,14 +235,12 @@ class System {
|
||||
|
||||
Future<void> back() async {
|
||||
await app?.moveTaskToBack();
|
||||
await window?.hide();
|
||||
}
|
||||
|
||||
Future<void> exit() async {
|
||||
if (system.isAndroid) {
|
||||
await SystemNavigator.pop();
|
||||
}
|
||||
window?.forceExit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,130 +308,134 @@ class Windows {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<AuthorizeCode> registerService() async {
|
||||
final readiness = await windowsHelperClient.readiness();
|
||||
switch (readiness) {
|
||||
case WindowsHelperReadiness.ready:
|
||||
commonPrint.log('helper service is ready');
|
||||
return AuthorizeCode.none;
|
||||
case WindowsHelperReadiness.manifestMissing:
|
||||
commonPrint.log(
|
||||
'Core manifest is missing or invalid; Helper service unavailable, '
|
||||
'falling back to direct Core',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
globalState.showNotifier(currentAppLocalizations.helperCorruptTip);
|
||||
return AuthorizeCode.error;
|
||||
case WindowsHelperReadiness.notReady:
|
||||
break;
|
||||
}
|
||||
|
||||
commonPrint.log(
|
||||
'helper service is unavailable, requesting elevated installation',
|
||||
logLevel: LogLevel.warning,
|
||||
Future<AuthorizeCode> registerService() {
|
||||
return registerHelperService(
|
||||
() async => runas(appPath.helperPath, 'install'),
|
||||
);
|
||||
if (!runas(appPath.helperPath, 'install')) {
|
||||
}
|
||||
}
|
||||
|
||||
typedef ElevatedHelperInstaller = Future<bool> Function();
|
||||
|
||||
@visibleForTesting
|
||||
Future<AuthorizeCode> registerHelperService(
|
||||
ElevatedHelperInstaller install,
|
||||
) async {
|
||||
final readiness = await helperClient.readiness();
|
||||
switch (readiness) {
|
||||
case HelperReadiness.ready:
|
||||
commonPrint.log('helper service is ready');
|
||||
return AuthorizeCode.none;
|
||||
case HelperReadiness.manifestMissing:
|
||||
commonPrint.log(
|
||||
'failed to launch elevated helper installation',
|
||||
logLevel: LogLevel.error,
|
||||
'Core manifest is missing or invalid; Helper service unavailable, '
|
||||
'falling back to direct Core',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
dialogs.showNotifier(
|
||||
currentAppLocalizations.helperCorruptTip,
|
||||
level: MessageLevel.error,
|
||||
);
|
||||
return AuthorizeCode.error;
|
||||
}
|
||||
case HelperReadiness.notReady:
|
||||
break;
|
||||
}
|
||||
|
||||
final isRunning = await _waitForHelperService();
|
||||
commonPrint.log(
|
||||
'helper service is unavailable, requesting elevated installation',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
if (!await install()) {
|
||||
commonPrint.log(
|
||||
isRunning
|
||||
? 'helper service installation completed'
|
||||
: 'helper service did not become ready after installation',
|
||||
logLevel: isRunning ? LogLevel.info : LogLevel.error,
|
||||
'failed to launch elevated helper installation',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
return isRunning ? AuthorizeCode.success : AuthorizeCode.error;
|
||||
return AuthorizeCode.error;
|
||||
}
|
||||
|
||||
Future<bool> _waitForHelperService() async {
|
||||
const timeout = Duration(seconds: 6);
|
||||
const interval = Duration(seconds: 1);
|
||||
const maxAttempts = 6;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
final remaining = timeout - stopwatch.elapsed;
|
||||
if (remaining <= Duration.zero) return false;
|
||||
final isRunning =
|
||||
await windowsHelperClient.readiness(
|
||||
timeout: remaining,
|
||||
logFailure: false,
|
||||
) ==
|
||||
WindowsHelperReadiness.ready;
|
||||
if (isRunning) return true;
|
||||
final delay = timeout - stopwatch.elapsed;
|
||||
if (delay <= Duration.zero || attempt == maxAttempts - 1) return false;
|
||||
await Future.delayed(delay < interval ? delay : interval);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final isRunning = await _waitForHelperService();
|
||||
commonPrint.log(
|
||||
isRunning
|
||||
? 'helper service installation completed'
|
||||
: 'helper service did not become ready after installation',
|
||||
logLevel: isRunning ? LogLevel.info : LogLevel.error,
|
||||
);
|
||||
return isRunning ? AuthorizeCode.success : AuthorizeCode.error;
|
||||
}
|
||||
|
||||
Future<bool> registerTask(String appName) async {
|
||||
final taskXml =
|
||||
'''
|
||||
<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>HighestAvailable</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Triggers>
|
||||
<LogonTrigger/>
|
||||
</Triggers>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>false</AllowHardTerminate>
|
||||
<StartWhenAvailable>false</StartWhenAvailable>
|
||||
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||||
<IdleSettings>
|
||||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||||
<RestartOnIdle>false</RestartOnIdle>
|
||||
</IdleSettings>
|
||||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||||
<Enabled>true</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT72H</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>"${Platform.resolvedExecutable}"</Command>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>''';
|
||||
final taskPath = join(await appPath.tempPath, 'task.xml');
|
||||
await File(taskPath).create(recursive: true);
|
||||
await File(
|
||||
taskPath,
|
||||
).writeAsBytes(taskXml.encodeUtf16LeWithBom, flush: true);
|
||||
final commandLine = [
|
||||
'/Create',
|
||||
'/TN',
|
||||
appName,
|
||||
'/XML',
|
||||
'%s',
|
||||
'/F',
|
||||
].join(' ');
|
||||
return runas('schtasks', commandLine.replaceFirst('%s', taskPath));
|
||||
Future<bool> _waitForHelperService() async {
|
||||
const timeout = Duration(seconds: 6);
|
||||
const interval = Duration(seconds: 1);
|
||||
const maxAttempts = 6;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
final remaining = timeout - stopwatch.elapsed;
|
||||
if (remaining <= Duration.zero) return false;
|
||||
final isRunning =
|
||||
await helperClient.readiness(timeout: remaining, logFailure: false) ==
|
||||
HelperReadiness.ready;
|
||||
if (isRunning) return true;
|
||||
final delay = timeout - stopwatch.elapsed;
|
||||
if (delay <= Duration.zero || attempt == maxAttempts - 1) return false;
|
||||
await Future.delayed(delay < interval ? delay : interval);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final windows = system.isWindows ? Windows() : null;
|
||||
|
||||
class MacOS {
|
||||
class Linux {
|
||||
static Linux? _instance;
|
||||
|
||||
@visibleForTesting
|
||||
ProcessRunner runProcess = Process.run;
|
||||
|
||||
Linux._internal();
|
||||
|
||||
factory Linux() {
|
||||
_instance ??= Linux._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<AuthorizeCode> registerService() {
|
||||
return registerHelperService(installService);
|
||||
}
|
||||
|
||||
/// pkexec raises the system polkit prompt and names the requesting user to
|
||||
/// the installer, which is where the unit takes the account it grants the
|
||||
/// Helper socket to.
|
||||
@visibleForTesting
|
||||
Future<bool> installService() async {
|
||||
try {
|
||||
final result = await runProcess('pkexec', [
|
||||
appPath.helperPath,
|
||||
'install',
|
||||
]);
|
||||
if (result.exitCode == 0) {
|
||||
return true;
|
||||
}
|
||||
commonPrint.log(
|
||||
'pkexec helper install exited with ${result.exitCode}: '
|
||||
'${result.stderr.toString().trim()}',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'pkexec is unavailable: ${compactError(error)}',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final linux = system.isLinux && system.hasHelperService ? Linux() : null;
|
||||
|
||||
class MacOS implements SystemDnsPort {
|
||||
static MacOS? _instance;
|
||||
|
||||
List<String>? originDns;
|
||||
@visibleForTesting
|
||||
ProcessRunner runProcess = Process.run;
|
||||
|
||||
MacOS._internal();
|
||||
|
||||
@@ -347,88 +444,112 @@ class MacOS {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<String?> get defaultServiceName async {
|
||||
final result = await Process.run('route', ['-n', 'get', 'default']);
|
||||
final output = result.stdout.toString();
|
||||
final deviceLine = output
|
||||
@visibleForTesting
|
||||
static String? parseDefaultInterface(String routeOutput) {
|
||||
final deviceLine = routeOutput
|
||||
.split('\n')
|
||||
.firstWhere((s) => s.contains('interface:'), orElse: () => '');
|
||||
final lineSplits = deviceLine.trim().split(' ');
|
||||
if (lineSplits.length != 2) {
|
||||
return null;
|
||||
}
|
||||
final device = lineSplits[1];
|
||||
final serviceResult = await Process.run('networksetup', [
|
||||
'-listnetworkserviceorder',
|
||||
]);
|
||||
final serviceResultOutput = serviceResult.stdout.toString();
|
||||
final currentService = serviceResultOutput
|
||||
return lineSplits[1];
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static String? parseServiceName(String serviceOrderOutput, String device) {
|
||||
final currentService = serviceOrderOutput
|
||||
.split('\n\n')
|
||||
.firstWhere((s) => s.contains('Device: $device'), orElse: () => '');
|
||||
if (currentService.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final currentServiceNameLine = currentService
|
||||
final nameLine = currentService
|
||||
.split('\n')
|
||||
.firstWhere(
|
||||
(line) => RegExp(r'^\(\d+\).*').hasMatch(line),
|
||||
orElse: () => '',
|
||||
);
|
||||
final currentServiceNameLineSplits = currentServiceNameLine.trim().split(
|
||||
' ',
|
||||
);
|
||||
if (currentServiceNameLineSplits.length < 2) {
|
||||
final name = RegExp(
|
||||
r'^\(\d+\)\s+(.+)$',
|
||||
).firstMatch(nameLine.trim())?.group(1)?.trim();
|
||||
if (name == null || name.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return currentServiceNameLineSplits[1];
|
||||
return name;
|
||||
}
|
||||
|
||||
Future<List<String>?> get systemDns async {
|
||||
final deviceServiceName = await defaultServiceName;
|
||||
if (deviceServiceName == null) {
|
||||
return null;
|
||||
}
|
||||
final result = await Process.run('networksetup', [
|
||||
'-getdnsservers',
|
||||
deviceServiceName,
|
||||
]);
|
||||
final output = result.stdout.toString().trim();
|
||||
@visibleForTesting
|
||||
static List<String> parseDnsServers(String getDnsServersOutput) {
|
||||
final output = getDnsServersOutput.trim();
|
||||
if (output.startsWith("There aren't any DNS Servers set on")) {
|
||||
originDns = [];
|
||||
} else {
|
||||
originDns = output.split('\n');
|
||||
return [];
|
||||
}
|
||||
return originDns;
|
||||
return output.split('\n');
|
||||
}
|
||||
|
||||
Future<void> updateDns(bool restore) async {
|
||||
final serviceName = await defaultServiceName;
|
||||
if (serviceName == null) {
|
||||
return;
|
||||
@override
|
||||
Future<String?> resolveDefaultService() async {
|
||||
final result = await _run('route', ['-n', 'get', 'default']);
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
List<String>? nextDns;
|
||||
if (restore) {
|
||||
nextDns = originDns;
|
||||
} else {
|
||||
final originDns = await systemDns;
|
||||
if (originDns == null) {
|
||||
return;
|
||||
}
|
||||
const needAddDns = '223.5.5.5';
|
||||
if (originDns.contains(needAddDns)) {
|
||||
return;
|
||||
}
|
||||
nextDns = List.from(originDns)..add(needAddDns);
|
||||
final device = parseDefaultInterface(result.stdout.toString());
|
||||
if (device == null) {
|
||||
return null;
|
||||
}
|
||||
if (nextDns == null) {
|
||||
return;
|
||||
}
|
||||
await Process.run('networksetup', [
|
||||
'-setdnsservers',
|
||||
serviceName,
|
||||
if (nextDns.isNotEmpty) ...nextDns,
|
||||
if (nextDns.isEmpty) 'Empty',
|
||||
final serviceResult = await _run('networksetup', [
|
||||
'-listnetworkserviceorder',
|
||||
]);
|
||||
if (serviceResult == null) {
|
||||
return null;
|
||||
}
|
||||
return parseServiceName(serviceResult.stdout.toString(), device);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>?> readDnsServers(String service) async {
|
||||
final result = await _run('networksetup', ['-getdnsservers', service]);
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
return parseDnsServers(result.stdout.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> writeDnsServers(String service, List<String> servers) async {
|
||||
final result = await _run('networksetup', [
|
||||
'-setdnsservers',
|
||||
service,
|
||||
if (servers.isEmpty) 'Empty',
|
||||
if (servers.isNotEmpty) ...servers,
|
||||
], logLevel: LogLevel.error);
|
||||
return result != null;
|
||||
}
|
||||
|
||||
Future<ProcessResult?> _run(
|
||||
String executable,
|
||||
List<String> arguments, {
|
||||
LogLevel logLevel = LogLevel.warning,
|
||||
}) async {
|
||||
final label = '$executable ${arguments.first}';
|
||||
try {
|
||||
final result = await runProcess(executable, arguments);
|
||||
if (result.exitCode != 0) {
|
||||
commonPrint.log(
|
||||
'$label exited with ${result.exitCode}: ${result.stderr.toString().trim()}',
|
||||
logLevel: logLevel,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'$label failed: ${compactError(error)}',
|
||||
logLevel: logLevel,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/common/preferences.dart';
|
||||
import 'package:fl_clash/common/print.dart';
|
||||
import 'package:fl_clash/common/system.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
class SystemDnsRecord {
|
||||
final String service;
|
||||
final List<String> servers;
|
||||
|
||||
const SystemDnsRecord({required this.service, required this.servers});
|
||||
|
||||
static SystemDnsRecord? fromJson(Object? json) {
|
||||
if (json is! Map) {
|
||||
return null;
|
||||
}
|
||||
final service = json['service'];
|
||||
final servers = json['servers'];
|
||||
if (service is! String || service.isEmpty || servers is! List) {
|
||||
return null;
|
||||
}
|
||||
return SystemDnsRecord(
|
||||
service: service,
|
||||
servers: servers.whereType<String>().toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {'service': service, 'servers': servers};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SystemDnsRecord &&
|
||||
other.service == service &&
|
||||
stringListEquality.equals(other.servers, servers);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(service, stringListEquality.hash(servers));
|
||||
|
||||
@override
|
||||
String toString() => 'SystemDnsRecord($service, $servers)';
|
||||
}
|
||||
|
||||
abstract interface class SystemDnsPort {
|
||||
Future<String?> resolveDefaultService();
|
||||
|
||||
Future<List<String>?> readDnsServers(String service);
|
||||
|
||||
Future<bool> writeDnsServers(String service, List<String> servers);
|
||||
}
|
||||
|
||||
abstract interface class SystemDnsStore {
|
||||
Future<SystemDnsRecord?> read();
|
||||
|
||||
Future<void> write(SystemDnsRecord record);
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
final class PreferencesSystemDnsStore implements SystemDnsStore {
|
||||
const PreferencesSystemDnsStore();
|
||||
|
||||
@override
|
||||
Future<SystemDnsRecord?> read() => preferences.getSystemDnsRecord();
|
||||
|
||||
@override
|
||||
Future<void> write(SystemDnsRecord record) =>
|
||||
preferences.saveSystemDnsRecord(record);
|
||||
|
||||
@override
|
||||
Future<void> clear() => preferences.clearSystemDnsRecord();
|
||||
}
|
||||
|
||||
final class SystemDnsCoordinator {
|
||||
final SystemDnsPort port;
|
||||
final SystemDnsStore store;
|
||||
final String fallbackDns;
|
||||
|
||||
Future<void> _queue = Future.value();
|
||||
SystemDnsRecord? _applied;
|
||||
bool _desired = false;
|
||||
bool _recovered = false;
|
||||
bool _shutdown = false;
|
||||
|
||||
SystemDnsCoordinator({
|
||||
required this.port,
|
||||
required this.store,
|
||||
this.fallbackDns = defaultSystemDnsFallback,
|
||||
});
|
||||
|
||||
@visibleForTesting
|
||||
SystemDnsRecord? get appliedRecord => _applied;
|
||||
|
||||
Future<void> sync(bool patch) {
|
||||
if (_shutdown) {
|
||||
return _queue;
|
||||
}
|
||||
_desired = patch;
|
||||
return _enqueue();
|
||||
}
|
||||
|
||||
Future<void> resync() {
|
||||
if (_shutdown) {
|
||||
return _queue;
|
||||
}
|
||||
return _enqueue();
|
||||
}
|
||||
|
||||
Future<void> shutdown() {
|
||||
_shutdown = true;
|
||||
_desired = false;
|
||||
final operation = _queue.then((_) => _converge()).catchError(_reportError);
|
||||
_queue = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
Future<void> _enqueue() {
|
||||
final operation = _queue.then((_) => _converge()).catchError(_reportError);
|
||||
_queue = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
void _reportError(Object error) {
|
||||
commonPrint.log(
|
||||
'System DNS update failed: ${compactError(error)}',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _converge() async {
|
||||
await _recover();
|
||||
if (_desired) {
|
||||
await _apply();
|
||||
return;
|
||||
}
|
||||
await _release();
|
||||
}
|
||||
|
||||
Future<void> _recover() async {
|
||||
if (_recovered) {
|
||||
return;
|
||||
}
|
||||
_applied = await store.read();
|
||||
_recovered = true;
|
||||
}
|
||||
|
||||
Future<void> _apply() async {
|
||||
final service = await port.resolveDefaultService();
|
||||
if (service == null) {
|
||||
commonPrint.log(
|
||||
'Skipped the system DNS update, no default network service',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final applied = _applied;
|
||||
if (applied != null) {
|
||||
if (applied.service == service) {
|
||||
await _reapply(service);
|
||||
return;
|
||||
}
|
||||
await _release();
|
||||
if (_applied != null) {
|
||||
commonPrint.log(
|
||||
'Skipped the system DNS update, ${applied.service} still holds it',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
final current = await port.readDnsServers(service);
|
||||
if (current == null) {
|
||||
commonPrint.log(
|
||||
'Skipped the system DNS update, unable to read $service',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final record = SystemDnsRecord(service: service, servers: current);
|
||||
await store.write(record);
|
||||
if (!current.contains(fallbackDns)) {
|
||||
final ok = await port.writeDnsServers(service, [...current, fallbackDns]);
|
||||
if (!ok) {
|
||||
await store.clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
_applied = record;
|
||||
}
|
||||
|
||||
Future<void> _reapply(String service) async {
|
||||
final current = await port.readDnsServers(service);
|
||||
if (current == null) {
|
||||
commonPrint.log(
|
||||
'Skipped the system DNS resync, unable to read $service',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (current.contains(fallbackDns)) {
|
||||
return;
|
||||
}
|
||||
final record = SystemDnsRecord(service: service, servers: current);
|
||||
await store.write(record);
|
||||
if (!await port.writeDnsServers(service, [...current, fallbackDns])) {
|
||||
_applied = null;
|
||||
await store.clear();
|
||||
return;
|
||||
}
|
||||
_applied = record;
|
||||
}
|
||||
|
||||
Future<void> _release() async {
|
||||
final applied = _applied;
|
||||
if (applied == null) {
|
||||
return;
|
||||
}
|
||||
if (!await port.writeDnsServers(applied.service, applied.servers)) {
|
||||
commonPrint.log(
|
||||
'Failed to restore the system DNS of ${applied.service}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_applied = null;
|
||||
await store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
final systemDnsCoordinator = system.isMacOS
|
||||
? SystemDnsCoordinator(
|
||||
port: macOS!,
|
||||
store: const PreferencesSystemDnsStore(),
|
||||
)
|
||||
: null;
|
||||
@@ -46,10 +46,11 @@ Future<String> _encodeMD5<T>(String content) async {
|
||||
}
|
||||
|
||||
Future<List<Group>> toGroupsTask(ComputeGroupsState data) async {
|
||||
return compute<ComputeGroupsState, List<Group>>(_toGroupsTask, data);
|
||||
return compute<ComputeGroupsState, List<Group>>(buildGroups, data);
|
||||
}
|
||||
|
||||
Future<List<Group>> _toGroupsTask(ComputeGroupsState state) async {
|
||||
@visibleForTesting
|
||||
Future<List<Group>> buildGroups(ComputeGroupsState state) async {
|
||||
final proxiesData = state.proxiesData;
|
||||
final all = proxiesData.all;
|
||||
final sortType = state.sortType;
|
||||
@@ -58,21 +59,18 @@ Future<List<Group>> _toGroupsTask(ComputeGroupsState state) async {
|
||||
final defaultTestUrl = state.defaultTestUrl;
|
||||
final proxies = proxiesData.proxies;
|
||||
if (proxies.isEmpty) return [];
|
||||
final groupsRaw = all
|
||||
.where((name) {
|
||||
final proxy = proxies[name] ?? {};
|
||||
return GroupTypeExtension.valueList.contains(proxy['type']);
|
||||
})
|
||||
.map((groupName) {
|
||||
final group = proxies[groupName];
|
||||
group['all'] = ((group['all'] ?? []) as List)
|
||||
.map((name) => proxies[name])
|
||||
.where((proxy) => proxy != null)
|
||||
.toList();
|
||||
return group;
|
||||
})
|
||||
.toList();
|
||||
final groups = groupsRaw.map((e) => Group.fromJson(e)).toList();
|
||||
final groups = <Group>[];
|
||||
for (final groupName in all) {
|
||||
final raw = proxies[groupName];
|
||||
if (raw is! Map) continue;
|
||||
if (!GroupTypeExtension.valueList.contains(raw['type'])) continue;
|
||||
final memberNames = raw['all'];
|
||||
final group = Map<String, dynamic>.from(raw);
|
||||
group['all'] = memberNames is List
|
||||
? memberNames.map((name) => proxies[name]).nonNulls.toList()
|
||||
: const [];
|
||||
groups.add(Group.fromJson(group));
|
||||
}
|
||||
return computeSort(
|
||||
groups: groups,
|
||||
sortType: sortType,
|
||||
@@ -82,16 +80,33 @@ Future<List<Group>> _toGroupsTask(ComputeGroupsState state) async {
|
||||
);
|
||||
}
|
||||
|
||||
Future<VM2<String, String>> makeRealProfileTask(
|
||||
Future<ClashConfig> clashConfigTask(Map<String, dynamic> data) async {
|
||||
return compute<Map<String, dynamic>, ClashConfig>(buildClashConfig, data);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
ClashConfig buildClashConfig(Map<String, dynamic> configMap) {
|
||||
final clashConfig = ClashConfig.fromJson(configMap);
|
||||
final proxyTypeMap = <String, String>{};
|
||||
for (final proxy in clashConfig.proxies) {
|
||||
proxyTypeMap[proxy.name] = proxy.type;
|
||||
}
|
||||
for (final proxyGroup in clashConfig.proxyGroups) {
|
||||
proxyTypeMap[proxyGroup.name] = proxyGroup.type.value;
|
||||
}
|
||||
return clashConfig.copyWith(proxyTypeMap: proxyTypeMap);
|
||||
}
|
||||
|
||||
Future<({String yaml, String md5})> makeRealProfileTask(
|
||||
MakeRealProfileState data,
|
||||
) async {
|
||||
return compute<MakeRealProfileState, VM2<String, String>>(
|
||||
return compute<MakeRealProfileState, ({String yaml, String md5})>(
|
||||
_makeRealProfileTask,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
Future<VM2<String, String>> _makeRealProfileTask(
|
||||
Future<({String yaml, String md5})> _makeRealProfileTask(
|
||||
MakeRealProfileState data,
|
||||
) async {
|
||||
final rawConfig = Map.from(data.rawConfig);
|
||||
@@ -102,19 +117,53 @@ Future<VM2<String, String>> _makeRealProfileTask(
|
||||
final addedRules = data.addedRules;
|
||||
final appendSystemDns = data.appendSystemDns;
|
||||
final defaultUA = data.defaultUA;
|
||||
String getProvidersFilePathInner(String type, String url) {
|
||||
String getProvidersFilePathInner(String type, String key) {
|
||||
return join(
|
||||
profilesPath,
|
||||
'providers',
|
||||
providersDirectoryName,
|
||||
profileId.toString(),
|
||||
type,
|
||||
url.toMd5(),
|
||||
key.toMd5(),
|
||||
);
|
||||
}
|
||||
|
||||
void confineProviders(String section, String type) {
|
||||
final providers = rawConfig[section];
|
||||
if (providers is! Map) {
|
||||
return;
|
||||
}
|
||||
for (final name in providers.keys) {
|
||||
final provider = providers[name];
|
||||
if (provider is! Map || provider['type'] == 'inline') {
|
||||
continue;
|
||||
}
|
||||
// Two providers may share a URL and differ only by header.
|
||||
final url = provider['url'];
|
||||
final hasUrl = url is String && url.isNotEmpty;
|
||||
final path = getProvidersFilePathInner(
|
||||
type,
|
||||
hasUrl ? '$name@$url' : '$section/$name',
|
||||
);
|
||||
if (hasUrl) {
|
||||
_migrateLegacyProviderFile(
|
||||
legacyPath: getProvidersFilePathInner(type, url),
|
||||
newPath: path,
|
||||
);
|
||||
}
|
||||
provider['path'] = path;
|
||||
}
|
||||
}
|
||||
|
||||
rawConfig['external-controller'] = realPatchConfig.externalController.value;
|
||||
rawConfig['external-ui'] = '';
|
||||
rawConfig['interface-name'] = '';
|
||||
switch (realPatchConfig.interfaceNameMode) {
|
||||
case InterfaceNameMode.clear:
|
||||
rawConfig['interface-name'] = '';
|
||||
case InterfaceNameMode.follow:
|
||||
break;
|
||||
case InterfaceNameMode.custom:
|
||||
rawConfig['interface-name'] = realPatchConfig.interfaceName;
|
||||
}
|
||||
rawConfig['external-ui-url'] = '';
|
||||
rawConfig['tcp-concurrent'] = realPatchConfig.tcpConcurrent;
|
||||
rawConfig['unified-delay'] = realPatchConfig.unifiedDelay;
|
||||
@@ -130,6 +179,10 @@ Future<VM2<String, String>> _makeRealProfileTask(
|
||||
rawConfig['tproxy-port'] = realPatchConfig.tproxyPort;
|
||||
rawConfig['find-process-mode'] = realPatchConfig.findProcessMode.name;
|
||||
rawConfig['allow-lan'] = realPatchConfig.allowLan;
|
||||
// The app owns local inbound authentication; a profile-provided
|
||||
// skip-auth-prefixes could silently exempt loopback and defeat it.
|
||||
rawConfig['authentication'] = data.authentication;
|
||||
rawConfig['skip-auth-prefixes'] = [];
|
||||
rawConfig['mode'] = realPatchConfig.mode.name;
|
||||
if (rawConfig['tun'] == null) {
|
||||
rawConfig['tun'] = {};
|
||||
@@ -141,6 +194,8 @@ Future<VM2<String, String>> _makeRealProfileTask(
|
||||
rawConfig['tun']['route-address'] = realPatchConfig.tun.routeAddress;
|
||||
rawConfig['tun']['auto-route'] = realPatchConfig.tun.autoRoute;
|
||||
rawConfig['geodata-loader'] = realPatchConfig.geodataLoader.name;
|
||||
rawConfig['geo-auto-update'] = realPatchConfig.geoAutoUpdate;
|
||||
rawConfig['geo-update-interval'] = realPatchConfig.geoUpdateInterval;
|
||||
if (rawConfig['sniffer']?['sniff'] != null) {
|
||||
for (final value in (rawConfig['sniffer']?['sniff'] as Map).values) {
|
||||
if (value['ports'] != null && value['ports'] is List) {
|
||||
@@ -152,36 +207,8 @@ Future<VM2<String, String>> _makeRealProfileTask(
|
||||
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'] = getProvidersFilePathInner(
|
||||
'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'] = getProvidersFilePathInner(
|
||||
'rules',
|
||||
ruleProvider['url'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
confineProviders('proxy-providers', proxiesProviderDirectoryName);
|
||||
confineProviders('rule-providers', rulesProviderDirectoryName);
|
||||
rawConfig['profile']['store-selected'] = false;
|
||||
rawConfig['geox-url'] = realPatchConfig.geoXUrl.raw;
|
||||
rawConfig['global-ua'] = realPatchConfig.globalUa ?? defaultUA;
|
||||
@@ -191,24 +218,24 @@ Future<VM2<String, String>> _makeRealProfileTask(
|
||||
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 rawDns = rawConfig['dns'] is Map
|
||||
? Map<String, dynamic>.from(rawConfig['dns'] as Map)
|
||||
: <String, dynamic>{};
|
||||
rawConfig['dns'] = rawDns;
|
||||
final isEnableDns = rawDns['enable'] == true;
|
||||
const systemDns = 'system://';
|
||||
if (overrideDns || !isEnableDns) {
|
||||
final dns = switch (!isEnableDns) {
|
||||
true => realPatchConfig.dns.copyWith(
|
||||
nameserver: [...realPatchConfig.dns.nameserver, systemDns],
|
||||
),
|
||||
false => realPatchConfig.dns,
|
||||
};
|
||||
rawConfig['dns'] = dns.toJson();
|
||||
rawConfig['dns']['nameserver-policy'] = {};
|
||||
final dns = realPatchConfig.dns;
|
||||
final nameserverPolicy = <String, dynamic>{};
|
||||
for (final entry in dns.nameserverPolicy.entries) {
|
||||
rawConfig['dns']['nameserver-policy'][entry.key] =
|
||||
entry.value.splitByMultipleSeparators;
|
||||
nameserverPolicy[entry.key] = entry.value.splitByMultipleSeparators;
|
||||
}
|
||||
// Merged, not assigned: the model only covers the keys FlClash can edit.
|
||||
rawConfig['dns'] = {
|
||||
...rawDns,
|
||||
...dns.toJson(),
|
||||
'nameserver-policy': nameserverPolicy,
|
||||
};
|
||||
}
|
||||
if (appendSystemDns) {
|
||||
final List<String> nameserver = List<String>.from(
|
||||
@@ -227,9 +254,12 @@ Future<VM2<String, String>> _makeRealProfileTask(
|
||||
final hasMatchPlaceholder = addedRules.any(
|
||||
(item) => item.ruleTarget?.toUpperCase() == 'MATCH',
|
||||
);
|
||||
String? replacementTarget;
|
||||
String? replacementTarget = data.matchTarget?.trim();
|
||||
if (replacementTarget?.isEmpty == true) {
|
||||
replacementTarget = null;
|
||||
}
|
||||
|
||||
if (hasMatchPlaceholder) {
|
||||
if (hasMatchPlaceholder && replacementTarget == null) {
|
||||
for (int i = rules.length - 1; i >= 0; i--) {
|
||||
final parsed = Rule.parse(rules[i]);
|
||||
if (parsed.ruleAction == RuleAction.MATCH) {
|
||||
@@ -268,57 +298,98 @@ Future<VM2<String, String>> _makeRealProfileTask(
|
||||
}
|
||||
rawConfig['rules'] = rules;
|
||||
final yaml = await _encodeYaml(Map<String, dynamic>.from(rawConfig));
|
||||
return VM2(yaml, yaml.toMd5());
|
||||
return (yaml: yaml, md5: yaml.toMd5());
|
||||
}
|
||||
|
||||
Future<List<String>> shakingProfileTask(
|
||||
VM2<Iterable<int>, Iterable<int>> data,
|
||||
({Iterable<int> profileIds, Iterable<int> scriptIds}) data,
|
||||
) async {
|
||||
return compute<
|
||||
VM3<Iterable<int>, Iterable<int>, RootIsolateToken>,
|
||||
({
|
||||
Iterable<int> profileIds,
|
||||
Iterable<int> scriptIds,
|
||||
RootIsolateToken token,
|
||||
}),
|
||||
List<String>
|
||||
>(_shakingProfileTask, VM3(data.a, data.b, RootIsolateToken.instance!));
|
||||
>(_shakingProfileTask, (
|
||||
profileIds: data.profileIds,
|
||||
scriptIds: data.scriptIds,
|
||||
token: RootIsolateToken.instance!,
|
||||
));
|
||||
}
|
||||
|
||||
Future<List<String>> _shakingProfileTask(
|
||||
VM3<Iterable<int>, Iterable<int>, RootIsolateToken> data,
|
||||
({Iterable<int> profileIds, Iterable<int> scriptIds, RootIsolateToken token})
|
||||
data,
|
||||
) async {
|
||||
final profileIds = data.a;
|
||||
final scriptIds = data.b;
|
||||
final token = data.c;
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
final profilesDir = Directory(await appPath.profilesPath);
|
||||
final scriptsDir = Directory(await appPath.scriptsDirPath);
|
||||
final providersDir = Directory(await appPath.getProvidersRootPath());
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(data.token);
|
||||
return shakeOrphanFiles(
|
||||
profileIds: data.profileIds,
|
||||
scriptIds: data.scriptIds,
|
||||
profilesDirPath: await appPath.profilesPath,
|
||||
providersDirPath: await appPath.getProvidersRootPath(),
|
||||
scriptsDirPath: await appPath.scriptsDirPath,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
List<String> shakeOrphanFiles({
|
||||
required Iterable<int> profileIds,
|
||||
required Iterable<int> scriptIds,
|
||||
required String profilesDirPath,
|
||||
required String providersDirPath,
|
||||
required String scriptsDirPath,
|
||||
}) {
|
||||
final List<String> targets = [];
|
||||
void scanDirectory(
|
||||
Directory dir,
|
||||
Iterable<int> baseNames, {
|
||||
bool skipProvidersFolder = false,
|
||||
bool includeDirectories = false,
|
||||
}) {
|
||||
if (!dir.existsSync()) return;
|
||||
final entities = dir.listSync(recursive: false, followLinks: false);
|
||||
|
||||
for (final entity in entities) {
|
||||
if (entity is File) {
|
||||
final id = basenameWithoutExtension(entity.path);
|
||||
if (!baseNames.contains(int.tryParse(id))) {
|
||||
targets.add(entity.path);
|
||||
}
|
||||
} else if (skipProvidersFolder && entity is Directory) {
|
||||
if (basename(entity.path) == 'providers') {
|
||||
continue;
|
||||
}
|
||||
final selected =
|
||||
entity is File || (includeDirectories && entity is Directory);
|
||||
if (!selected) {
|
||||
continue;
|
||||
}
|
||||
final id = basenameWithoutExtension(entity.path);
|
||||
if (!baseNames.contains(int.tryParse(id))) {
|
||||
targets.add(entity.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDirectory(profilesDir, profileIds, skipProvidersFolder: true);
|
||||
scanDirectory(providersDir, profileIds);
|
||||
scanDirectory(scriptsDir, scriptIds);
|
||||
scanDirectory(Directory(profilesDirPath), profileIds);
|
||||
scanDirectory(
|
||||
Directory(providersDirPath),
|
||||
profileIds,
|
||||
includeDirectories: true,
|
||||
);
|
||||
scanDirectory(Directory(scriptsDirPath), scriptIds);
|
||||
return targets;
|
||||
}
|
||||
|
||||
// Best-effort: a legacy url shared by two providers, or any rename failure,
|
||||
// just leaves the file to be re-downloaded under the new path.
|
||||
void _migrateLegacyProviderFile({
|
||||
required String legacyPath,
|
||||
required String newPath,
|
||||
}) {
|
||||
if (legacyPath == newPath || File(newPath).existsSync()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final legacyFile = File(legacyPath);
|
||||
if (legacyFile.existsSync()) {
|
||||
Directory(dirname(newPath)).createSync(recursive: true);
|
||||
legacyFile.renameSync(newPath);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<String> encodeLogsTask(List<Log> data) async {
|
||||
return compute<List<Log>, String>(_encodeLogsTask, data);
|
||||
}
|
||||
@@ -331,19 +402,28 @@ Future<String> _encodeLogsTask(List<Log> data) async {
|
||||
|
||||
Future<MigrationData> oldToNowTask(Map<String, Object?> data) async {
|
||||
final homeDir = await appPath.homeDirPath;
|
||||
return compute<VM3<Map<String, Object?>, String, String>, MigrationData>(
|
||||
_oldToNowTask,
|
||||
VM3(data, homeDir, homeDir),
|
||||
);
|
||||
return compute<
|
||||
({Map<String, Object?> configMap, String sourcePath, String targetPath}),
|
||||
MigrationData
|
||||
>(_oldToNowTask, (configMap: data, sourcePath: homeDir, targetPath: homeDir));
|
||||
}
|
||||
|
||||
Future<MigrationData> _oldToNowTask(
|
||||
VM3<Map<String, Object?>, String, String> data,
|
||||
) async {
|
||||
final configMap = data.a;
|
||||
final sourcePath = data.b;
|
||||
final targetPath = data.c;
|
||||
({Map<String, Object?> configMap, String sourcePath, String targetPath}) data,
|
||||
) {
|
||||
return migrateLegacyConfig(
|
||||
configMap: data.configMap,
|
||||
sourcePath: data.sourcePath,
|
||||
targetPath: data.targetPath,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<MigrationData> migrateLegacyConfig({
|
||||
required Map<String, Object?> configMap,
|
||||
required String sourcePath,
|
||||
required String targetPath,
|
||||
}) async {
|
||||
final accessControlMap = configMap['accessControl'];
|
||||
final isAccessControl = configMap['isAccessControl'];
|
||||
if (accessControlMap != null) {
|
||||
@@ -363,7 +443,6 @@ Future<MigrationData> _oldToNowTask(
|
||||
appSettingProps['restoreStrategy'] = appSettingProps['recoveryStrategy'];
|
||||
configMap['appSettingProps'] = appSettingProps;
|
||||
configMap['proxiesStyleProps'] = configMap['proxiesStyle'];
|
||||
configMap['proxiesStyleProps'] = configMap['proxiesStyle'];
|
||||
List rawScripts = configMap['scripts'] as List<dynamic>? ?? [];
|
||||
if (rawScripts.isEmpty) {
|
||||
final scriptPropsJson = configMap['scriptProps'] as Map<String, dynamic>?;
|
||||
@@ -471,60 +550,84 @@ Future<String> backupTask(
|
||||
Iterable<String> fileNames,
|
||||
) async {
|
||||
return compute<
|
||||
VM3<Map<String, dynamic>, Iterable<String>, RootIsolateToken>,
|
||||
({
|
||||
Map<String, dynamic> configMap,
|
||||
Iterable<String> fileNames,
|
||||
RootIsolateToken token,
|
||||
}),
|
||||
String
|
||||
>(_backupTask, VM3(configMap, fileNames, RootIsolateToken.instance!));
|
||||
>(_backupTask, (
|
||||
configMap: configMap,
|
||||
fileNames: fileNames,
|
||||
token: RootIsolateToken.instance!,
|
||||
));
|
||||
}
|
||||
|
||||
Future<String> _backupTask<T>(
|
||||
VM3<Map<String, dynamic>, Iterable<String>, RootIsolateToken> args,
|
||||
({
|
||||
Map<String, dynamic> configMap,
|
||||
Iterable<String> fileNames,
|
||||
RootIsolateToken token,
|
||||
})
|
||||
args,
|
||||
) async {
|
||||
final configMap = args.a;
|
||||
final fileNames = args.b;
|
||||
final token = args.c;
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
final dbPath = await appPath.databasePath;
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(args.token);
|
||||
final tempPath = await appPath.tempPath;
|
||||
final prefix = 'backup$uniqueId';
|
||||
return writeBackupArchive(
|
||||
configMap: args.configMap,
|
||||
fileNames: args.fileNames,
|
||||
databasePath: await appPath.databasePath,
|
||||
profilesDirPath: await appPath.profilesPath,
|
||||
scriptsDirPath: await appPath.scriptsDirPath,
|
||||
zipFilePath: join(tempPath, '$prefix.zip'),
|
||||
tempDatabasePath: join(tempPath, '$prefix.db'),
|
||||
tempConfigPath: join(tempPath, '$prefix.json'),
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<String> writeBackupArchive({
|
||||
required Map<String, dynamic> configMap,
|
||||
required Iterable<String> fileNames,
|
||||
required String databasePath,
|
||||
required String profilesDirPath,
|
||||
required String scriptsDirPath,
|
||||
required String zipFilePath,
|
||||
required String tempDatabasePath,
|
||||
required String tempConfigPath,
|
||||
}) async {
|
||||
final configStr = json.encode(configMap);
|
||||
final profilesDir = Directory(await appPath.profilesPath);
|
||||
final scriptsDir = Directory(await appPath.scriptsDirPath);
|
||||
final tempZipFilePath = await appPath.tempFilePath;
|
||||
final tempDBFile = File(await appPath.tempFilePath);
|
||||
final tempConfigFile = File(await appPath.tempFilePath);
|
||||
final dbFile = File(dbPath);
|
||||
final profilesDir = Directory(profilesDirPath);
|
||||
final scriptsDir = Directory(scriptsDirPath);
|
||||
final tempDBFile = File(tempDatabasePath);
|
||||
final tempConfigFile = File(tempConfigPath);
|
||||
final dbFile = File(databasePath);
|
||||
if (await dbFile.exists()) {
|
||||
await dbFile.copy(tempDBFile.path);
|
||||
}
|
||||
final encoder = ZipFileEncoder();
|
||||
encoder.create(tempZipFilePath);
|
||||
encoder.create(zipFilePath);
|
||||
await tempConfigFile.writeAsString(configStr);
|
||||
await encoder.addFile(tempDBFile, backupDatabaseName);
|
||||
await encoder.addFile(tempConfigFile, configJsonName);
|
||||
ZipFileOperation keepListed(FileSystemEntity entity, double _) {
|
||||
if (!fileNames.contains(basename(entity.path))) {
|
||||
return ZipFileOperation.skip;
|
||||
}
|
||||
return ZipFileOperation.include;
|
||||
}
|
||||
|
||||
if (await profilesDir.exists()) {
|
||||
await encoder.addDirectory(
|
||||
profilesDir,
|
||||
filter: (file, _) {
|
||||
if (!fileNames.contains(basename(file.path))) {
|
||||
return ZipFileOperation.skip;
|
||||
}
|
||||
return ZipFileOperation.include;
|
||||
},
|
||||
);
|
||||
await encoder.addDirectory(profilesDir, filter: keepListed);
|
||||
}
|
||||
if (await scriptsDir.exists()) {
|
||||
await encoder.addDirectory(
|
||||
scriptsDir,
|
||||
filter: (file, _) {
|
||||
if (!fileNames.contains(basename(file.path))) {
|
||||
return ZipFileOperation.skip;
|
||||
}
|
||||
return ZipFileOperation.include;
|
||||
},
|
||||
);
|
||||
await encoder.addDirectory(scriptsDir, filter: keepListed);
|
||||
}
|
||||
encoder.close();
|
||||
await encoder.close();
|
||||
await tempConfigFile.safeDelete();
|
||||
await tempDBFile.safeDelete();
|
||||
return tempZipFilePath;
|
||||
return zipFilePath;
|
||||
}
|
||||
|
||||
Future<MigrationData> restoreTask() async {
|
||||
@@ -536,16 +639,48 @@ Future<MigrationData> restoreTask() async {
|
||||
|
||||
Future<MigrationData> _restoreTask(RootIsolateToken token) async {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
final backupFilePath = await appPath.backupFilePath;
|
||||
final restoreDirPath = await appPath.restoreDirPath;
|
||||
final homeDirPath = await appPath.homeDirPath;
|
||||
return readBackupArchive(
|
||||
backupFilePath: await appPath.backupFilePath,
|
||||
restoreDirPath: await appPath.restoreDirPath,
|
||||
homeDirPath: await appPath.homeDirPath,
|
||||
);
|
||||
}
|
||||
|
||||
/// `posix.normalize` was doing this on its own and it does not: it collapses
|
||||
/// `a/../b`, but a name that starts with `../` normalizes to itself and an
|
||||
/// absolute one stays absolute, so either writes wherever the archive asks. A
|
||||
/// backup file is untrusted input — it is whatever the user picked off disk.
|
||||
String? _restoreEntryPath(String restoreDirPath, String name) {
|
||||
final normalized = posix.normalize(name.replaceAll('\\', '/'));
|
||||
if (normalized.isEmpty ||
|
||||
posix.isAbsolute(normalized) ||
|
||||
normalized == '..' ||
|
||||
normalized.startsWith('../')) {
|
||||
return null;
|
||||
}
|
||||
final outPath = normalize(join(restoreDirPath, normalized));
|
||||
if (!isWithin(restoreDirPath, outPath)) {
|
||||
return null;
|
||||
}
|
||||
return outPath;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<MigrationData> readBackupArchive({
|
||||
required String backupFilePath,
|
||||
required String restoreDirPath,
|
||||
required String homeDirPath,
|
||||
}) async {
|
||||
final zipDecoder = ZipDecoder();
|
||||
final input = InputFileStream(backupFilePath);
|
||||
final archive = zipDecoder.decodeStream(input);
|
||||
final dir = Directory(restoreDirPath);
|
||||
await dir.create(recursive: true);
|
||||
for (final file in archive.files) {
|
||||
final outPath = join(restoreDirPath, posix.normalize(file.name));
|
||||
final outPath = _restoreEntryPath(restoreDirPath, file.name);
|
||||
if (outPath == null) {
|
||||
continue;
|
||||
}
|
||||
final outputStream = OutputFileStream(outPath);
|
||||
file.writeContent(outputStream);
|
||||
await outputStream.close();
|
||||
@@ -553,7 +688,7 @@ Future<MigrationData> _restoreTask(RootIsolateToken token) async {
|
||||
await input.close();
|
||||
final restoreConfigFile = File(join(restoreDirPath, configJsonName));
|
||||
if (!await restoreConfigFile.exists()) {
|
||||
throw currentAppLocalizations.invalidBackupFile;
|
||||
throw MessageException(currentAppLocalizations.invalidBackupFile);
|
||||
}
|
||||
final restoreConfigMap =
|
||||
json.decode(await restoreConfigFile.readAsString())
|
||||
@@ -561,8 +696,10 @@ Future<MigrationData> _restoreTask(RootIsolateToken token) async {
|
||||
final version = restoreConfigMap?['version'] ?? 0;
|
||||
MigrationData migrationData = MigrationData(configMap: restoreConfigMap);
|
||||
if (version == 0 && restoreConfigMap != null) {
|
||||
migrationData = await _oldToNowTask(
|
||||
VM3(restoreConfigMap, restoreDirPath, homeDirPath),
|
||||
migrationData = await migrateLegacyConfig(
|
||||
configMap: restoreConfigMap,
|
||||
sourcePath: restoreDirPath,
|
||||
targetPath: homeDirPath,
|
||||
);
|
||||
return migrationData;
|
||||
}
|
||||
@@ -578,42 +715,46 @@ Future<MigrationData> _restoreTask(RootIsolateToken token) async {
|
||||
),
|
||||
),
|
||||
);
|
||||
final results = await Future.wait([
|
||||
database.profilesDao.query().get(),
|
||||
database.scriptsDao.query().get(),
|
||||
database.rules.all().map((item) => item.toRule()).get(),
|
||||
database.profileRuleLinks.all().map((item) => item.toLink()).get(),
|
||||
database.proxyGroups.all().map((item) => item.toProxyGroup()).get(),
|
||||
]);
|
||||
final profiles = results[0].cast<Profile>();
|
||||
final scripts = results[1].cast<Script>();
|
||||
final profilesMigration = profiles.map(
|
||||
(item) => VM2(
|
||||
_getProfilePath(restoreDirPath, item.id.toString()),
|
||||
_getProfilePath(homeDirPath, item.id.toString()),
|
||||
),
|
||||
);
|
||||
final scriptsMigration = scripts.map(
|
||||
(item) => VM2(
|
||||
_getScriptPath(restoreDirPath, item.id.toString()),
|
||||
_getScriptPath(homeDirPath, item.id.toString()),
|
||||
),
|
||||
);
|
||||
await _copyWithMapList([...profilesMigration, ...scriptsMigration]);
|
||||
migrationData = migrationData.copyWith(
|
||||
profiles: profiles,
|
||||
scripts: scripts,
|
||||
rules: results[2].cast<Rule>(),
|
||||
links: results[3].cast<ProfileRuleLink>(),
|
||||
proxyGroups: results[4].cast<ProxyGroup>(),
|
||||
);
|
||||
await database.close();
|
||||
return migrationData;
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
database.profilesDao.query().get(),
|
||||
database.scriptsDao.query().get(),
|
||||
database.rules.all().map((item) => item.toRule()).get(),
|
||||
database.profileRuleLinks.all().map((item) => item.toLink()).get(),
|
||||
database.proxyGroups.all().map((item) => item.toProxyGroup()).get(),
|
||||
]);
|
||||
final profiles = results[0].cast<Profile>();
|
||||
final scripts = results[1].cast<Script>();
|
||||
final profilesMigration = profiles.map(
|
||||
(item) => (
|
||||
from: _getProfilePath(restoreDirPath, item.id.toString()),
|
||||
to: _getProfilePath(homeDirPath, item.id.toString()),
|
||||
),
|
||||
);
|
||||
final scriptsMigration = scripts.map(
|
||||
(item) => (
|
||||
from: _getScriptPath(restoreDirPath, item.id.toString()),
|
||||
to: _getScriptPath(homeDirPath, item.id.toString()),
|
||||
),
|
||||
);
|
||||
await _copyWithMapList([...profilesMigration, ...scriptsMigration]);
|
||||
return migrationData.copyWith(
|
||||
profiles: profiles,
|
||||
scripts: scripts,
|
||||
rules: results[2].cast<Rule>(),
|
||||
links: results[3].cast<ProfileRuleLink>(),
|
||||
proxyGroups: results[4].cast<ProxyGroup>(),
|
||||
);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyWithMapList(List<VM2<String, String>> copyMapList) async {
|
||||
Future<void> _copyWithMapList(
|
||||
List<({String from, String to})> copyMapList,
|
||||
) async {
|
||||
await Future.wait(
|
||||
copyMapList.map((item) => File(item.a).safeCopy(item.b)).toList(),
|
||||
copyMapList.map((item) => File(item.from).safeCopy(item.to)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -626,14 +767,14 @@ String _getProfilePath(String root, String fileName) {
|
||||
}
|
||||
|
||||
Future<List<T>> mapListTask<T, S>(List<S> results, T Function(S) mapper) async {
|
||||
return compute<VM2<List<S>, T Function(S)>, List<T>>(
|
||||
return compute<({List<S> results, T Function(S) mapper}), List<T>>(
|
||||
_mapListTask,
|
||||
VM2(results, mapper),
|
||||
(results: results, mapper: mapper),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<T>> _mapListTask<T, S>(VM2<List<S>, T Function(S)> vm2) async {
|
||||
final results = vm2.a;
|
||||
final mapper = vm2.b;
|
||||
return results.map((item) => mapper(item)).toList();
|
||||
Future<List<T>> _mapListTask<T, S>(
|
||||
({List<S> results, T Function(S) mapper}) args,
|
||||
) async {
|
||||
return args.results.map((item) => args.mapper(item)).toList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
class TaskPool {
|
||||
TaskPool(this.concurrency) : assert(concurrency > 0);
|
||||
|
||||
final int concurrency;
|
||||
|
||||
final Queue<Completer<void>> _waiting = Queue();
|
||||
int _active = 0;
|
||||
|
||||
int get activeCount => _active;
|
||||
|
||||
int get pendingCount => _waiting.length;
|
||||
|
||||
Future<T> run<T>(Future<T> Function() task) async {
|
||||
if (_active >= concurrency || _waiting.isNotEmpty) {
|
||||
final waiter = Completer<void>();
|
||||
_waiting.add(waiter);
|
||||
await waiter.future;
|
||||
} else {
|
||||
_active++;
|
||||
}
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
if (_waiting.isNotEmpty) {
|
||||
_waiting.removeFirst().complete();
|
||||
} else {
|
||||
_active--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'color.dart';
|
||||
|
||||
extension TextStyleExtension on TextStyle {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class CommonTheme {
|
||||
final BuildContext context;
|
||||
|
||||
@@ -1,223 +1,209 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
import 'package:tray/tray.dart';
|
||||
|
||||
import 'app_localizations.dart';
|
||||
import 'l10n_labels.dart';
|
||||
import 'app_ports.dart';
|
||||
import 'constant.dart';
|
||||
import 'provider_reader.dart';
|
||||
import 'system.dart';
|
||||
import 'window.dart';
|
||||
|
||||
class Tray {
|
||||
static Tray? _instance;
|
||||
class AppTray implements TrayPort {
|
||||
static AppTray? _instance;
|
||||
|
||||
Tray._internal();
|
||||
final bool isMacOS;
|
||||
final bool isWindows;
|
||||
|
||||
factory Tray() {
|
||||
_instance ??= Tray._internal();
|
||||
bool _isShutDown = false;
|
||||
|
||||
AppTray._internal({required this.isMacOS, required this.isWindows});
|
||||
|
||||
factory AppTray() {
|
||||
_instance ??= AppTray._internal(
|
||||
isMacOS: system.isMacOS,
|
||||
isWindows: system.isWindows,
|
||||
);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
String get trayIconSuffix {
|
||||
return system.isWindows ? 'ico' : 'png';
|
||||
@visibleForTesting
|
||||
factory AppTray.forPlatform({
|
||||
required bool isMacOS,
|
||||
required bool isWindows,
|
||||
}) {
|
||||
return AppTray._internal(isMacOS: isMacOS, isWindows: isWindows);
|
||||
}
|
||||
|
||||
Future<void> destroy() async {
|
||||
await trayManager.destroy();
|
||||
String get _trayIconSuffix {
|
||||
return isWindows ? 'ico' : 'png';
|
||||
}
|
||||
|
||||
String getTryIcon({required bool isStart, required bool tunEnable}) {
|
||||
if (system.isMacOS || !isStart) {
|
||||
return 'assets/images/icon/status_1.$trayIconSuffix';
|
||||
}
|
||||
if (!tunEnable) {
|
||||
return 'assets/images/icon/status_2.$trayIconSuffix';
|
||||
}
|
||||
return 'assets/images/icon/status_3.$trayIconSuffix';
|
||||
String get _trayIconDir {
|
||||
return isWindows ? 'assets/images/tray/windows' : 'assets/images/tray/unix';
|
||||
}
|
||||
|
||||
Future _updateSystemTray({
|
||||
required bool isStart,
|
||||
required bool tunEnable,
|
||||
}) async {
|
||||
if (Platform.isLinux) {
|
||||
await trayManager.destroy();
|
||||
}
|
||||
await trayManager.setIcon(
|
||||
getTryIcon(isStart: isStart, tunEnable: tunEnable),
|
||||
isTemplate: system.isMacOS,
|
||||
);
|
||||
if (!Platform.isLinux) {
|
||||
await trayManager.setToolTip(appName);
|
||||
}
|
||||
String getTrayIcon({required bool isStart, required bool tunEnable}) {
|
||||
final status = switch ((isMacOS || !isStart, tunEnable)) {
|
||||
(true, _) => 1,
|
||||
(false, false) => 2,
|
||||
(false, true) => 3,
|
||||
};
|
||||
return '$_trayIconDir/status_$status.$_trayIconSuffix';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> shutdown() async {
|
||||
_isShutDown = true;
|
||||
await Tray.instance.hide();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update({
|
||||
required TrayState trayState,
|
||||
required Traffic traffic,
|
||||
required ProviderReader read,
|
||||
}) async {
|
||||
if (system.isAndroid) {
|
||||
if (_isShutDown) {
|
||||
return;
|
||||
}
|
||||
if (!system.isLinux) {
|
||||
await _updateSystemTray(
|
||||
isStart: trayState.isStart,
|
||||
tunEnable: trayState.tunEnable,
|
||||
);
|
||||
}
|
||||
final List<MenuItem> menuItems = [];
|
||||
final ref = globalState.container;
|
||||
final commonAction = ref.read(commonActionProvider.notifier);
|
||||
final systemAction = ref.read(systemActionProvider.notifier);
|
||||
final setupAction = ref.read(setupActionProvider.notifier);
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
final showMenuItem = MenuItem(
|
||||
label: appLocalizations.show,
|
||||
onClick: (_) {
|
||||
window?.show();
|
||||
},
|
||||
);
|
||||
menuItems.add(showMenuItem);
|
||||
final startMenuItem = MenuItem.checkbox(
|
||||
label: trayState.isStart ? appLocalizations.stop : appLocalizations.start,
|
||||
onClick: (_) async {
|
||||
commonAction.toggleRunning();
|
||||
},
|
||||
checked: false,
|
||||
);
|
||||
menuItems.add(startMenuItem);
|
||||
if (system.isMacOS) {
|
||||
final speedStatistics = MenuItem.checkbox(
|
||||
label: appLocalizations.speedStatistics,
|
||||
onClick: (_) async {
|
||||
commonAction.updateSpeedStatistics();
|
||||
},
|
||||
checked: trayState.showTrayTitle,
|
||||
);
|
||||
menuItems.add(speedStatistics);
|
||||
}
|
||||
menuItems.add(MenuItem.separator());
|
||||
for (final mode in Mode.values) {
|
||||
menuItems.add(
|
||||
MenuItem.checkbox(
|
||||
label: Intl.message(mode.name),
|
||||
onClick: (_) {
|
||||
setupAction.changeMode(mode);
|
||||
},
|
||||
checked: mode == trayState.mode,
|
||||
),
|
||||
);
|
||||
}
|
||||
menuItems.add(MenuItem.separator());
|
||||
if (system.isMacOS) {
|
||||
for (final group in trayState.groups) {
|
||||
final List<MenuItem> subMenuItems = [];
|
||||
for (final proxy in group.all) {
|
||||
subMenuItems.add(
|
||||
MenuItem.checkbox(
|
||||
label: proxy.name,
|
||||
checked:
|
||||
ref.read(selectedProxyNameProvider(group.name)) == proxy.name,
|
||||
onClick: (_) {
|
||||
ref
|
||||
.read(profilesActionProvider.notifier)
|
||||
.updateCurrentSelectedMap(group.name, proxy.name);
|
||||
ref
|
||||
.read(proxiesActionProvider.notifier)
|
||||
.changeProxy(groupName: group.name, proxyName: proxy.name);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
menuItems.add(
|
||||
MenuItem.submenu(
|
||||
label: group.name,
|
||||
submenu: Menu(items: subMenuItems),
|
||||
await Tray.instance.show(
|
||||
TraySpec(
|
||||
icon: TrayIcon.asset(
|
||||
getTrayIcon(
|
||||
isStart: trayState.isStart,
|
||||
tunEnable: trayState.tunEnable,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (trayState.groups.isNotEmpty) {
|
||||
menuItems.add(MenuItem.separator());
|
||||
}
|
||||
}
|
||||
if (trayState.isStart) {
|
||||
menuItems.add(
|
||||
MenuItem.checkbox(
|
||||
label: appLocalizations.tun,
|
||||
onClick: (_) {
|
||||
systemAction.updateTun();
|
||||
},
|
||||
checked: trayState.tunEnable,
|
||||
isTemplate: isMacOS,
|
||||
),
|
||||
);
|
||||
menuItems.add(
|
||||
MenuItem.checkbox(
|
||||
label: appLocalizations.systemProxy,
|
||||
onClick: (_) {
|
||||
systemAction.updateSystemProxy();
|
||||
},
|
||||
checked: trayState.systemProxy,
|
||||
),
|
||||
);
|
||||
menuItems.add(MenuItem.separator());
|
||||
}
|
||||
final autoStartMenuItem = MenuItem.checkbox(
|
||||
label: appLocalizations.autoLaunch,
|
||||
onClick: (_) async {
|
||||
systemAction.updateAutoLaunch();
|
||||
},
|
||||
checked: trayState.autoLaunch,
|
||||
toolTip: appName,
|
||||
menu: _buildMenu(trayState: trayState, read: read),
|
||||
),
|
||||
);
|
||||
final copyEnvVarMenuItem = MenuItem(
|
||||
label: appLocalizations.copyEnvVar,
|
||||
onClick: (_) async {
|
||||
await _copyEnv(trayState.port);
|
||||
},
|
||||
);
|
||||
menuItems.add(autoStartMenuItem);
|
||||
menuItems.add(copyEnvVarMenuItem);
|
||||
menuItems.add(MenuItem.separator());
|
||||
final exitMenuItem = MenuItem(
|
||||
label: appLocalizations.exit,
|
||||
onClick: (_) async {
|
||||
await systemAction.handleExit();
|
||||
},
|
||||
);
|
||||
menuItems.add(exitMenuItem);
|
||||
final menu = Menu(items: menuItems);
|
||||
await trayManager.setContextMenu(menu);
|
||||
if (system.isLinux) {
|
||||
await _updateSystemTray(
|
||||
isStart: trayState.isStart,
|
||||
tunEnable: trayState.tunEnable,
|
||||
);
|
||||
}
|
||||
updateTrayTitle(showTrayTitle: trayState.showTrayTitle, traffic: traffic);
|
||||
await updateTitle(showTrayTitle: trayState.showTrayTitle, traffic: traffic);
|
||||
}
|
||||
|
||||
Future<void> updateTrayTitle({
|
||||
Future<void> updateTitle({
|
||||
required bool showTrayTitle,
|
||||
required Traffic traffic,
|
||||
}) async {
|
||||
if (!system.isMacOS) {
|
||||
if (_isShutDown || !isMacOS) {
|
||||
return;
|
||||
}
|
||||
if (!showTrayTitle) {
|
||||
await trayManager.setTitle('');
|
||||
} else {
|
||||
await trayManager.setTitle(traffic.trayTitle);
|
||||
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);
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
|
||||
return [
|
||||
TrayMenuAction(
|
||||
label: appLocalizations.show,
|
||||
onSelected: () {
|
||||
window?.show();
|
||||
},
|
||||
),
|
||||
TrayMenuCheckbox(
|
||||
label: trayState.isStart
|
||||
? appLocalizations.stop
|
||||
: appLocalizations.start,
|
||||
checked: false,
|
||||
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,
|
||||
onSelected: () {
|
||||
setupAction.changeMode(mode);
|
||||
},
|
||||
),
|
||||
const TrayMenuSeparator(),
|
||||
if (isMacOS) ..._buildGroupMenu(trayState: trayState, read: read),
|
||||
if (trayState.isStart) ...[
|
||||
TrayMenuCheckbox(
|
||||
label: appLocalizations.tun,
|
||||
checked: trayState.tunEnable,
|
||||
onSelected: systemAction.updateTun,
|
||||
),
|
||||
TrayMenuCheckbox(
|
||||
label: appLocalizations.systemProxy,
|
||||
checked: trayState.systemProxy,
|
||||
onSelected: systemAction.updateSystemProxy,
|
||||
),
|
||||
const TrayMenuSeparator(),
|
||||
],
|
||||
TrayMenuCheckbox(
|
||||
label: appLocalizations.autoLaunch,
|
||||
checked: trayState.autoLaunch,
|
||||
onSelected: systemAction.updateAutoLaunch,
|
||||
),
|
||||
TrayMenuAction(
|
||||
label: appLocalizations.copyEnvVar,
|
||||
onSelected: () {
|
||||
_copyEnv(trayState.port);
|
||||
},
|
||||
),
|
||||
const TrayMenuSeparator(),
|
||||
TrayMenuAction(
|
||||
label: appLocalizations.exit,
|
||||
onSelected: () {
|
||||
systemAction.handleExit();
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<TrayMenuItem> _buildGroupMenu({
|
||||
required TrayState trayState,
|
||||
required ProviderReader read,
|
||||
}) {
|
||||
if (trayState.groups.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final group in trayState.groups)
|
||||
TrayMenuSubmenu(
|
||||
label: group.name,
|
||||
items: [
|
||||
for (final proxy in group.all)
|
||||
TrayMenuCheckbox(
|
||||
label: proxy.name,
|
||||
checked:
|
||||
read(selectedProxyNameProvider(group.name)) == proxy.name,
|
||||
onSelected: () {
|
||||
read(
|
||||
proxiesActionProvider.notifier,
|
||||
).changeProxy(groupName: group.name, proxyName: proxy.name);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const TrayMenuSeparator(),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _copyEnv(int port) async {
|
||||
final url = 'http://127.0.0.1:$port';
|
||||
|
||||
final cmdline = system.isWindows
|
||||
final cmdline = isWindows
|
||||
? 'set \$env:all_proxy=$url'
|
||||
: 'export all_proxy=$url';
|
||||
|
||||
@@ -225,4 +211,4 @@ class Tray {
|
||||
}
|
||||
}
|
||||
|
||||
final tray = system.isDesktop ? Tray() : null;
|
||||
final appTray = system.isDesktop ? AppTray() : null;
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class Utils {
|
||||
static Utils? _instance;
|
||||
|
||||
Utils._internal();
|
||||
|
||||
factory Utils() {
|
||||
_instance ??= Utils._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Color? getDelayColor(int? delay) {
|
||||
if (delay == null) return null;
|
||||
if (delay < 0) return Colors.red;
|
||||
if (delay < 600) return Colors.green;
|
||||
return const Color(0xFFC57F0A);
|
||||
}
|
||||
|
||||
String get id {
|
||||
final timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
final random = Random();
|
||||
final randomStr = String.fromCharCodes(
|
||||
List.generate(8, (_) => random.nextInt(26) + 97),
|
||||
);
|
||||
return '$timestamp$randomStr';
|
||||
}
|
||||
|
||||
String getDateStringLast2(int value) {
|
||||
final valueRaw = '0$value';
|
||||
return valueRaw.substring(valueRaw.length - 2);
|
||||
}
|
||||
|
||||
String generateRandomString({int minLength = 10, int maxLength = 100}) {
|
||||
const latinChars =
|
||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
final random = Random();
|
||||
|
||||
final int length = minLength + random.nextInt(maxLength - minLength + 1);
|
||||
|
||||
String result = '';
|
||||
for (int i = 0; i < length; i++) {
|
||||
if (random.nextBool()) {
|
||||
result += String.fromCharCode(
|
||||
0x4E00 + random.nextInt(0x9FA5 - 0x4E00 + 1),
|
||||
);
|
||||
} else {
|
||||
result += latinChars[random.nextInt(latinChars.length)];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
String get uuidV4 {
|
||||
final Random random = Random();
|
||||
final bytes = List.generate(16, (_) => random.nextInt(256));
|
||||
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80;
|
||||
|
||||
final hex = bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}';
|
||||
}
|
||||
|
||||
String getTimeDifference(DateTime dateTime) {
|
||||
final currentDateTime = DateTime.now();
|
||||
final difference = currentDateTime.difference(dateTime);
|
||||
final inHours = difference.inHours;
|
||||
final inMinutes = difference.inMinutes;
|
||||
final inSeconds = difference.inSeconds;
|
||||
|
||||
return '${getDateStringLast2(inHours)}:${getDateStringLast2(inMinutes)}:${getDateStringLast2(inSeconds)}';
|
||||
}
|
||||
|
||||
String getTimeText(int? timeStamp) {
|
||||
if (timeStamp == null) {
|
||||
return '00:00:00';
|
||||
}
|
||||
final diff = timeStamp / 1000;
|
||||
final inHours = (diff / 3600).floor();
|
||||
if (inHours > 999) {
|
||||
return '999:59:59';
|
||||
}
|
||||
final inMinutes = (diff / 60 % 60).floor();
|
||||
final inSeconds = (diff % 60).floor();
|
||||
final hoursText = inHours.toString().padLeft(2, '0');
|
||||
|
||||
return '$hoursText:${getDateStringLast2(inMinutes)}:${getDateStringLast2(inSeconds)}';
|
||||
}
|
||||
|
||||
Locale? getLocaleForString(String? localString) {
|
||||
if (localString == null) return null;
|
||||
final localSplit = localString.split('_');
|
||||
if (localSplit.length == 1) {
|
||||
return Locale(localSplit[0]);
|
||||
}
|
||||
if (localSplit.length == 2) {
|
||||
return Locale(localSplit[0], localSplit[1]);
|
||||
}
|
||||
if (localSplit.length == 3) {
|
||||
return Locale.fromSubtags(
|
||||
languageCode: localSplit[0],
|
||||
scriptCode: localSplit[1],
|
||||
countryCode: localSplit[2],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int sortByChar(String a, String b) {
|
||||
if (a.isEmpty && b.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
if (a.isEmpty) {
|
||||
return -1;
|
||||
}
|
||||
if (b.isEmpty) {
|
||||
return 1;
|
||||
}
|
||||
final charA = a[0];
|
||||
final charB = b[0];
|
||||
|
||||
if (charA == charB) {
|
||||
return sortByChar(a.substring(1), b.substring(1));
|
||||
} else {
|
||||
return charA.compareToLower(charB);
|
||||
}
|
||||
}
|
||||
|
||||
String getOverwriteLabel(String label) {
|
||||
final reg = RegExp(r'\((\d+)\)$');
|
||||
final matches = reg.allMatches(label);
|
||||
if (matches.isNotEmpty) {
|
||||
final match = matches.last;
|
||||
final number = int.parse(match[1] ?? '0') + 1;
|
||||
return label.replaceFirst(reg, '($number)', label.length - 3 - 1);
|
||||
} else {
|
||||
return '$label(1)';
|
||||
}
|
||||
}
|
||||
|
||||
String get traySuffix {
|
||||
final suffix = system.isWindows ? 'ico' : 'png';
|
||||
return 'assets/images/icon/status_2.$suffix';
|
||||
}
|
||||
|
||||
int compareVersions(String version1, String version2) {
|
||||
final List<String> v1 = version1.split('+')[0].split('.');
|
||||
final List<String> v2 = version2.split('+')[0].split('.');
|
||||
final int major1 = int.parse(v1[0]);
|
||||
final int major2 = int.parse(v2[0]);
|
||||
if (major1 != major2) {
|
||||
return major1.compareTo(major2);
|
||||
}
|
||||
final int minor1 = v1.length > 1 ? int.parse(v1[1]) : 0;
|
||||
final int minor2 = v2.length > 1 ? int.parse(v2[1]) : 0;
|
||||
if (minor1 != minor2) {
|
||||
return minor1.compareTo(minor2);
|
||||
}
|
||||
final int patch1 = v1.length > 2 ? int.parse(v1[2]) : 0;
|
||||
final int patch2 = v2.length > 2 ? int.parse(v2[2]) : 0;
|
||||
if (patch1 != patch2) {
|
||||
return patch1.compareTo(patch2);
|
||||
}
|
||||
final int build1 = version1.contains('+')
|
||||
? int.parse(version1.split('+')[1])
|
||||
: 0;
|
||||
final int build2 = version2.contains('+')
|
||||
? int.parse(version2.split('+')[1])
|
||||
: 0;
|
||||
return build1.compareTo(build2);
|
||||
}
|
||||
|
||||
String? getFileNameForDisposition(String? disposition) {
|
||||
if (disposition == null) return null;
|
||||
final parseValue = HeaderValue.parse(disposition);
|
||||
final parameters = parseValue.parameters;
|
||||
final fileNamePointKey = parameters.keys.firstWhere(
|
||||
(key) => key == 'filename*',
|
||||
orElse: () => '',
|
||||
);
|
||||
if (fileNamePointKey.isNotEmpty) {
|
||||
final res = parameters[fileNamePointKey]?.split("''") ?? [];
|
||||
if (res.length >= 2) {
|
||||
return Uri.decodeComponent(res[1]);
|
||||
}
|
||||
}
|
||||
final fileNameKey = parameters.keys.firstWhere(
|
||||
(key) => key == 'filename',
|
||||
orElse: () => '',
|
||||
);
|
||||
if (fileNameKey.isEmpty) return null;
|
||||
return parameters[fileNameKey];
|
||||
}
|
||||
|
||||
FlutterView getScreen() {
|
||||
return WidgetsBinding.instance.platformDispatcher.views.first;
|
||||
}
|
||||
|
||||
List<String> parseReleaseBody(String? body) {
|
||||
if (body == null) return [];
|
||||
const pattern = r'- \s*(.*)';
|
||||
final regex = RegExp(pattern);
|
||||
return regex
|
||||
.allMatches(body)
|
||||
.map((match) => match.group(1) ?? '')
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
ViewMode getViewMode(double viewWidth) {
|
||||
if (viewWidth <= maxMobileWidth) return ViewMode.mobile;
|
||||
if (viewWidth <= maxLaptopWidth) return ViewMode.laptop;
|
||||
return ViewMode.desktop;
|
||||
}
|
||||
|
||||
int getProxiesColumns(double viewWidth, ProxiesLayout proxiesLayout) {
|
||||
final columns = max((viewWidth / 250).ceil(), 2);
|
||||
return switch (proxiesLayout) {
|
||||
ProxiesLayout.tight => columns + 1,
|
||||
ProxiesLayout.standard => columns,
|
||||
ProxiesLayout.loose => columns - 1,
|
||||
};
|
||||
}
|
||||
|
||||
int getProfilesColumns(double viewWidth) {
|
||||
return max((viewWidth / 280).floor(), 1);
|
||||
}
|
||||
|
||||
final _indexPrimary = [50, 100, 200, 300, 400, 500, 600, 700, 800, 850, 900];
|
||||
|
||||
MaterialColor _createPrimarySwatch(Color color) {
|
||||
final Map<int, Color> swatch = <int, Color>{};
|
||||
final int a = color.alpha8bit;
|
||||
final int r = color.red8bit;
|
||||
final int g = color.green8bit;
|
||||
final int b = color.blue8bit;
|
||||
for (final int strength in _indexPrimary) {
|
||||
final double ds = 0.5 - strength / 1000;
|
||||
swatch[strength] = Color.fromARGB(
|
||||
a,
|
||||
r + ((ds < 0 ? r : (255 - r)) * ds).round(),
|
||||
g + ((ds < 0 ? g : (255 - g)) * ds).round(),
|
||||
b + ((ds < 0 ? b : (255 - b)) * ds).round(),
|
||||
);
|
||||
}
|
||||
swatch[50] = swatch[50]!.lighten(18);
|
||||
swatch[100] = swatch[100]!.lighten(16);
|
||||
swatch[200] = swatch[200]!.lighten(14);
|
||||
swatch[300] = swatch[300]!.lighten(10);
|
||||
swatch[400] = swatch[400]!.lighten(6);
|
||||
swatch[700] = swatch[700]!.darken(2);
|
||||
swatch[800] = swatch[800]!.darken(3);
|
||||
swatch[900] = swatch[900]!.darken(4);
|
||||
return MaterialColor(color.value32bit, swatch);
|
||||
}
|
||||
|
||||
List<Color> getMaterialColorShades(Color color) {
|
||||
final swatch = _createPrimarySwatch(color);
|
||||
return <Color>[
|
||||
if (swatch[50] != null) swatch[50]!,
|
||||
if (swatch[100] != null) swatch[100]!,
|
||||
if (swatch[200] != null) swatch[200]!,
|
||||
if (swatch[300] != null) swatch[300]!,
|
||||
if (swatch[400] != null) swatch[400]!,
|
||||
if (swatch[500] != null) swatch[500]!,
|
||||
if (swatch[600] != null) swatch[600]!,
|
||||
if (swatch[700] != null) swatch[700]!,
|
||||
if (swatch[800] != null) swatch[800]!,
|
||||
if (swatch[850] != null) swatch[850]!,
|
||||
if (swatch[900] != null) swatch[900]!,
|
||||
];
|
||||
}
|
||||
|
||||
String getBackupFileName() {
|
||||
return '${appName}_backup_${DateTime.now().show}.zip';
|
||||
}
|
||||
|
||||
String get logFile {
|
||||
return '${appName}_${DateTime.now().show}.log';
|
||||
}
|
||||
|
||||
Future<String?> getLocalIpAddress() async {
|
||||
final List<NetworkInterface> interfaces =
|
||||
await NetworkInterface.list(includeLoopback: false)
|
||||
..sort((a, b) {
|
||||
if (a.isWifi && !b.isWifi) return -1;
|
||||
if (!a.isWifi && b.isWifi) return 1;
|
||||
if (a.includesIPv4 && !b.includesIPv4) return -1;
|
||||
if (!a.includesIPv4 && b.includesIPv4) return 1;
|
||||
return 0;
|
||||
});
|
||||
for (final interface in interfaces) {
|
||||
final addresses = interface.addresses;
|
||||
if (addresses.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
addresses.sort((a, b) {
|
||||
if (a.isIPv4 && !b.isIPv4) return -1;
|
||||
if (!a.isIPv4 && b.isIPv4) return 1;
|
||||
return 0;
|
||||
});
|
||||
return addresses.first.address;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
SingleActivator controlSingleActivator(LogicalKeyboardKey trigger) {
|
||||
final control = system.isMacOS ? false : true;
|
||||
return SingleActivator(trigger, control: control, meta: !control);
|
||||
}
|
||||
|
||||
FutureOr<T> handleWatch<T>({
|
||||
required Function function,
|
||||
required void Function() onStart,
|
||||
required void Function(T data, int elapsedMilliseconds) onEnd,
|
||||
}) async {
|
||||
if (kDebugMode && watchExecution) {
|
||||
onStart();
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final res = await function();
|
||||
stopwatch.stop();
|
||||
onEnd(res, stopwatch.elapsedMilliseconds);
|
||||
return res;
|
||||
}
|
||||
return await function();
|
||||
}
|
||||
|
||||
int fastHash(String string) {
|
||||
var hash = 0xcbf29ce484222325;
|
||||
|
||||
var i = 0;
|
||||
while (i < string.length) {
|
||||
final codeUnit = string.codeUnitAt(i++);
|
||||
hash ^= codeUnit >> 8;
|
||||
hash *= 0x100000001b3;
|
||||
hash ^= codeUnit & 0xFF;
|
||||
hash *= 0x100000001b3;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
final utils = Utils();
|
||||
@@ -0,0 +1,322 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fl_clash/common/string.dart';
|
||||
|
||||
final class DAVException implements Exception {
|
||||
final String method;
|
||||
final String path;
|
||||
final int? statusCode;
|
||||
final String? reasonPhrase;
|
||||
|
||||
const DAVException({
|
||||
required this.method,
|
||||
required this.path,
|
||||
this.statusCode,
|
||||
this.reasonPhrase,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final reason = reasonPhrase?.trim();
|
||||
final detail = [
|
||||
if (statusCode != null) '$statusCode',
|
||||
if (reason != null && reason.isNotEmpty) reason,
|
||||
].join(' ');
|
||||
return 'WebDAV $method $path failed: ${detail.isEmpty ? 'no response' : detail}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests go through `Dio`, so they inherit the app-wide HttpOverrides: the
|
||||
/// user's proxy selection and the TLS certificate setting apply here too.
|
||||
class DAVTransport {
|
||||
DAVTransport({
|
||||
required String uri,
|
||||
required this.user,
|
||||
required this.password,
|
||||
Dio? dio,
|
||||
}) : _base = Uri.parse(uri.trim()),
|
||||
_dio = dio ?? Dio() {
|
||||
_dio.options
|
||||
..connectTimeout = const Duration(seconds: 8)
|
||||
..sendTimeout = const Duration(seconds: 60)
|
||||
..receiveTimeout = const Duration(seconds: 60);
|
||||
}
|
||||
|
||||
static const _maxRedirects = 5;
|
||||
|
||||
final String user;
|
||||
final String password;
|
||||
final Uri _base;
|
||||
final Dio _dio;
|
||||
|
||||
_DigestChallenge? _digest;
|
||||
int _digestNonceCount = 0;
|
||||
|
||||
Uri resolve(String path, {bool collection = false}) {
|
||||
final segments = [
|
||||
..._base.pathSegments.where((segment) => segment.isNotEmpty),
|
||||
...path.split('/').where((segment) => segment.isNotEmpty),
|
||||
];
|
||||
return Uri(
|
||||
scheme: _base.scheme,
|
||||
host: _base.host,
|
||||
port: _base.hasPort ? _base.port : null,
|
||||
pathSegments: collection ? [...segments, ''] : segments,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> options(String path) async {
|
||||
await _send('OPTIONS', path, collection: true, accept: const {200, 204});
|
||||
}
|
||||
|
||||
/// 405 means the collection is already there, which is the common case: the
|
||||
/// backup directory only has to be created once per account.
|
||||
Future<void> mkcol(String path) async {
|
||||
await _send(
|
||||
'MKCOL',
|
||||
path,
|
||||
collection: true,
|
||||
accept: const {200, 201, 204, 405},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> put(String path, List<int> bytes) async {
|
||||
await _send(
|
||||
'PUT',
|
||||
path,
|
||||
body: bytes,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: 'application/octet-stream',
|
||||
Headers.contentLengthHeader: bytes.length,
|
||||
},
|
||||
accept: const {200, 201, 204},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> get(String path) async {
|
||||
final response = await _send(
|
||||
'GET',
|
||||
path,
|
||||
responseType: ResponseType.bytes,
|
||||
accept: const {200},
|
||||
);
|
||||
final data = response.data;
|
||||
return data is List<int> ? Uint8List.fromList(data) : Uint8List(0);
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _send(
|
||||
String method,
|
||||
String path, {
|
||||
bool collection = false,
|
||||
List<int>? body,
|
||||
Map<String, dynamic> headers = const {},
|
||||
ResponseType responseType = ResponseType.plain,
|
||||
required Set<int> accept,
|
||||
}) async {
|
||||
final origin = resolve(path, collection: collection);
|
||||
var uri = origin;
|
||||
var retriedAuth = false;
|
||||
for (var redirects = 0; ; redirects++) {
|
||||
final response = await _request(
|
||||
method,
|
||||
uri,
|
||||
origin: origin,
|
||||
body: body,
|
||||
headers: headers,
|
||||
responseType: responseType,
|
||||
);
|
||||
final status = response.statusCode;
|
||||
if (status != null && accept.contains(status)) {
|
||||
return response;
|
||||
}
|
||||
if (status == 401 && !retriedAuth && _adoptChallenge(response)) {
|
||||
retriedAuth = true;
|
||||
continue;
|
||||
}
|
||||
final location = _redirectTarget(response);
|
||||
if (location != null && redirects < _maxRedirects) {
|
||||
uri = uri.resolveUri(location);
|
||||
continue;
|
||||
}
|
||||
throw DAVException(
|
||||
method: method,
|
||||
path: path,
|
||||
statusCode: status,
|
||||
reasonPhrase: response.statusMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<dynamic>> _request(
|
||||
String method,
|
||||
Uri uri, {
|
||||
required Uri origin,
|
||||
List<int>? body,
|
||||
required Map<String, dynamic> headers,
|
||||
required ResponseType responseType,
|
||||
}) {
|
||||
return _dio.requestUri<dynamic>(
|
||||
uri,
|
||||
data: body,
|
||||
options: Options(
|
||||
method: method,
|
||||
headers: {...headers, ..._authorization(method, uri, origin)},
|
||||
responseType: responseType,
|
||||
// Redirects are followed by hand so that a PUT stays a PUT, and every
|
||||
// status reaches the caller instead of becoming a DioException.
|
||||
followRedirects: false,
|
||||
maxRedirects: 0,
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Uri? _redirectTarget(Response<dynamic> response) {
|
||||
const redirects = {301, 302, 303, 307, 308};
|
||||
if (!redirects.contains(response.statusCode)) {
|
||||
return null;
|
||||
}
|
||||
final location = response.headers.value('location');
|
||||
if (location == null || location.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return Uri.tryParse(location);
|
||||
}
|
||||
|
||||
bool _adoptChallenge(Response<dynamic> response) {
|
||||
// Basic and Digest may each arrive as their own www-authenticate line.
|
||||
final headerValues = response.headers[Headers.wwwAuthenticateHeader];
|
||||
if (headerValues == null) {
|
||||
return false;
|
||||
}
|
||||
for (final header in headerValues) {
|
||||
final challenge = _DigestChallenge.parse(header);
|
||||
if (challenge != null) {
|
||||
_digest = challenge;
|
||||
_digestNonceCount = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _sameOrigin(Uri a, Uri b) {
|
||||
return a.scheme == b.scheme && a.host == b.host && _port(a) == _port(b);
|
||||
}
|
||||
|
||||
static int _port(Uri uri) {
|
||||
if (uri.hasPort) return uri.port;
|
||||
return uri.scheme == 'https' ? 443 : 80;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _authorization(String method, Uri uri, Uri origin) {
|
||||
// Never forward credentials to a redirect target on another origin.
|
||||
if (!_sameOrigin(uri, origin)) {
|
||||
return const {};
|
||||
}
|
||||
if (user.isEmpty && password.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
final digest = _digest;
|
||||
if (digest == null) {
|
||||
final credentials = base64.encode(utf8.encode('$user:$password'));
|
||||
return {'authorization': 'Basic $credentials'};
|
||||
}
|
||||
return {
|
||||
'authorization': digest.authorize(
|
||||
user: user,
|
||||
password: password,
|
||||
method: method,
|
||||
uri: uri,
|
||||
nonceCount: ++_digestNonceCount,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _DigestChallenge {
|
||||
const _DigestChallenge({
|
||||
required this.realm,
|
||||
required this.nonce,
|
||||
this.qop,
|
||||
this.opaque,
|
||||
this.algorithm,
|
||||
});
|
||||
|
||||
final String realm;
|
||||
final String nonce;
|
||||
final String? qop;
|
||||
final String? opaque;
|
||||
final String? algorithm;
|
||||
|
||||
static _DigestChallenge? parse(String? header) {
|
||||
if (header == null) return null;
|
||||
final start = header.toLowerCase().indexOf('digest');
|
||||
if (start == -1) return null;
|
||||
final values = <String, String>{};
|
||||
final pattern = RegExp(r'(\w+)\s*=\s*(?:"([^"]*)"|([^\s,]+))');
|
||||
for (final match in pattern.allMatches(header.substring(start + 6))) {
|
||||
values[match.group(1)!.toLowerCase()] = match.group(2) ?? match.group(3)!;
|
||||
}
|
||||
final nonce = values['nonce'];
|
||||
if (nonce == null) return null;
|
||||
final algorithm = values['algorithm'];
|
||||
// Only MD5 is implemented; anything else would produce a wrong response
|
||||
// hash, and Basic at least has a chance of being accepted.
|
||||
if (algorithm != null && algorithm.toUpperCase() != 'MD5') {
|
||||
return null;
|
||||
}
|
||||
return _DigestChallenge(
|
||||
realm: values['realm'] ?? '',
|
||||
nonce: nonce,
|
||||
qop: _pickQop(values['qop']),
|
||||
opaque: values['opaque'],
|
||||
algorithm: algorithm,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _pickQop(String? raw) {
|
||||
if (raw == null) return null;
|
||||
final options = raw.split(',').map((item) => item.trim().toLowerCase());
|
||||
return options.contains('auth') ? 'auth' : null;
|
||||
}
|
||||
|
||||
String authorize({
|
||||
required String user,
|
||||
required String password,
|
||||
required String method,
|
||||
required Uri uri,
|
||||
required int nonceCount,
|
||||
}) {
|
||||
final path = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
|
||||
final ha1 = '$user:$realm:$password'.toMd5();
|
||||
final ha2 = '$method:$path'.toMd5();
|
||||
final count = nonceCount.toRadixString(16).padLeft(8, '0');
|
||||
final cnonce = _cnonce();
|
||||
final response = qop == null
|
||||
? '$ha1:$nonce:$ha2'.toMd5()
|
||||
: '$ha1:$nonce:$count:$cnonce:$qop:$ha2'.toMd5();
|
||||
final parts = [
|
||||
'username="$user"',
|
||||
'realm="$realm"',
|
||||
'nonce="$nonce"',
|
||||
'uri="$path"',
|
||||
'response="$response"',
|
||||
if (algorithm != null) 'algorithm=$algorithm',
|
||||
if (opaque != null) 'opaque="$opaque"',
|
||||
if (qop != null) 'qop=$qop',
|
||||
if (qop != null) 'nc=$count',
|
||||
if (qop != null) 'cnonce="$cnonce"',
|
||||
];
|
||||
return 'Digest ${parts.join(', ')}';
|
||||
}
|
||||
|
||||
static String _cnonce() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(8, (_) => random.nextInt(256));
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/config.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class Window {
|
||||
class Window implements WindowPort {
|
||||
static Window? _instance;
|
||||
bool _supportsPosition = false;
|
||||
late final WindowVisibilityController _visibility =
|
||||
WindowVisibilityController(
|
||||
showWindow: _showWindow,
|
||||
hideWindow: _hideWindow,
|
||||
isWindowVisible: _isWindowVisible,
|
||||
setSkipTaskbar: (skip) => windowManager.setSkipTaskbar(skip),
|
||||
dockSettleDuration: system.isMacOS
|
||||
? const Duration(seconds: 1)
|
||||
: Duration.zero,
|
||||
);
|
||||
|
||||
Window._internal();
|
||||
|
||||
@@ -19,14 +32,22 @@ class Window {
|
||||
Future<void> init(int version, WindowProps props) async {
|
||||
final acquire = await singleInstanceLock.acquire();
|
||||
if (!acquire) {
|
||||
commonPrint.log('another instance owns the data directory, exiting');
|
||||
exit(0);
|
||||
}
|
||||
if (system.isWindows) {
|
||||
protocol.register('clash');
|
||||
protocol.register('clashmeta');
|
||||
protocol.register('flclash');
|
||||
for (final scheme in protocolSchemes) {
|
||||
protocol.register(scheme);
|
||||
}
|
||||
}
|
||||
if (system.isLinux) {
|
||||
unawaited(protocol.registerLinux(protocolSchemes));
|
||||
}
|
||||
await windowManager.ensureInitialized();
|
||||
_supportsPosition = !system.isMacOS;
|
||||
if (system.isLinux) {
|
||||
_supportsPosition = await windowManager.isPositionSupported();
|
||||
}
|
||||
final WindowOptions windowOptions = WindowOptions(
|
||||
size: props.size,
|
||||
minimumSize: const Size(380, 400),
|
||||
@@ -35,26 +56,37 @@ class Window {
|
||||
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
|
||||
}
|
||||
await windowManager.setMaximizable(true);
|
||||
await _windowPosition(props);
|
||||
await windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.setPreventClose(true);
|
||||
});
|
||||
// On Linux the compositor only honors positioning after the window is shown;
|
||||
// elsewhere position it pre-show to avoid a visible jump.
|
||||
if (!system.isLinux) {
|
||||
await _windowPosition(props);
|
||||
}
|
||||
await windowManager.waitUntilReadyToShow(windowOptions);
|
||||
if (system.isLinux) {
|
||||
await _windowPosition(props);
|
||||
}
|
||||
await windowManager.setPreventClose(true);
|
||||
}
|
||||
|
||||
Future<void> _windowPosition(WindowProps props) async {
|
||||
if (!system.isMacOS) {
|
||||
final left = props.left ?? 0;
|
||||
final top = props.top ?? 0;
|
||||
final right = left + props.width;
|
||||
final bottom = top + props.height;
|
||||
if (left == 0 && top == 0) {
|
||||
if (_supportsPosition) {
|
||||
final left = props.left;
|
||||
final top = props.top;
|
||||
if (left == null || top == null) {
|
||||
await windowManager.setAlignment(Alignment.center);
|
||||
} else {
|
||||
final size = props.size;
|
||||
final right = left + size.width;
|
||||
final bottom = top + size.height;
|
||||
final displays = await screenRetriever.getAllDisplays();
|
||||
final isPositionValid = displays.any((display) {
|
||||
final visiblePosition = display.visiblePosition;
|
||||
if (visiblePosition == null) {
|
||||
return false;
|
||||
}
|
||||
final displayBounds = Rect.fromLTWH(
|
||||
display.visiblePosition!.dx,
|
||||
display.visiblePosition!.dy,
|
||||
visiblePosition.dx,
|
||||
visiblePosition.dy,
|
||||
display.size.width,
|
||||
display.size.height,
|
||||
);
|
||||
@@ -63,36 +95,183 @@ class Window {
|
||||
});
|
||||
if (isPositionValid) {
|
||||
await windowManager.setPosition(Offset(left, top));
|
||||
} else {
|
||||
await windowManager.setAlignment(Alignment.center);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> show() async {
|
||||
@override
|
||||
Future<WindowProps?> captureNormalGeometry(WindowProps current) async {
|
||||
final states = await Future.wait<bool>([
|
||||
windowManager.isMaximized(),
|
||||
windowManager.isFullScreen(),
|
||||
windowManager.isMinimized(),
|
||||
]);
|
||||
if (states.any((state) => state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final bounds = await windowManager.getBounds();
|
||||
if (!bounds.width.isFinite ||
|
||||
!bounds.height.isFinite ||
|
||||
bounds.width <= 0 ||
|
||||
bounds.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
final hasValidPosition =
|
||||
bounds.left.isFinite && bounds.top.isFinite && _supportsPosition;
|
||||
return current.copyWith(
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
left: hasValidPosition ? bounds.left : current.left,
|
||||
top: hasValidPosition ? bounds.top : current.top,
|
||||
);
|
||||
}
|
||||
|
||||
/// Every desktop runner leaves the window hidden until [init] reveals it, so
|
||||
/// a failure before that point would leave the error screen with no window.
|
||||
Future<void> showInitFailure() async {
|
||||
try {
|
||||
await windowManager.ensureInitialized();
|
||||
if (await windowManager.isVisible()) {
|
||||
return;
|
||||
}
|
||||
await windowManager.waitUntilReadyToShow(
|
||||
const WindowOptions(size: Size(680, 580), center: true),
|
||||
);
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'show init failure window failed ${e.toString()}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> show() => _visibility.show();
|
||||
|
||||
@override
|
||||
Future<void> hide() => _visibility.hide();
|
||||
|
||||
@override
|
||||
Future<void> toggle() => _visibility.toggle();
|
||||
|
||||
Future<void> _showWindow() async {
|
||||
render?.resume();
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
await windowManager.setSkipTaskbar(false);
|
||||
}
|
||||
|
||||
Future<bool> get isVisible async {
|
||||
Future<void> _hideWindow() async {
|
||||
render?.pause();
|
||||
await windowManager.hide();
|
||||
}
|
||||
|
||||
Future<bool> _isWindowVisible() async {
|
||||
final value = await windowManager.isVisible();
|
||||
commonPrint.log('window visible check: $value');
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await windowManager.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void forceExit() {
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> hide() async {
|
||||
render?.pause();
|
||||
await windowManager.hide();
|
||||
await windowManager.setSkipTaskbar(true);
|
||||
/// Serializes visibility requests so a burst of hotkey toggles lands in
|
||||
/// order, and holds back the Dock-hiding activation policy switch while a
|
||||
/// preceding regular switch settles: flipping regular → accessory → regular
|
||||
/// within about a second leaves macOS with stray Dock icons.
|
||||
class WindowVisibilityController {
|
||||
WindowVisibilityController({
|
||||
required Future<void> Function() showWindow,
|
||||
required Future<void> Function() hideWindow,
|
||||
required Future<bool> Function() isWindowVisible,
|
||||
required Future<void> Function(bool skip) setSkipTaskbar,
|
||||
required this.dockSettleDuration,
|
||||
}) : _showWindow = showWindow,
|
||||
_hideWindow = hideWindow,
|
||||
_isWindowVisible = isWindowVisible,
|
||||
_setSkipTaskbar = setSkipTaskbar;
|
||||
|
||||
final Future<void> Function() _showWindow;
|
||||
final Future<void> Function() _hideWindow;
|
||||
final Future<bool> Function() _isWindowVisible;
|
||||
final Future<void> Function(bool skip) _setSkipTaskbar;
|
||||
final Duration dockSettleDuration;
|
||||
|
||||
Future<void>? _queue;
|
||||
Timer? _dockSettleTimer;
|
||||
bool _dockHidePending = false;
|
||||
|
||||
Future<void> show() => _enqueue(_show);
|
||||
|
||||
Future<void> hide() => _enqueue(_hide);
|
||||
|
||||
Future<void> toggle() => _enqueue(() async {
|
||||
if (await _isWindowVisible()) {
|
||||
await _hide();
|
||||
} else {
|
||||
await _show();
|
||||
}
|
||||
});
|
||||
|
||||
Future<void> _enqueue(Future<void> Function() step) {
|
||||
final previous = _queue;
|
||||
final result = previous == null ? step() : previous.then((_) => step());
|
||||
final tail = result.catchError((_) {});
|
||||
_queue = tail;
|
||||
tail.whenComplete(() {
|
||||
if (identical(_queue, tail)) {
|
||||
_queue = null;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _show() async {
|
||||
_dockHidePending = false;
|
||||
await _showWindow();
|
||||
await _setSkipTaskbar(false);
|
||||
_dockSettleTimer?.cancel();
|
||||
_dockSettleTimer = dockSettleDuration == Duration.zero
|
||||
? null
|
||||
: Timer(dockSettleDuration, _onDockSettled);
|
||||
}
|
||||
|
||||
Future<void> _hide() async {
|
||||
await _hideWindow();
|
||||
if (_dockSettleTimer?.isActive ?? false) {
|
||||
_dockHidePending = true;
|
||||
return;
|
||||
}
|
||||
await _setSkipTaskbar(true);
|
||||
}
|
||||
|
||||
void _onDockSettled() {
|
||||
_dockSettleTimer = null;
|
||||
if (!_dockHidePending) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
_enqueue(() async {
|
||||
if (!_dockHidePending) {
|
||||
return;
|
||||
}
|
||||
_dockHidePending = false;
|
||||
await _setSkipTaskbar(true);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,12 @@ class CoreController {
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
CoreController.test(this._interface);
|
||||
CoreController.test(this._interface) {
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
CoreController.scoped(this._interface);
|
||||
|
||||
@visibleForTesting
|
||||
static void resetInstance() {
|
||||
@@ -43,13 +48,18 @@ class CoreController {
|
||||
|
||||
Future<CoreLifecycleResult> close() => _interface.close();
|
||||
|
||||
static Future<void> initGeo() async {
|
||||
static Future<void> ensureHomeDir() async {
|
||||
final homePath = await appPath.homeDirPath;
|
||||
final homeDir = Directory(homePath);
|
||||
final isExists = await homeDir.exists();
|
||||
if (!isExists) {
|
||||
await homeDir.create(recursive: true);
|
||||
}
|
||||
await system.grantHomeDirAccess(homePath);
|
||||
}
|
||||
|
||||
static Future<void> initGeo() async {
|
||||
final homePath = await appPath.homeDirPath;
|
||||
const geoFileNameList = [MMDB, GEOIP, GEOSITE, ASN];
|
||||
try {
|
||||
for (final geoFileName in geoFileNameList) {
|
||||
@@ -72,6 +82,7 @@ class CoreController {
|
||||
}
|
||||
|
||||
Future<bool> init(int version) async {
|
||||
await ensureHomeDir();
|
||||
await initGeo();
|
||||
final homeDirPath = await appPath.homeDirPath;
|
||||
return _interface.init(InitParams(homeDir: homeDirPath, version: version));
|
||||
@@ -185,7 +196,7 @@ class CoreController {
|
||||
return _interface.stopListener();
|
||||
}
|
||||
|
||||
Future<Delay> getDelay(String url, String proxyName) async {
|
||||
Future<Delay?> getDelay(String url, String proxyName) async {
|
||||
return _interface.asyncTestDelay(url, proxyName);
|
||||
}
|
||||
|
||||
@@ -203,14 +214,6 @@ class CoreController {
|
||||
return _interface.getTraffic(onlyStatisticsProxy);
|
||||
}
|
||||
|
||||
Future<IpInfo?> getCountryCode(String ip) async {
|
||||
final countryCode = await _interface.getCountryCode(ip);
|
||||
if (countryCode.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return IpInfo(ip: ip, countryCode: countryCode);
|
||||
}
|
||||
|
||||
Future<Traffic> getTotalTraffic(bool onlyStatisticsProxy) async {
|
||||
return _interface.getTotalTraffic(onlyStatisticsProxy);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import 'package:path/path.dart' as p;
|
||||
import 'core_manifest.dart';
|
||||
import 'launcher.dart';
|
||||
import 'model.dart';
|
||||
import 'process_probe.dart';
|
||||
|
||||
enum WindowsHelperReadiness { ready, notReady, manifestMissing }
|
||||
enum HelperReadiness { ready, notReady, manifestMissing }
|
||||
|
||||
final class HelperStartResponse {
|
||||
final String sessionId;
|
||||
@@ -34,34 +35,35 @@ final class HelperStopResponse {
|
||||
});
|
||||
}
|
||||
|
||||
final class WindowsHelperException implements Exception {
|
||||
final class HelperException implements Exception {
|
||||
final String code;
|
||||
final String message;
|
||||
final Object? details;
|
||||
|
||||
const WindowsHelperException({
|
||||
const HelperException({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'WindowsHelperException($code, $message, $details)';
|
||||
String toString() => 'HelperException($code, $message, $details)';
|
||||
}
|
||||
|
||||
final class WindowsHelperClient {
|
||||
final class HelperClient {
|
||||
final Dio _dio;
|
||||
final String Function() _expectedHelperPath;
|
||||
final Future<String> Function() _readCoreSha256;
|
||||
final String baseUrl;
|
||||
String? _coreSha256Cache;
|
||||
|
||||
WindowsHelperClient({
|
||||
HelperClient({
|
||||
Dio? dio,
|
||||
String Function()? expectedHelperPath,
|
||||
Future<String> Function()? readCoreSha256,
|
||||
this.baseUrl = 'http://$localhost:$helperPort',
|
||||
}) : _dio = dio ?? _createLoopbackDio(),
|
||||
String? baseUrl,
|
||||
}) : _dio = dio ?? _createDio(),
|
||||
baseUrl = baseUrl ?? _defaultBaseUrl(),
|
||||
_expectedHelperPath = expectedHelperPath ?? _defaultHelperPath,
|
||||
_readCoreSha256 = readCoreSha256 ?? _readBundledCoreSha256;
|
||||
|
||||
@@ -84,36 +86,61 @@ final class WindowsHelperClient {
|
||||
return coreSha256;
|
||||
}
|
||||
|
||||
// The Helper protocol is loopback-only; never route it through a proxy.
|
||||
static Dio _createLoopbackDio() {
|
||||
// The Helper protocol never leaves the machine; never route it through a
|
||||
// proxy. Linux carries it over a Unix socket the systemd unit owns, so the
|
||||
// authority in the URL is a placeholder the connection factory ignores.
|
||||
static Dio _createDio() {
|
||||
return Dio()
|
||||
..httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
final client = HttpClient();
|
||||
client.findProxy = (uri) => 'DIRECT';
|
||||
if (Platform.isLinux) {
|
||||
client.connectionFactory = (uri, proxyHost, proxyPort) {
|
||||
return Socket.startConnect(
|
||||
InternetAddress(
|
||||
helperSocketPath,
|
||||
type: InternetAddressType.unix,
|
||||
),
|
||||
0,
|
||||
);
|
||||
};
|
||||
}
|
||||
return client;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _defaultBaseUrl() {
|
||||
return Platform.isLinux
|
||||
? 'http://$appHelperService'
|
||||
: 'http://$localhost:$helperPort';
|
||||
}
|
||||
|
||||
static Future<String> _readBundledCoreSha256() async {
|
||||
return (await CoreManifest.readCoreSha256()) ?? '';
|
||||
}
|
||||
|
||||
static String _defaultHelperPath() {
|
||||
final context = p.Context(style: p.Style.windows);
|
||||
return context.join(
|
||||
context.dirname(Platform.resolvedExecutable),
|
||||
'$appHelperService.exe',
|
||||
static p.Context get _pathContext {
|
||||
return p.Context(
|
||||
style: Platform.isWindows ? p.Style.windows : p.Style.posix,
|
||||
);
|
||||
}
|
||||
|
||||
Future<WindowsHelperReadiness> readiness({
|
||||
static String _defaultHelperPath() {
|
||||
final context = _pathContext;
|
||||
return context.join(
|
||||
context.dirname(Platform.resolvedExecutable),
|
||||
Platform.isWindows ? '$appHelperService.exe' : appHelperService,
|
||||
);
|
||||
}
|
||||
|
||||
Future<HelperReadiness> readiness({
|
||||
Duration? timeout,
|
||||
bool logFailure = true,
|
||||
}) async {
|
||||
if (timeout != null && timeout <= Duration.zero) {
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
}
|
||||
final cancelToken = CancelToken();
|
||||
final timeoutTimer = timeout == null
|
||||
@@ -126,7 +153,7 @@ final class WindowsHelperClient {
|
||||
final coreSha256 = await _readCoreSha256Once();
|
||||
if (coreSha256.isEmpty) {
|
||||
_logPingFailure('Core manifest is missing or invalid', logFailure);
|
||||
return WindowsHelperReadiness.manifestMissing;
|
||||
return HelperReadiness.manifestMissing;
|
||||
}
|
||||
final response = await _dio.get<Object?>(
|
||||
'$baseUrl/ping',
|
||||
@@ -139,37 +166,35 @@ final class WindowsHelperClient {
|
||||
: _notReadyFromPing(response, logFailure);
|
||||
} catch (error) {
|
||||
_logPingFailure('helper ping failed: $error', logFailure);
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
} finally {
|
||||
timeoutTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
WindowsHelperReadiness _readyFromOk(
|
||||
Response<Object?> response,
|
||||
bool logFailure,
|
||||
) {
|
||||
HelperReadiness _readyFromOk(Response<Object?> response, bool logFailure) {
|
||||
final protocolVersion = response.headers.value(helperProtocolVersionHeader);
|
||||
final helperPath = response.data;
|
||||
if (helperPath is! String) {
|
||||
_logPingFailure('helper ping returned invalid response', logFailure);
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
}
|
||||
if (protocolVersion != helperProtocolVersion) {
|
||||
_logPingFailure('helper protocol mismatch: $protocolVersion', logFailure);
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
}
|
||||
final matches = p.Context(
|
||||
style: p.Style.windows,
|
||||
).equals(helperPath.trim(), _expectedHelperPath());
|
||||
final matches = _pathContext.equals(
|
||||
helperPath.trim(),
|
||||
_expectedHelperPath(),
|
||||
);
|
||||
if (!matches) {
|
||||
_logPingFailure('helper executable path mismatch', logFailure);
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
}
|
||||
return WindowsHelperReadiness.ready;
|
||||
return HelperReadiness.ready;
|
||||
}
|
||||
|
||||
WindowsHelperReadiness _notReadyFromPing(
|
||||
HelperReadiness _notReadyFromPing(
|
||||
Response<Object?> response,
|
||||
bool logFailure,
|
||||
) {
|
||||
@@ -179,28 +204,28 @@ final class WindowsHelperClient {
|
||||
final code = _mapFrom(response.data)?['code'];
|
||||
if (code == 'coreSha256Mismatch') {
|
||||
_logPingFailure('Helper Core SHA256 mismatch', logFailure);
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
}
|
||||
if (protocolVersion == helperProtocolVersion) {
|
||||
_logPingFailure(
|
||||
'helper could not access the Core executable',
|
||||
logFailure,
|
||||
);
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
}
|
||||
_logPingFailure(
|
||||
'helper returned an unrecognized conflict '
|
||||
'(protocol $protocolVersion)',
|
||||
logFailure,
|
||||
);
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
}
|
||||
_logPingFailure(
|
||||
'helper ping returned HTTP $statusCode '
|
||||
'(protocol $protocolVersion)',
|
||||
logFailure,
|
||||
);
|
||||
return WindowsHelperReadiness.notReady;
|
||||
return HelperReadiness.notReady;
|
||||
}
|
||||
|
||||
void _logPingFailure(String message, bool enabled) {
|
||||
@@ -209,6 +234,15 @@ final class WindowsHelperClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// /start hashes the whole Core binary before spawning it, and Smart App
|
||||
/// Control adds a cloud reputation lookup inside CreateProcess; the ping
|
||||
/// budget cut those off as a transport error and hid the Helper's answer.
|
||||
static const startTimeout = Duration(seconds: 15);
|
||||
|
||||
/// Covers the Helper's graceful-exit wait plus its forced kill, so a Core
|
||||
/// slow to tear down its TUN reports as stopped rather than as a transport error.
|
||||
static const stopTimeout = Duration(seconds: 6);
|
||||
|
||||
Future<HelperStartResponse> start({
|
||||
required String address,
|
||||
required String sessionId,
|
||||
@@ -218,13 +252,13 @@ final class WindowsHelperClient {
|
||||
final response = await _dio.post<Object?>(
|
||||
'$baseUrl/start',
|
||||
data: {'address': address, 'sessionId': sessionId},
|
||||
options: _options(ResponseType.json),
|
||||
options: _options(ResponseType.json, receiveTimeout: startTimeout),
|
||||
);
|
||||
final data = _responseMap(response, operation: 'start');
|
||||
final returnedSession = data['sessionId'];
|
||||
final pid = data['pid'];
|
||||
if (returnedSession != sessionId || pid is! int || pid <= 0) {
|
||||
throw const WindowsHelperException(
|
||||
throw const HelperException(
|
||||
code: 'invalidResponse',
|
||||
message: 'Helper returned an invalid start response',
|
||||
);
|
||||
@@ -233,12 +267,12 @@ final class WindowsHelperClient {
|
||||
sessionId: returnedSession as String,
|
||||
pid: pid,
|
||||
);
|
||||
} on WindowsHelperException {
|
||||
} on HelperException {
|
||||
rethrow;
|
||||
} on DioException catch (error) {
|
||||
throw _mapDioException(error, operation: 'start');
|
||||
} catch (error) {
|
||||
throw WindowsHelperException(
|
||||
throw HelperException(
|
||||
code: 'transportError',
|
||||
message: 'Unable to start Core through Helper',
|
||||
details: error.toString(),
|
||||
@@ -252,10 +286,10 @@ final class WindowsHelperClient {
|
||||
final response = await _dio.post<Object?>(
|
||||
'$baseUrl/stop',
|
||||
data: {'sessionId': sessionId},
|
||||
options: _options(ResponseType.json),
|
||||
options: _options(ResponseType.json, receiveTimeout: stopTimeout),
|
||||
);
|
||||
return _parseStopResponse(response, sessionId);
|
||||
} on WindowsHelperException {
|
||||
} on HelperException {
|
||||
rethrow;
|
||||
} on DioException catch (error) {
|
||||
final response = error.response;
|
||||
@@ -263,7 +297,7 @@ final class WindowsHelperClient {
|
||||
final data = _mapFrom(response?.data);
|
||||
final reason = data?['reason'];
|
||||
if (reason is String) {
|
||||
throw WindowsHelperException(
|
||||
throw HelperException(
|
||||
code: reason,
|
||||
message: 'Helper refused to stop the requested Core session',
|
||||
details: data,
|
||||
@@ -272,7 +306,7 @@ final class WindowsHelperClient {
|
||||
}
|
||||
throw _mapDioException(error, operation: 'stop');
|
||||
} catch (error) {
|
||||
throw WindowsHelperException(
|
||||
throw HelperException(
|
||||
code: 'transportError',
|
||||
message: 'Unable to stop Core through Helper',
|
||||
details: error.toString(),
|
||||
@@ -292,7 +326,7 @@ final class WindowsHelperClient {
|
||||
stopped is! bool ||
|
||||
(stopped && reason != null) ||
|
||||
(!stopped && reason != 'notRunning')) {
|
||||
throw const WindowsHelperException(
|
||||
throw const HelperException(
|
||||
code: 'invalidResponse',
|
||||
message: 'Helper returned an invalid stop response',
|
||||
);
|
||||
@@ -309,7 +343,7 @@ final class WindowsHelperClient {
|
||||
required String operation,
|
||||
}) {
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw WindowsHelperException(
|
||||
throw HelperException(
|
||||
code: 'unexpectedStatus',
|
||||
message: 'Helper $operation returned HTTP ${response.statusCode}',
|
||||
details: response.data,
|
||||
@@ -317,7 +351,7 @@ final class WindowsHelperClient {
|
||||
}
|
||||
final data = _mapFrom(response.data);
|
||||
if (data == null) {
|
||||
throw WindowsHelperException(
|
||||
throw HelperException(
|
||||
code: 'invalidResponse',
|
||||
message: 'Helper returned an invalid $operation response',
|
||||
details: response.data,
|
||||
@@ -326,7 +360,7 @@ final class WindowsHelperClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
WindowsHelperException _mapDioException(
|
||||
HelperException _mapDioException(
|
||||
DioException error, {
|
||||
required String operation,
|
||||
}) {
|
||||
@@ -334,13 +368,13 @@ final class WindowsHelperClient {
|
||||
final code = data?['code'];
|
||||
final message = data?['message'];
|
||||
if (code is String && message is String) {
|
||||
return WindowsHelperException(
|
||||
return HelperException(
|
||||
code: code,
|
||||
message: message,
|
||||
details: data?['details'],
|
||||
);
|
||||
}
|
||||
return WindowsHelperException(
|
||||
return HelperException(
|
||||
code: 'transportError',
|
||||
message: 'Helper $operation request failed',
|
||||
details: error.toString(),
|
||||
@@ -363,18 +397,22 @@ final class WindowsHelperClient {
|
||||
|
||||
void _validateSessionId(String sessionId) {
|
||||
if (!RegExp(r'^[0-9a-f]{32}$').hasMatch(sessionId)) {
|
||||
throw const WindowsHelperException(
|
||||
throw const HelperException(
|
||||
code: 'invalidSessionId',
|
||||
message: 'Core session ID must be 128-bit lowercase hexadecimal',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Options _options(ResponseType responseType, {bool acceptAnyStatus = false}) {
|
||||
Options _options(
|
||||
ResponseType responseType, {
|
||||
bool acceptAnyStatus = false,
|
||||
Duration receiveTimeout = const Duration(seconds: 2),
|
||||
}) {
|
||||
return Options(
|
||||
responseType: responseType,
|
||||
connectTimeout: const Duration(milliseconds: 300),
|
||||
receiveTimeout: const Duration(seconds: 2),
|
||||
receiveTimeout: receiveTimeout,
|
||||
// The Helper reports readiness via non-2xx (400, 409, ...), so only
|
||||
// transport errors should surface as DioExceptions.
|
||||
validateStatus: acceptAnyStatus ? (_) => true : null,
|
||||
@@ -382,10 +420,11 @@ final class WindowsHelperClient {
|
||||
}
|
||||
}
|
||||
|
||||
final class WindowsHelperLauncher implements CoreProcessLauncher {
|
||||
final WindowsHelperClient client;
|
||||
final class HelperLauncher implements CoreProcessLauncher {
|
||||
final HelperClient client;
|
||||
final ProcessLivenessProbe livenessProbe;
|
||||
|
||||
const WindowsHelperLauncher(this.client);
|
||||
const HelperLauncher(this.client, {this.livenessProbe = isProcessAlive});
|
||||
|
||||
@override
|
||||
Future<CoreProcessLease> start({
|
||||
@@ -401,11 +440,18 @@ final class WindowsHelperLauncher implements CoreProcessLauncher {
|
||||
sessionId: response.sessionId,
|
||||
pid: response.pid,
|
||||
client: client,
|
||||
livenessProbe: livenessProbe,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
try {
|
||||
await client.stop(sessionId);
|
||||
} catch (_) {}
|
||||
} catch (releaseError) {
|
||||
commonPrint.log(
|
||||
'Failed to release Helper session $sessionId after a start failure: '
|
||||
'${compactError(releaseError)}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
Error.throwWithStackTrace(error, stackTrace);
|
||||
}
|
||||
}
|
||||
@@ -429,7 +475,7 @@ final class FallbackCoreLauncher implements CoreProcessLauncher {
|
||||
}) async {
|
||||
try {
|
||||
return await primary.start(sessionId: sessionId, address: address);
|
||||
} on WindowsHelperException catch (error) {
|
||||
} on HelperException catch (error) {
|
||||
if (!_preSpawnHelperErrors.contains(error.code)) rethrow;
|
||||
commonPrint.log(
|
||||
'Helper could not start the Core ($error); '
|
||||
@@ -441,17 +487,16 @@ final class FallbackCoreLauncher implements CoreProcessLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
typedef HelperReadinessProbe = Future<WindowsHelperReadiness> Function();
|
||||
typedef HelperReadinessProbe = Future<HelperReadiness> Function();
|
||||
|
||||
final class WindowsHelperLauncherResolver
|
||||
implements DesktopCoreLauncherResolver {
|
||||
final bool isWindows;
|
||||
final class HelperLauncherResolver implements DesktopCoreLauncherResolver {
|
||||
final bool hasHelper;
|
||||
final CoreProcessLauncher directLauncher;
|
||||
final CoreProcessLauncher helperLauncher;
|
||||
final HelperReadinessProbe helperReady;
|
||||
|
||||
const WindowsHelperLauncherResolver({
|
||||
required this.isWindows,
|
||||
const HelperLauncherResolver({
|
||||
required this.hasHelper,
|
||||
required this.directLauncher,
|
||||
required this.helperLauncher,
|
||||
required this.helperReady,
|
||||
@@ -459,9 +504,9 @@ final class WindowsHelperLauncherResolver
|
||||
|
||||
@override
|
||||
Future<CoreProcessLauncher> resolve() async {
|
||||
if (!isWindows) return directLauncher;
|
||||
if (!hasHelper) return directLauncher;
|
||||
final readiness = await helperReady();
|
||||
if (readiness == WindowsHelperReadiness.ready) {
|
||||
if (readiness == HelperReadiness.ready) {
|
||||
return FallbackCoreLauncher(
|
||||
primary: helperLauncher,
|
||||
fallback: directLauncher,
|
||||
@@ -478,17 +523,20 @@ final class HelperCoreLease implements CoreProcessLease {
|
||||
@override
|
||||
final int pid;
|
||||
|
||||
final WindowsHelperClient _client;
|
||||
final HelperClient _client;
|
||||
final ProcessLivenessProbe _livenessProbe;
|
||||
Future<CoreProcessStopResult>? _stopOperation;
|
||||
|
||||
HelperCoreLease({
|
||||
required this.sessionId,
|
||||
required this.pid,
|
||||
required WindowsHelperClient client,
|
||||
}) : _client = client;
|
||||
required HelperClient client,
|
||||
ProcessLivenessProbe livenessProbe = isProcessAlive,
|
||||
}) : _client = client,
|
||||
_livenessProbe = livenessProbe;
|
||||
|
||||
@override
|
||||
CoreProcessOwner get owner => CoreProcessOwner.windowsHelper;
|
||||
CoreProcessOwner get owner => CoreProcessOwner.helper;
|
||||
|
||||
@override
|
||||
Future<CoreProcessStopResult> stop(Duration timeout) {
|
||||
@@ -507,8 +555,22 @@ final class HelperCoreLease implements CoreProcessLease {
|
||||
return nextOperation;
|
||||
}
|
||||
|
||||
// A dead Helper takes its Core with it; the OS is then the only witness.
|
||||
Future<CoreProcessStopResult> _stop() async {
|
||||
final response = await _client.stop(sessionId);
|
||||
final HelperStopResponse response;
|
||||
try {
|
||||
response = await _client.stop(sessionId);
|
||||
} on HelperException catch (error) {
|
||||
if (error.code != 'transportError' || await _livenessProbe(pid)) {
|
||||
rethrow;
|
||||
}
|
||||
commonPrint.log(
|
||||
'Helper is unreachable and Core $pid has already exited; '
|
||||
'session $sessionId is released',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return const CoreProcessStopResult(stopped: false, exitConfirmed: true);
|
||||
}
|
||||
return CoreProcessStopResult(
|
||||
stopped: response.stopped,
|
||||
exitConfirmed: true,
|
||||
@@ -516,4 +578,4 @@ final class HelperCoreLease implements CoreProcessLease {
|
||||
}
|
||||
}
|
||||
|
||||
final windowsHelperClient = WindowsHelperClient();
|
||||
final helperClient = HelperClient();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/core/desktop/helper_client.dart';
|
||||
import 'package:fl_clash/core/desktop/model.dart';
|
||||
import 'package:win32_registry/win32_registry.dart';
|
||||
|
||||
/// CreateProcess reports these when a policy, not the file, refused the image:
|
||||
/// ERROR_VIRUS_INFECTED, ERROR_INVALID_IMAGE_HASH, ERROR_ACCESS_DISABLED_BY_POLICY.
|
||||
const policyBlockedOsErrors = {225, 577, 1260};
|
||||
|
||||
enum SmartAppControlState { off, on, evaluation, unknown }
|
||||
|
||||
typedef SmartAppControlStateReader = SmartAppControlState Function();
|
||||
|
||||
/// Swapped out by tests; the real reader needs a Windows registry.
|
||||
SmartAppControlStateReader smartAppControlStateReader =
|
||||
readSmartAppControlState;
|
||||
|
||||
SmartAppControlState readSmartAppControlState() {
|
||||
if (!Platform.isWindows) {
|
||||
return SmartAppControlState.unknown;
|
||||
}
|
||||
try {
|
||||
final key = LOCAL_MACHINE.open(
|
||||
r'SYSTEM\CurrentControlSet\Control\CI\Policy',
|
||||
);
|
||||
try {
|
||||
return switch (key.getInt('VerifiedAndReputablePolicyState')) {
|
||||
0 => SmartAppControlState.off,
|
||||
1 => SmartAppControlState.on,
|
||||
2 => SmartAppControlState.evaluation,
|
||||
_ => SmartAppControlState.unknown,
|
||||
};
|
||||
} finally {
|
||||
key.close();
|
||||
}
|
||||
} catch (_) {
|
||||
return SmartAppControlState.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// The OS error behind a failed Core launch, whether the Helper spawned it
|
||||
/// (the code travels in the response details, older Helpers only in the
|
||||
/// message) or the app did (dart:io keeps it on the exception).
|
||||
int? launchOsError(Object? error) {
|
||||
return switch (error) {
|
||||
DesktopCoreFailure(:final cause) => launchOsError(cause),
|
||||
ProcessException(:final errorCode) => errorCode == 0 ? null : errorCode,
|
||||
HelperException(code: 'processLaunchFailed') => _helperOsError(error),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
bool isPolicyBlockedLaunch(Object? error) {
|
||||
return policyBlockedOsErrors.contains(launchOsError(error));
|
||||
}
|
||||
|
||||
int? _helperOsError(HelperException error) {
|
||||
final details = error.details;
|
||||
if (details is Map && details['osError'] is int) {
|
||||
return details['osError'] as int;
|
||||
}
|
||||
final match = RegExp(r'os error (\d+)').firstMatch(error.message);
|
||||
return match == null ? null : int.tryParse(match.group(1)!);
|
||||
}
|
||||
@@ -294,18 +294,10 @@ final class DesktopCoreLifecycle implements DesktopCoreLifecycleController {
|
||||
Future<_LifecycleAchievement> _reconcile(_LifecycleIntent intent) async {
|
||||
final unconfirmedLease = _unconfirmedLease;
|
||||
if (unconfirmedLease != null) {
|
||||
if (intent.target == _LifecycleTarget.closed) {
|
||||
await _stopUnconfirmedLease(unconfirmedLease, allowFailure: true);
|
||||
} else if (intent.target == _LifecycleTarget.stopped) {
|
||||
await _stopUnconfirmedLease(unconfirmedLease, allowFailure: false);
|
||||
} else {
|
||||
throw _failure(
|
||||
code: 'process_exit_unconfirmed',
|
||||
phase: DesktopCorePhase.failed,
|
||||
revision: intent.revision,
|
||||
lease: unconfirmedLease,
|
||||
);
|
||||
}
|
||||
await _stopUnconfirmedLease(
|
||||
unconfirmedLease,
|
||||
allowFailure: intent.target == _LifecycleTarget.closed,
|
||||
);
|
||||
}
|
||||
switch (intent.target) {
|
||||
case _LifecycleTarget.running:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
enum CoreProcessOwner { direct, windowsHelper }
|
||||
enum CoreProcessOwner { direct, helper }
|
||||
|
||||
enum CoreLifecycleOutcome { applied, coalesced, superseded }
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
typedef ProcessLivenessProbe = Future<bool> Function(int pid);
|
||||
|
||||
/// A recycled pid reads as alive, which delays a confirmation but never fakes one.
|
||||
Future<bool> isProcessAlive(int pid) async {
|
||||
if (pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return _isWindowsProcessAlive(pid);
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return _isLinuxProcessAlive(pid);
|
||||
}
|
||||
final result = await Process.run('ps', ['-o', 'stat=', '-p', '$pid']);
|
||||
final state = (result.stdout as String).trim();
|
||||
return result.exitCode == 0 && state.isNotEmpty && !state.startsWith('Z');
|
||||
}
|
||||
|
||||
Future<bool> _isLinuxProcessAlive(int pid) async {
|
||||
final String stat;
|
||||
try {
|
||||
stat = await File('/proc/$pid/stat').readAsString();
|
||||
} on FileSystemException {
|
||||
return false;
|
||||
}
|
||||
final state = stat.substring(stat.lastIndexOf(')') + 1).trim();
|
||||
return state.isNotEmpty && state[0] != 'Z' && state[0] != 'X';
|
||||
}
|
||||
|
||||
const _processQueryLimitedInformation = 0x1000;
|
||||
const _errorInvalidParameter = 87;
|
||||
const _stillActive = 259;
|
||||
|
||||
// A Helper-spawned Core belongs to SYSTEM; OpenProcess fails with
|
||||
// ERROR_INVALID_PARAMETER only when no such process exists.
|
||||
bool _isWindowsProcessAlive(int pid) {
|
||||
final kernel32 = DynamicLibrary.open('kernel32.dll');
|
||||
final openProcess = kernel32
|
||||
.lookupFunction<
|
||||
Pointer<Void> Function(Uint32, Int32, Uint32),
|
||||
Pointer<Void> Function(int, int, int)
|
||||
>('OpenProcess');
|
||||
final getLastError = kernel32
|
||||
.lookupFunction<Uint32 Function(), int Function()>('GetLastError');
|
||||
final getExitCodeProcess = kernel32
|
||||
.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Uint32>),
|
||||
int Function(Pointer<Void>, Pointer<Uint32>)
|
||||
>('GetExitCodeProcess');
|
||||
final closeHandle = kernel32
|
||||
.lookupFunction<
|
||||
Int32 Function(Pointer<Void>),
|
||||
int Function(Pointer<Void>)
|
||||
>('CloseHandle');
|
||||
|
||||
final handle = openProcess(_processQueryLimitedInformation, 0, pid);
|
||||
if (handle == nullptr) {
|
||||
return getLastError() != _errorInvalidParameter;
|
||||
}
|
||||
final exitCode = calloc<Uint32>();
|
||||
try {
|
||||
if (getExitCodeProcess(handle, exitCode) == 0) {
|
||||
return true;
|
||||
}
|
||||
return exitCode.value == _stillActive;
|
||||
} finally {
|
||||
calloc.free(exitCode);
|
||||
closeHandle(handle);
|
||||
}
|
||||
}
|
||||
@@ -258,10 +258,13 @@ final class IPCCoreTransport implements DesktopCoreTransport {
|
||||
return;
|
||||
}
|
||||
final type = data[0];
|
||||
final payload = data.length > 1 ? data.sublist(1) : Uint8List(0);
|
||||
final payload = data.length > 1
|
||||
? Uint8List.sublistView(data, 1)
|
||||
: Uint8List(0);
|
||||
switch (type) {
|
||||
case _typeReady:
|
||||
commonPrint.log('IPC Ready');
|
||||
_failure = null;
|
||||
_state = DesktopTransportState.ready;
|
||||
_eventController.add(const TransportReady());
|
||||
case _typeConnected:
|
||||
@@ -273,6 +276,7 @@ final class IPCCoreTransport implements DesktopCoreTransport {
|
||||
commonPrint.log(
|
||||
'IPC Connected${processId == null ? '' : ': $processId'}',
|
||||
);
|
||||
_failure = null;
|
||||
_connectionGeneration++;
|
||||
_connection = TransportConnected(
|
||||
pid: processId,
|
||||
|
||||
@@ -45,7 +45,7 @@ class CoreEventManager {
|
||||
|
||||
CoreEventManager._() {
|
||||
_controller.stream.listen((event) {
|
||||
for (final CoreEventListener listener in _listeners) {
|
||||
for (final CoreEventListener listener in List.of(_listeners)) {
|
||||
try {
|
||||
switch (event.type) {
|
||||
case CoreEventType.log:
|
||||
|
||||
@@ -25,7 +25,7 @@ mixin CoreInterface {
|
||||
|
||||
Future<Map<String, dynamic>> getConfig(String path);
|
||||
|
||||
Future<Delay> asyncTestDelay(String url, String proxyName);
|
||||
Future<Delay?> asyncTestDelay(String url, String proxyName);
|
||||
|
||||
Future<String> updateConfig(UpdateParams updateParams);
|
||||
|
||||
@@ -56,8 +56,6 @@ mixin CoreInterface {
|
||||
|
||||
FutureOr<Traffic> getTotalTraffic(bool onlyStatisticsProxy);
|
||||
|
||||
FutureOr<String> getCountryCode(String ip);
|
||||
|
||||
FutureOr<int> getMemory();
|
||||
|
||||
FutureOr<void> resetTraffic();
|
||||
@@ -85,7 +83,7 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
Object? arguments,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
return await utils.handleWatch(
|
||||
return await handleWatch(
|
||||
onStart: () {
|
||||
commonPrint.log(
|
||||
'Invoke method ${method.name} ${DateTime.now()} $arguments',
|
||||
@@ -112,6 +110,25 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
Duration? timeout,
|
||||
});
|
||||
|
||||
Future<String> _invokeMessage({
|
||||
required CoreMethod method,
|
||||
Object? arguments,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
final message = await _invokeMethod<String>(
|
||||
method: method,
|
||||
arguments: arguments,
|
||||
timeout: timeout,
|
||||
);
|
||||
if (message == null) {
|
||||
throw CoreMethodException(
|
||||
code: 'no_response',
|
||||
message: 'Core did not answer ${method.name}',
|
||||
);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> init(InitParams params) async {
|
||||
return await _invokeMethod<bool>(
|
||||
@@ -133,20 +150,15 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
|
||||
@override
|
||||
Future<String> validateConfig(String path) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.validateConfig,
|
||||
arguments: path,
|
||||
) ??
|
||||
'';
|
||||
return _invokeMessage(method: CoreMethod.validateConfig, arguments: path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> updateConfig(UpdateParams updateParams) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.updateConfig,
|
||||
arguments: updateParams.toJson(),
|
||||
) ??
|
||||
'';
|
||||
return _invokeMessage(
|
||||
method: CoreMethod.updateConfig,
|
||||
arguments: updateParams.toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -166,11 +178,10 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
|
||||
@override
|
||||
Future<String> setupConfig(SetupParams setupParams) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.setupConfig,
|
||||
arguments: setupParams.toJson(),
|
||||
) ??
|
||||
'';
|
||||
return _invokeMessage(
|
||||
method: CoreMethod.setupConfig,
|
||||
arguments: setupParams.toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -190,11 +201,10 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
|
||||
@override
|
||||
Future<String> changeProxy(ChangeProxyParams changeProxyParams) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.changeProxy,
|
||||
arguments: changeProxyParams.toJson(),
|
||||
) ??
|
||||
'';
|
||||
return _invokeMessage(
|
||||
method: CoreMethod.changeProxy,
|
||||
arguments: changeProxyParams.toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -225,11 +235,7 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
|
||||
@override
|
||||
Future<String> updateGeoData(String type) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.updateGeoData,
|
||||
arguments: type,
|
||||
) ??
|
||||
'';
|
||||
return _invokeMessage(method: CoreMethod.updateGeoData, arguments: type);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -237,20 +243,18 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
required String providerName,
|
||||
required String data,
|
||||
}) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.sideLoadExternalProvider,
|
||||
arguments: {'providerName': providerName, 'data': data},
|
||||
) ??
|
||||
'';
|
||||
return _invokeMessage(
|
||||
method: CoreMethod.sideLoadExternalProvider,
|
||||
arguments: {'providerName': providerName, 'data': data},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> updateExternalProvider(String providerName) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.updateExternalProvider,
|
||||
arguments: providerName,
|
||||
) ??
|
||||
'';
|
||||
return _invokeMessage(
|
||||
method: CoreMethod.updateExternalProvider,
|
||||
arguments: providerName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -309,11 +313,7 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
|
||||
@override
|
||||
Future<String> clearEffect(int profileId) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.clearEffect,
|
||||
arguments: profileId,
|
||||
) ??
|
||||
'';
|
||||
return _invokeMessage(method: CoreMethod.clearEffect, arguments: profileId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -342,29 +342,18 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Delay> asyncTestDelay(String url, String proxyName) async {
|
||||
Future<Delay?> asyncTestDelay(String url, String proxyName) async {
|
||||
final delayParams = {
|
||||
'proxy-name': proxyName,
|
||||
'timeout': httpTimeoutDuration.inMilliseconds,
|
||||
'timeout': delayTestTimeoutDuration.inMilliseconds,
|
||||
'test-url': url,
|
||||
};
|
||||
final data = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.asyncTestDelay,
|
||||
arguments: delayParams,
|
||||
timeout: const Duration(seconds: 6),
|
||||
timeout: delayTestGuardDuration,
|
||||
);
|
||||
return data == null
|
||||
? Delay(name: proxyName, value: -1, url: url)
|
||||
: Delay.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getCountryCode(String ip) async {
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.getCountryCode,
|
||||
arguments: ip,
|
||||
) ??
|
||||
'';
|
||||
return data == null ? null : Delay.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/plugins/service.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
import 'desktop/model.dart';
|
||||
import 'interface.dart';
|
||||
@@ -12,13 +13,23 @@ import 'method.dart';
|
||||
class CoreLib extends CoreHandlerInterface {
|
||||
static CoreLib? _instance;
|
||||
|
||||
final Service? _service;
|
||||
|
||||
Completer<bool> _connectedCompleter = Completer<bool>();
|
||||
Future<CoreLifecycleResult>? _closeOperation;
|
||||
int _lifecycleRevision = 0;
|
||||
int _methodCallId = 0;
|
||||
bool _closed = false;
|
||||
|
||||
CoreLib._internal();
|
||||
CoreLib._internal() : _service = service;
|
||||
|
||||
@visibleForTesting
|
||||
CoreLib.scoped(Service this._service);
|
||||
|
||||
@visibleForTesting
|
||||
static void resetInstance() {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
factory CoreLib() {
|
||||
_instance ??= CoreLib._internal();
|
||||
@@ -37,19 +48,19 @@ class CoreLib extends CoreHandlerInterface {
|
||||
outcome: CoreLifecycleOutcome.coalesced,
|
||||
);
|
||||
}
|
||||
final initializationError = await service?.init() ?? '';
|
||||
final initializationError = await _service?.init() ?? '';
|
||||
if (initializationError.isNotEmpty) {
|
||||
throw StateError(initializationError);
|
||||
}
|
||||
_connectedCompleter.complete(true);
|
||||
final syncError =
|
||||
await service?.syncState(
|
||||
await _service?.syncState(
|
||||
globalState.container.read(sharedStateProvider),
|
||||
) ??
|
||||
'';
|
||||
if (syncError.isNotEmpty) {
|
||||
_connectedCompleter = Completer<bool>();
|
||||
await service?.shutdown();
|
||||
await _service?.shutdown();
|
||||
throw StateError(syncError);
|
||||
}
|
||||
return CoreLifecycleResult(
|
||||
@@ -79,7 +90,7 @@ class CoreLib extends CoreHandlerInterface {
|
||||
);
|
||||
}
|
||||
_connectedCompleter = Completer<bool>();
|
||||
final stopped = await service?.shutdown() ?? true;
|
||||
final stopped = await _service?.shutdown() ?? true;
|
||||
if (!stopped) {
|
||||
throw StateError('Android Core service shutdown failed');
|
||||
}
|
||||
@@ -102,13 +113,13 @@ class CoreLib extends CoreHandlerInterface {
|
||||
@override
|
||||
Future<bool> startListener() async {
|
||||
final listenerStarted = await super.startListener();
|
||||
final serviceStarted = await service?.start() ?? false;
|
||||
final serviceStarted = await _service?.start() ?? false;
|
||||
return listenerStarted && serviceStarted;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> stopListener() async {
|
||||
final serviceStopped = await service?.stop() ?? false;
|
||||
final serviceStopped = await _service?.stop() ?? false;
|
||||
final listenerStopped = await super.stopListener();
|
||||
return serviceStopped && listenerStopped;
|
||||
}
|
||||
@@ -118,9 +129,19 @@ class CoreLib extends CoreHandlerInterface {
|
||||
required CoreMethod method,
|
||||
Object? arguments,
|
||||
Duration? timeout,
|
||||
}) {
|
||||
return _invokeMethod<T>(
|
||||
method: method,
|
||||
arguments: arguments,
|
||||
).withTimeout(timeout: timeout, onTimeout: () => null);
|
||||
}
|
||||
|
||||
Future<T?> _invokeMethod<T>({
|
||||
required CoreMethod method,
|
||||
Object? arguments,
|
||||
}) async {
|
||||
try {
|
||||
await _connectedCompleter.future.timeout(const Duration(seconds: 10));
|
||||
await _connectedCompleter.future.timeout(coreConnectionWaitDuration);
|
||||
} catch (error) {
|
||||
commonPrint.log(
|
||||
'Invoke method ${method.name} before connection timed out: $error',
|
||||
@@ -129,11 +150,9 @@ class CoreLib extends CoreHandlerInterface {
|
||||
return null;
|
||||
}
|
||||
final id = '${++_methodCallId}';
|
||||
final response = await service
|
||||
?.invokeMethod(
|
||||
CoreMethodCall(id: id, method: method, arguments: arguments),
|
||||
)
|
||||
.withTimeout(timeout: timeout, onTimeout: () => null);
|
||||
final response = await _service?.invokeMethod(
|
||||
CoreMethodCall(id: id, method: method, arguments: arguments),
|
||||
);
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ enum CoreMethod {
|
||||
stopLog,
|
||||
startListener,
|
||||
stopListener,
|
||||
getCountryCode,
|
||||
getMemory,
|
||||
crash,
|
||||
setupConfig,
|
||||
|
||||
@@ -28,17 +28,22 @@ class CoreService extends CoreHandlerInterface {
|
||||
return _instance ??= CoreService._create();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void resetInstance() {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
factory CoreService._create() {
|
||||
final address = system.isWindows ? windowsPipeName : unixSocketPath;
|
||||
final directLauncher = DirectCoreLauncher();
|
||||
|
||||
final lifecycle = DesktopCoreLifecycle(
|
||||
transportFactory: () => IPCCoreTransport(address: address),
|
||||
launcherResolver: WindowsHelperLauncherResolver(
|
||||
isWindows: system.isWindows,
|
||||
launcherResolver: HelperLauncherResolver(
|
||||
hasHelper: system.hasHelperService,
|
||||
directLauncher: directLauncher,
|
||||
helperLauncher: WindowsHelperLauncher(windowsHelperClient),
|
||||
helperReady: () => windowsHelperClient.readiness(),
|
||||
helperLauncher: HelperLauncher(helperClient),
|
||||
helperReady: () => helperClient.readiness(),
|
||||
),
|
||||
verifyPeerPid: system.isWindows,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:drift/native.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
part 'converter.dart';
|
||||
part 'generated/database.g.dart';
|
||||
@@ -33,7 +34,7 @@ class Database extends _$Database {
|
||||
Database([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 3;
|
||||
|
||||
static LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
@@ -52,10 +53,31 @@ class Database extends _$Database {
|
||||
await _resetOrders();
|
||||
await _migrateRules(m);
|
||||
}
|
||||
if (from < 3) {
|
||||
await _addColumnIfMissing(m, profiles, profiles.matchTarget);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Drift rewinds user_version on downgrade but keeps the columns it added.
|
||||
Future<void> _addColumnIfMissing(
|
||||
Migrator m,
|
||||
TableInfo table,
|
||||
GeneratedColumn column,
|
||||
) async {
|
||||
final tableInfo = await customSelect(
|
||||
'PRAGMA table_info(${table.actualTableName})',
|
||||
).get();
|
||||
final exists = tableInfo.any(
|
||||
(row) => row.read<String>('name') == column.name,
|
||||
);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
await m.addColumn(table, column);
|
||||
}
|
||||
|
||||
Future<void> _migrateRules(Migrator m) async {
|
||||
final tableInfo = await customSelect('PRAGMA table_info(rules)').get();
|
||||
final columnNames = tableInfo
|
||||
@@ -115,22 +137,29 @@ class Database extends _$Database {
|
||||
List<ProxyGroup> proxyGroups, {
|
||||
bool isOverride = false,
|
||||
}) async {
|
||||
if (profiles.isNotEmpty ||
|
||||
scripts.isNotEmpty ||
|
||||
rules.isNotEmpty ||
|
||||
links.isNotEmpty) {
|
||||
await batch((b) {
|
||||
isOverride
|
||||
? profilesDao.setAllWithBatch(b, profiles)
|
||||
: profilesDao.putAllWithBatch(
|
||||
b,
|
||||
profiles.map((item) => item.toCompanion()),
|
||||
);
|
||||
if (profiles.isEmpty &&
|
||||
scripts.isEmpty &&
|
||||
rules.isEmpty &&
|
||||
links.isEmpty &&
|
||||
proxyGroups.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await batch((b) {
|
||||
if (isOverride) {
|
||||
profilesDao.setAllWithBatch(b, profiles);
|
||||
scriptsDao.setAllWithBatch(b, scripts);
|
||||
rulesDao.restoreWithBatch(b, rules, links);
|
||||
proxyGroupsDao.setAllWithBatch(null, b, proxyGroups);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
profilesDao.putAllWithBatch(
|
||||
b,
|
||||
profiles.map((item) => item.toCompanion()),
|
||||
);
|
||||
scriptsDao.putAllWithBatch(b, scripts);
|
||||
rulesDao.mergeWithBatch(b, rules, links);
|
||||
proxyGroupsDao.putAllWithBatch(b, proxyGroups);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setProfileCustomData(
|
||||
@@ -194,4 +223,9 @@ extension JoinedSelectStatementExt<T extends HasResultSet, D>
|
||||
}
|
||||
}
|
||||
|
||||
final database = Database();
|
||||
Database _database = Database();
|
||||
|
||||
Database get database => _database;
|
||||
|
||||
@visibleForTesting
|
||||
set database(Database value) => _database = value;
|
||||
|
||||
@@ -79,6 +79,17 @@ class $ProfilesTable extends Profiles
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _matchTargetMeta = const VerificationMeta(
|
||||
'matchTarget',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> matchTarget = GeneratedColumn<String>(
|
||||
'match_target',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _autoUpdateDurationMillisMeta =
|
||||
const VerificationMeta('autoUpdateDurationMillis');
|
||||
@override
|
||||
@@ -149,6 +160,7 @@ class $ProfilesTable extends Profiles
|
||||
lastUpdateDate,
|
||||
overwriteType,
|
||||
scriptId,
|
||||
matchTarget,
|
||||
autoUpdateDurationMillis,
|
||||
subscriptionInfo,
|
||||
autoUpdate,
|
||||
@@ -211,6 +223,15 @@ class $ProfilesTable extends Profiles
|
||||
scriptId.isAcceptableOrUnknown(data['script_id']!, _scriptIdMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('match_target')) {
|
||||
context.handle(
|
||||
_matchTargetMeta,
|
||||
matchTarget.isAcceptableOrUnknown(
|
||||
data['match_target']!,
|
||||
_matchTargetMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('auto_update_duration_millis')) {
|
||||
context.handle(
|
||||
_autoUpdateDurationMillisMeta,
|
||||
@@ -275,6 +296,10 @@ class $ProfilesTable extends Profiles
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}script_id'],
|
||||
),
|
||||
matchTarget: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}match_target'],
|
||||
),
|
||||
autoUpdateDurationMillis: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}auto_update_duration_millis'],
|
||||
@@ -333,6 +358,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
final DateTime? lastUpdateDate;
|
||||
final OverwriteType overwriteType;
|
||||
final int? scriptId;
|
||||
final String? matchTarget;
|
||||
final int autoUpdateDurationMillis;
|
||||
final SubscriptionInfo? subscriptionInfo;
|
||||
final bool autoUpdate;
|
||||
@@ -347,6 +373,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
this.lastUpdateDate,
|
||||
required this.overwriteType,
|
||||
this.scriptId,
|
||||
this.matchTarget,
|
||||
required this.autoUpdateDurationMillis,
|
||||
this.subscriptionInfo,
|
||||
required this.autoUpdate,
|
||||
@@ -374,6 +401,9 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
if (!nullToAbsent || scriptId != null) {
|
||||
map['script_id'] = Variable<int>(scriptId);
|
||||
}
|
||||
if (!nullToAbsent || matchTarget != null) {
|
||||
map['match_target'] = Variable<String>(matchTarget);
|
||||
}
|
||||
map['auto_update_duration_millis'] = Variable<int>(
|
||||
autoUpdateDurationMillis,
|
||||
);
|
||||
@@ -414,6 +444,9 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
scriptId: scriptId == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(scriptId),
|
||||
matchTarget: matchTarget == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(matchTarget),
|
||||
autoUpdateDurationMillis: Value(autoUpdateDurationMillis),
|
||||
subscriptionInfo: subscriptionInfo == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
@@ -442,6 +475,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
serializer.fromJson<String>(json['overwriteType']),
|
||||
),
|
||||
scriptId: serializer.fromJson<int?>(json['scriptId']),
|
||||
matchTarget: serializer.fromJson<String?>(json['matchTarget']),
|
||||
autoUpdateDurationMillis: serializer.fromJson<int>(
|
||||
json['autoUpdateDurationMillis'],
|
||||
),
|
||||
@@ -469,6 +503,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
$ProfilesTable.$converteroverwriteType.toJson(overwriteType),
|
||||
),
|
||||
'scriptId': serializer.toJson<int?>(scriptId),
|
||||
'matchTarget': serializer.toJson<String?>(matchTarget),
|
||||
'autoUpdateDurationMillis': serializer.toJson<int>(
|
||||
autoUpdateDurationMillis,
|
||||
),
|
||||
@@ -490,6 +525,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
Value<DateTime?> lastUpdateDate = const Value.absent(),
|
||||
OverwriteType? overwriteType,
|
||||
Value<int?> scriptId = const Value.absent(),
|
||||
Value<String?> matchTarget = const Value.absent(),
|
||||
int? autoUpdateDurationMillis,
|
||||
Value<SubscriptionInfo?> subscriptionInfo = const Value.absent(),
|
||||
bool? autoUpdate,
|
||||
@@ -508,6 +544,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
: this.lastUpdateDate,
|
||||
overwriteType: overwriteType ?? this.overwriteType,
|
||||
scriptId: scriptId.present ? scriptId.value : this.scriptId,
|
||||
matchTarget: matchTarget.present ? matchTarget.value : this.matchTarget,
|
||||
autoUpdateDurationMillis:
|
||||
autoUpdateDurationMillis ?? this.autoUpdateDurationMillis,
|
||||
subscriptionInfo: subscriptionInfo.present
|
||||
@@ -533,6 +570,9 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
? data.overwriteType.value
|
||||
: this.overwriteType,
|
||||
scriptId: data.scriptId.present ? data.scriptId.value : this.scriptId,
|
||||
matchTarget: data.matchTarget.present
|
||||
? data.matchTarget.value
|
||||
: this.matchTarget,
|
||||
autoUpdateDurationMillis: data.autoUpdateDurationMillis.present
|
||||
? data.autoUpdateDurationMillis.value
|
||||
: this.autoUpdateDurationMillis,
|
||||
@@ -560,6 +600,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
..write('lastUpdateDate: $lastUpdateDate, ')
|
||||
..write('overwriteType: $overwriteType, ')
|
||||
..write('scriptId: $scriptId, ')
|
||||
..write('matchTarget: $matchTarget, ')
|
||||
..write('autoUpdateDurationMillis: $autoUpdateDurationMillis, ')
|
||||
..write('subscriptionInfo: $subscriptionInfo, ')
|
||||
..write('autoUpdate: $autoUpdate, ')
|
||||
@@ -579,6 +620,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
lastUpdateDate,
|
||||
overwriteType,
|
||||
scriptId,
|
||||
matchTarget,
|
||||
autoUpdateDurationMillis,
|
||||
subscriptionInfo,
|
||||
autoUpdate,
|
||||
@@ -597,6 +639,7 @@ class RawProfile extends DataClass implements Insertable<RawProfile> {
|
||||
other.lastUpdateDate == this.lastUpdateDate &&
|
||||
other.overwriteType == this.overwriteType &&
|
||||
other.scriptId == this.scriptId &&
|
||||
other.matchTarget == this.matchTarget &&
|
||||
other.autoUpdateDurationMillis == this.autoUpdateDurationMillis &&
|
||||
other.subscriptionInfo == this.subscriptionInfo &&
|
||||
other.autoUpdate == this.autoUpdate &&
|
||||
@@ -613,6 +656,7 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
final Value<DateTime?> lastUpdateDate;
|
||||
final Value<OverwriteType> overwriteType;
|
||||
final Value<int?> scriptId;
|
||||
final Value<String?> matchTarget;
|
||||
final Value<int> autoUpdateDurationMillis;
|
||||
final Value<SubscriptionInfo?> subscriptionInfo;
|
||||
final Value<bool> autoUpdate;
|
||||
@@ -627,6 +671,7 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
this.lastUpdateDate = const Value.absent(),
|
||||
this.overwriteType = const Value.absent(),
|
||||
this.scriptId = const Value.absent(),
|
||||
this.matchTarget = const Value.absent(),
|
||||
this.autoUpdateDurationMillis = const Value.absent(),
|
||||
this.subscriptionInfo = const Value.absent(),
|
||||
this.autoUpdate = const Value.absent(),
|
||||
@@ -642,6 +687,7 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
this.lastUpdateDate = const Value.absent(),
|
||||
required OverwriteType overwriteType,
|
||||
this.scriptId = const Value.absent(),
|
||||
this.matchTarget = const Value.absent(),
|
||||
required int autoUpdateDurationMillis,
|
||||
this.subscriptionInfo = const Value.absent(),
|
||||
required bool autoUpdate,
|
||||
@@ -663,6 +709,7 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
Expression<DateTime>? lastUpdateDate,
|
||||
Expression<String>? overwriteType,
|
||||
Expression<int>? scriptId,
|
||||
Expression<String>? matchTarget,
|
||||
Expression<int>? autoUpdateDurationMillis,
|
||||
Expression<String>? subscriptionInfo,
|
||||
Expression<bool>? autoUpdate,
|
||||
@@ -678,6 +725,7 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
if (lastUpdateDate != null) 'last_update_date': lastUpdateDate,
|
||||
if (overwriteType != null) 'overwrite_type': overwriteType,
|
||||
if (scriptId != null) 'script_id': scriptId,
|
||||
if (matchTarget != null) 'match_target': matchTarget,
|
||||
if (autoUpdateDurationMillis != null)
|
||||
'auto_update_duration_millis': autoUpdateDurationMillis,
|
||||
if (subscriptionInfo != null) 'subscription_info': subscriptionInfo,
|
||||
@@ -696,6 +744,7 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
Value<DateTime?>? lastUpdateDate,
|
||||
Value<OverwriteType>? overwriteType,
|
||||
Value<int?>? scriptId,
|
||||
Value<String?>? matchTarget,
|
||||
Value<int>? autoUpdateDurationMillis,
|
||||
Value<SubscriptionInfo?>? subscriptionInfo,
|
||||
Value<bool>? autoUpdate,
|
||||
@@ -711,6 +760,7 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
lastUpdateDate: lastUpdateDate ?? this.lastUpdateDate,
|
||||
overwriteType: overwriteType ?? this.overwriteType,
|
||||
scriptId: scriptId ?? this.scriptId,
|
||||
matchTarget: matchTarget ?? this.matchTarget,
|
||||
autoUpdateDurationMillis:
|
||||
autoUpdateDurationMillis ?? this.autoUpdateDurationMillis,
|
||||
subscriptionInfo: subscriptionInfo ?? this.subscriptionInfo,
|
||||
@@ -747,6 +797,9 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
if (scriptId.present) {
|
||||
map['script_id'] = Variable<int>(scriptId.value);
|
||||
}
|
||||
if (matchTarget.present) {
|
||||
map['match_target'] = Variable<String>(matchTarget.value);
|
||||
}
|
||||
if (autoUpdateDurationMillis.present) {
|
||||
map['auto_update_duration_millis'] = Variable<int>(
|
||||
autoUpdateDurationMillis.value,
|
||||
@@ -786,6 +839,7 @@ class ProfilesCompanion extends UpdateCompanion<RawProfile> {
|
||||
..write('lastUpdateDate: $lastUpdateDate, ')
|
||||
..write('overwriteType: $overwriteType, ')
|
||||
..write('scriptId: $scriptId, ')
|
||||
..write('matchTarget: $matchTarget, ')
|
||||
..write('autoUpdateDurationMillis: $autoUpdateDurationMillis, ')
|
||||
..write('subscriptionInfo: $subscriptionInfo, ')
|
||||
..write('autoUpdate: $autoUpdate, ')
|
||||
@@ -3474,6 +3528,7 @@ typedef $$ProfilesTableCreateCompanionBuilder =
|
||||
Value<DateTime?> lastUpdateDate,
|
||||
required OverwriteType overwriteType,
|
||||
Value<int?> scriptId,
|
||||
Value<String?> matchTarget,
|
||||
required int autoUpdateDurationMillis,
|
||||
Value<SubscriptionInfo?> subscriptionInfo,
|
||||
required bool autoUpdate,
|
||||
@@ -3490,6 +3545,7 @@ typedef $$ProfilesTableUpdateCompanionBuilder =
|
||||
Value<DateTime?> lastUpdateDate,
|
||||
Value<OverwriteType> overwriteType,
|
||||
Value<int?> scriptId,
|
||||
Value<String?> matchTarget,
|
||||
Value<int> autoUpdateDurationMillis,
|
||||
Value<SubscriptionInfo?> subscriptionInfo,
|
||||
Value<bool> autoUpdate,
|
||||
@@ -3586,6 +3642,11 @@ class $$ProfilesTableFilterComposer
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get matchTarget => $composableBuilder(
|
||||
column: $table.matchTarget,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get autoUpdateDurationMillis => $composableBuilder(
|
||||
column: $table.autoUpdateDurationMillis,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -3718,6 +3779,11 @@ class $$ProfilesTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get matchTarget => $composableBuilder(
|
||||
column: $table.matchTarget,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get autoUpdateDurationMillis => $composableBuilder(
|
||||
column: $table.autoUpdateDurationMillis,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -3786,6 +3852,11 @@ class $$ProfilesTableAnnotationComposer
|
||||
GeneratedColumn<int> get scriptId =>
|
||||
$composableBuilder(column: $table.scriptId, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get matchTarget => $composableBuilder(
|
||||
column: $table.matchTarget,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get autoUpdateDurationMillis => $composableBuilder(
|
||||
column: $table.autoUpdateDurationMillis,
|
||||
builder: (column) => column,
|
||||
@@ -3903,6 +3974,7 @@ class $$ProfilesTableTableManager
|
||||
Value<DateTime?> lastUpdateDate = const Value.absent(),
|
||||
Value<OverwriteType> overwriteType = const Value.absent(),
|
||||
Value<int?> scriptId = const Value.absent(),
|
||||
Value<String?> matchTarget = const Value.absent(),
|
||||
Value<int> autoUpdateDurationMillis = const Value.absent(),
|
||||
Value<SubscriptionInfo?> subscriptionInfo =
|
||||
const Value.absent(),
|
||||
@@ -3918,6 +3990,7 @@ class $$ProfilesTableTableManager
|
||||
lastUpdateDate: lastUpdateDate,
|
||||
overwriteType: overwriteType,
|
||||
scriptId: scriptId,
|
||||
matchTarget: matchTarget,
|
||||
autoUpdateDurationMillis: autoUpdateDurationMillis,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
autoUpdate: autoUpdate,
|
||||
@@ -3934,6 +4007,7 @@ class $$ProfilesTableTableManager
|
||||
Value<DateTime?> lastUpdateDate = const Value.absent(),
|
||||
required OverwriteType overwriteType,
|
||||
Value<int?> scriptId = const Value.absent(),
|
||||
Value<String?> matchTarget = const Value.absent(),
|
||||
required int autoUpdateDurationMillis,
|
||||
Value<SubscriptionInfo?> subscriptionInfo =
|
||||
const Value.absent(),
|
||||
@@ -3949,6 +4023,7 @@ class $$ProfilesTableTableManager
|
||||
lastUpdateDate: lastUpdateDate,
|
||||
overwriteType: overwriteType,
|
||||
scriptId: scriptId,
|
||||
matchTarget: matchTarget,
|
||||
autoUpdateDurationMillis: autoUpdateDurationMillis,
|
||||
subscriptionInfo: subscriptionInfo,
|
||||
autoUpdate: autoUpdate,
|
||||
|
||||
@@ -129,6 +129,16 @@ class ProxyGroupsDao extends DatabaseAccessor<Database>
|
||||
preDelete: true,
|
||||
);
|
||||
}
|
||||
|
||||
void putAllWithBatch(Batch batch, Iterable<ProxyGroup> proxyGroups) {
|
||||
final keys = indexing.generateNKeys(proxyGroups.length);
|
||||
batch.insertAllOnConflictUpdate(
|
||||
this.proxyGroups,
|
||||
proxyGroups.mapIndexed(
|
||||
(index, item) => item.toCompanion(null, keys[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension RawProxyGroupExt on RawProxyGroup {
|
||||
|
||||
@@ -19,6 +19,8 @@ class Profiles extends Table {
|
||||
|
||||
IntColumn get scriptId => integer().nullable()();
|
||||
|
||||
TextColumn get matchTarget => text().nullable()();
|
||||
|
||||
IntColumn get autoUpdateDurationMillis => integer()();
|
||||
|
||||
TextColumn get subscriptionInfo =>
|
||||
@@ -119,6 +121,7 @@ extension RawProfilExt on RawProfile {
|
||||
unfoldSet: unfoldSet,
|
||||
overwriteType: overwriteType,
|
||||
scriptId: scriptId,
|
||||
matchTarget: matchTarget,
|
||||
order: order,
|
||||
);
|
||||
}
|
||||
@@ -139,6 +142,7 @@ extension ProfilesCompanionExt on Profile {
|
||||
unfoldSet: unfoldSet,
|
||||
overwriteType: overwriteType,
|
||||
scriptId: Value(scriptId),
|
||||
matchTarget: Value(matchTarget),
|
||||
order: Value(order ?? this.order),
|
||||
);
|
||||
}
|
||||
|
||||