Refactor
This commit is contained in:
24
lib/features/connection/data/connection_data_providers.dart
Normal file
24
lib/features/connection/data/connection_data_providers.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:hiddify/features/config_option/data/config_option_data_providers.dart';
|
||||
import 'package:hiddify/features/connection/data/connection_platform_source.dart';
|
||||
import 'package:hiddify/features/connection/data/connection_repository.dart';
|
||||
import 'package:hiddify/features/geo_asset/data/geo_asset_data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_providers.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:hiddify/singbox/service/singbox_service_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'connection_data_providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ConnectionRepository connectionRepository(
|
||||
ConnectionRepositoryRef ref,
|
||||
) {
|
||||
return ConnectionRepositoryImpl(
|
||||
directories: ref.watch(filesEditorServiceProvider).dirs,
|
||||
configOptionRepository: ref.watch(configOptionRepositoryProvider),
|
||||
singbox: ref.watch(singboxServiceProvider),
|
||||
platformSource: ConnectionPlatformSourceImpl(),
|
||||
profilePathResolver: ref.watch(profilePathResolverProvider),
|
||||
geoAssetPathResolver: ref.watch(geoAssetPathResolverProvider),
|
||||
);
|
||||
}
|
||||
67
lib/features/connection/data/connection_platform_source.dart
Normal file
67
lib/features/connection/data/connection_platform_source.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hiddify/core/utils/ffi_utils.dart';
|
||||
import 'package:hiddify/utils/custom_loggers.dart';
|
||||
import 'package:posix/posix.dart';
|
||||
import 'package:win32/win32.dart';
|
||||
|
||||
abstract interface class ConnectionPlatformSource {
|
||||
Future<bool> checkPrivilege();
|
||||
}
|
||||
|
||||
class ConnectionPlatformSourceImpl
|
||||
with InfraLogger
|
||||
implements ConnectionPlatformSource {
|
||||
@override
|
||||
Future<bool> checkPrivilege() async {
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
bool isElevated = false;
|
||||
withMemory<void, Uint32>(sizeOf<Uint32>(), (phToken) {
|
||||
withMemory<void, Uint32>(sizeOf<Uint32>(), (pReturnedSize) {
|
||||
withMemory<void, _TokenElevation>(sizeOf<_TokenElevation>(),
|
||||
(pElevation) {
|
||||
if (OpenProcessToken(
|
||||
GetCurrentProcess(),
|
||||
TOKEN_QUERY,
|
||||
phToken.cast(),
|
||||
) ==
|
||||
1) {
|
||||
if (GetTokenInformation(
|
||||
phToken.value,
|
||||
TOKEN_INFORMATION_CLASS.TokenElevation,
|
||||
pElevation,
|
||||
sizeOf<_TokenElevation>(),
|
||||
pReturnedSize,
|
||||
) ==
|
||||
1) {
|
||||
isElevated = pElevation.ref.tokenIsElevated != 0;
|
||||
}
|
||||
}
|
||||
if (phToken.value != 0) {
|
||||
CloseHandle(phToken.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return isElevated;
|
||||
} else if (Platform.isLinux || Platform.isMacOS) {
|
||||
final euid = geteuid();
|
||||
return euid == 0;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
loggy.warning("error checking privilege", e);
|
||||
return true; // return true so core handles it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class _TokenElevation extends Struct {
|
||||
/// A nonzero value if the token has elevated privileges;
|
||||
/// otherwise, a zero value.
|
||||
@Int32()
|
||||
external int tokenIsElevated;
|
||||
}
|
||||
214
lib/features/connection/data/connection_repository.dart
Normal file
214
lib/features/connection/data/connection_repository.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/core/model/directories.dart';
|
||||
import 'package:hiddify/core/utils/exception_handler.dart';
|
||||
import 'package:hiddify/features/config_option/data/config_option_repository.dart';
|
||||
import 'package:hiddify/features/connection/data/connection_platform_source.dart';
|
||||
import 'package:hiddify/features/connection/model/connection_failure.dart';
|
||||
import 'package:hiddify/features/connection/model/connection_status.dart';
|
||||
import 'package:hiddify/features/geo_asset/data/geo_asset_path_resolver.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
|
||||
import 'package:hiddify/singbox/model/singbox_config_option.dart';
|
||||
import 'package:hiddify/singbox/model/singbox_status.dart';
|
||||
import 'package:hiddify/singbox/service/singbox_service.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
abstract interface class ConnectionRepository {
|
||||
Stream<ConnectionStatus> watchConnectionStatus();
|
||||
TaskEither<ConnectionFailure, Unit> connect(
|
||||
String fileName,
|
||||
bool disableMemoryLimit,
|
||||
);
|
||||
TaskEither<ConnectionFailure, Unit> disconnect();
|
||||
TaskEither<ConnectionFailure, Unit> reconnect(
|
||||
String fileName,
|
||||
bool disableMemoryLimit,
|
||||
);
|
||||
}
|
||||
|
||||
class ConnectionRepositoryImpl
|
||||
with ExceptionHandler, InfraLogger
|
||||
implements ConnectionRepository {
|
||||
ConnectionRepositoryImpl({
|
||||
required this.directories,
|
||||
required this.singbox,
|
||||
required this.platformSource,
|
||||
required this.configOptionRepository,
|
||||
required this.profilePathResolver,
|
||||
required this.geoAssetPathResolver,
|
||||
});
|
||||
|
||||
final Directories directories;
|
||||
final SingboxService singbox;
|
||||
final ConnectionPlatformSource platformSource;
|
||||
final ConfigOptionRepository configOptionRepository;
|
||||
final ProfilePathResolver profilePathResolver;
|
||||
final GeoAssetPathResolver geoAssetPathResolver;
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
Stream<ConnectionStatus> watchConnectionStatus() {
|
||||
return singbox.watchStatus().map(
|
||||
(event) => switch (event) {
|
||||
SingboxStopped(:final alert?, :final message) => Disconnected(
|
||||
switch (alert) {
|
||||
SingboxAlert.emptyConfiguration =>
|
||||
ConnectionFailure.invalidConfig(message),
|
||||
SingboxAlert.requestNotificationPermission =>
|
||||
ConnectionFailure.missingNotificationPermission(message),
|
||||
SingboxAlert.requestVPNPermission =>
|
||||
ConnectionFailure.missingVpnPermission(message),
|
||||
SingboxAlert.startCommandServer ||
|
||||
SingboxAlert.createService ||
|
||||
SingboxAlert.startService =>
|
||||
ConnectionFailure.unexpected(message),
|
||||
},
|
||||
),
|
||||
SingboxStopped() => const Disconnected(),
|
||||
SingboxStarting() => const Connecting(),
|
||||
SingboxStarted() => const Connected(),
|
||||
SingboxStopping() => const Disconnecting(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
TaskEither<ConnectionFailure, SingboxConfigOption> getConfigOption() {
|
||||
return TaskEither<ConnectionFailure, SingboxConfigOption>.Do(
|
||||
($) async {
|
||||
final options = await $(
|
||||
configOptionRepository
|
||||
.getFullSingboxConfigOption()
|
||||
.mapLeft((l) => const InvalidConfigOption()),
|
||||
);
|
||||
|
||||
return $(
|
||||
TaskEither(
|
||||
() async {
|
||||
final geoip = geoAssetPathResolver.resolvePath(options.geoipPath);
|
||||
final geosite =
|
||||
geoAssetPathResolver.resolvePath(options.geositePath);
|
||||
if (!await File(geoip).exists() ||
|
||||
!await File(geosite).exists()) {
|
||||
return left(const ConnectionFailure.missingGeoAssets());
|
||||
}
|
||||
return right(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
).handleExceptions(UnexpectedConnectionFailure.new);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
TaskEither<ConnectionFailure, Unit> applyConfigOption(
|
||||
SingboxConfigOption options,
|
||||
) {
|
||||
return exceptionHandler(
|
||||
() {
|
||||
return singbox
|
||||
.changeOptions(options)
|
||||
.mapLeft(InvalidConfigOption.new)
|
||||
.run();
|
||||
},
|
||||
UnexpectedConnectionFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
TaskEither<ConnectionFailure, Unit> setup() {
|
||||
if (_initialized) return TaskEither.of(unit);
|
||||
return exceptionHandler(
|
||||
() {
|
||||
loggy.debug("setting up singbox");
|
||||
return singbox
|
||||
.setup(
|
||||
directories,
|
||||
false,
|
||||
)
|
||||
.map((r) {
|
||||
loggy.debug("setup complete");
|
||||
_initialized = true;
|
||||
return r;
|
||||
})
|
||||
.mapLeft(UnexpectedConnectionFailure.new)
|
||||
.run();
|
||||
},
|
||||
UnexpectedConnectionFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ConnectionFailure, Unit> connect(
|
||||
String fileName,
|
||||
bool disableMemoryLimit,
|
||||
) {
|
||||
return TaskEither<ConnectionFailure, Unit>.Do(
|
||||
($) async {
|
||||
final options = await $(getConfigOption());
|
||||
loggy.info(
|
||||
"config options: ${options.format()}\nMemory Limit: ${!disableMemoryLimit}",
|
||||
);
|
||||
|
||||
await $(
|
||||
TaskEither(() async {
|
||||
if (options.enableTun) {
|
||||
final hasPrivilege = await platformSource.checkPrivilege();
|
||||
if (!hasPrivilege) {
|
||||
loggy.warning("missing privileges for tun mode");
|
||||
return left(const MissingPrivilege());
|
||||
}
|
||||
}
|
||||
return right(unit);
|
||||
}),
|
||||
);
|
||||
await $(setup());
|
||||
loggy.debug("after setup");
|
||||
await $(applyConfigOption(options));
|
||||
loggy.debug("after apply");
|
||||
return await $(
|
||||
singbox
|
||||
.start(
|
||||
profilePathResolver.file(fileName).path,
|
||||
disableMemoryLimit,
|
||||
)
|
||||
.mapLeft(UnexpectedConnectionFailure.new),
|
||||
);
|
||||
},
|
||||
).handleExceptions(UnexpectedConnectionFailure.new);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ConnectionFailure, Unit> disconnect() {
|
||||
return exceptionHandler(
|
||||
() => singbox.stop().mapLeft(UnexpectedConnectionFailure.new).run(),
|
||||
UnexpectedConnectionFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ConnectionFailure, Unit> reconnect(
|
||||
String fileName,
|
||||
bool disableMemoryLimit,
|
||||
) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
return getConfigOption()
|
||||
.flatMap((options) => applyConfigOption(options))
|
||||
.andThen(
|
||||
() => singbox
|
||||
.restart(
|
||||
profilePathResolver.file(fileName).path,
|
||||
disableMemoryLimit,
|
||||
)
|
||||
.mapLeft(UnexpectedConnectionFailure.new),
|
||||
)
|
||||
.run();
|
||||
},
|
||||
UnexpectedConnectionFailure.new,
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/features/connection/model/connection_failure.dart
Normal file
75
lib/features/connection/model/connection_failure.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/localization/translations.dart';
|
||||
import 'package:hiddify/core/model/failures.dart';
|
||||
|
||||
part 'connection_failure.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ConnectionFailure with _$ConnectionFailure, Failure {
|
||||
const ConnectionFailure._();
|
||||
|
||||
@With<UnexpectedFailure>()
|
||||
const factory ConnectionFailure.unexpected([
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
]) = UnexpectedConnectionFailure;
|
||||
|
||||
@With<ExpectedMeasuredFailure>()
|
||||
const factory ConnectionFailure.missingVpnPermission([String? message]) =
|
||||
MissingVpnPermission;
|
||||
|
||||
@With<ExpectedMeasuredFailure>()
|
||||
const factory ConnectionFailure.missingNotificationPermission([
|
||||
String? message,
|
||||
]) = MissingNotificationPermission;
|
||||
|
||||
@With<ExpectedMeasuredFailure>()
|
||||
const factory ConnectionFailure.missingPrivilege() = MissingPrivilege;
|
||||
|
||||
@With<ExpectedMeasuredFailure>()
|
||||
const factory ConnectionFailure.missingGeoAssets() = MissingGeoAssets;
|
||||
|
||||
@With<ExpectedMeasuredFailure>()
|
||||
const factory ConnectionFailure.invalidConfigOption([
|
||||
String? message,
|
||||
]) = InvalidConfigOption;
|
||||
|
||||
@With<ExpectedMeasuredFailure>()
|
||||
const factory ConnectionFailure.invalidConfig([
|
||||
String? message,
|
||||
]) = InvalidConfig;
|
||||
|
||||
@override
|
||||
({String type, String? message}) present(TranslationsEn t) {
|
||||
return switch (this) {
|
||||
UnexpectedConnectionFailure() => (
|
||||
type: t.failure.connectivity.unexpected,
|
||||
message: null,
|
||||
),
|
||||
MissingVpnPermission(:final message) => (
|
||||
type: t.failure.connectivity.missingVpnPermission,
|
||||
message: message
|
||||
),
|
||||
MissingNotificationPermission(:final message) => (
|
||||
type: t.failure.connectivity.missingNotificationPermission,
|
||||
message: message
|
||||
),
|
||||
MissingPrivilege() => (
|
||||
type: t.failure.singbox.missingPrivilege,
|
||||
message: t.failure.singbox.missingPrivilegeMsg,
|
||||
),
|
||||
MissingGeoAssets() => (
|
||||
type: t.failure.singbox.missingGeoAssets,
|
||||
message: t.failure.singbox.missingGeoAssetsMsg,
|
||||
),
|
||||
InvalidConfigOption(:final message) => (
|
||||
type: t.failure.singbox.invalidConfigOptions,
|
||||
message: message,
|
||||
),
|
||||
InvalidConfig(:final message) => (
|
||||
type: t.failure.singbox.invalidConfig,
|
||||
message: message,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
41
lib/features/connection/model/connection_status.dart
Normal file
41
lib/features/connection/model/connection_status.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hiddify/core/localization/translations.dart';
|
||||
import 'package:hiddify/features/connection/model/connection_failure.dart';
|
||||
|
||||
part 'connection_status.freezed.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ConnectionStatus with _$ConnectionStatus {
|
||||
const ConnectionStatus._();
|
||||
|
||||
const factory ConnectionStatus.disconnected([
|
||||
ConnectionFailure? connectionFailure,
|
||||
]) = Disconnected;
|
||||
const factory ConnectionStatus.connecting() = Connecting;
|
||||
const factory ConnectionStatus.connected() = Connected;
|
||||
const factory ConnectionStatus.disconnecting() = Disconnecting;
|
||||
|
||||
bool get isConnected => switch (this) { Connected() => true, _ => false };
|
||||
|
||||
bool get isSwitching => switch (this) {
|
||||
Connecting() => true,
|
||||
Disconnecting() => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
String format() => switch (this) {
|
||||
Disconnected(:final connectionFailure) => connectionFailure != null
|
||||
? "CONNECTION FAILURE: $connectionFailure"
|
||||
: "DISCONNECTED",
|
||||
Connecting() => "CONNECTING",
|
||||
Connected() => "CONNECTED",
|
||||
Disconnecting() => "DISCONNECTING",
|
||||
};
|
||||
|
||||
String present(TranslationsEn t) => switch (this) {
|
||||
Disconnected() => t.home.connection.tapToConnect,
|
||||
Connecting() => t.home.connection.connecting,
|
||||
Connected() => t.home.connection.connected,
|
||||
Disconnecting() => t.home.connection.disconnecting,
|
||||
};
|
||||
}
|
||||
114
lib/features/connection/notifier/connection_notifier.dart
Normal file
114
lib/features/connection/notifier/connection_notifier.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:hiddify/core/preferences/general_preferences.dart';
|
||||
import 'package:hiddify/core/preferences/service_preferences.dart';
|
||||
import 'package:hiddify/features/connection/data/connection_data_providers.dart';
|
||||
import 'package:hiddify/features/connection/data/connection_repository.dart';
|
||||
import 'package:hiddify/features/connection/model/connection_status.dart';
|
||||
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
part 'connection_notifier.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
|
||||
@override
|
||||
Stream<ConnectionStatus> build() {
|
||||
ref.listen(
|
||||
activeProfileProvider.select((value) => value.asData?.value),
|
||||
(previous, next) async {
|
||||
if (previous == null) return;
|
||||
final shouldReconnect = next == null || previous.id != next.id;
|
||||
if (shouldReconnect) {
|
||||
await reconnect(next?.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
return _connectionRepo.watchConnectionStatus().doOnData((event) {
|
||||
if (event case Disconnected(connectionFailure: final _?)
|
||||
when PlatformUtils.isDesktop) {
|
||||
ref.read(startedByUserProvider.notifier).update(false);
|
||||
}
|
||||
loggy.info("connection status: ${event.format()}");
|
||||
});
|
||||
}
|
||||
|
||||
ConnectionRepository get _connectionRepo =>
|
||||
ref.read(connectionRepositoryProvider);
|
||||
|
||||
Future<void> mayConnect() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
if (value case Disconnected()) return _connect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleConnection() async {
|
||||
if (state case AsyncError()) {
|
||||
await _connect();
|
||||
} else if (state case AsyncData(:final value)) {
|
||||
switch (value) {
|
||||
case Disconnected():
|
||||
await ref.read(startedByUserProvider.notifier).update(true);
|
||||
await _connect();
|
||||
case Connected():
|
||||
await ref.read(startedByUserProvider.notifier).update(false);
|
||||
await _disconnect();
|
||||
default:
|
||||
loggy.warning("switching status, debounce");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reconnect(String? profileId) async {
|
||||
if (state case AsyncData(:final value) when value == const Connected()) {
|
||||
if (profileId == null) {
|
||||
loggy.info("no active profile, disconnecting");
|
||||
return _disconnect();
|
||||
}
|
||||
loggy.info("active profile changed, reconnecting");
|
||||
await ref.read(startedByUserProvider.notifier).update(true);
|
||||
await _connectionRepo
|
||||
.reconnect(profileId, ref.read(disableMemoryLimitProvider))
|
||||
.mapLeft((err) {
|
||||
loggy.warning("error reconnecting", err);
|
||||
state = AsyncError(err, StackTrace.current);
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> abortConnection() async {
|
||||
if (state case AsyncData(:final value)) {
|
||||
switch (value) {
|
||||
case Connected() || Connecting():
|
||||
loggy.debug("aborting connection");
|
||||
await _disconnect();
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
final activeProfile = await ref.read(activeProfileProvider.future);
|
||||
await _connectionRepo
|
||||
.connect(activeProfile!.id, ref.read(disableMemoryLimitProvider))
|
||||
.mapLeft((err) async {
|
||||
loggy.warning("error connecting", err);
|
||||
await ref.read(startedByUserProvider.notifier).update(false);
|
||||
state = AsyncError(err, StackTrace.current);
|
||||
}).run();
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
await _connectionRepo.disconnect().mapLeft((err) {
|
||||
loggy.warning("error disconnecting", err);
|
||||
state = AsyncError(err, StackTrace.current);
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<bool> serviceRunning(ServiceRunningRef ref) => ref
|
||||
.watch(
|
||||
connectionNotifierProvider.selectAsync((data) => data.isConnected),
|
||||
)
|
||||
.onError((error, stackTrace) => false);
|
||||
Reference in New Issue
Block a user