Files
FlClash/lib/common/system.dart
T

568 lines
16 KiB
Dart
Raw Normal View History

2025-06-07 01:48:34 +08:00
import 'dart:ffi';
2024-04-30 23:38:49 +08:00
import 'dart:io';
2024-09-08 21:21:21 +08:00
import 'package:device_info_plus/device_info_plus.dart';
2025-06-07 01:48:34 +08:00
import 'package:ffi/ffi.dart';
import 'package:fl_clash/common/boot_record.dart';
2024-12-03 21:47:12 +08:00
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/system_dns.dart';
2026-08-05 10:56:40 +08:00
import 'package:fl_clash/core/desktop/helper_client.dart';
2024-12-03 21:47:12 +08:00
import 'package:fl_clash/enum/enum.dart';
2024-04-30 23:38:49 +08:00
import 'package:fl_clash/plugins/app.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
2024-04-30 23:38:49 +08:00
import 'package:flutter/services.dart';
typedef ProcessRunner =
Future<ProcessResult> Function(String executable, List<String> arguments);
2024-04-30 23:38:49 +08:00
class System {
static System? _instance;
2026-08-05 10:56:40 +08:00
bool _isTV = false;
2024-04-30 23:38:49 +08:00
@visibleForTesting
ProcessRunner runProcess = Process.run;
2024-04-30 23:38:49 +08:00
System._internal();
factory System() {
_instance ??= System._internal();
return _instance!;
}
2025-06-07 01:48:34 +08:00
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;
2024-04-30 23:38:49 +08:00
2026-08-05 10:56:40 +08:00
bool get isTV => _isTV;
Future<int> init() async {
2024-09-08 21:21:21 +08:00
final deviceInfo = await DeviceInfoPlugin().deviceInfo;
2026-08-05 10:56:40 +08:00
_isTV = switch (deviceInfo) {
AndroidDeviceInfo(:final systemFeatures) => systemFeatures.any(
const {
'android.hardware.type.television',
'android.software.leanback',
}.contains,
),
_ => false,
};
2024-09-08 21:21:21 +08:00
return switch (Platform.operatingSystem) {
2025-06-07 01:48:34 +08:00
'macos' => (deviceInfo as MacOsDeviceInfo).majorVersion,
'android' => (deviceInfo as AndroidDeviceInfo).version.sdkInt,
'windows' => (deviceInfo as WindowsDeviceInfo).majorVersion,
2025-07-31 17:09:18 +08:00
String() => 0,
2024-09-08 21:21:21 +08:00
};
}
2026-08-05 10:56:40 +08:00
Future<bool> didCrashOnPreviousExecution() async {
if (!isAndroid) return false;
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];
}
/// 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<bool> _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);
2024-12-03 21:47:12 +08:00
Future<bool> 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:',
);
2024-12-03 21:47:12 +08:00
}
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,
);
}
}
2026-02-02 10:14:13 +08:00
static String _shellEscape(String value) {
return "'${value.replaceAll("'", "'\\''")}'";
}
2024-12-03 21:47:12 +08:00
Future<AuthorizeCode> authorizeCore() async {
2025-06-07 01:48:34 +08:00
if (system.isAndroid) {
2025-05-02 02:24:12 +08:00
return AuthorizeCode.error;
}
2026-08-05 10:56:40 +08:00
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;
}
2024-12-03 21:47:12 +08:00
final isAdmin = await checkIsAdmin();
if (isAdmin) {
return AuthorizeCode.none;
}
2025-06-07 01:48:34 +08:00
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);
2024-12-03 21:47:12 +08:00
final arguments = [
2025-06-07 01:48:34 +08:00
'-e',
2024-12-03 21:47:12 +08:00
'do shell script "$shell" with administrator privileges',
];
final result = await runProcess('osascript', arguments);
2024-12-03 21:47:12 +08:00
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;
2024-12-03 21:47:12 +08:00
}
return AuthorizeCode.error;
}
2025-06-07 01:48:34 +08:00
Future<void> back() async {
await app?.moveTaskToBack();
}
Future<void> exit() async {
if (system.isAndroid) {
await SystemNavigator.pop();
2025-05-02 02:24:12 +08:00
}
2025-06-07 01:48:34 +08:00
}
}
final system = System();
class Windows {
static Windows? _instance;
static const _swHide = 0;
static const _swShowNormal = 1;
2025-06-07 01:48:34 +08:00
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}) {
2025-06-07 01:48:34 +08:00
final commandPtr = command.toNativeUtf16();
final argumentsPtr = arguments.toNativeUtf16();
final operationPtr = 'runas'.toNativeUtf16();
2025-07-31 17:09:18 +08:00
final shellExecute = _shell32
.lookupFunction<
Int32 Function(
2025-06-07 01:48:34 +08:00
Pointer<Utf16> hwnd,
Pointer<Utf16> lpOperation,
Pointer<Utf16> lpFile,
Pointer<Utf16> lpParameters,
Pointer<Utf16> lpDirectory,
2025-07-31 17:09:18 +08:00
Int32 nShowCmd,
),
int Function(
2025-06-07 01:48:34 +08:00
Pointer<Utf16> hwnd,
Pointer<Utf16> lpOperation,
Pointer<Utf16> lpFile,
Pointer<Utf16> lpParameters,
Pointer<Utf16> lpDirectory,
2025-07-31 17:09:18 +08:00
int nShowCmd,
)
>('ShellExecuteW');
2025-06-07 01:48:34 +08:00
final result = shellExecute(
nullptr,
operationPtr,
commandPtr,
argumentsPtr,
nullptr,
showWindow ? _swShowNormal : _swHide,
2025-06-07 01:48:34 +08:00
);
calloc.free(commandPtr);
calloc.free(argumentsPtr);
calloc.free(operationPtr);
2025-07-31 17:09:18 +08:00
commonPrint.log(
'windows runas: $command $arguments resultCode:$result',
logLevel: LogLevel.warning,
);
2025-06-07 01:48:34 +08:00
2025-09-23 21:02:47 +08:00
if (result <= 32) {
2025-06-07 01:48:34 +08:00
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<AuthorizeCode> registerService() {
return registerHelperService(
() async =>
runas(appPath.helperPath, installArguments(pid), showWindow: false),
2026-08-05 10:56:40 +08:00
);
}
}
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:
2026-08-05 10:56:40 +08:00
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,
2026-08-05 10:56:40 +08:00
);
return AuthorizeCode.error;
case HelperReadiness.notReady:
break;
}
2025-06-07 01:48:34 +08:00
commonPrint.log(
'helper service is unavailable, requesting elevated installation',
logLevel: LogLevel.warning,
);
if (!await install()) {
2026-08-05 10:56:40 +08:00
commonPrint.log(
'failed to launch elevated helper installation',
logLevel: LogLevel.error,
2025-09-23 21:02:47 +08:00
);
return AuthorizeCode.error;
2026-08-05 10:56:40 +08:00
}
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;
}
2025-06-07 01:48:34 +08:00
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);
2025-06-07 01:48:34 +08:00
}
return false;
2025-06-07 01:48:34 +08:00
}
final windows = system.isWindows ? Windows() : null;
class Linux {
static Linux? _instance;
Linux._internal();
factory Linux() {
_instance ??= Linux._internal();
return _instance!;
}
Future<AuthorizeCode> 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<bool> installService() {
return linuxElevation.elevate([appPath.helperPath, 'install']);
}
}
final linux = system.isLinux && system.hasHelperService ? Linux() : null;
class MacOS implements SystemDnsPort {
2025-06-07 01:48:34 +08:00
static MacOS? _instance;
@visibleForTesting
ProcessRunner runProcess = Process.run;
2025-06-07 01:48:34 +08:00
MacOS._internal();
factory MacOS() {
_instance ??= MacOS._internal();
return _instance!;
}
@visibleForTesting
static String? parseDefaultInterface(String routeOutput) {
final deviceLine = routeOutput
2025-05-02 02:24:12 +08:00
.split('\n')
2025-06-07 01:48:34 +08:00
.firstWhere((s) => s.contains('interface:'), orElse: () => '');
2025-05-02 02:24:12 +08:00
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
2025-07-31 17:09:18 +08:00
.split('\n\n')
.firstWhere((s) => s.contains('Device: $device'), orElse: () => '');
2025-05-02 02:24:12 +08:00
if (currentService.isEmpty) {
return null;
}
final nameLine = currentService
2025-07-31 17:09:18 +08:00
.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) {
2025-05-02 02:24:12 +08:00
return null;
}
return name;
2025-05-02 02:24:12 +08:00
}
@visibleForTesting
static List<String> 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<String?> resolveDefaultService() async {
final result = await _run('route', ['-n', 'get', 'default']);
if (result == null) {
return null;
}
final device = parseDefaultInterface(result.stdout.toString());
if (device == null) {
2025-05-02 02:24:12 +08:00
return null;
}
final serviceResult = await _run('networksetup', [
'-listnetworkserviceorder',
2025-07-31 17:09:18 +08:00
]);
if (serviceResult == null) {
return null;
2025-05-02 02:24:12 +08:00
}
return parseServiceName(serviceResult.stdout.toString(), device);
2025-05-02 02:24:12 +08:00
}
@override
Future<List<String>?> readDnsServers(String service) async {
final result = await _run('networksetup', ['-getdnsservers', service]);
if (result == null) {
return null;
2025-05-02 02:24:12 +08:00
}
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;
2025-05-02 02:24:12 +08:00
}
return result;
} catch (error) {
commonPrint.log(
'$label failed: ${compactError(error)}',
logLevel: logLevel,
);
return null;
2025-05-02 02:24:12 +08:00
}
}
2024-04-30 23:38:49 +08:00
}
2025-06-07 01:48:34 +08:00
final macOS = system.isMacOS ? MacOS() : null;