Add proxy routing and sing-box support
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.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/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
||||
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
/// Single entry point that prompts the user to start whichever proxy backend
|
||||
/// a container is configured to use. No-op when the container has no proxy
|
||||
/// assigned or when the relevant backend is already running.
|
||||
Future<bool> ensureProxyStartedForContainer(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ContainerData container,
|
||||
) async {
|
||||
final proxyConnectionId = container.metadata.proxyConnectionId;
|
||||
if (proxyConnectionId == null) return true;
|
||||
|
||||
if (proxyConnectionId is TorProxyConnectionId) {
|
||||
return await _ensureTorStarted(context, ref);
|
||||
}
|
||||
|
||||
if (proxyConnectionId is SingboxProxyConnectionId) {
|
||||
return await _maybeStartSingboxProxyForContainer(context, ref, container);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _ensureTorStarted(BuildContext context, WidgetRef ref) async {
|
||||
final shouldPrompt = await ref
|
||||
.read(startProxyControllerProvider.notifier)
|
||||
.shouldPromptProxyStart();
|
||||
if (!context.mounted) return false;
|
||||
if (!shouldPrompt) return true;
|
||||
|
||||
final dialogResult = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => const TorDialog(),
|
||||
);
|
||||
|
||||
if (dialogResult == true) {
|
||||
await ref.read(startProxyControllerProvider.notifier).startProxy();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _maybeStartSingboxProxyForContainer(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ContainerData container,
|
||||
) async {
|
||||
final proxyConnectionId = container.metadata.proxyConnectionId;
|
||||
if (proxyConnectionId == null) return true;
|
||||
|
||||
if (proxyConnectionId is! SingboxProxyConnectionId) return true;
|
||||
|
||||
// Await any start/stop in flight so we don't prompt the user a second time
|
||||
// while a start they already triggered is still resolving. If the runtime
|
||||
// provider is already in AsyncError, keep treating that as "not running" so
|
||||
// the user can retry from the container entry point.
|
||||
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider);
|
||||
final resolvedRuntimeState = await switch (runtimeState) {
|
||||
AsyncData(:final value) => Future.value(value),
|
||||
AsyncLoading() => _resolveRuntimeStateForPrompt(ref),
|
||||
AsyncError(:final error, :final stackTrace) => Future.value(
|
||||
_runtimeStateRetryFallback(
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
message:
|
||||
'Singbox proxy runtime is in error state; continuing so the user can retry startup',
|
||||
),
|
||||
),
|
||||
};
|
||||
if (!context.mounted) return false;
|
||||
|
||||
final isRunning = resolvedRuntimeState.endpoints.any(
|
||||
(endpoint) => endpoint.profileId == proxyConnectionId.encode(),
|
||||
);
|
||||
|
||||
if (isRunning) return true;
|
||||
|
||||
final proxyTitle = proxyConnectionTitle(
|
||||
ref.read(proxyConnectionOptionsProvider),
|
||||
proxyConnectionId,
|
||||
);
|
||||
final shouldStart = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
icon: const Icon(Icons.route_outlined),
|
||||
title: const Text('Start Proxy Connection?'),
|
||||
content: Text(
|
||||
'This container uses $proxyTitle, but that connection is not running. Start it now?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Start'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldStart != true) return false;
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||
.startProfile(proxyConnectionId.profileId);
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
logger.e(
|
||||
'Failed to start singbox proxy profile ${proxyConnectionId.profileId}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (context.mounted) {
|
||||
showErrorMessage(context, 'Failed to start proxy: $error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SingboxProxyRuntimeState> _resolveRuntimeStateForPrompt(
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
try {
|
||||
return await ref.read(singboxProxyRuntimeRepositoryProvider.future);
|
||||
} catch (error, stackTrace) {
|
||||
return _runtimeStateRetryFallback(
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
message:
|
||||
'Singbox proxy runtime is unresolved; continuing so the user can retry startup',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SingboxProxyRuntimeState _runtimeStateRetryFallback({
|
||||
required Object error,
|
||||
required StackTrace stackTrace,
|
||||
required String message,
|
||||
}) {
|
||||
logger.w(message, error: error, stackTrace: stackTrace);
|
||||
return SingboxProxyRuntimeState(
|
||||
status: SingboxProxyRuntimeStatus.error,
|
||||
endpoints: const [],
|
||||
message: error.toString(),
|
||||
);
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/uuid.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/repositories/singbox_proxy_credentials.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/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
|
||||
part 'proxy_profile_draft_controller.g.dart';
|
||||
|
||||
const defaultCustomOutboundConfigJson = '''
|
||||
{
|
||||
"type": "socks",
|
||||
"server": "127.0.0.1",
|
||||
"server_port": 1080
|
||||
}''';
|
||||
|
||||
sealed class SaveOutcome {
|
||||
const SaveOutcome();
|
||||
}
|
||||
|
||||
class SaveSucceeded extends SaveOutcome {
|
||||
const SaveSucceeded();
|
||||
}
|
||||
|
||||
class SaveFailed extends SaveOutcome {
|
||||
final String message;
|
||||
|
||||
const SaveFailed(this.message);
|
||||
}
|
||||
|
||||
@CopyWith()
|
||||
class ProxyProfileDraftState with FastEquatable {
|
||||
final String? profileId;
|
||||
final ProxyProfile? existingProfile;
|
||||
final String? loadError;
|
||||
final String name;
|
||||
final SingboxProxyProfileType type;
|
||||
final Map<String, String> values;
|
||||
final String? dnsOverrideJson;
|
||||
final String customConfigJson;
|
||||
final String customSecretJson;
|
||||
final bool isSaving;
|
||||
final bool secretLoaded;
|
||||
|
||||
ProxyProfileDraftState({
|
||||
required this.profileId,
|
||||
required this.existingProfile,
|
||||
required this.loadError,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.values,
|
||||
required this.dnsOverrideJson,
|
||||
required this.customConfigJson,
|
||||
required this.customSecretJson,
|
||||
required this.isSaving,
|
||||
required this.secretLoaded,
|
||||
});
|
||||
|
||||
factory ProxyProfileDraftState.newProfile({ProxyProfileSeed? seed}) {
|
||||
final type = seed?.type ?? SingboxProxyProfileType.customOutbound;
|
||||
return ProxyProfileDraftState(
|
||||
profileId: null,
|
||||
existingProfile: null,
|
||||
loadError: null,
|
||||
name: seed?.name ?? '',
|
||||
type: type,
|
||||
values: _initialValuesForType(type, overlay: seed?.values),
|
||||
dnsOverrideJson: seed?.dnsOverrideJson,
|
||||
customConfigJson: defaultCustomOutboundConfigJson,
|
||||
customSecretJson: '',
|
||||
isSaving: false,
|
||||
secretLoaded: true,
|
||||
);
|
||||
}
|
||||
|
||||
factory ProxyProfileDraftState.loadingExisting(String profileId) {
|
||||
return ProxyProfileDraftState(
|
||||
profileId: profileId,
|
||||
existingProfile: null,
|
||||
loadError: null,
|
||||
name: '',
|
||||
type: SingboxProxyProfileType.customOutbound,
|
||||
values: const {},
|
||||
dnsOverrideJson: null,
|
||||
customConfigJson: defaultCustomOutboundConfigJson,
|
||||
customSecretJson: '',
|
||||
isSaving: false,
|
||||
secretLoaded: false,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isEditing => profileId != null;
|
||||
|
||||
bool get isLoading =>
|
||||
isEditing && existingProfile == null && loadError == null;
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
profileId,
|
||||
existingProfile,
|
||||
loadError,
|
||||
name,
|
||||
type,
|
||||
values,
|
||||
dnsOverrideJson,
|
||||
customConfigJson,
|
||||
customSecretJson,
|
||||
isSaving,
|
||||
secretLoaded,
|
||||
];
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ProxyProfileDraft extends _$ProxyProfileDraft {
|
||||
@override
|
||||
ProxyProfileDraftState build({String? profileId, ProxyProfileSeed? seed}) {
|
||||
if (profileId == null) {
|
||||
return ProxyProfileDraftState.newProfile(seed: seed);
|
||||
}
|
||||
|
||||
unawaited(_loadExistingProfile(profileId));
|
||||
return ProxyProfileDraftState.loadingExisting(profileId);
|
||||
}
|
||||
|
||||
Future<void> _loadExistingProfile(String profileId) async {
|
||||
try {
|
||||
final profile = await ref
|
||||
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||
.findProfile(profileId);
|
||||
if (!ref.mounted) return;
|
||||
|
||||
if (profile == null) {
|
||||
state = state.copyWith(
|
||||
loadError: 'Proxy profile not found.',
|
||||
secretLoaded: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final secretJson = await ref
|
||||
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||
.readSecretJson(profile.id);
|
||||
if (!ref.mounted) return;
|
||||
|
||||
final spec = singboxProxyFormSpecs[profile.type];
|
||||
state = state.copyWith(
|
||||
existingProfile: profile,
|
||||
name: profile.name,
|
||||
type: profile.type,
|
||||
values: spec == null
|
||||
? const {}
|
||||
: spec.valuesFromJson(
|
||||
configJson: profile.configJson,
|
||||
secretJson: secretJson,
|
||||
),
|
||||
dnsOverrideJson: profile.dnsOverrideJson,
|
||||
customConfigJson: profile.type == SingboxProxyProfileType.customOutbound
|
||||
? profile.configJson
|
||||
: defaultCustomOutboundConfigJson,
|
||||
customSecretJson: profile.type == SingboxProxyProfileType.customOutbound
|
||||
? secretJson ?? ''
|
||||
: '',
|
||||
secretLoaded: true,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
loadError: 'Failed to load proxy profile: $error',
|
||||
secretLoaded: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void setName(String name) {
|
||||
state = state.copyWith(name: name);
|
||||
}
|
||||
|
||||
void setType(SingboxProxyProfileType type) {
|
||||
if (state.isEditing || state.type == type) return;
|
||||
state = state.copyWith(
|
||||
type: type,
|
||||
values: _initialValuesForType(type),
|
||||
customConfigJson: defaultCustomOutboundConfigJson,
|
||||
customSecretJson: '',
|
||||
secretLoaded: true,
|
||||
);
|
||||
}
|
||||
|
||||
void setFieldValue(String key, String value) {
|
||||
state = state.copyWith(values: {...state.values, key: value});
|
||||
}
|
||||
|
||||
void setDnsOverrideJson(String? json) {
|
||||
state = state.copyWith.dnsOverrideJson(json);
|
||||
}
|
||||
|
||||
void setCustomConfigJson(String json) {
|
||||
state = state.copyWith(customConfigJson: json);
|
||||
}
|
||||
|
||||
void setCustomSecretJson(String json) {
|
||||
state = state.copyWith(customSecretJson: json);
|
||||
}
|
||||
|
||||
Future<SaveOutcome> save() async {
|
||||
if (state.isSaving) {
|
||||
return const SaveFailed('Profile is already saving.');
|
||||
}
|
||||
|
||||
final draft = state;
|
||||
final trimmedName = draft.name.trim();
|
||||
if (trimmedName.isEmpty) {
|
||||
return const SaveFailed('Profile name is required.');
|
||||
}
|
||||
|
||||
if (draft.isLoading) {
|
||||
return const SaveFailed('Profile is still loading, please wait.');
|
||||
}
|
||||
|
||||
if (draft.loadError != null) {
|
||||
return SaveFailed(draft.loadError!);
|
||||
}
|
||||
|
||||
final encoded = _encodeDraft(draft);
|
||||
switch (encoded) {
|
||||
case _DraftEncodeFailure(:final message):
|
||||
return SaveFailed(message);
|
||||
case _DraftEncodeSuccess(:final configJson, :final secretJson):
|
||||
state = state.copyWith(isSaving: true);
|
||||
|
||||
try {
|
||||
final existing = draft.existingProfile;
|
||||
final profile = ProxyProfile(
|
||||
id: existing?.id ?? uuid.v4(),
|
||||
name: trimmedName,
|
||||
type: draft.type,
|
||||
configJson: configJson,
|
||||
dnsOverrideJson: draft.dnsOverrideJson,
|
||||
createdAt: existing?.createdAt ?? DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final validationMessage = await ref
|
||||
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||
.validateProfileDraft(profile, secretJson: secretJson);
|
||||
if (validationMessage != null) {
|
||||
return SaveFailed(validationMessage);
|
||||
}
|
||||
|
||||
if (existing == null) {
|
||||
await ref
|
||||
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||
.createProfile(
|
||||
name: trimmedName,
|
||||
type: draft.type,
|
||||
configJson: configJson,
|
||||
secretJson: secretJson,
|
||||
dnsOverrideJson: draft.dnsOverrideJson,
|
||||
);
|
||||
} else {
|
||||
await ref
|
||||
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||
.updateProfile(profile);
|
||||
await ref
|
||||
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||
.writeSecretJson(profile.id, secretJson);
|
||||
}
|
||||
|
||||
return const SaveSucceeded();
|
||||
} catch (error) {
|
||||
return SaveFailed('Failed to save proxy profile: $error');
|
||||
} finally {
|
||||
if (ref.mounted) {
|
||||
state = state.copyWith(isSaving: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class _DraftEncodeResult {
|
||||
const _DraftEncodeResult();
|
||||
}
|
||||
|
||||
class _DraftEncodeSuccess extends _DraftEncodeResult {
|
||||
final String configJson;
|
||||
final String? secretJson;
|
||||
|
||||
const _DraftEncodeSuccess({
|
||||
required this.configJson,
|
||||
required this.secretJson,
|
||||
});
|
||||
}
|
||||
|
||||
class _DraftEncodeFailure extends _DraftEncodeResult {
|
||||
final String message;
|
||||
|
||||
const _DraftEncodeFailure(this.message);
|
||||
}
|
||||
|
||||
/// Validates and encodes the draft into wire-format JSON in a single pass.
|
||||
/// For custom-outbound profiles we only check JSON shape; for spec-driven
|
||||
/// types the spec carries field-level validation.
|
||||
_DraftEncodeResult _encodeDraft(ProxyProfileDraftState draft) {
|
||||
if (draft.type == SingboxProxyProfileType.customOutbound) {
|
||||
final normalizedConfigJson = _normalizeJsonObject(draft.customConfigJson);
|
||||
if (normalizedConfigJson == null) {
|
||||
return const _DraftEncodeFailure('Config must be a JSON object.');
|
||||
}
|
||||
|
||||
final rawSecret = draft.customSecretJson.trim();
|
||||
if (rawSecret.isEmpty) {
|
||||
return _DraftEncodeSuccess(
|
||||
configJson: normalizedConfigJson,
|
||||
secretJson: null,
|
||||
);
|
||||
}
|
||||
final normalizedSecretJson = _normalizeJsonObject(draft.customSecretJson);
|
||||
if (normalizedSecretJson == null) {
|
||||
return const _DraftEncodeFailure('Secrets must be a JSON object.');
|
||||
}
|
||||
return _DraftEncodeSuccess(
|
||||
configJson: normalizedConfigJson,
|
||||
secretJson: normalizedSecretJson,
|
||||
);
|
||||
}
|
||||
|
||||
final spec = singboxProxyFormSpecs[draft.type]!;
|
||||
final validationMessage = spec.validate(draft.values);
|
||||
if (validationMessage != null) {
|
||||
return _DraftEncodeFailure(validationMessage);
|
||||
}
|
||||
return _DraftEncodeSuccess(
|
||||
configJson: spec.toConfigJson(draft.values),
|
||||
secretJson: spec.toSecretJson(draft.values),
|
||||
);
|
||||
}
|
||||
|
||||
String? _normalizeJsonObject(String rawJson) {
|
||||
try {
|
||||
final decoded = jsonDecode(rawJson) as Object?;
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _initialValuesForType(
|
||||
SingboxProxyProfileType type, {
|
||||
Map<String, String>? overlay,
|
||||
}) {
|
||||
final spec = singboxProxyFormSpecs[type];
|
||||
if (spec == null) return overlay == null ? const {} : Map.of(overlay);
|
||||
|
||||
return {
|
||||
for (final field in spec.fields)
|
||||
if (field.defaultValue != null) field.key: field.defaultValue!,
|
||||
if (overlay != null) ...overlay,
|
||||
};
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'proxy_profile_draft_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$ProxyProfileDraftStateCWProxy {
|
||||
ProxyProfileDraftState profileId(String? profileId);
|
||||
|
||||
ProxyProfileDraftState existingProfile(ProxyProfile? existingProfile);
|
||||
|
||||
ProxyProfileDraftState loadError(String? loadError);
|
||||
|
||||
ProxyProfileDraftState name(String name);
|
||||
|
||||
ProxyProfileDraftState type(SingboxProxyProfileType type);
|
||||
|
||||
ProxyProfileDraftState values(Map<String, String> values);
|
||||
|
||||
ProxyProfileDraftState dnsOverrideJson(String? dnsOverrideJson);
|
||||
|
||||
ProxyProfileDraftState customConfigJson(String customConfigJson);
|
||||
|
||||
ProxyProfileDraftState customSecretJson(String customSecretJson);
|
||||
|
||||
ProxyProfileDraftState isSaving(bool isSaving);
|
||||
|
||||
ProxyProfileDraftState secretLoaded(bool secretLoaded);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ProxyProfileDraftState(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// ProxyProfileDraftState(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
ProxyProfileDraftState call({
|
||||
String? profileId,
|
||||
ProxyProfile? existingProfile,
|
||||
String? loadError,
|
||||
String name,
|
||||
SingboxProxyProfileType type,
|
||||
Map<String, String> values,
|
||||
String? dnsOverrideJson,
|
||||
String customConfigJson,
|
||||
String customSecretJson,
|
||||
bool isSaving,
|
||||
bool secretLoaded,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfProxyProfileDraftState.copyWith(...)` or call `instanceOfProxyProfileDraftState.copyWith.fieldName(value)` for a single field.
|
||||
class _$ProxyProfileDraftStateCWProxyImpl
|
||||
implements _$ProxyProfileDraftStateCWProxy {
|
||||
const _$ProxyProfileDraftStateCWProxyImpl(this._value);
|
||||
|
||||
final ProxyProfileDraftState _value;
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState profileId(String? profileId) =>
|
||||
call(profileId: profileId);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState existingProfile(ProxyProfile? existingProfile) =>
|
||||
call(existingProfile: existingProfile);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState loadError(String? loadError) =>
|
||||
call(loadError: loadError);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState name(String name) => call(name: name);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState type(SingboxProxyProfileType type) => call(type: type);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState values(Map<String, String> values) =>
|
||||
call(values: values);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState dnsOverrideJson(String? dnsOverrideJson) =>
|
||||
call(dnsOverrideJson: dnsOverrideJson);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState customConfigJson(String customConfigJson) =>
|
||||
call(customConfigJson: customConfigJson);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState customSecretJson(String customSecretJson) =>
|
||||
call(customSecretJson: customSecretJson);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState isSaving(bool isSaving) => call(isSaving: isSaving);
|
||||
|
||||
@override
|
||||
ProxyProfileDraftState secretLoaded(bool secretLoaded) =>
|
||||
call(secretLoaded: secretLoaded);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ProxyProfileDraftState(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// ProxyProfileDraftState(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
ProxyProfileDraftState call({
|
||||
Object? profileId = const $CopyWithPlaceholder(),
|
||||
Object? existingProfile = const $CopyWithPlaceholder(),
|
||||
Object? loadError = const $CopyWithPlaceholder(),
|
||||
Object? name = const $CopyWithPlaceholder(),
|
||||
Object? type = const $CopyWithPlaceholder(),
|
||||
Object? values = const $CopyWithPlaceholder(),
|
||||
Object? dnsOverrideJson = const $CopyWithPlaceholder(),
|
||||
Object? customConfigJson = const $CopyWithPlaceholder(),
|
||||
Object? customSecretJson = const $CopyWithPlaceholder(),
|
||||
Object? isSaving = const $CopyWithPlaceholder(),
|
||||
Object? secretLoaded = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return ProxyProfileDraftState(
|
||||
profileId: profileId == const $CopyWithPlaceholder()
|
||||
? _value.profileId
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: profileId as String?,
|
||||
existingProfile: existingProfile == const $CopyWithPlaceholder()
|
||||
? _value.existingProfile
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: existingProfile as ProxyProfile?,
|
||||
loadError: loadError == const $CopyWithPlaceholder()
|
||||
? _value.loadError
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: loadError as String?,
|
||||
name: name == const $CopyWithPlaceholder() || name == null
|
||||
? _value.name
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: name as String,
|
||||
type: type == const $CopyWithPlaceholder() || type == null
|
||||
? _value.type
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: type as SingboxProxyProfileType,
|
||||
values: values == const $CopyWithPlaceholder() || values == null
|
||||
? _value.values
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: values as Map<String, String>,
|
||||
dnsOverrideJson: dnsOverrideJson == const $CopyWithPlaceholder()
|
||||
? _value.dnsOverrideJson
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: dnsOverrideJson as String?,
|
||||
customConfigJson:
|
||||
customConfigJson == const $CopyWithPlaceholder() ||
|
||||
customConfigJson == null
|
||||
? _value.customConfigJson
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: customConfigJson as String,
|
||||
customSecretJson:
|
||||
customSecretJson == const $CopyWithPlaceholder() ||
|
||||
customSecretJson == null
|
||||
? _value.customSecretJson
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: customSecretJson as String,
|
||||
isSaving: isSaving == const $CopyWithPlaceholder() || isSaving == null
|
||||
? _value.isSaving
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: isSaving as bool,
|
||||
secretLoaded:
|
||||
secretLoaded == const $CopyWithPlaceholder() || secretLoaded == null
|
||||
? _value.secretLoaded
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: secretLoaded as bool,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $ProxyProfileDraftStateCopyWith on ProxyProfileDraftState {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfProxyProfileDraftState.copyWith(...)` or `instanceOfProxyProfileDraftState.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$ProxyProfileDraftStateCWProxy get copyWith =>
|
||||
_$ProxyProfileDraftStateCWProxyImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ProxyProfileDraft)
|
||||
final proxyProfileDraftProvider = ProxyProfileDraftFamily._();
|
||||
|
||||
final class ProxyProfileDraftProvider
|
||||
extends $NotifierProvider<ProxyProfileDraft, ProxyProfileDraftState> {
|
||||
ProxyProfileDraftProvider._({
|
||||
required ProxyProfileDraftFamily super.from,
|
||||
required ({String? profileId, ProxyProfileSeed? seed}) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'proxyProfileDraftProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$proxyProfileDraftHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'proxyProfileDraftProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ProxyProfileDraft create() => ProxyProfileDraft();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ProxyProfileDraftState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ProxyProfileDraftState>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProxyProfileDraftProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$proxyProfileDraftHash() => r'88dccb74624a8257f77646ab6755dce4e0de465a';
|
||||
|
||||
final class ProxyProfileDraftFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
ProxyProfileDraft,
|
||||
ProxyProfileDraftState,
|
||||
ProxyProfileDraftState,
|
||||
ProxyProfileDraftState,
|
||||
({String? profileId, ProxyProfileSeed? seed})
|
||||
> {
|
||||
ProxyProfileDraftFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'proxyProfileDraftProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
ProxyProfileDraftProvider call({String? profileId, ProxyProfileSeed? seed}) =>
|
||||
ProxyProfileDraftProvider._(
|
||||
argument: (profileId: profileId, seed: seed),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'proxyProfileDraftProvider';
|
||||
}
|
||||
|
||||
abstract class _$ProxyProfileDraft extends $Notifier<ProxyProfileDraftState> {
|
||||
late final _$args = ref.$arg as ({String? profileId, ProxyProfileSeed? seed});
|
||||
String? get profileId => _$args.profileId;
|
||||
ProxyProfileSeed? get seed => _$args.seed;
|
||||
|
||||
ProxyProfileDraftState build({String? profileId, ProxyProfileSeed? seed});
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<ProxyProfileDraftState, ProxyProfileDraftState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<ProxyProfileDraftState, ProxyProfileDraftState>,
|
||||
ProxyProfileDraftState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(
|
||||
ref,
|
||||
() => build(profileId: _$args.profileId, seed: _$args.seed),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
+265
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* 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:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||
import 'package:weblibre/features/proxy/domain/services/proxy_input_consumer.dart';
|
||||
import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
/// Outcome of the add-proxy bottom sheet. The sheet itself does not navigate
|
||||
/// or surface success messages: it pops with one of these so the caller can
|
||||
/// drive navigation from a stable, non-deactivated context.
|
||||
sealed class AddProxyAction {
|
||||
const AddProxyAction();
|
||||
}
|
||||
|
||||
class AddProxyManual extends AddProxyAction {
|
||||
const AddProxyManual();
|
||||
}
|
||||
|
||||
class AddProxySubscription extends AddProxyAction {
|
||||
const AddProxySubscription();
|
||||
}
|
||||
|
||||
class AddProxyWithSeed extends AddProxyAction {
|
||||
final ProxyProfileSeed seed;
|
||||
const AddProxyWithSeed(this.seed);
|
||||
}
|
||||
|
||||
class AddProxyImported extends AddProxyAction {
|
||||
final String message;
|
||||
const AddProxyImported(this.message);
|
||||
}
|
||||
|
||||
/// Guided bottom sheet shown when the user adds a new proxy profile. Each
|
||||
/// method either pops with an [AddProxyAction] for the caller to apply or
|
||||
/// stays open so the user can try another method on error.
|
||||
class AddProxyMethodSheet extends ConsumerWidget {
|
||||
const AddProxyMethodSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
void popWith(AddProxyAction action) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop(action);
|
||||
}
|
||||
|
||||
Future<void> scanQr() async {
|
||||
final result = await showDialog<Barcode>(
|
||||
context: context,
|
||||
builder: (_) => const QrScannerDialog(),
|
||||
);
|
||||
final code = result?.code?.trim();
|
||||
if (code == null || code.isEmpty) return;
|
||||
if (!context.mounted) return;
|
||||
final action = await _consumeRawText(context, ref, code);
|
||||
if (action == null) return;
|
||||
popWith(action);
|
||||
}
|
||||
|
||||
Future<void> pasteClipboard() async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = data?.text?.trim();
|
||||
if (text == null || text.isEmpty) {
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Clipboard is empty.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final action = await _consumeRawText(context, ref, text);
|
||||
if (action == null) return;
|
||||
popWith(action);
|
||||
}
|
||||
|
||||
Future<void> importFromFile() async {
|
||||
final kind = await showModalBottomSheet<ProxyFileImportKind>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _FileKindPicker(),
|
||||
);
|
||||
if (kind == null) return;
|
||||
if (!context.mounted) return;
|
||||
final action = await _consumeFile(context, ref, kind);
|
||||
if (action == null) return;
|
||||
popWith(action);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Add Connection',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Choose how you want to add a proxy profile.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.25,
|
||||
children: [
|
||||
_MethodCard(
|
||||
icon: Icons.content_paste,
|
||||
title: 'Clipboard',
|
||||
subtitle: 'Paste share link or URI',
|
||||
onTap: pasteClipboard,
|
||||
isPrimary: true,
|
||||
),
|
||||
_MethodCard(
|
||||
icon: Icons.qr_code_scanner,
|
||||
title: 'Scan QR',
|
||||
subtitle: 'From another device',
|
||||
onTap: scanQr,
|
||||
),
|
||||
_MethodCard(
|
||||
icon: Icons.cloud_download_outlined,
|
||||
title: 'Subscription',
|
||||
subtitle: 'Fetch from URL',
|
||||
onTap: () => popWith(const AddProxySubscription()),
|
||||
),
|
||||
_MethodCard(
|
||||
icon: Icons.upload_file_outlined,
|
||||
title: 'Import file',
|
||||
subtitle: '.conf or sing-box JSON',
|
||||
onTap: importFromFile,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => popWith(const AddProxyManual()),
|
||||
icon: const Icon(Icons.edit_note),
|
||||
label: const Text('Enter manually'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MethodCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
final bool isPrimary;
|
||||
|
||||
const _MethodCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
this.isPrimary = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = isPrimary
|
||||
? scheme.primaryContainer
|
||||
: scheme.surfaceContainerHigh;
|
||||
final iconColor = isPrimary ? scheme.onPrimaryContainer : scheme.primary;
|
||||
final titleColor = isPrimary ? scheme.onPrimaryContainer : scheme.onSurface;
|
||||
final subtitleColor = isPrimary
|
||||
? scheme.onPrimaryContainer.withValues(alpha: 0.75)
|
||||
: scheme.onSurfaceVariant;
|
||||
|
||||
return Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 28, color: iconColor),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: subtitleColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileKindPicker extends StatelessWidget {
|
||||
const _FileKindPicker();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Import from file',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.vpn_lock),
|
||||
title: const Text('WireGuard config'),
|
||||
subtitle: const Text('.conf file with [Interface]/[Peer]'),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pop(ProxyFileImportKind.wireguardConf),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.data_object),
|
||||
title: const Text('Sing-box outbound JSON'),
|
||||
subtitle: const Text(
|
||||
'Shadowsocks, Trojan, VMess, VLESS, Hysteria, …',
|
||||
),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).pop(ProxyFileImportKind.singboxOutboundJson),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<AddProxyAction?> _consumeFile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProxyFileImportKind kind,
|
||||
) async {
|
||||
final result = await FilePicker.pickFiles(withData: true);
|
||||
final picked = result?.files.singleOrNull;
|
||||
if (picked == null) return null;
|
||||
|
||||
final outcome = await ref
|
||||
.read(proxyInputConsumerProvider.notifier)
|
||||
.consumeFile(kind, picked);
|
||||
if (!context.mounted) return null;
|
||||
return _actionFromOutcome(context, outcome);
|
||||
}
|
||||
|
||||
Future<AddProxyAction?> _consumeRawText(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String rawText,
|
||||
) async {
|
||||
final outcome = await ref
|
||||
.read(proxyInputConsumerProvider.notifier)
|
||||
.consumeRawText(rawText);
|
||||
if (!context.mounted) return null;
|
||||
return _actionFromOutcome(context, outcome);
|
||||
}
|
||||
|
||||
AddProxyAction? _actionFromOutcome(
|
||||
BuildContext context,
|
||||
ProxyInputOutcome outcome,
|
||||
) {
|
||||
switch (outcome) {
|
||||
case ProxyInputImported(:final created):
|
||||
return AddProxyImported('Imported profile "${created.name}"');
|
||||
case ProxyInputSeed(:final seed):
|
||||
return AddProxyWithSeed(seed);
|
||||
case ProxyInputError(:final message):
|
||||
showErrorMessage(context, message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
|
||||
import 'package:weblibre/presentation/widgets/obscurable_text_field.dart';
|
||||
|
||||
class CustomOutboundProfileForm extends HookConsumerWidget {
|
||||
final ProxyProfileDraftProvider draftProvider;
|
||||
final ProxyProfileDraftState draft;
|
||||
|
||||
const CustomOutboundProfileForm({
|
||||
super.key,
|
||||
required this.draftProvider,
|
||||
required this.draft,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final configController = useTextEditingController(
|
||||
text: draft.customConfigJson,
|
||||
);
|
||||
final secretController = useTextEditingController(
|
||||
text: draft.customSecretJson,
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (configController.text != draft.customConfigJson) {
|
||||
configController.text = draft.customConfigJson;
|
||||
}
|
||||
return null;
|
||||
}, [draft.customConfigJson]);
|
||||
|
||||
useEffect(() {
|
||||
if (secretController.text != draft.customSecretJson) {
|
||||
secretController.text = draft.customSecretJson;
|
||||
}
|
||||
return null;
|
||||
}, [draft.customSecretJson]);
|
||||
|
||||
final notifier = ref.read(draftProvider.notifier);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ProfileEditorSection(
|
||||
title: 'Outbound',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: configController,
|
||||
minLines: 10,
|
||||
maxLines: 18,
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: const InputDecoration(
|
||||
alignLabelWithHint: true,
|
||||
labelText: 'Outbound JSON',
|
||||
helperText: 'Public sing-box outbound object.',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: notifier.setCustomConfigJson,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ProfileEditorSection(
|
||||
title: 'Secrets',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ObscurableTextField(
|
||||
controller: secretController,
|
||||
enabled: draft.secretLoaded,
|
||||
revealedMinLines: 4,
|
||||
revealedMaxLines: 10,
|
||||
decoration: const InputDecoration(
|
||||
alignLabelWithHint: true,
|
||||
labelText: 'Secret JSON',
|
||||
helperText:
|
||||
'Optional values merged into the outbound at runtime.',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: notifier.setCustomSecretJson,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
|
||||
|
||||
/// Per-profile DNS override editor. Keeps the UI surface minimal: a switch to
|
||||
/// opt in, plus the most common shape (single resolver routed through *this*
|
||||
/// profile).
|
||||
class ProfileDnsOverrideSection extends HookConsumerWidget {
|
||||
final ProxyProfileDraftProvider draftProvider;
|
||||
|
||||
const ProfileDnsOverrideSection({super.key, required this.draftProvider});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final overrideJson = ref.watch(
|
||||
draftProvider.select((state) => state.dnsOverrideJson),
|
||||
);
|
||||
final initialOverride = useMemoized(() {
|
||||
if (overrideJson == null || overrideJson.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return ProxyDnsOverride.fromJson(
|
||||
jsonDecode(overrideJson) as Map<String, dynamic>,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}, [overrideJson]);
|
||||
|
||||
final enabled = useState(initialOverride != null);
|
||||
final addressController = useTextEditingController(
|
||||
text: initialOverride?.remoteServerAddress ?? '',
|
||||
);
|
||||
|
||||
// Reseed controls when the parent passes a new override (e.g. the
|
||||
// WireGuard form populating DNS from an imported `[Interface] DNS = …`).
|
||||
useEffect(() {
|
||||
enabled.value = initialOverride != null;
|
||||
final next = initialOverride?.remoteServerAddress ?? '';
|
||||
if (addressController.text != next) {
|
||||
addressController.text = next;
|
||||
}
|
||||
return null;
|
||||
}, [initialOverride]);
|
||||
|
||||
void emitChange() {
|
||||
if (!enabled.value) {
|
||||
ref.read(draftProvider.notifier).setDnsOverrideJson(null);
|
||||
return;
|
||||
}
|
||||
final override = ProxyDnsOverride(
|
||||
remoteServerAddress: addressController.text.trim().isEmpty
|
||||
? null
|
||||
: addressController.text.trim(),
|
||||
);
|
||||
ref
|
||||
.read(draftProvider.notifier)
|
||||
.setDnsOverrideJson(jsonEncode(override.toJson()));
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Resolve names through a server reachable inside this profile '
|
||||
'(e.g. an internal DoH server behind a corporate WireGuard). '
|
||||
'Leave off to use automatic DNS handling.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: enabled.value,
|
||||
onChanged: (value) {
|
||||
enabled.value = value;
|
||||
emitChange();
|
||||
},
|
||||
title: const Text('Use a profile-specific resolver'),
|
||||
),
|
||||
if (enabled.value) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: addressController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'DNS server address',
|
||||
hintText: 'https://10.0.0.1/dns-query',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => emitChange(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
class ProfileEditorSection extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const ProfileEditorSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: scheme.surfaceContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* 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/features/proxy/data/forms/singbox_form_field.dart';
|
||||
import 'package:weblibre/features/proxy/data/forms/singbox_form_spec.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
|
||||
import 'package:weblibre/presentation/widgets/obscurable_text_field.dart';
|
||||
|
||||
class StructuredProfileForm extends HookConsumerWidget {
|
||||
final SingboxProxyFormSpec spec;
|
||||
final ProxyProfileDraftProvider draftProvider;
|
||||
final ProxyProfileDraftState draft;
|
||||
|
||||
const StructuredProfileForm({
|
||||
super.key,
|
||||
required this.spec,
|
||||
required this.draftProvider,
|
||||
required this.draft,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controllers = useMemoized(
|
||||
() => {
|
||||
for (final field in spec.fields) field.key: TextEditingController(),
|
||||
},
|
||||
[spec.type],
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
return () {
|
||||
for (final controller in controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
};
|
||||
}, [controllers]);
|
||||
|
||||
useEffect(() {
|
||||
_syncControllers(controllers, draft.values);
|
||||
return null;
|
||||
}, [controllers, draft.values]);
|
||||
|
||||
final sections = _structuredFieldSections(spec.fields);
|
||||
final notifier = ref.read(draftProvider.notifier);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (sectionIndex, section) in sections.indexed) ...[
|
||||
if (sectionIndex > 0) const SizedBox(height: 24),
|
||||
ProfileEditorSection(
|
||||
title: section.title,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _SectionFields(
|
||||
fields: section.fields,
|
||||
controllers: controllers,
|
||||
secretLoaded: draft.secretLoaded,
|
||||
onChanged: notifier.setFieldValue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Advanced protocol options can still be entered with Custom Outbound JSON.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionFields extends StatelessWidget {
|
||||
final List<SingboxProxyFormField> fields;
|
||||
final Map<String, TextEditingController> controllers;
|
||||
final bool secretLoaded;
|
||||
final void Function(String key, String value) onChanged;
|
||||
|
||||
const _SectionFields({
|
||||
required this.fields,
|
||||
required this.controllers,
|
||||
required this.secretLoaded,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (index, field) in fields.indexed) ...[
|
||||
if (index > 0) const SizedBox(height: 16),
|
||||
if (field.isBoolean)
|
||||
_BooleanField(
|
||||
field: field,
|
||||
controller: controllers[field.key]!,
|
||||
onChanged: (value) => onChanged(field.key, value),
|
||||
)
|
||||
else if (field.isSecret)
|
||||
ObscurableTextField(
|
||||
controller: controllers[field.key],
|
||||
enabled: secretLoaded,
|
||||
keyboardType: field.isNumber
|
||||
? TextInputType.number
|
||||
: TextInputType.text,
|
||||
textInputAction: index == fields.length - 1
|
||||
? TextInputAction.done
|
||||
: TextInputAction.next,
|
||||
revealedMinLines: field.key == 'private_key' ? 4 : null,
|
||||
revealedMaxLines: field.key == 'private_key' ? 8 : 1,
|
||||
decoration: InputDecoration(
|
||||
labelText: field.required ? '${field.label} *' : field.label,
|
||||
helperText: field.helperText ?? 'Stored in secure storage.',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (value) => onChanged(field.key, value),
|
||||
)
|
||||
else
|
||||
TextField(
|
||||
controller: controllers[field.key],
|
||||
keyboardType: field.isNumber
|
||||
? TextInputType.number
|
||||
: field.isStringList
|
||||
? TextInputType.multiline
|
||||
: TextInputType.text,
|
||||
textInputAction: field.isStringList
|
||||
? TextInputAction.newline
|
||||
: index == fields.length - 1
|
||||
? TextInputAction.done
|
||||
: TextInputAction.next,
|
||||
minLines: field.isStringList ? 2 : 1,
|
||||
maxLines: field.isStringList ? 4 : 1,
|
||||
decoration: InputDecoration(
|
||||
labelText: field.required ? '${field.label} *' : field.label,
|
||||
helperText: field.helperText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (value) => onChanged(field.key, value),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
({String title, List<SingboxProxyFormField> fields}) _section(
|
||||
String title,
|
||||
List<SingboxProxyFormField> fields,
|
||||
) {
|
||||
return (title: title, fields: fields);
|
||||
}
|
||||
|
||||
List<({String title, List<SingboxProxyFormField> fields})>
|
||||
_structuredFieldSections(List<SingboxProxyFormField> fields) {
|
||||
final basic = <SingboxProxyFormField>[];
|
||||
final tls = <SingboxProxyFormField>[];
|
||||
final transport = <SingboxProxyFormField>[];
|
||||
final multiplex = <SingboxProxyFormField>[];
|
||||
final dial = <SingboxProxyFormField>[];
|
||||
final secrets = <SingboxProxyFormField>[];
|
||||
final protocol = <SingboxProxyFormField>[];
|
||||
|
||||
for (final field in fields) {
|
||||
if (field.key.startsWith('tls.')) {
|
||||
tls.add(field);
|
||||
} else if (field.key.startsWith('transport.')) {
|
||||
transport.add(field);
|
||||
} else if (field.key.startsWith('multiplex.')) {
|
||||
multiplex.add(field);
|
||||
} else if (_dialFieldKeys.contains(field.key)) {
|
||||
dial.add(field);
|
||||
} else if (field.isSecret) {
|
||||
secrets.add(field);
|
||||
} else if (_basicFieldKeys.contains(field.key)) {
|
||||
basic.add(field);
|
||||
} else {
|
||||
protocol.add(field);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
if (basic.isNotEmpty) _section('Connection', basic),
|
||||
if (secrets.isNotEmpty) _section('Credentials', secrets),
|
||||
if (protocol.isNotEmpty) _section('Protocol Options', protocol),
|
||||
if (tls.isNotEmpty) _section('TLS', tls),
|
||||
if (transport.isNotEmpty) _section('Transport', transport),
|
||||
if (multiplex.isNotEmpty) _section('Multiplex', multiplex),
|
||||
if (dial.isNotEmpty) _section('Dial', dial),
|
||||
];
|
||||
}
|
||||
|
||||
const _basicFieldKeys = {
|
||||
'server',
|
||||
'server_port',
|
||||
'version',
|
||||
'local_address',
|
||||
'peer_public_key',
|
||||
};
|
||||
const _dialFieldKeys = {
|
||||
'detour',
|
||||
'bind_interface',
|
||||
'routing_mark',
|
||||
'domain_strategy',
|
||||
'connect_timeout',
|
||||
};
|
||||
|
||||
void _syncControllers(
|
||||
Map<String, TextEditingController> controllers,
|
||||
Map<String, String> values,
|
||||
) {
|
||||
for (final entry in controllers.entries) {
|
||||
final next = values[entry.key] ?? '';
|
||||
if (entry.value.text != next) {
|
||||
entry.value.text = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BooleanField extends HookWidget {
|
||||
final SingboxProxyFormField field;
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const _BooleanField({
|
||||
required this.field,
|
||||
required this.controller,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final value = useListenableSelector(
|
||||
controller,
|
||||
() => parseFormBool(controller.text),
|
||||
);
|
||||
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: field.required ? '${field.label} *' : field.label,
|
||||
helperText: field.helperText,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
value == null
|
||||
? 'Unset (uses default)'
|
||||
: (value ? 'Enabled' : 'Disabled'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
if (value != null)
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
onPressed: () {
|
||||
controller.text = '';
|
||||
onChanged('');
|
||||
},
|
||||
),
|
||||
Switch(
|
||||
value: value ?? false,
|
||||
onChanged: (next) {
|
||||
final text = next ? 'true' : 'false';
|
||||
controller.text = text;
|
||||
onChanged(text);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
class IpChip extends StatelessWidget {
|
||||
final String ip;
|
||||
|
||||
const IpChip({super.key, required this.ip});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Tooltip(
|
||||
message: 'Egress IP $ip',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
ip,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSecondaryContainer,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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/domain/services/proxy_latency_tester.dart';
|
||||
|
||||
class LatencyChip extends StatelessWidget {
|
||||
final AsyncValue<ProxyLatencyData> result;
|
||||
|
||||
const LatencyChip({super.key, required this.result});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (result) {
|
||||
AsyncLoading() => const _LatencyStatusChip.loading(),
|
||||
AsyncError(:final error) => _LatencyStatusChip.error(error),
|
||||
AsyncData(:final value) => _LatencySuccessChip(value: value),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _LatencyStatusChip extends StatelessWidget {
|
||||
final String label;
|
||||
final String tooltip;
|
||||
final bool isError;
|
||||
|
||||
const _LatencyStatusChip({
|
||||
required this.label,
|
||||
required this.tooltip,
|
||||
required this.isError,
|
||||
});
|
||||
|
||||
const _LatencyStatusChip.loading()
|
||||
: this(
|
||||
label: 'Testing...',
|
||||
tooltip: 'Latency test running',
|
||||
isError: false,
|
||||
);
|
||||
|
||||
_LatencyStatusChip.error(Object error)
|
||||
: this(label: 'Failed', tooltip: error.toString(), isError: true);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return _LatencyChipContainer(
|
||||
label: label,
|
||||
tooltip: tooltip,
|
||||
backgroundColor: isError
|
||||
? scheme.errorContainer
|
||||
: scheme.surfaceContainerHighest,
|
||||
foregroundColor: isError
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LatencySuccessChip extends StatelessWidget {
|
||||
final ProxyLatencyData value;
|
||||
|
||||
const _LatencySuccessChip({required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (background, foreground) = _latencyColors(scheme, value.latency);
|
||||
|
||||
return _LatencyChipContainer(
|
||||
label: '${value.latency.inMilliseconds} ms',
|
||||
tooltip: 'HTTP ${value.statusCode} in ${value.latency.inMilliseconds} ms',
|
||||
backgroundColor: background,
|
||||
foregroundColor: foreground,
|
||||
);
|
||||
}
|
||||
|
||||
static (Color, Color) _latencyColors(ColorScheme scheme, Duration latency) {
|
||||
final ms = latency.inMilliseconds;
|
||||
if (ms < 500) return (scheme.primaryContainer, scheme.onPrimaryContainer);
|
||||
if (ms < 1500) {
|
||||
return (scheme.tertiaryContainer, scheme.onTertiaryContainer);
|
||||
}
|
||||
return (scheme.errorContainer, scheme.onErrorContainer);
|
||||
}
|
||||
}
|
||||
|
||||
class _LatencyChipContainer extends StatelessWidget {
|
||||
final String label;
|
||||
final String tooltip;
|
||||
final Color backgroundColor;
|
||||
final Color foregroundColor;
|
||||
|
||||
const _LatencyChipContainer({
|
||||
required this.label,
|
||||
required this.tooltip,
|
||||
required this.backgroundColor,
|
||||
required this.foregroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: foregroundColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
class MenuRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const MenuRow({super.key, required this.icon, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Icon(icon, size: 20), const SizedBox(width: 12), Text(label)],
|
||||
);
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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/domain/services/proxy_latency_tester.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/ip_chip.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/latency_chip.dart';
|
||||
|
||||
class ProfileSubtitle extends StatelessWidget {
|
||||
final String typeLabel;
|
||||
final AsyncValue<ProxyLatencyData>? latency;
|
||||
|
||||
const ProfileSubtitle({
|
||||
super.key,
|
||||
required this.typeLabel,
|
||||
required this.latency,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latency = this.latency;
|
||||
if (latency == null) {
|
||||
return Text(typeLabel);
|
||||
}
|
||||
|
||||
final egressIp = latency.value?.egressIp;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(typeLabel),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
LatencyChip(result: latency),
|
||||
if (egressIp != null) IpChip(ip: egressIp),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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/core/logger.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/proxy_share.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.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/profile_list/menu_row.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_subtitle.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/protocol_badge.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/run_switch.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/share_profile_dialog.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
enum ProfileAction { edit, testLatency, share, delete }
|
||||
|
||||
class ProfileTile extends ConsumerWidget {
|
||||
final ProxyProfile profile;
|
||||
final bool isRunning;
|
||||
final bool isDeleting;
|
||||
final bool runtimeBusy;
|
||||
final void Function(String profileId, bool deleting) onDeletingChanged;
|
||||
|
||||
const ProfileTile({
|
||||
super.key,
|
||||
required this.profile,
|
||||
required this.isRunning,
|
||||
required this.isDeleting,
|
||||
required this.runtimeBusy,
|
||||
required this.onDeletingChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = runtimeBusy || isDeleting;
|
||||
final latencyResult = ref.watch(
|
||||
proxyLatencyResultsProvider.select(
|
||||
(map) => map[SingboxProxyConnectionId(profile.id)],
|
||||
),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: isDeleting
|
||||
? const SizedBox.square(
|
||||
dimension: 36,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: ProtocolBadge(type: profile.type, active: isRunning),
|
||||
title: Text(
|
||||
profile.name,
|
||||
style: TextStyle(
|
||||
fontWeight: isRunning ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: ProfileSubtitle(
|
||||
typeLabel: profile.type.label,
|
||||
latency: latencyResult,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PopupMenuButton<ProfileAction>(
|
||||
enabled: !isBusy,
|
||||
onSelected: (action) => _onAction(context, ref, action),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: ProfileAction.edit,
|
||||
child: MenuRow(icon: Icons.edit_outlined, label: 'Edit'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ProfileAction.testLatency,
|
||||
enabled: isRunning,
|
||||
child: MenuRow(
|
||||
icon: latencyResult is AsyncLoading
|
||||
? Icons.hourglass_bottom
|
||||
: Icons.network_check,
|
||||
label: 'Test connection',
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: ProfileAction.share,
|
||||
child: MenuRow(icon: Icons.share_outlined, label: 'Share'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: ProfileAction.delete,
|
||||
child: MenuRow(icon: Icons.delete_outline, label: 'Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
RunSwitch(
|
||||
isRunning: isRunning,
|
||||
disabled: isBusy,
|
||||
onTap: () => _toggleRunState(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () =>
|
||||
SingboxProxyProfileEditorRoute(profileId: profile.id).push(context),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleRunState(BuildContext context, WidgetRef ref) async {
|
||||
try {
|
||||
final notifier = ref.read(singboxProxyRuntimeRepositoryProvider.notifier);
|
||||
if (isRunning) {
|
||||
await notifier.stopProfiles([profile.id]);
|
||||
ref
|
||||
.read(proxyLatencyResultsProvider.notifier)
|
||||
.clear(SingboxProxyConnectionId(profile.id));
|
||||
} else {
|
||||
await notifier.startProfile(profile.id);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
logger.e(
|
||||
'Failed to toggle singbox proxy run state for ${profile.id}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (context.mounted) {
|
||||
showErrorMessage(
|
||||
context,
|
||||
isRunning
|
||||
? 'Failed to stop proxy: $error'
|
||||
: 'Failed to start proxy: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAction(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProfileAction action,
|
||||
) async {
|
||||
switch (action) {
|
||||
case ProfileAction.edit:
|
||||
await _handleEdit(context);
|
||||
case ProfileAction.testLatency:
|
||||
await _handleTestLatency(ref);
|
||||
case ProfileAction.share:
|
||||
await _handleShare(context, ref);
|
||||
case ProfileAction.delete:
|
||||
await _handleDelete(context, ref);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleEdit(BuildContext context) {
|
||||
return SingboxProxyProfileEditorRoute(profileId: profile.id).push(context);
|
||||
}
|
||||
|
||||
Future<void> _handleTestLatency(WidgetRef ref) {
|
||||
return ref.read(proxyLatencyResultsProvider.notifier).test(profile.id);
|
||||
}
|
||||
|
||||
Future<void> _handleShare(BuildContext context, WidgetRef ref) async {
|
||||
final secret = await ref
|
||||
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||
.readSecretJson(profile.id);
|
||||
final shareUri = encodeProxyShareUri(
|
||||
ProxyShareEnvelope(
|
||||
name: profile.name,
|
||||
type: profile.type,
|
||||
configJson: profile.configJson,
|
||||
secretJson: secret,
|
||||
dnsOverrideJson: profile.dnsOverrideJson,
|
||||
),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ShareProfileDialog(profileName: profile.name, shareUri: shareUri),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDelete(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Profile?'),
|
||||
content: Text(
|
||||
isRunning
|
||||
? 'Stop ${profile.name}, then delete it and its stored secrets? Tabs and containers assigned to this profile will be blocked until you choose another proxy or clear the assignment.'
|
||||
: 'Delete ${profile.name} and its stored secrets? Tabs and containers assigned to this profile will be blocked until you choose another proxy or clear the assignment.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(isRunning ? 'Stop and Delete' : 'Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
onDeletingChanged(profile.id, true);
|
||||
try {
|
||||
await ref
|
||||
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||
.deleteProfile(profile.id);
|
||||
ref
|
||||
.read(proxyLatencyResultsProvider.notifier)
|
||||
.clear(SingboxProxyConnectionId(profile.id));
|
||||
} catch (error, stackTrace) {
|
||||
logger.e(
|
||||
'Failed to delete singbox proxy profile ${profile.id}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (context.mounted) {
|
||||
showErrorMessage(context, 'Failed to delete profile: $error');
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) onDeletingChanged(profile.id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
|
||||
|
||||
class ProtocolBadge extends StatelessWidget {
|
||||
final SingboxProxyProfileType type;
|
||||
final bool active;
|
||||
|
||||
const ProtocolBadge({super.key, required this.type, required this.active});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = active
|
||||
? scheme.primary.withValues(alpha: 0.15)
|
||||
: scheme.surfaceContainerHighest;
|
||||
final foreground = active ? scheme.primary : scheme.onSurfaceVariant;
|
||||
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
type.badge,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
class RunSwitch extends StatelessWidget {
|
||||
final bool isRunning;
|
||||
final bool disabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const RunSwitch({
|
||||
super.key,
|
||||
required this.isRunning,
|
||||
required this.disabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = isRunning
|
||||
? scheme.primary
|
||||
: scheme.surfaceContainerHighest;
|
||||
final foreground = isRunning ? scheme.onPrimary : scheme.onSurface;
|
||||
|
||||
return IconButton.filled(
|
||||
tooltip: isRunning ? 'Stop' : 'Start',
|
||||
onPressed: disabled ? null : onTap,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: background,
|
||||
foregroundColor: foreground,
|
||||
disabledBackgroundColor: scheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
icon: Icon(isRunning ? Icons.stop_rounded : Icons.play_arrow_rounded),
|
||||
);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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:share_plus/share_plus.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class ShareProfileDialog extends StatelessWidget {
|
||||
final String profileName;
|
||||
final String shareUri;
|
||||
|
||||
const ShareProfileDialog({
|
||||
super.key,
|
||||
required this.profileName,
|
||||
required this.shareUri,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Share "$profileName"'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This link contains the full profile, including any '
|
||||
'stored credentials. Share carefully.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
constraints: const BoxConstraints(maxHeight: 160),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
shareUri,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy'),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: shareUri));
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Copied to clipboard');
|
||||
}
|
||||
},
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.share),
|
||||
label: const Text('Share'),
|
||||
onPressed: () async {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(text: shareUri, subject: profileName),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
class StatusHeader extends StatelessWidget {
|
||||
final int totalCount;
|
||||
final int runningCount;
|
||||
final bool isBusy;
|
||||
final VoidCallback? onStopAll;
|
||||
|
||||
const StatusHeader({
|
||||
super.key,
|
||||
required this.totalCount,
|
||||
required this.runningCount,
|
||||
required this.isBusy,
|
||||
required this.onStopAll,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isAnyRunning = runningCount > 0;
|
||||
|
||||
final background = isAnyRunning
|
||||
? scheme.primaryContainer
|
||||
: scheme.surfaceContainerHigh;
|
||||
final onBackground = isAnyRunning
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: onBackground.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isAnyRunning ? Icons.cloud_done : Icons.cloud_off_outlined,
|
||||
color: onBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isAnyRunning ? 'Active' : 'Disconnected',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: onBackground,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isAnyRunning
|
||||
? '$runningCount of $totalCount routing traffic'
|
||||
: 'Tap a profile to connect',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: onBackground.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onStopAll != null)
|
||||
IconButton.filled(
|
||||
tooltip: 'Stop all',
|
||||
onPressed: isBusy ? null : onStopAll,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: scheme.errorContainer,
|
||||
foregroundColor: scheme.onErrorContainer,
|
||||
),
|
||||
icon: const Icon(Icons.stop_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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/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/services/proxy_latency_tester.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/menu_row.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_subtitle.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/run_switch.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/presentation/icons/tor_icons.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
enum TorAction { edit, testLatency }
|
||||
|
||||
class TorProfileTile extends ConsumerWidget {
|
||||
final bool isRunning;
|
||||
final bool isBusy;
|
||||
|
||||
const TorProfileTile({
|
||||
super.key,
|
||||
required this.isRunning,
|
||||
required this.isBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = isRunning
|
||||
? scheme.primary.withValues(alpha: 0.15)
|
||||
: scheme.surfaceContainerHighest;
|
||||
final foreground = isRunning ? scheme.primary : scheme.onSurfaceVariant;
|
||||
final latencyResult = ref.watch(
|
||||
proxyLatencyResultsProvider.select(
|
||||
(map) => map[const TorProxyConnectionId()],
|
||||
),
|
||||
);
|
||||
final torReady = ref.watch(
|
||||
torProxyServiceProvider.select((s) => s.isReady),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(TorIcons.onionAlt, color: foreground, size: 24),
|
||||
),
|
||||
title: Text(
|
||||
'Tor',
|
||||
style: TextStyle(
|
||||
fontWeight: isRunning ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: ProfileSubtitle(
|
||||
typeLabel: 'Onion routing',
|
||||
latency: latencyResult,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PopupMenuButton<TorAction>(
|
||||
enabled: !isBusy,
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case TorAction.edit:
|
||||
await const TorProxyRoute().push(context);
|
||||
case TorAction.testLatency:
|
||||
await ref
|
||||
.read(proxyLatencyResultsProvider.notifier)
|
||||
.testTor();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: TorAction.edit,
|
||||
child: MenuRow(icon: Icons.edit_outlined, label: 'Edit'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: TorAction.testLatency,
|
||||
enabled: torReady,
|
||||
child: MenuRow(
|
||||
icon: latencyResult is AsyncLoading
|
||||
? Icons.hourglass_bottom
|
||||
: Icons.network_check,
|
||||
label: 'Test connection',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RunSwitch(
|
||||
isRunning: isRunning,
|
||||
disabled: isBusy,
|
||||
onTap: () => _toggle(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => const TorProxyRoute().push(context),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggle(BuildContext context, WidgetRef ref) async {
|
||||
try {
|
||||
final service = ref.read(torProxyServiceProvider.notifier);
|
||||
if (isRunning) {
|
||||
await service.disconnect();
|
||||
ref
|
||||
.read(proxyLatencyResultsProvider.notifier)
|
||||
.clear(const TorProxyConnectionId());
|
||||
} else {
|
||||
await service.startOrReconfigure(reconfigureIfRunning: false);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
logger.e(
|
||||
'Failed to toggle Tor proxy',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (context.mounted) {
|
||||
showErrorMessage(
|
||||
context,
|
||||
isRunning
|
||||
? 'Failed to stop Tor: $error'
|
||||
: 'Failed to start Tor: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user