Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
const List<SettingsSectionDefinition> proxyRoutingSettingsSections = [
SettingsSectionDefinition(
title: 'Regular Tabs',
keywords: ['routing'],
entries: [
SettingsEntryDefinition(
title: 'Regular Tabs Routing Mode',
subtitle: 'Choose how regular tabs are routed through proxies',
keywords: ['container', 'global'],
child: _RegularTabsModeSection(),
),
SettingsEntryDefinition(
title: 'Proxy for global routing',
subtitle: 'Selected proxy when global routing is enabled',
keywords: ['proxy'],
child: _GlobalRoutingProxySection(),
),
],
),
SettingsSectionDefinition(
title: 'Private Tabs',
keywords: ['private', 'incognito'],
entries: [
SettingsEntryDefinition(
title: 'Proxy for private tabs',
subtitle: 'Selected proxy that carries private-tab traffic',
keywords: ['proxy'],
child: _PrivateTabsProxySection(),
),
],
),
];
class ProxyRoutingSettingsScreen extends StatelessWidget {
const ProxyRoutingSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return const SettingsDetailScaffold(
title: 'Proxy Routing',
subtitle: 'Choose which proxy carries regular and private tab traffic.',
icon: Icons.route_outlined,
sections: proxyRoutingSettingsSections,
);
}
}
class _RegularTabsModeSection extends ConsumerWidget {
const _RegularTabsModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
return RadioGroup<ProxyRegularTabRoutingMode>(
groupValue: settings.regularTabsMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(proxyRoutingSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith(regularTabsMode: value),
);
}
},
child: const Column(
children: [
RadioListTile<ProxyRegularTabRoutingMode>.adaptive(
value: ProxyRegularTabRoutingMode.container,
title: Text('Container-Based Routing'),
subtitle: Text(
'Only tabs in containers with a proxy assigned are routed.',
),
),
RadioListTile<ProxyRegularTabRoutingMode>.adaptive(
value: ProxyRegularTabRoutingMode.all,
title: Text('Global Routing'),
subtitle: Text(
'Route every regular tab through the selected proxy.',
),
),
],
),
);
}
}
class _GlobalRoutingProxySection extends ConsumerWidget {
const _GlobalRoutingProxySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
if (settings.regularTabsMode != ProxyRegularTabRoutingMode.all) {
return const ListTile(
leading: Icon(Icons.info_outline),
title: Text('Not used in container-based routing'),
subtitle: Text(
'Switch to global routing above to pick the proxy that carries every regular tab.',
),
);
}
final options = ref.watch(proxyConnectionOptionsProvider);
return _ProxyConnectionPicker(
options: options,
selectedId: settings.regularTabsProxyConnectionId,
onChanged: (id) => ref
.read(proxyRoutingSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith(regularTabsProxyConnectionId: id),
),
);
}
}
class _PrivateTabsProxySection extends ConsumerWidget {
const _PrivateTabsProxySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
final options = ref.watch(proxyConnectionOptionsProvider);
return _ProxyConnectionPicker(
options: options,
selectedId: settings.privateTabsProxyConnectionId,
onChanged: (id) => ref
.read(proxyRoutingSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith(privateTabsProxyConnectionId: id),
),
);
}
}
class _ProxyConnectionPicker extends StatelessWidget {
final List<ProxyConnectionOption> options;
final ProxyConnectionId? selectedId;
final ValueChanged<ProxyConnectionId?> onChanged;
const _ProxyConnectionPicker({
required this.options,
required this.selectedId,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final hasUnknownSelection =
selectedId != null && !options.any((option) => option.id == selectedId);
return RadioGroup<ProxyConnectionId?>(
groupValue: selectedId,
onChanged: onChanged,
child: Column(
children: [
const RadioListTile<ProxyConnectionId?>.adaptive(
value: null,
title: Text('None'),
subtitle: Text('Use the normal browser connection'),
secondary: Icon(Icons.public),
),
if (hasUnknownSelection)
ListTile(
leading: Icon(
Icons.warning_amber_outlined,
color: Theme.of(context).colorScheme.error,
),
title: const Text('Unknown proxy'),
subtitle: const Text('The selected proxy no longer exists.'),
trailing: TextButton(
onPressed: () => onChanged(null),
child: const Text('Clear'),
),
),
for (final option in options)
RadioListTile<ProxyConnectionId?>.adaptive(
value: option.id,
title: Text(option.title),
subtitle: Text(option.subtitle),
secondary: const Icon(Icons.route_outlined),
),
],
),
);
}
}
@@ -0,0 +1,231 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart';
import 'package:weblibre/features/proxy/data/models/proxy_log_message.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart';
import 'package:weblibre/utils/ui_helper.dart';
class SingboxProxyLogsScreen extends HookConsumerWidget {
const SingboxProxyLogsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final logs = ref.watch(singboxProxyLogsProvider);
final filter = useState<String?>(null);
final autoScroll = useState(true);
final scrollController = useScrollController();
// Stick to the bottom when new lines arrive — unless the user scrolled up.
// We coalesce scroll-to-bottom across rapid bursts via a pending flag so a
// chatty proxy can't fight the user trying to scroll up.
final pendingAutoScroll = useRef(false);
useEffect(() {
if (!autoScroll.value) return null;
if (pendingAutoScroll.value) return null;
pendingAutoScroll.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
pendingAutoScroll.value = false;
if (!autoScroll.value) return;
if (scrollController.hasClients) {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
}
});
return null;
}, [logs.length, autoScroll.value]);
useEffect(() {
void onScroll() {
if (!scrollController.hasClients) return;
final atBottom =
scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 24;
if (autoScroll.value != atBottom) {
autoScroll.value = atBottom;
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
final filtered = filter.value == null
? logs
: logs.where((m) => m.level.toLowerCase() == filter.value).toList();
return Scaffold(
appBar: AppBar(
title: const Text('Proxy Logs'),
actions: [
PopupMenuButton<String?>(
tooltip: 'Filter by level',
icon: const Icon(Icons.filter_list),
onSelected: (value) => filter.value = value,
itemBuilder: (context) => const [
PopupMenuItem<String?>(child: Text('All levels')),
PopupMenuItem(value: 'error', child: Text('Error')),
PopupMenuItem(value: 'warn', child: Text('Warning')),
PopupMenuItem(value: 'info', child: Text('Info')),
PopupMenuItem(value: 'debug', child: Text('Debug')),
PopupMenuItem(value: 'trace', child: Text('Trace')),
],
),
IconButton(
tooltip: 'Copy all',
icon: const Icon(Icons.copy_all),
onPressed: filtered.isEmpty
? null
: () async {
await Clipboard.setData(
ClipboardData(text: _formatLogs(filtered)),
);
if (context.mounted) {
showInfoMessage(context, 'Copied to clipboard');
}
},
),
IconButton(
tooltip: 'Share',
icon: const Icon(Icons.share),
onPressed: filtered.isEmpty
? null
: () => SharePlus.instance.share(
ShareParams(
text: _formatLogs(filtered),
subject: 'proxy logs',
),
),
),
IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.delete_outline),
onPressed: logs.isEmpty
? null
: () => ref.read(singboxProxyLogsProvider.notifier).clear(),
),
],
),
body: filtered.isEmpty
? _EmptyLogs(hasFilter: filter.value != null)
: ListView.builder(
controller: scrollController,
itemCount: filtered.length,
itemBuilder: (context, index) =>
_LogLine(message: filtered[index]),
),
);
}
}
class _LogLine extends StatelessWidget {
final ProxyLogMessage message;
const _LogLine({required this.message});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = switch (message.level.toLowerCase()) {
'error' || 'fatal' => scheme.error,
'warn' || 'warning' => scheme.tertiary,
_ => scheme.onSurface,
};
final time = DateFormat(
'HH:mm:ss',
).format(DateTime.fromMillisecondsSinceEpoch(message.timestamp));
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: SelectableText.rich(
TextSpan(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: color,
),
children: [
TextSpan(
text: '$time ',
style: TextStyle(color: scheme.onSurfaceVariant),
),
TextSpan(
text: '[${_sourceLabel(message.source)}] ',
style: TextStyle(color: scheme.primary),
),
TextSpan(
text: '[${message.level}] ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (message.profileId != null)
TextSpan(
text: '${message.profileId} ',
style: TextStyle(color: scheme.primary),
),
TextSpan(text: message.message),
],
),
),
);
}
}
class _EmptyLogs extends StatelessWidget {
final bool hasFilter;
const _EmptyLogs({required this.hasFilter});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
hasFilter
? 'No log lines match the current filter.'
: 'No log lines yet. Start a proxy or Tor to see output here.',
textAlign: TextAlign.center,
),
),
);
}
}
String _formatLogs(List<ProxyLogMessage> messages) {
final buffer = StringBuffer();
for (final m in messages) {
final time = DateTime.fromMillisecondsSinceEpoch(
m.timestamp,
).toIso8601String();
buffer.writeln(
'$time [${_sourceLabel(m.source)}] [${m.level}]${m.profileId == null ? '' : ' (${m.profileId})'} ${m.message}',
);
}
return buffer.toString();
}
String _sourceLabel(ProxyLogSource source) {
return switch (source) {
ProxyLogSource.singBox => 'sing-box',
ProxyLogSource.tor => 'tor',
};
}
@@ -0,0 +1,265 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/custom_outbound_profile_form.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_dns_override_section.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/structured_profile_form.dart';
import 'package:weblibre/presentation/widgets/button_spinner.dart';
import 'package:weblibre/utils/ui_helper.dart';
class SingboxProxyProfileEditorScreen extends ConsumerWidget {
final String? profileId;
final ProxyProfileSeed? seed;
const SingboxProxyProfileEditorScreen({super.key, this.profileId, this.seed});
@override
Widget build(BuildContext context, WidgetRef ref) {
final draftProvider = proxyProfileDraftProvider(
profileId: profileId,
seed: seed,
);
final draft = ref.watch(draftProvider);
if (draft.isLoading) {
return Scaffold(
appBar: AppBar(title: const Text('Edit Profile')),
body: const Center(child: CircularProgressIndicator()),
);
}
if (draft.loadError != null) {
return Scaffold(
appBar: AppBar(title: const Text('Edit Profile')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(draft.loadError!),
),
),
);
}
return _Editor(draftProvider: draftProvider, draft: draft);
}
}
class _Editor extends ConsumerWidget {
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const _Editor({required this.draftProvider, required this.draft});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> handleSave() async {
final outcome = await ref.read(draftProvider.notifier).save();
if (!context.mounted) return;
switch (outcome) {
case SaveSucceeded():
Navigator.pop(context);
case SaveFailed(:final message):
showErrorMessage(context, message);
}
}
final scheme = Theme.of(context).colorScheme;
return Scaffold(
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FilledButton.icon(
onPressed: draft.isSaving ? null : handleSave,
icon: draft.isSaving
? const ButtonSpinner()
: const Icon(Icons.check),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
label: Text(draft.isEditing ? 'Save Changes' : 'Create Profile'),
),
),
),
body: SafeArea(
bottom: false,
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
SliverAppBar.large(
centerTitle: false,
title: Text(draft.isEditing ? 'Edit Profile' : 'New Profile'),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
sliver: SliverList(
delegate: SliverChildListDelegate.fixed([
ProfileEditorSection(
title: 'General',
child: Padding(
padding: const EdgeInsets.all(16),
child: _GeneralSection(
draftProvider: draftProvider,
draft: draft,
),
),
),
const SizedBox(height: 24),
_ProtocolForm(draftProvider: draftProvider, draft: draft),
const SizedBox(height: 24),
ProfileEditorSection(
title: 'DNS Override',
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: ProfileDnsOverrideSection(
draftProvider: draftProvider,
),
),
),
const SizedBox(height: 16),
if (!draft.isEditing)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'Tip: use the add menu on the previous screen to '
'import from a file, paste a share link, or scan '
'a QR code.',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: scheme.onSurfaceVariant),
),
),
]),
),
),
],
);
},
),
),
);
}
}
class _GeneralSection extends HookConsumerWidget {
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const _GeneralSection({required this.draftProvider, required this.draft});
@override
Widget build(BuildContext context, WidgetRef ref) {
final nameController = useTextEditingController(text: draft.name);
useEffect(() {
if (nameController.text != draft.name) {
nameController.text = draft.name;
}
return null;
}, [draft.name]);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: nameController,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Profile Name',
border: OutlineInputBorder(),
),
onChanged: ref.read(draftProvider.notifier).setName,
),
const SizedBox(height: 16),
if (draft.isEditing)
// Protocol is locked after creation: each type stores a different
// config/secret JSON shape, so switching mid-edit would silently
// rewrite the profile under a foreign schema. To change protocol,
// create a new profile.
InputDecorator(
decoration: const InputDecoration(
labelText: 'Protocol',
border: OutlineInputBorder(),
helperText: 'Protocol is fixed once a profile is created.',
),
child: Text(draft.type.label),
)
else
DropdownButtonFormField<SingboxProxyProfileType>(
key: ValueKey(draft.type),
initialValue: draft.type,
decoration: const InputDecoration(
labelText: 'Protocol',
border: OutlineInputBorder(),
),
items: [
for (final type in SingboxProxyProfileType.values)
DropdownMenuItem(value: type, child: Text(type.label)),
],
onChanged: (value) {
if (value != null) {
ref.read(draftProvider.notifier).setType(value);
}
},
),
],
);
}
}
class _ProtocolForm extends StatelessWidget {
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const _ProtocolForm({required this.draftProvider, required this.draft});
@override
Widget build(BuildContext context) {
final spec = singboxProxyFormSpecs[draft.type];
if (spec != null) {
return StructuredProfileForm(
key: ValueKey((draft.type, draft.profileId)),
spec: spec,
draftProvider: draftProvider,
draft: draft,
);
}
return CustomOutboundProfileForm(
key: ValueKey(('custom', draft.profileId)),
draftProvider: draftProvider,
draft: draft,
);
}
}
@@ -0,0 +1,303 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
import 'package:weblibre/features/proxy/presentation/widgets/add_proxy_method_sheet.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_tile.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/status_header.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/tor_tile.dart';
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/utils/ui_helper.dart';
class SingboxProxyProfilesScreen extends HookConsumerWidget {
const SingboxProxyProfilesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profilesAsync = ref.watch(singboxProxyProfilesRepositoryProvider);
final runtimeState = ref.watch(singboxProxyRuntimeRepositoryProvider);
final torState = ref.watch(torProxyServiceProvider);
final deletingProfileIds = useState(<String>{});
final activeProfileIds = _activeProfileIds(runtimeState);
final runtimeBusy = runtimeState.isLoading;
final torIsRunning = torState.value?.isRunning ?? false;
final torIsBusy = torState.isBusy;
// Drop cached latency results for profiles that are no longer running so a
// stale "120 ms" chip can't outlive its connection.
ref.listen(singboxProxyRuntimeRepositoryProvider, (_, _) {
_pruneLatencyCache(ref);
});
ref.listen(torProxyServiceProvider, (_, _) {
_pruneLatencyCache(ref);
});
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: () => unawaited(_showAddSheet(context)),
icon: const Icon(Icons.add),
label: const Text('Add Profile'),
),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
SliverAppBar.large(
centerTitle: false,
title: const Text('Proxy Connections'),
actions: [
IconButton(
tooltip: 'View logs',
icon: const Icon(Icons.subject),
onPressed: () =>
const SingboxProxyLogsRoute().push(context),
),
],
),
...profilesAsync.when(
data: (profiles) {
// Prune ids whose profile was deleted (or otherwise
// disappeared) so the set can't grow unbounded if a tile
// is unmounted while its delete is still in flight.
final liveProfileIds = {
for (final profile in profiles) profile.id,
};
final pruned = deletingProfileIds.value.intersection(
liveProfileIds,
);
if (pruned.length != deletingProfileIds.value.length) {
// Schedule for the next frame to avoid mutating state
// during build.
WidgetsBinding.instance.addPostFrameCallback((_) {
deletingProfileIds.value = pruned;
});
}
return [
_ProfileListBody(
profiles: profiles,
activeProfileIds: activeProfileIds,
deletingProfileIds: pruned,
runtimeBusy: runtimeBusy,
torIsRunning: torIsRunning,
torIsBusy: torIsBusy,
onDeletingChanged: (id, deleting) {
final next = {...deletingProfileIds.value};
if (deleting) {
next.add(id);
} else {
next.remove(id);
}
deletingProfileIds.value = next;
},
),
];
},
loading: () => const [
SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
],
error: (error, stackTrace) {
logger.e(
'Failed to load singbox proxy profiles',
error: error,
stackTrace: stackTrace,
);
return [
SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
'Failed to load proxy profiles:\n$error',
textAlign: TextAlign.center,
),
),
),
),
];
},
),
],
);
},
),
),
);
}
}
Set<ProxyConnectionId> _activeConnectionIds(
AsyncValue<SingboxProxyRuntimeState> runtimeState,
) {
return runtimeState.asData?.value.endpoints
.map((endpoint) => ProxyConnectionId.decode(endpoint.profileId))
.nonNulls
.toSet() ??
const <ProxyConnectionId>{};
}
Set<String> _activeProfileIds(
AsyncValue<SingboxProxyRuntimeState> runtimeState,
) {
return _activeConnectionIds(
runtimeState,
).whereType<SingboxProxyConnectionId>().map((id) => id.profileId).toSet();
}
void _pruneLatencyCache(WidgetRef ref) {
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider);
final torRunning =
ref.read(torProxyServiceProvider).value?.isRunning ?? false;
ref.read(proxyLatencyResultsProvider.notifier).retainRunning({
..._activeConnectionIds(runtimeState),
if (torRunning) const TorProxyConnectionId(),
});
}
Future<void> _showAddSheet(BuildContext context) async {
final action = await showModalBottomSheet<AddProxyAction>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const AddProxyMethodSheet(),
);
if (action == null) return;
if (!context.mounted) return;
switch (action) {
case AddProxyManual():
await const SingboxProxyProfileEditorRoute().push(context);
case AddProxySubscription():
await const SubscriptionImportRoute().push(context);
case AddProxyWithSeed(:final seed):
await SingboxProxyProfileEditorRoute($extra: seed).push(context);
case AddProxyImported(:final message):
showInfoMessage(context, message);
}
}
class _ProfileListBody extends ConsumerWidget {
final List<ProxyProfile> profiles;
final Set<String> activeProfileIds;
final Set<String> deletingProfileIds;
final bool runtimeBusy;
final bool torIsRunning;
final bool torIsBusy;
final void Function(String id, bool deleting) onDeletingChanged;
const _ProfileListBody({
required this.profiles,
required this.activeProfileIds,
required this.deletingProfileIds,
required this.runtimeBusy,
required this.torIsRunning,
required this.torIsBusy,
required this.onDeletingChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sorted = [...profiles]
..sort((a, b) {
final aRunning = activeProfileIds.contains(a.id);
final bRunning = activeProfileIds.contains(b.id);
if (aRunning == bRunning) return 0;
return aRunning ? -1 : 1;
});
final scheme = Theme.of(context).colorScheme;
final totalRunning = activeProfileIds.length + (torIsRunning ? 1 : 0);
final totalCount = profiles.length + 1;
Future<void> stopAll() async {
await ref.read(singboxProxyRuntimeRepositoryProvider.notifier).stopAll();
ref.read(proxyLatencyResultsProvider.notifier).retainRunning(const {});
if (torIsRunning) {
await ref.read(torProxyServiceProvider.notifier).disconnect();
}
}
return SliverList.list(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: StatusHeader(
totalCount: totalCount,
runningCount: totalRunning,
isBusy: runtimeBusy || torIsBusy,
onStopAll: totalRunning == 0 ? null : stopAll,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'Profiles',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w700,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
child: Card.filled(
margin: EdgeInsets.zero,
color: scheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
child: Column(
children: [
TorProfileTile(isRunning: torIsRunning, isBusy: torIsBusy),
for (final profile in sorted) ...[
const Divider(height: 1),
ProfileTile(
profile: profile,
isRunning: activeProfileIds.contains(profile.id),
isDeleting: deletingProfileIds.contains(profile.id),
runtimeBusy: runtimeBusy,
onDeletingChanged: onDeletingChanged,
),
],
],
),
),
),
],
);
}
}
@@ -0,0 +1,286 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/services/subscription_importer.dart';
import 'package:weblibre/presentation/widgets/button_spinner.dart';
import 'package:weblibre/utils/ui_helper.dart';
class SubscriptionImportScreen extends HookConsumerWidget {
const SubscriptionImportScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final urlController = useTextEditingController();
final hasUrl = useListenableSelector(
urlController,
() => urlController.text.trim().isNotEmpty,
);
final fetching = useState(false);
final result = useState<SubscriptionImportResult?>(null);
final selection = useState(<int>{});
final fetchError = useState<String?>(null);
final isImporting = useState(false);
Future<void> fetch() async {
final raw = urlController.text.trim();
if (raw.isEmpty) return;
final uri = Uri.tryParse(raw);
if (uri == null || !uri.hasScheme) {
fetchError.value = 'Enter a full https:// subscription URL.';
return;
}
fetching.value = true;
fetchError.value = null;
try {
final outcome = await fetchSubscription(uri);
result.value = outcome;
selection.value = {
for (final (index, entry) in outcome.entries.indexed)
if (entry is SubscriptionEntrySuccess) index,
};
} catch (error, stackTrace) {
logger.e(
'Failed to fetch subscription from $uri',
error: error,
stackTrace: stackTrace,
);
fetchError.value = error.toString();
result.value = null;
} finally {
if (context.mounted) fetching.value = false;
}
}
Future<void> importSelected() async {
final outcome = result.value;
if (outcome == null) return;
isImporting.value = true;
var imported = 0;
try {
final notifier = ref.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
for (final (index, entry) in outcome.entries.indexed) {
if (!selection.value.contains(index)) continue;
if (entry is! SubscriptionEntrySuccess) continue;
final parsed = entry.imported;
final spec = singboxProxyFormSpecs[parsed.type];
if (spec == null) continue;
await notifier.createProfile(
name: parsed.name ?? 'Imported ${imported + 1}',
type: parsed.type,
configJson: spec.toConfigJson(parsed.values),
secretJson: spec.toSecretJson(parsed.values),
);
imported++;
}
} finally {
if (context.mounted) isImporting.value = false;
}
if (context.mounted) {
showInfoMessage(context, 'Imported $imported profile(s)');
Navigator.of(context).pop();
}
}
return Scaffold(
appBar: AppBar(title: const Text('Import Subscription')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: urlController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Subscription URL',
hintText: 'https://example.com/sub',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
Text(
'Supports the v2rayN-style format: a base64-encoded list of '
'ss://, vless://, vmess://, trojan://, hysteria2://, tuic:// '
'and similar URIs. Routing rules from the subscription are '
'ignored — only proxy nodes are imported.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: !hasUrl || fetching.value ? null : fetch,
icon: fetching.value
? const ButtonSpinner()
: const Icon(Icons.cloud_download_outlined),
label: const Text('Fetch'),
),
if (fetchError.value != null) ...[
const SizedBox(height: 12),
Text(
fetchError.value!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (result.value != null) ...[
const SizedBox(height: 24),
_ResultsSection(
result: result.value!,
selectedIndices: selection.value,
isImporting: isImporting.value,
onSelectionChanged: (next) => selection.value = next,
onImport: importSelected,
),
],
],
),
);
}
}
class _ResultsSection extends StatelessWidget {
final SubscriptionImportResult result;
final Set<int> selectedIndices;
final bool isImporting;
final ValueChanged<Set<int>> onSelectionChanged;
final Future<void> Function() onImport;
const _ResultsSection({
required this.result,
required this.selectedIndices,
required this.isImporting,
required this.onSelectionChanged,
required this.onImport,
});
@override
Widget build(BuildContext context) {
final successCount = result.successes.length;
final failureCount = result.failures.length;
void selectAll() {
onSelectionChanged({
for (final (index, entry) in result.entries.indexed)
if (entry is SubscriptionEntrySuccess) index,
});
}
void toggle(int index, bool selected) {
final next = {...selectedIndices};
if (selected) {
next.add(index);
} else {
next.remove(index);
}
onSelectionChanged(next);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'$successCount usable node(s)'
'${failureCount > 0 ? ', $failureCount failed' : ''}',
style: Theme.of(context).textTheme.titleSmall,
),
),
TextButton(
onPressed: successCount == 0 ? null : selectAll,
child: const Text('Select all'),
),
TextButton(
onPressed: () => onSelectionChanged(const {}),
child: const Text('Clear'),
),
],
),
const Divider(),
for (final (index, entry) in result.entries.indexed)
_EntryTile(
entry: entry,
selected: selectedIndices.contains(index),
onChanged: entry is SubscriptionEntrySuccess
? (checked) => toggle(index, checked ?? false)
: null,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: isImporting || selectedIndices.isEmpty ? null : onImport,
icon: isImporting
? const ButtonSpinner()
: const Icon(Icons.download_done),
label: Text('Import ${selectedIndices.length} profile(s)'),
),
],
);
}
}
class _EntryTile extends StatelessWidget {
final SubscriptionImportEntry entry;
final bool selected;
final ValueChanged<bool?>? onChanged;
const _EntryTile({
required this.entry,
required this.selected,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return switch (entry) {
SubscriptionEntrySuccess(:final imported) => CheckboxListTile(
value: selected,
onChanged: onChanged,
title: Text(imported.name ?? entry.rawLine),
subtitle: Text(
imported.type.name,
style: Theme.of(context).textTheme.bodySmall,
),
dense: true,
),
SubscriptionEntryFailure(:final error) => ListTile(
leading: Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
),
title: Text(
entry.rawLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
error is FormatException ? error.message : error.toString(),
),
dense: true,
),
};
}
}