import 'dart:ffi'; 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:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; typedef ProcessRunner = Future Function(String executable, List arguments); class System { static System? _instance; bool _isTV = false; @visibleForTesting ProcessRunner runProcess = Process.run; System._internal(); factory System() { _instance ??= System._internal(); return _instance!; } bool get isDesktop => isWindows || isMacOS || isLinux; bool get isWindows => Platform.isWindows; bool get isMacOS => Platform.isMacOS; bool get isAndroid => Platform.isAndroid; bool get isLinux => Platform.isLinux; bool get isTV => _isTV; Future init() async { final deviceInfo = await DeviceInfoPlugin().deviceInfo; _isTV = switch (deviceInfo) { AndroidDeviceInfo(:final systemFeatures) => systemFeatures.any( const { 'android.hardware.type.television', 'android.software.leanback', }.contains, ), _ => false, }; return switch (Platform.operatingSystem) { 'macos' => (deviceInfo as MacOsDeviceInfo).majorVersion, 'android' => (deviceInfo as AndroidDeviceInfo).version.sdkInt, 'windows' => (deviceInfo as WindowsDeviceInfo).majorVersion, String() => 0, }; } Future didCrashOnPreviousExecution() async { if (!isAndroid) return false; return await app?.didCrashOnPreviousExecution() ?? false; } Future 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 statArguments(String corePath, {required bool isMacOS}) { return isMacOS ? ['-f', '%Su:%Sg %Sp', corePath] : ['-c', '%U:%G %A', corePath]; } /// Execute for others fails it, so an older `+sx` Core is re-authorized. @visibleForTesting static bool isPrivilegedStatOutput( String output, { required String ownerPrefix, }) { final trimmed = output.trim(); if (!trimmed.startsWith(ownerPrefix)) { return false; } final mode = trimmed.split(RegExp(r'\s+')).last; return mode.length >= 10 && mode[3] == 's' && mode[9] != 'x' && mode[9] != 't'; } @visibleForTesting static String macOSElevationShell(String corePath) { final path = _shellEscape(corePath); return 'chown root:admin $path && chmod 4750 $path'; } @visibleForTesting static String linuxElevationShell(String corePath) { final path = _shellEscape(corePath); return 'group=\$(id -g "\${PKEXEC_UID:-\$SUDO_UID}") && ' 'chown "root:\$group" $path && ' 'chmod 4750 $path'; } Future _isInAdminGroup() async { try { final result = await runProcess('id', ['-Gn']); return result.exitCode == 0 && result.stdout .toString() .trim() .split(RegExp(r'\s+')) .contains('admin'); } on ProcessException { return false; } } /// 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 checkIsAdmin() async { 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 aclArguments(String homeDirPath, String userName) { return [ '-R', '+a', 'user:$userName allow $_inheritedAclPermissions', homeDirPath, ]; } Future 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("'", "'\\''")}'"; } Future authorizeCore() async { if (system.isAndroid) { return AuthorizeCode.error; } 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; } if (system.isMacOS) { if (!await _isInAdminGroup()) { commonPrint.log( 'TUN needs an administrator account: only the admin group may run ' 'the elevated Core', logLevel: LogLevel.error, ); return AuthorizeCode.error; } final shell = macOSElevationShell(appPath.corePath); final arguments = [ '-e', 'do shell script "$shell" with administrator privileges', ]; final result = await runProcess('osascript', arguments); if (result.exitCode != 0) { return AuthorizeCode.error; } return AuthorizeCode.success; } else if (system.isLinux) { final elevated = await linuxElevation.elevate([ '/bin/sh', '-c', linuxElevationShell(appPath.corePath), ]); return elevated ? AuthorizeCode.success : AuthorizeCode.error; } return AuthorizeCode.error; } Future back() async { await app?.moveTaskToBack(); } Future exit() async { if (system.isAndroid) { await SystemNavigator.pop(); } } } final system = System(); class Windows { static Windows? _instance; static const _swHide = 0; static const _swShowNormal = 1; late DynamicLibrary _shell32; Windows._internal() { _shell32 = DynamicLibrary.open('shell32.dll'); } factory Windows() { _instance ??= Windows._internal(); return _instance!; } bool runas(String command, String arguments, {bool showWindow = true}) { final commandPtr = command.toNativeUtf16(); final argumentsPtr = arguments.toNativeUtf16(); final operationPtr = 'runas'.toNativeUtf16(); final shellExecute = _shell32 .lookupFunction< Int32 Function( Pointer hwnd, Pointer lpOperation, Pointer lpFile, Pointer lpParameters, Pointer lpDirectory, Int32 nShowCmd, ), int Function( Pointer hwnd, Pointer lpOperation, Pointer lpFile, Pointer lpParameters, Pointer lpDirectory, int nShowCmd, ) >('ShellExecuteW'); final result = shellExecute( nullptr, operationPtr, commandPtr, argumentsPtr, nullptr, showWindow ? _swShowNormal : _swHide, ); calloc.free(commandPtr); calloc.free(argumentsPtr); calloc.free(operationPtr); commonPrint.log( 'windows runas: $command $arguments resultCode:$result', logLevel: LogLevel.warning, ); if (result <= 32) { return false; } return true; } /// The installer may be another administrator, so this process names the owner. @visibleForTesting static String installArguments(int ownerPid) => 'install --owner-pid $ownerPid'; Future registerService() { return registerHelperService( () async => runas(appPath.helperPath, installArguments(pid), showWindow: false), ); } } typedef ElevatedHelperInstaller = Future Function(); @visibleForTesting Future 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( '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; } commonPrint.log( 'helper service is unavailable, requesting elevated installation', logLevel: LogLevel.warning, ); if (!await install()) { commonPrint.log( 'failed to launch elevated helper installation', logLevel: LogLevel.error, ); return AuthorizeCode.error; } 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 _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 Linux { static Linux? _instance; Linux._internal(); factory Linux() { _instance ??= Linux._internal(); return _instance!; } Future registerService() { return registerHelperService(installService); } /// pkexec and sudo both name the requesting user to the installer, which is /// where the unit takes the account it grants the Helper socket to. @visibleForTesting Future installService() { return linuxElevation.elevate([appPath.helperPath, 'install']); } } final linux = system.isLinux && system.hasHelperService ? Linux() : null; class MacOS implements SystemDnsPort { static MacOS? _instance; @visibleForTesting ProcessRunner runProcess = Process.run; MacOS._internal(); factory MacOS() { _instance ??= MacOS._internal(); return _instance!; } @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; } 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 nameLine = currentService .split('\n') .firstWhere( (line) => RegExp(r'^\(\d+\).*').hasMatch(line), orElse: () => '', ); final name = RegExp( r'^\(\d+\)\s+(.+)$', ).firstMatch(nameLine.trim())?.group(1)?.trim(); if (name == null || name.isEmpty) { return null; } return name; } @visibleForTesting static List parseDnsServers(String getDnsServersOutput) { final output = getDnsServersOutput.trim(); if (output.startsWith("There aren't any DNS Servers set on")) { return []; } return output.split('\n'); } @override Future resolveDefaultService() async { final result = await _run('route', ['-n', 'get', 'default']); if (result == null) { return null; } final device = parseDefaultInterface(result.stdout.toString()); if (device == null) { return null; } final serviceResult = await _run('networksetup', [ '-listnetworkserviceorder', ]); if (serviceResult == null) { return null; } return parseServiceName(serviceResult.stdout.toString(), device); } @override Future?> readDnsServers(String service) async { final result = await _run('networksetup', ['-getdnsservers', service]); if (result == null) { return null; } return parseDnsServers(result.stdout.toString()); } @override Future writeDnsServers(String service, List servers) async { final result = await _run('networksetup', [ '-setdnsservers', service, if (servers.isEmpty) 'Empty', if (servers.isNotEmpty) ...servers, ], logLevel: LogLevel.error); return result != null; } Future _run( String executable, List 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; } } } final macOS = system.isMacOS ? MacOS() : null;