Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ ConnectionRepository connectionRepository(Ref ref) {
configOptionRepository: ref.watch(configOptionRepositoryProvider),
singbox: ref.watch(hiddifyCoreServiceProvider),
profilePathResolver: ref.watch(profilePathResolverProvider),
mergedConfigBuilder: ref.watch(mergedConfigBuilderProvider),
);
}
58 changes: 47 additions & 11 deletions lib/features/connection/data/connection_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:hiddify/core/router/dialog/dialog_notifier.dart';
import 'package:hiddify/core/utils/exception_handler.dart';
import 'package:hiddify/features/connection/model/connection_failure.dart';
import 'package:hiddify/features/connection/model/connection_status.dart';
import 'package:hiddify/features/profile/data/merged_config_builder.dart';
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
import 'package:hiddify/features/profile/model/profile_entity.dart';
import 'package:hiddify/features/settings/data/config_option_repository.dart';
Expand All @@ -20,9 +21,13 @@ abstract interface class ConnectionRepository {

TaskEither<ConnectionFailure, Unit> setup();
Stream<ConnectionStatus> watchConnectionStatus();
TaskEither<ConnectionFailure, Unit> connect(ProfileEntity activeProfile, bool disableMemoryLimit);
/// Connect using a POOL of active profiles. All their outbounds are merged
/// into a single sing-box config, and a top-level `urltest` group named
/// `select` is created so the core can auto-pick the fastest outbound
/// across every profile.
TaskEither<ConnectionFailure, Unit> connect(List<ProfileEntity> activeProfiles, bool disableMemoryLimit);
TaskEither<ConnectionFailure, Unit> disconnect();
TaskEither<ConnectionFailure, Unit> reconnect(ProfileEntity activeProfile, bool disableMemoryLimit);
TaskEither<ConnectionFailure, Unit> reconnect(List<ProfileEntity> activeProfiles, bool disableMemoryLimit);
}

class ConnectionRepositoryImpl with ExceptionHandler, InfraLogger implements ConnectionRepository {
Expand All @@ -32,6 +37,7 @@ class ConnectionRepositoryImpl with ExceptionHandler, InfraLogger implements Con
required this.singbox,
required this.configOptionRepository,
required this.profilePathResolver,
required this.mergedConfigBuilder,
});

final Ref ref;
Expand All @@ -41,6 +47,7 @@ class ConnectionRepositoryImpl with ExceptionHandler, InfraLogger implements Con

final ConfigOptionRepository configOptionRepository;
final ProfilePathResolver profilePathResolver;
final MergedConfigBuilder mergedConfigBuilder;

SingboxConfigOption? _configOptionsSnapshot;
@override
Expand Down Expand Up @@ -78,23 +85,52 @@ class ConnectionRepositoryImpl with ExceptionHandler, InfraLogger implements Con
}

@override
TaskEither<ConnectionFailure, Unit> connect(ProfileEntity activeProfile, bool disableMemoryLimit) => setup().flatMap(
(_) => applyConfigOption(activeProfile).flatMap(
(_) => singbox.start(profilePathResolver.file(activeProfile.id).path, activeProfile.name, disableMemoryLimit),
// .mapLeft(UnexpectedConnectionFailure.new),
),
);
TaskEither<ConnectionFailure, Unit> connect(List<ProfileEntity> activeProfiles, bool disableMemoryLimit) {
if (activeProfiles.isEmpty) {
return TaskEither.left(const UnexpectedConnectionFailure("no active profile to connect"));
}
return setup().flatMap((_) => _buildMergedConfig(activeProfiles).flatMap((mergedPath) {
final displayName = activeProfiles.length == 1
? activeProfiles.first.name
: "Multi (${activeProfiles.length})";
// Apply the FIRST active profile's user-override (warp/fragment/etc).
// This is a simplification — the global SingboxConfigOption dominates
// anyway, and per-profile overrides are rare. If you need per-profile
// overrides to be merged, see `ProfileParser.applyProfileOverride` /
// `_mergeJson` and extend `applyConfigOption` to take a list.
return applyConfigOption(activeProfiles.first).flatMap(
(_) => singbox.start(mergedPath, displayName, disableMemoryLimit),
);
}));
}

@override
TaskEither<ConnectionFailure, Unit> disconnect() => singbox.stop().mapLeft(UnexpectedConnectionFailure.new);

@override
TaskEither<ConnectionFailure, Unit> reconnect(ProfileEntity activeProfile, bool disableMemoryLimit) =>
applyConfigOption(activeProfile).flatMap(
TaskEither<ConnectionFailure, Unit> reconnect(List<ProfileEntity> activeProfiles, bool disableMemoryLimit) {
if (activeProfiles.isEmpty) {
// Nothing to reconnect to — disconnect instead.
return disconnect();
}
return _buildMergedConfig(activeProfiles).flatMap((mergedPath) {
final displayName = activeProfiles.length == 1
? activeProfiles.first.name
: "Multi (${activeProfiles.length})";
return applyConfigOption(activeProfiles.first).flatMap(
(_) => singbox
.restart(profilePathResolver.file(activeProfile.id).path, activeProfile.name, disableMemoryLimit)
.restart(mergedPath, displayName, disableMemoryLimit)
.mapLeft(UnexpectedConnectionFailure.new),
);
});
}

/// Build the merged config from [activeProfiles] and return its file path.
TaskEither<ConnectionFailure, String> _buildMergedConfig(List<ProfileEntity> activeProfiles) {
return mergedConfigBuilder
.buildMergedConfig(activeProfiles)
.mapLeft((l) => ConnectionFailure.unexpected(l));
}

@visibleForTesting
TaskEither<ConnectionFailure, Unit> applyConfigOption(ProfileEntity prof) =>
Expand Down
32 changes: 19 additions & 13 deletions lib/features/connection/notifier/connection_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,17 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
}
});

ref.listen(activeProfileProvider.select((value) => value.asData?.value), (previous, next) async {
if (previous == null) return;
final shouldReconnect = next == null || previous.id != next.id;
ref.listen(activeProfilesProvider.select((value) => value.asData?.value), (previous, next) async {
// Compare the SET of active profile ids — only reconnect when the
// active set actually changes. This is what powers "switch to a
// different profile by itself" when an outbound in another profile
// becomes the fastest.
final prevIds = previous?.map((e) => e.id).toSet() ?? <String>{};
final nextList = next ?? const <ProfileEntity>[];
final nextIds = nextList.map((e) => e.id).toSet();
final shouldReconnect = prevIds.length != nextIds.length || !prevIds.containsAll(nextIds);
if (shouldReconnect) {
await reconnect(next);
await reconnect(nextList);
}
});
ref.watch(coreRestartSignalProvider);
Expand Down Expand Up @@ -93,15 +99,15 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
}
}

Future<void> reconnect(ProfileEntity? profile) async {
Future<void> reconnect(List<ProfileEntity>? profiles) async {
if (state case AsyncData(:final value) when value == const Connected()) {
if (profile == null) {
loggy.info("no active profile, disconnecting");
if (profiles == null || profiles.isEmpty) {
loggy.info("no active profile(s), disconnecting");
return _disconnect();
}
loggy.info("active profile changed, reconnecting");
loggy.info("active profile set changed, reconnecting with ${profiles.length} profile(s)");
await ref.read(Preferences.startedByUser.notifier).update(true);
await _connectionRepo.reconnect(profile, ref.read(Preferences.disableMemoryLimit)).mapLeft((err) async {
await _connectionRepo.reconnect(profiles, ref.read(Preferences.disableMemoryLimit)).mapLeft((err) async {
loggy.warning("error reconnecting", err);
state = AsyncError(err, StackTrace.current);
await ref
Expand Down Expand Up @@ -136,12 +142,12 @@ class ConnectionNotifier extends _$ConnectionNotifier with AppLogger {
}

Future<void> _connectThrottled() async {
final activeProfile = await ref.read(activeProfileProvider.future);
if (activeProfile == null) {
loggy.info("no active profile, not connecting");
final activeProfiles = await ref.read(activeProfilesProvider.future);
if (activeProfiles.isEmpty) {
loggy.info("no active profile(s), not connecting");
return;
}
await _connectionRepo.connect(activeProfile, ref.read(Preferences.disableMemoryLimit)).mapLeft((
await _connectionRepo.connect(activeProfiles, ref.read(Preferences.disableMemoryLimit)).mapLeft((
ConnectionFailure err,
) async {
loggy.warning("error connecting", err);
Expand Down
11 changes: 10 additions & 1 deletion lib/features/connection/widget/connection_wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:hiddify/core/localization/translations.dart';
import 'package:hiddify/core/notification/in_app_notification_controller.dart';
import 'package:hiddify/features/connection/notifier/connection_notifier.dart';
import 'package:hiddify/features/profile/notifier/active_profile_notifier.dart';
import 'package:hiddify/features/profile/notifier/auto_profile_switch_notifier.dart';
import 'package:hiddify/features/settings/notifier/config_option/config_option_notifier.dart';
import 'package:hiddify/utils/custom_loggers.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
Expand All @@ -21,6 +22,12 @@ class _ConnectionWrapperState extends ConsumerState<ConnectionWrapper> with AppL
Widget build(BuildContext context) {
ref.listen(connectionNotifierProvider, (_, _) {});

// Auto-switch: keep the auto-profile-switch notifier alive whenever the
// service is running. It listens to the active-proxy stream and
// deactivates profiles whose outbounds are all unreachable, causing the
// connection notifier to reconnect with the remaining (healthy) profiles.
ref.watch(autoProfileSwitchNotifierProvider);

ref.listen(configOptionNotifierProvider, (previous, next) async {
if (next case AsyncData(value: true)) {
final t = ref.read(translationsProvider).requireValue;
Expand All @@ -35,7 +42,9 @@ class _ConnectionWrapperState extends ConsumerState<ConnectionWrapper> with AppL
// .reconnect(await ref.read(activeProfileProvider.future));
// },
);
await ref.read(connectionNotifierProvider.notifier).reconnect(await ref.read(activeProfileProvider.future));
await ref
.read(connectionNotifierProvider.notifier)
.reconnect(await ref.read(activeProfilesProvider.future));
}
});

Expand Down
8 changes: 4 additions & 4 deletions lib/features/home/widget/connection_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,11 @@ class ConnectionButton extends HookConsumerWidget {
return _ConnectionButton(
onTap: switch (connectionStatus) {
AsyncData(value: Connected()) when requiresReconnect == true => () async {
final activeProfile = await ref.read(activeProfileProvider.future);
return await ref.read(connectionNotifierProvider.notifier).reconnect(activeProfile);
final activeProfiles = await ref.read(activeProfilesProvider.future);
return await ref.read(connectionNotifierProvider.notifier).reconnect(activeProfiles);
},
AsyncData(value: Disconnected()) || AsyncError() => () async {
if (ref.read(activeProfileProvider).valueOrNull == null) {
if (ref.read(activeProfilesProvider).valueOrNull?.isEmpty ?? true) {
await ref.read(dialogNotifierProvider.notifier).showNoActiveProfile();
ref.read(bottomSheetsNotifierProvider.notifier).showAddProfile();
}
Expand All @@ -136,7 +136,7 @@ class ConnectionButton extends HookConsumerWidget {
await ref.read(dialogNotifierProvider.notifier).showExperimentalFeatureNotice()) {
return await ref
.read(connectionNotifierProvider.notifier)
.reconnect(await ref.read(activeProfileProvider.future));
.reconnect(await ref.read(activeProfilesProvider.future));
}
return await ref.read(connectionNotifierProvider.notifier).toggleConnection();
},
Expand Down
32 changes: 32 additions & 0 deletions lib/features/home/widget/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class HomePage extends HookConsumerWidget {
final t = ref.watch(translationsProvider).requireValue;
// final hasAnyProfile = ref.watch(hasAnyProfileProvider);
final activeProfile = ref.watch(activeProfileProvider);
// Multi-profile mode: also watch the plural list so we can show how many
// profiles are currently active (and pooled for fastest-node selection).
final activeProfiles = ref.watch(activeProfilesProvider).valueOrNull ?? const [];

return Scaffold(
appBar: AppBar(
Expand Down Expand Up @@ -109,6 +112,35 @@ class HomePage extends HookConsumerWidget {
),
_ => const Text(""),
},
// Multi-profile badge: if more than one profile is
// active, show a small chip indicating how many are
// being pooled together for fastest-node selection.
if (activeProfiles.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bolt_rounded, size: 14, color: theme.colorScheme.primary),
const Gap(4),
Text(
"${activeProfiles.length} active profiles pooled",
style: theme.textTheme.labelSmall,
),
],
),
),
),
),
const SliverFillRemaining(
hasScrollBody: false,
child: Column(
Expand Down
Loading