From 183cdf98eb6c594c06330092057381be538a029e Mon Sep 17 00:00:00 2001 From: GT610 <79314033+GT-610@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:30:55 +0800 Subject: [PATCH] feat(port_forward): Supports local, remote, and dynamic port forwarding types (#1096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(port_forward): Supports local, remote, and dynamic port forwarding types Added the PortForwardType enumeration to extend port forwarding functionality, supporting three modes: 1. Local forwarding (Local) 2. Remote forwarding (Remote) 3. Dynamic forwarding (SOCKS5) Refactored the PortForwardConfig model and related adapters, and updated the UI configuration interface to support type selection * fix(port_forward): Fixed display and validation issues with port forwarding configurations Fixed the display logic for the local host; when the type is set to “Dynamic Forwarding,” 127.0.0.1 is used by default Added validation for required fields in remote forwarding configurations to ensure that the remote host and port are not empty Optimized remote forwarding log messages by removing redundant local address displays * fix(port_forward): Fixed issues with remote port forwarding configuration and connections - Fixed the handling of default values when the remote port forwarding type field is empty - Corrected the labels for local/remote host and port displayed on the remote port forwarding interface - Fixed the local port validation logic to disallow 0 or negative numbers - Implemented connection management and error handling for remote port forwarding * feat (Port Forwarding): Add localization labels for types and optimize code Add localization labels for local and remote types in the port forwarding feature Simplify the logic for retrieving prompt text on the port forwarding page Change the default binding host from ‘0.0.0.0’ to 'localhost' * fix(port_forward): Fixed an issue with the display format of remote port forwarding addresses Added special handling for remote port forwarding types in the `displayAddr` method of `PortForwardConfig` to correctly display the remote bound address and port. Also optimized the code formatting to improve readability. * refactor(port_forward): Remove automatically generated JSON serialization code and implement it manually Modify the JSON parsing logic in PortForwardConfig and remove the automatically generated .g.dart files Simplify the handling of localhost addresses in displayAddr --- lib/data/model/server/port_forward.dart | 56 ++++- .../model/server/port_forward.freezed.dart | 86 ++++---- lib/data/model/server/port_forward.g.dart | 31 --- lib/data/provider/port_forward_provider.dart | 203 +++++++++++++++--- .../provider/port_forward_provider.g.dart | 2 +- lib/data/store/setting.dart | 3 + lib/generated/l10n/l10n.dart | 12 ++ lib/generated/l10n/l10n_de.dart | 6 + lib/generated/l10n/l10n_en.dart | 6 + lib/generated/l10n/l10n_es.dart | 6 + lib/generated/l10n/l10n_fr.dart | 6 + lib/generated/l10n/l10n_id.dart | 6 + lib/generated/l10n/l10n_it.dart | 6 + lib/generated/l10n/l10n_ja.dart | 6 + lib/generated/l10n/l10n_ko.dart | 6 + lib/generated/l10n/l10n_nl.dart | 6 + lib/generated/l10n/l10n_pt.dart | 6 + lib/generated/l10n/l10n_ru.dart | 6 + lib/generated/l10n/l10n_tr.dart | 6 + lib/generated/l10n/l10n_uk.dart | 6 + lib/generated/l10n/l10n_zh.dart | 6 + lib/hive/hive_adapters.dart | 1 + lib/hive/hive_adapters.g.dart | 57 ++++- lib/hive/hive_adapters.g.yaml | 18 +- lib/hive/hive_registrar.g.dart | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/view/page/port_forward.dart | 192 +++++++++++------ 28 files changed, 568 insertions(+), 183 deletions(-) delete mode 100644 lib/data/model/server/port_forward.g.dart diff --git a/lib/data/model/server/port_forward.dart b/lib/data/model/server/port_forward.dart index 79b2860c..2c6b3745 100644 --- a/lib/data/model/server/port_forward.dart +++ b/lib/data/model/server/port_forward.dart @@ -1,7 +1,15 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'port_forward.freezed.dart'; -part 'port_forward.g.dart'; + +enum PortForwardType { + @JsonValue('local') + local, + @JsonValue('remote') + remote, + @JsonValue('dynamic') + dynamic, +} @freezed abstract class PortForwardConfig with _$PortForwardConfig { @@ -9,18 +17,50 @@ abstract class PortForwardConfig with _$PortForwardConfig { required String id, required String serverId, required String name, - @Default('localhost') String localHost, - required int localPort, - required String remoteHost, - required int remotePort, - String? description, + required PortForwardType type, + String? localHost, + @Default(0) int localPort, + String? remoteHost, + int? remotePort, }) = _PortForwardConfig; - factory PortForwardConfig.fromJson(Map json) => _$PortForwardConfigFromJson(json); + factory PortForwardConfig.fromJson(Map json) { + PortForwardType type; + if (json['type'] == null) { + type = PortForwardType.local; + } else { + final typeStr = json['type'] as String; + type = PortForwardType.values.firstWhere( + (e) => e.name == typeStr, + orElse: () => PortForwardType.local, + ); + } + return PortForwardConfig( + id: json['id'] as String, + serverId: json['serverId'] as String, + name: json['name'] as String, + type: type, + localHost: json['localHost'] as String?, + localPort: (json['localPort'] as num?)?.toInt() ?? 0, + remoteHost: json['remoteHost'] as String?, + remotePort: (json['remotePort'] as num?)?.toInt(), + ); + } const PortForwardConfig._(); - String get displayAddr => '$localHost:$localPort → $remoteHost:$remotePort'; + String get displayAddr { + final localBindHost = + localHost ?? 'localhost'; + if (type == PortForwardType.dynamic) { + return '$localBindHost:$localPort (SOCKS5)'; + } + if (type == PortForwardType.remote) { + final remoteBindHost = remoteHost ?? '?'; + return '$remoteBindHost:${remotePort ?? "?"} → $localBindHost:$localPort'; + } + return '$localBindHost:$localPort → ${remoteHost ?? "?"}:${remotePort ?? "?"}'; + } } @freezed diff --git a/lib/data/model/server/port_forward.freezed.dart b/lib/data/model/server/port_forward.freezed.dart index 567c5742..713f11ad 100644 --- a/lib/data/model/server/port_forward.freezed.dart +++ b/lib/data/model/server/port_forward.freezed.dart @@ -11,33 +11,30 @@ part of 'port_forward.dart'; // dart format off T _$identity(T value) => value; - /// @nodoc mixin _$PortForwardConfig { - String get id; String get serverId; String get name; String get localHost; int get localPort; String get remoteHost; int get remotePort; String? get description; + String get id; String get serverId; String get name; PortForwardType get type; String? get localHost; int get localPort; String? get remoteHost; int? get remotePort; /// Create a copy of PortForwardConfig /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') $PortForwardConfigCopyWith get copyWith => _$PortForwardConfigCopyWithImpl(this as PortForwardConfig, _$identity); - /// Serializes this PortForwardConfig to a JSON map. - Map toJson(); @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is PortForwardConfig&&(identical(other.id, id) || other.id == id)&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.name, name) || other.name == name)&&(identical(other.localHost, localHost) || other.localHost == localHost)&&(identical(other.localPort, localPort) || other.localPort == localPort)&&(identical(other.remoteHost, remoteHost) || other.remoteHost == remoteHost)&&(identical(other.remotePort, remotePort) || other.remotePort == remotePort)&&(identical(other.description, description) || other.description == description)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is PortForwardConfig&&(identical(other.id, id) || other.id == id)&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.name, name) || other.name == name)&&(identical(other.type, type) || other.type == type)&&(identical(other.localHost, localHost) || other.localHost == localHost)&&(identical(other.localPort, localPort) || other.localPort == localPort)&&(identical(other.remoteHost, remoteHost) || other.remoteHost == remoteHost)&&(identical(other.remotePort, remotePort) || other.remotePort == remotePort)); } -@JsonKey(includeFromJson: false, includeToJson: false) + @override -int get hashCode => Object.hash(runtimeType,id,serverId,name,localHost,localPort,remoteHost,remotePort,description); +int get hashCode => Object.hash(runtimeType,id,serverId,name,type,localHost,localPort,remoteHost,remotePort); @override String toString() { - return 'PortForwardConfig(id: $id, serverId: $serverId, name: $name, localHost: $localHost, localPort: $localPort, remoteHost: $remoteHost, remotePort: $remotePort, description: $description)'; + return 'PortForwardConfig(id: $id, serverId: $serverId, name: $name, type: $type, localHost: $localHost, localPort: $localPort, remoteHost: $remoteHost, remotePort: $remotePort)'; } @@ -48,7 +45,7 @@ abstract mixin class $PortForwardConfigCopyWith<$Res> { factory $PortForwardConfigCopyWith(PortForwardConfig value, $Res Function(PortForwardConfig) _then) = _$PortForwardConfigCopyWithImpl; @useResult $Res call({ - String id, String serverId, String name, String localHost, int localPort, String remoteHost, int remotePort, String? description + String id, String serverId, String name, PortForwardType type, String? localHost, int localPort, String? remoteHost, int? remotePort }); @@ -65,17 +62,17 @@ class _$PortForwardConfigCopyWithImpl<$Res> /// Create a copy of PortForwardConfig /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? serverId = null,Object? name = null,Object? localHost = null,Object? localPort = null,Object? remoteHost = null,Object? remotePort = null,Object? description = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? serverId = null,Object? name = null,Object? type = null,Object? localHost = freezed,Object? localPort = null,Object? remoteHost = freezed,Object? remotePort = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable -as String,localHost: null == localHost ? _self.localHost : localHost // ignore: cast_nullable_to_non_nullable -as String,localPort: null == localPort ? _self.localPort : localPort // ignore: cast_nullable_to_non_nullable -as int,remoteHost: null == remoteHost ? _self.remoteHost : remoteHost // ignore: cast_nullable_to_non_nullable -as String,remotePort: null == remotePort ? _self.remotePort : remotePort // ignore: cast_nullable_to_non_nullable -as int,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable -as String?, +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as PortForwardType,localHost: freezed == localHost ? _self.localHost : localHost // ignore: cast_nullable_to_non_nullable +as String?,localPort: null == localPort ? _self.localPort : localPort // ignore: cast_nullable_to_non_nullable +as int,remoteHost: freezed == remoteHost ? _self.remoteHost : remoteHost // ignore: cast_nullable_to_non_nullable +as String?,remotePort: freezed == remotePort ? _self.remotePort : remotePort // ignore: cast_nullable_to_non_nullable +as int?, )); } @@ -160,10 +157,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String serverId, String name, String localHost, int localPort, String remoteHost, int remotePort, String? description)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String serverId, String name, PortForwardType type, String? localHost, int localPort, String? remoteHost, int? remotePort)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _PortForwardConfig() when $default != null: -return $default(_that.id,_that.serverId,_that.name,_that.localHost,_that.localPort,_that.remoteHost,_that.remotePort,_that.description);case _: +return $default(_that.id,_that.serverId,_that.name,_that.type,_that.localHost,_that.localPort,_that.remoteHost,_that.remotePort);case _: return orElse(); } @@ -181,10 +178,10 @@ return $default(_that.id,_that.serverId,_that.name,_that.localHost,_that.localPo /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String serverId, String name, String localHost, int localPort, String remoteHost, int remotePort, String? description) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String serverId, String name, PortForwardType type, String? localHost, int localPort, String? remoteHost, int? remotePort) $default,) {final _that = this; switch (_that) { case _PortForwardConfig(): -return $default(_that.id,_that.serverId,_that.name,_that.localHost,_that.localPort,_that.remoteHost,_that.remotePort,_that.description);case _: +return $default(_that.id,_that.serverId,_that.name,_that.type,_that.localHost,_that.localPort,_that.remoteHost,_that.remotePort);case _: throw StateError('Unexpected subclass'); } @@ -201,10 +198,10 @@ return $default(_that.id,_that.serverId,_that.name,_that.localHost,_that.localPo /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String serverId, String name, String localHost, int localPort, String remoteHost, int remotePort, String? description)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String serverId, String name, PortForwardType type, String? localHost, int localPort, String? remoteHost, int? remotePort)? $default,) {final _that = this; switch (_that) { case _PortForwardConfig() when $default != null: -return $default(_that.id,_that.serverId,_that.name,_that.localHost,_that.localPort,_that.remoteHost,_that.remotePort,_that.description);case _: +return $default(_that.id,_that.serverId,_that.name,_that.type,_that.localHost,_that.localPort,_that.remoteHost,_that.remotePort);case _: return null; } @@ -213,20 +210,20 @@ return $default(_that.id,_that.serverId,_that.name,_that.localHost,_that.localPo } /// @nodoc -@JsonSerializable() + class _PortForwardConfig extends PortForwardConfig { - const _PortForwardConfig({required this.id, required this.serverId, required this.name, this.localHost = 'localhost', required this.localPort, required this.remoteHost, required this.remotePort, this.description}): super._(); - factory _PortForwardConfig.fromJson(Map json) => _$PortForwardConfigFromJson(json); + const _PortForwardConfig({required this.id, required this.serverId, required this.name, required this.type, this.localHost, this.localPort = 0, this.remoteHost, this.remotePort}): super._(); + @override final String id; @override final String serverId; @override final String name; -@override@JsonKey() final String localHost; -@override final int localPort; -@override final String remoteHost; -@override final int remotePort; -@override final String? description; +@override final PortForwardType type; +@override final String? localHost; +@override@JsonKey() final int localPort; +@override final String? remoteHost; +@override final int? remotePort; /// Create a copy of PortForwardConfig /// with the given fields replaced by the non-null parameter values. @@ -234,23 +231,20 @@ class _PortForwardConfig extends PortForwardConfig { @pragma('vm:prefer-inline') _$PortForwardConfigCopyWith<_PortForwardConfig> get copyWith => __$PortForwardConfigCopyWithImpl<_PortForwardConfig>(this, _$identity); -@override -Map toJson() { - return _$PortForwardConfigToJson(this, ); -} + @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _PortForwardConfig&&(identical(other.id, id) || other.id == id)&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.name, name) || other.name == name)&&(identical(other.localHost, localHost) || other.localHost == localHost)&&(identical(other.localPort, localPort) || other.localPort == localPort)&&(identical(other.remoteHost, remoteHost) || other.remoteHost == remoteHost)&&(identical(other.remotePort, remotePort) || other.remotePort == remotePort)&&(identical(other.description, description) || other.description == description)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PortForwardConfig&&(identical(other.id, id) || other.id == id)&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.name, name) || other.name == name)&&(identical(other.type, type) || other.type == type)&&(identical(other.localHost, localHost) || other.localHost == localHost)&&(identical(other.localPort, localPort) || other.localPort == localPort)&&(identical(other.remoteHost, remoteHost) || other.remoteHost == remoteHost)&&(identical(other.remotePort, remotePort) || other.remotePort == remotePort)); } -@JsonKey(includeFromJson: false, includeToJson: false) + @override -int get hashCode => Object.hash(runtimeType,id,serverId,name,localHost,localPort,remoteHost,remotePort,description); +int get hashCode => Object.hash(runtimeType,id,serverId,name,type,localHost,localPort,remoteHost,remotePort); @override String toString() { - return 'PortForwardConfig(id: $id, serverId: $serverId, name: $name, localHost: $localHost, localPort: $localPort, remoteHost: $remoteHost, remotePort: $remotePort, description: $description)'; + return 'PortForwardConfig(id: $id, serverId: $serverId, name: $name, type: $type, localHost: $localHost, localPort: $localPort, remoteHost: $remoteHost, remotePort: $remotePort)'; } @@ -261,7 +255,7 @@ abstract mixin class _$PortForwardConfigCopyWith<$Res> implements $PortForwardCo factory _$PortForwardConfigCopyWith(_PortForwardConfig value, $Res Function(_PortForwardConfig) _then) = __$PortForwardConfigCopyWithImpl; @override @useResult $Res call({ - String id, String serverId, String name, String localHost, int localPort, String remoteHost, int remotePort, String? description + String id, String serverId, String name, PortForwardType type, String? localHost, int localPort, String? remoteHost, int? remotePort }); @@ -278,17 +272,17 @@ class __$PortForwardConfigCopyWithImpl<$Res> /// Create a copy of PortForwardConfig /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? serverId = null,Object? name = null,Object? localHost = null,Object? localPort = null,Object? remoteHost = null,Object? remotePort = null,Object? description = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? serverId = null,Object? name = null,Object? type = null,Object? localHost = freezed,Object? localPort = null,Object? remoteHost = freezed,Object? remotePort = freezed,}) { return _then(_PortForwardConfig( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable -as String,localHost: null == localHost ? _self.localHost : localHost // ignore: cast_nullable_to_non_nullable -as String,localPort: null == localPort ? _self.localPort : localPort // ignore: cast_nullable_to_non_nullable -as int,remoteHost: null == remoteHost ? _self.remoteHost : remoteHost // ignore: cast_nullable_to_non_nullable -as String,remotePort: null == remotePort ? _self.remotePort : remotePort // ignore: cast_nullable_to_non_nullable -as int,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable -as String?, +as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as PortForwardType,localHost: freezed == localHost ? _self.localHost : localHost // ignore: cast_nullable_to_non_nullable +as String?,localPort: null == localPort ? _self.localPort : localPort // ignore: cast_nullable_to_non_nullable +as int,remoteHost: freezed == remoteHost ? _self.remoteHost : remoteHost // ignore: cast_nullable_to_non_nullable +as String?,remotePort: freezed == remotePort ? _self.remotePort : remotePort // ignore: cast_nullable_to_non_nullable +as int?, )); } diff --git a/lib/data/model/server/port_forward.g.dart b/lib/data/model/server/port_forward.g.dart deleted file mode 100644 index fe29c834..00000000 --- a/lib/data/model/server/port_forward.g.dart +++ /dev/null @@ -1,31 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'port_forward.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_PortForwardConfig _$PortForwardConfigFromJson(Map json) => - _PortForwardConfig( - id: json['id'] as String, - serverId: json['serverId'] as String, - name: json['name'] as String, - localHost: json['localHost'] as String? ?? 'localhost', - localPort: (json['localPort'] as num).toInt(), - remoteHost: json['remoteHost'] as String, - remotePort: (json['remotePort'] as num).toInt(), - description: json['description'] as String?, - ); - -Map _$PortForwardConfigToJson(_PortForwardConfig instance) => - { - 'id': instance.id, - 'serverId': instance.serverId, - 'name': instance.name, - 'localHost': instance.localHost, - 'localPort': instance.localPort, - 'remoteHost': instance.remoteHost, - 'remotePort': instance.remotePort, - 'description': instance.description, - }; diff --git a/lib/data/provider/port_forward_provider.dart b/lib/data/provider/port_forward_provider.dart index a658cfbd..517657e8 100644 --- a/lib/data/provider/port_forward_provider.dart +++ b/lib/data/provider/port_forward_provider.dart @@ -12,7 +12,7 @@ part 'port_forward_provider.g.dart'; @Riverpod(keepAlive: true) class PortForwardNotifier extends _$PortForwardNotifier { - final Map _forwards = {}; + final Map _forwards = {}; final Set _inFlight = {}; @override @@ -56,11 +56,16 @@ class PortForwardNotifier extends _$PortForwardNotifier { state = state.copyWith(configs: configs); } - Future updateConfig(PortForwardConfig oldConfig, PortForwardConfig newConfig) async { + Future updateConfig( + PortForwardConfig oldConfig, + PortForwardConfig newConfig, + ) async { await stopForward(oldConfig.id); final configWithServerId = newConfig.copyWith(serverId: _serverId); Stores.portForward.update(oldConfig, configWithServerId); - final configs = state.configs.map((c) => c.id == oldConfig.id ? configWithServerId : c).toList(); + final configs = state.configs + .map((c) => c.id == oldConfig.id ? configWithServerId : c) + .toList(); state = state.copyWith(configs: configs); } @@ -71,7 +76,9 @@ class PortForwardNotifier extends _$PortForwardNotifier { Stores.portForward.delete(config); } final configs = state.configs.where((c) => c.id != id).toList(); - final activeForwards = Map.from(state.activeForwards)..remove(id); + final activeForwards = Map.from( + state.activeForwards, + )..remove(id); state = state.copyWith(configs: configs, activeForwards: activeForwards); } @@ -91,24 +98,88 @@ class PortForwardNotifier extends _$PortForwardNotifier { } try { - final serverSocket = await ServerSocket.bind(config.localHost, config.localPort); - - Loggers.app.info('Port forward started: ${config.localHost}:${config.localPort} -> ${config.remoteHost}:${config.remotePort}'); - - final entry = _LocalForwardEntry(serverSocket: serverSocket); - entry.start(config.remoteHost, config.remotePort, () => _client); - _forwards[id] = entry; - - _updateStatus(id, PortForwardStatus(id: id, isActive: true)); + switch (config.type) { + case PortForwardType.local: + await _startLocalForward(config); + case PortForwardType.remote: + await _startRemoteForward(config); + case PortForwardType.dynamic: + await _startDynamicForward(config); + } } catch (e) { Loggers.app.warning('Port forward failed to start: $e'); - _updateStatus(id, PortForwardStatus(id: id, isActive: false, error: e.toString())); + _updateStatus( + id, + PortForwardStatus(id: id, isActive: false, error: e.toString()), + ); } } finally { _inFlight.remove(id); } } + Future _startLocalForward(PortForwardConfig config) async { + if (config.remoteHost == null || config.remotePort == null) { + throw Exception('Invalid local port forward: remote destination not set'); + } + final serverSocket = await ServerSocket.bind( + config.localHost ?? 'localhost', + config.localPort, + ); + Loggers.app.info( + 'Local port forward started: ${config.localHost ?? "localhost"}:${config.localPort} -> ${config.remoteHost}:${config.remotePort}', + ); + final entry = _LocalForwardEntry( + serverSocket: serverSocket, + remoteHost: config.remoteHost!, + remotePort: config.remotePort!, + clientGetter: () => _client, + ); + entry.start(); + _forwards[config.id] = entry; + _updateStatus(config.id, PortForwardStatus(id: config.id, isActive: true)); + } + + Future _startRemoteForward(PortForwardConfig config) async { + if (config.remoteHost == null || config.remotePort == null) { + throw Exception( + 'Invalid remote port forward: remote destination not set', + ); + } + final forward = await _client.forwardRemote( + host: config.remoteHost!, + port: config.remotePort!, + ); + if (forward == null) { + throw Exception('Failed to start remote port forward: server rejected'); + } + Loggers.app.info( + 'Remote port forward started: ${config.remoteHost}:${config.remotePort}', + ); + final entry = _RemoteForwardEntry( + forward: forward, + remoteHost: config.localHost ?? 'localhost', + remotePort: config.localPort, + ); + entry.start(); + _forwards[config.id] = entry; + _updateStatus(config.id, PortForwardStatus(id: config.id, isActive: true)); + } + + Future _startDynamicForward(PortForwardConfig config) async { + final bindHost = config.localHost ?? 'localhost'; + final dynamicForward = await _client.forwardDynamic( + bindHost: bindHost, + bindPort: config.localPort, + ); + Loggers.app.info( + 'Dynamic port forward (SOCKS5) started: $bindHost:${config.localPort}', + ); + final entry = _DynamicForwardEntry(dynamicForward: dynamicForward); + _forwards[config.id] = entry; + _updateStatus(config.id, PortForwardStatus(id: config.id, isActive: true)); + } + Future stopForward(String id) async { if (!_inFlight.add(id)) return; try { @@ -134,27 +205,50 @@ class PortForwardNotifier extends _$PortForwardNotifier { } void _updateStatus(String id, PortForwardStatus status) { - final activeForwards = Map.from(state.activeForwards); + final activeForwards = Map.from( + state.activeForwards, + ); activeForwards[id] = status; state = state.copyWith(activeForwards: activeForwards); } } -class _LocalForwardEntry { +abstract class _ForwardEntry { + Future close(); +} + +class _LocalForwardEntry extends _ForwardEntry { final ServerSocket serverSocket; + final String remoteHost; + final int remotePort; + final SSHClient Function() clientGetter; final List<_ActiveConnection> _connections = []; StreamSubscription? _subscription; - _LocalForwardEntry({required this.serverSocket}); + _LocalForwardEntry({ + required this.serverSocket, + required this.remoteHost, + required this.remotePort, + required this.clientGetter, + }); - void start(String remoteHost, int remotePort, SSHClient Function() clientGetter) { + void start() { _subscription = serverSocket.listen((socket) async { try { - final forward = await clientGetter().forwardLocal(remoteHost, remotePort); + final forward = await clientGetter().forwardLocal( + remoteHost, + remotePort, + ); final conn = _ActiveConnection(socket: socket, forward: forward); _connections.add(conn); - final pipe1 = forward.stream.cast>().pipe(socket).catchError((_) {}); - final pipe2 = socket.cast>().pipe(forward.sink).catchError((_) {}); + final pipe1 = forward.stream + .cast>() + .pipe(socket) + .catchError((_) {}); + final pipe2 = socket + .cast>() + .pipe(forward.sink) + .catchError((_) {}); Future.wait([pipe1, pipe2]).whenComplete(() { _connections.remove(conn); conn.close(); @@ -166,6 +260,7 @@ class _LocalForwardEntry { }); } + @override Future close() async { await _subscription?.cancel(); await serverSocket.close(); @@ -177,14 +272,72 @@ class _LocalForwardEntry { } } +class _RemoteForwardEntry extends _ForwardEntry { + final SSHRemoteForward forward; + final String remoteHost; + final int remotePort; + final List<_ActiveConnection> _connections = []; + StreamSubscription? _subscription; + + _RemoteForwardEntry({ + required this.forward, + required this.remoteHost, + required this.remotePort, + }); + + void start() { + _subscription = forward.connections.listen((channel) async { + try { + final socket = await Socket.connect(remoteHost, remotePort); + final conn = _ActiveConnection(socket: socket, forward: channel); + _connections.add(conn); + final pipe1 = channel.stream + .cast>() + .pipe(socket) + .catchError((_) {}); + final pipe2 = socket + .cast>() + .pipe(channel.sink) + .catchError((_) {}); + Future.wait([pipe1, pipe2]).whenComplete(() { + _connections.remove(conn); + conn.close(); + }); + } catch (e, s) { + Loggers.app.warning('Remote forward connection failed', e, s); + channel.close(); + } + }); + } + + @override + Future close() async { + await _subscription?.cancel(); + final connections = _connections.toList(); + for (final conn in connections) { + await conn.close().catchError((_) {}); + } + _connections.clear(); + try { + await Future.microtask(() => forward.close()); + } catch (_) {} + } +} + +class _DynamicForwardEntry extends _ForwardEntry { + final SSHDynamicForward dynamicForward; + + _DynamicForwardEntry({required this.dynamicForward}); + + @override + Future close() => dynamicForward.close(); +} + class _ActiveConnection { final Socket socket; final SSHForwardChannel forward; - _ActiveConnection({ - required this.socket, - required this.forward, - }); + _ActiveConnection({required this.socket, required this.forward}); Future close() async { try { diff --git a/lib/data/provider/port_forward_provider.g.dart b/lib/data/provider/port_forward_provider.g.dart index f21d9155..7f6b6cdb 100644 --- a/lib/data/provider/port_forward_provider.g.dart +++ b/lib/data/provider/port_forward_provider.g.dart @@ -59,7 +59,7 @@ final class PortForwardNotifierProvider } String _$portForwardNotifierHash() => - r'c56425252253c276b6202f478d3475e8fe0c1c64'; + r'2406d86f55759c13977daab9ba9c40fb6aca370d'; final class PortForwardNotifierFamily extends $Family with diff --git a/lib/data/store/setting.dart b/lib/data/store/setting.dart index d765913d..968e91d7 100644 --- a/lib/data/store/setting.dart +++ b/lib/data/store/setting.dart @@ -279,4 +279,7 @@ class SettingStore extends HiveStore { return val?.map((e) => e.name).toList() ?? []; }, ); + + /// Hide port forward beta warning + late final portForwardBetaWarned = propertyDefault('portForwardBetaWarned', false); } diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index ddb44eb1..1a804470 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -1686,6 +1686,18 @@ abstract class AppLocalizations { /// **'Remote Port'** String get portForward_remotePort; + /// No description provided for @portForward_type_local. + /// + /// In en, this message translates to: + /// **'Local'** + String get portForward_type_local; + + /// No description provided for @portForward_type_remote. + /// + /// In en, this message translates to: + /// **'Remote'** + String get portForward_type_remote; + /// No description provided for @portForward_deleteConfirmFmt. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/l10n_de.dart b/lib/generated/l10n/l10n_de.dart index d28619c8..df99a9b7 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -916,6 +916,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_en.dart b/lib/generated/l10n/l10n_en.dart index fa33cc96..666aa363 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -907,6 +907,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_es.dart b/lib/generated/l10n/l10n_es.dart index e2ab6e49..e94d853d 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -918,6 +918,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_fr.dart b/lib/generated/l10n/l10n_fr.dart index 04c9b4d1..bfdbce2b 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -921,6 +921,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_id.dart b/lib/generated/l10n/l10n_id.dart index 74b49291..2b8f161e 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -907,6 +907,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_it.dart b/lib/generated/l10n/l10n_it.dart index 738d970a..cb962e56 100644 --- a/lib/generated/l10n/l10n_it.dart +++ b/lib/generated/l10n/l10n_it.dart @@ -913,6 +913,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_ja.dart b/lib/generated/l10n/l10n_ja.dart index 3d539ec0..f3924745 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -877,6 +877,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_ko.dart b/lib/generated/l10n/l10n_ko.dart index 3a1058d6..b09ad0a9 100644 --- a/lib/generated/l10n/l10n_ko.dart +++ b/lib/generated/l10n/l10n_ko.dart @@ -876,6 +876,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_nl.dart b/lib/generated/l10n/l10n_nl.dart index c3012296..33916c5c 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -914,6 +914,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_pt.dart b/lib/generated/l10n/l10n_pt.dart index 50d0184e..8c6add83 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -909,6 +909,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_ru.dart b/lib/generated/l10n/l10n_ru.dart index 2d539cea..d56c5953 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -913,6 +913,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_tr.dart b/lib/generated/l10n/l10n_tr.dart index 201d294b..beed936c 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -908,6 +908,12 @@ class AppLocalizationsTr extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_uk.dart b/lib/generated/l10n/l10n_uk.dart index 898a32c9..519384ac 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -913,6 +913,12 @@ class AppLocalizationsUk extends AppLocalizations { @override String get portForward_remotePort => 'Remote Port'; + @override + String get portForward_type_local => 'Local'; + + @override + String get portForward_type_remote => 'Remote'; + @override String portForward_deleteConfirmFmt(Object name) { return 'Delete $name?'; diff --git a/lib/generated/l10n/l10n_zh.dart b/lib/generated/l10n/l10n_zh.dart index 456b40db..d09eda69 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -855,6 +855,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get portForward_remotePort => '远端端口'; + @override + String get portForward_type_local => '本地'; + + @override + String get portForward_type_remote => '远程'; + @override String portForward_deleteConfirmFmt(Object name) { return '删除 $name?'; diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index b1053c0d..f25bc79e 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -20,6 +20,7 @@ import 'package:server_box/data/model/ssh/virtual_key.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), + AdapterSpec(), AdapterSpec(), ]) part 'hive_adapters.g.dart'; diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index 2f11e5f6..98e7bec8 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -629,11 +629,11 @@ class PortForwardConfigAdapter extends TypeAdapter { id: fields[0] as String, serverId: fields[7] as String, name: fields[1] as String, - localHost: fields[2] == null ? 'localhost' : fields[2] as String, - localPort: (fields[3] as num).toInt(), - remoteHost: fields[4] as String, - remotePort: (fields[5] as num).toInt(), - description: fields[6] as String?, + type: fields[8] as PortForwardType, + localHost: fields[2] as String?, + localPort: fields[3] == null ? 0 : (fields[3] as num).toInt(), + remoteHost: fields[4] as String?, + remotePort: (fields[5] as num?)?.toInt(), ); } @@ -653,10 +653,10 @@ class PortForwardConfigAdapter extends TypeAdapter { ..write(obj.remoteHost) ..writeByte(5) ..write(obj.remotePort) - ..writeByte(6) - ..write(obj.description) ..writeByte(7) - ..write(obj.serverId); + ..write(obj.serverId) + ..writeByte(8) + ..write(obj.type); } @override @@ -669,3 +669,44 @@ class PortForwardConfigAdapter extends TypeAdapter { runtimeType == other.runtimeType && typeId == other.typeId; } + +class PortForwardTypeAdapter extends TypeAdapter { + @override + final typeId = 12; + + @override + PortForwardType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return PortForwardType.local; + case 1: + return PortForwardType.remote; + case 2: + return PortForwardType.dynamic; + default: + return PortForwardType.local; + } + } + + @override + void write(BinaryWriter writer, PortForwardType obj) { + switch (obj) { + case PortForwardType.local: + writer.writeByte(0); + case PortForwardType.remote: + writer.writeByte(1); + case PortForwardType.dynamic: + writer.writeByte(2); + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PortForwardTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 3072a248..07b7cddb 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -1,7 +1,7 @@ # Generated by Hive CE # Manual modifications may be necessary for certain migrations # Check in to version control -nextTypeId: 11 +nextTypeId: 13 types: PrivateKeyInfo: typeId: 1 @@ -229,7 +229,7 @@ types: index: 2 PortForwardConfig: typeId: 10 - nextIndex: 8 + nextIndex: 9 fields: id: index: 0 @@ -243,7 +243,17 @@ types: index: 4 remotePort: index: 5 - description: - index: 6 serverId: index: 7 + type: + index: 8 + PortForwardType: + typeId: 12 + nextIndex: 3 + fields: + local: + index: 0 + remote: + index: 1 + dynamic: + index: 2 diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index c9ece21d..56494d3d 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -14,6 +14,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(ConnectionStatAdapter()); registerAdapter(NetViewTypeAdapter()); registerAdapter(PortForwardConfigAdapter()); + registerAdapter(PortForwardTypeAdapter()); registerAdapter(PrivateKeyInfoAdapter()); registerAdapter(ServerConnectionStatsAdapter()); registerAdapter(ServerCustomAdapter()); @@ -33,6 +34,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(ConnectionStatAdapter()); registerAdapter(NetViewTypeAdapter()); registerAdapter(PortForwardConfigAdapter()); + registerAdapter(PortForwardTypeAdapter()); registerAdapter(PrivateKeyInfoAdapter()); registerAdapter(ServerConnectionStatsAdapter()); registerAdapter(ServerCustomAdapter()); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8cd63e8a..5f0dcfe5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -263,6 +263,8 @@ "portForward_localPort": "Local Port", "portForward_remoteHost": "Remote Host", "portForward_remotePort": "Remote Port", + "portForward_type_local": "Local", + "portForward_type_remote": "Remote", "portForward_deleteConfirmFmt": "Delete {name}?", "sponsor": "Sponsor" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e59df0e0..e5d199da 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -260,6 +260,8 @@ "portForward_localPort": "本地端口", "portForward_remoteHost": "远端主机", "portForward_remotePort": "远端端口", + "portForward_type_local": "本地", + "portForward_type_remote": "远程", "portForward_deleteConfirmFmt": "删除 {name}?", "sponsor": "赞助" } diff --git a/lib/view/page/port_forward.dart b/lib/view/page/port_forward.dart index 046a8719..130eb0c2 100644 --- a/lib/view/page/port_forward.dart +++ b/lib/view/page/port_forward.dart @@ -5,6 +5,7 @@ import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/server/port_forward.dart'; import 'package:server_box/data/provider/port_forward_provider.dart'; +import 'package:server_box/data/res/store.dart'; final class PortForwardPage extends ConsumerStatefulWidget { final SpiRequiredArgs args; @@ -31,9 +32,33 @@ final class _PortForwardPageState extends ConsumerState { } void _showBetaWarning() { + if (Stores.setting.portForwardBetaWarned.fetch()) return; + var noMore = false; context.showRoundDialog( title: libL10n.attention, - child: Text(context.l10n.portForwardBeta), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(context.l10n.portForwardBeta)), + UIs.height13, + StatefulBuilder( + builder: (context, setState) { + return Row( + children: [ + Checkbox( + value: noMore, + onChanged: (v) { + setState(() => noMore = v ?? false); + Stores.setting.portForwardBetaWarned.put(noMore); + }, + ), + Text(l10n.noPromptAgain), + ], + ); + }, + ), + ], + ), actions: [Btnx.ok], ); } @@ -42,7 +67,7 @@ final class _PortForwardPageState extends ConsumerState { Widget build(BuildContext context) { return Scaffold( appBar: CustomAppBar( - title: Text(libL10n.portForward), + title: Text('${libL10n.portForward} (Beta)'), actions: [ IconButton( icon: const Icon(Icons.add), @@ -214,18 +239,18 @@ class _PortForwardConfigDialogState extends State<_PortForwardConfigDialog> { late final TextEditingController localPortController; late final TextEditingController remoteHostController; late final TextEditingController remotePortController; - late final TextEditingController descController; + late PortForwardType _selectedType; bool _saving = false; @override void initState() { super.initState(); nameController = TextEditingController(text: widget.existing?.name ?? ''); - localHostController = TextEditingController(text: widget.existing?.localHost ?? 'localhost'); + localHostController = TextEditingController(text: widget.existing?.localHost ?? ''); localPortController = TextEditingController(text: widget.existing?.localPort.toString() ?? ''); remoteHostController = TextEditingController(text: widget.existing?.remoteHost ?? ''); - remotePortController = TextEditingController(text: widget.existing?.remotePort.toString() ?? ''); - descController = TextEditingController(text: widget.existing?.description ?? ''); + remotePortController = TextEditingController(text: widget.existing?.remotePort?.toString() ?? ''); + _selectedType = widget.existing?.type ?? PortForwardType.local; } @override @@ -235,7 +260,6 @@ class _PortForwardConfigDialogState extends State<_PortForwardConfigDialog> { localPortController.dispose(); remoteHostController.dispose(); remotePortController.dispose(); - descController.dispose(); super.dispose(); } @@ -249,73 +273,121 @@ class _PortForwardConfigDialogState extends State<_PortForwardConfigDialog> { children: [ Input(controller: nameController, hint: libL10n.name), const SizedBox(height: 8), - Row( - children: [ - Expanded(child: Input(controller: localHostController, hint: context.l10n.portForward_localHost)), - const SizedBox(width: 8), - Expanded(child: Input(controller: localPortController, hint: context.l10n.portForward_localPort, type: TextInputType.number)), - ], - ), + _buildTypeSelector(), const SizedBox(height: 8), Row( children: [ - Expanded(child: Input(controller: remoteHostController, hint: context.l10n.portForward_remoteHost)), + Expanded(child: Input(controller: localHostController, hint: _localHostHint)), const SizedBox(width: 8), - Expanded(child: Input(controller: remotePortController, hint: context.l10n.portForward_remotePort, type: TextInputType.number)), + Expanded(child: Input(controller: localPortController, hint: _localPortHint, type: TextInputType.number)), ], ), - const SizedBox(height: 8), - Input(controller: descController, hint: libL10n.note), + if (_selectedType != PortForwardType.dynamic) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: Input(controller: remoteHostController, hint: _remoteHostHint)), + const SizedBox(width: 8), + Expanded(child: Input(controller: remotePortController, hint: _remotePortHint, type: TextInputType.number)), + ], + ), + ], ], ), ), actions: [ Btn.cancel(), Btn.ok( - onTap: () async { - if (_saving) return; - setState(() => _saving = true); - try { - final name = nameController.text.trim(); - final localHost = localHostController.text.trim(); - final localPort = int.tryParse(localPortController.text.trim()) ?? 0; - final remoteHost = remoteHostController.text.trim(); - final remotePort = int.tryParse(remotePortController.text.trim()) ?? 0; - final desc = descController.text.trim(); - - if (name.isEmpty || - localHost.isEmpty || - localPort <= 0 || - localPort > 65535 || - remoteHost.isEmpty || - remotePort <= 0 || - remotePort > 65535) { - if (mounted) context.showSnackBar(libL10n.invalid); - return; - } - - final config = PortForwardConfig( - id: widget.existing?.id ?? ShortId.generate(), - serverId: widget.serverId, - name: name, - localHost: localHost, - localPort: localPort, - remoteHost: remoteHost, - remotePort: remotePort, - description: desc.isEmpty ? null : desc, - ); - - await widget.onSave(config); - if (mounted) Navigator.of(context).pop(); - } catch (e, s) { - Loggers.app.warning('Failed to save port forward config', e, s); - if (mounted) context.showSnackBar(libL10n.error); - } finally { - if (mounted) setState(() => _saving = false); - } - }, + onTap: _onSave, ), ], ); } + + String get _localHostHint => context.l10n.portForward_localHost; + + String get _localPortHint => context.l10n.portForward_localPort; + + String get _remoteHostHint => + _selectedType == PortForwardType.dynamic ? '' : context.l10n.portForward_remoteHost; + + String get _remotePortHint => + _selectedType == PortForwardType.dynamic ? '' : context.l10n.portForward_remotePort; + + Widget _buildTypeSelector() { + return SegmentedButton( + segments: [ + ButtonSegment( + value: PortForwardType.local, + label: Text(_localTypeLabel), + icon: const Icon(Icons.arrow_forward, size: 16), + ), + ButtonSegment( + value: PortForwardType.remote, + label: Text(_remoteTypeLabel), + icon: const Icon(Icons.arrow_back, size: 16), + ), + ButtonSegment( + value: PortForwardType.dynamic, + label: Text(_dynamicTypeLabel), + icon: const Icon(Icons.hub, size: 16), + ), + ], + selected: {_selectedType}, + onSelectionChanged: (selection) { + setState(() { + _selectedType = selection.first; + }); + }, + ); + } + + String get _localTypeLabel => context.l10n.portForward_type_local; + + String get _remoteTypeLabel => context.l10n.portForward_type_remote; + + String get _dynamicTypeLabel => 'SOCKS5'; + + void _onSave() async { + if (_saving) return; + setState(() => _saving = true); + try { + final name = nameController.text.trim(); + final localHost = localHostController.text.trim(); + final localPort = int.tryParse(localPortController.text.trim()) ?? 0; + final remoteHost = remoteHostController.text.trim(); + final remotePort = int.tryParse(remotePortController.text.trim()) ?? 0; + + if (name.isEmpty || localHost.isEmpty || localPort <= 0 || localPort > 65535) { + if (mounted) context.showSnackBar(libL10n.invalid); + return; + } + + if (_selectedType != PortForwardType.dynamic) { + if (remoteHost.isEmpty || remotePort <= 0 || remotePort > 65535) { + if (mounted) context.showSnackBar(libL10n.invalid); + return; + } + } + + final config = PortForwardConfig( + id: widget.existing?.id ?? ShortId.generate(), + serverId: widget.serverId, + name: name, + type: _selectedType, + localHost: localHost, + localPort: localPort, + remoteHost: _selectedType == PortForwardType.dynamic ? null : remoteHost, + remotePort: _selectedType == PortForwardType.dynamic ? null : remotePort, + ); + + await widget.onSave(config); + if (mounted) Navigator.of(context).pop(); + } catch (e, s) { + Loggers.app.warning('Failed to save port forward config', e, s); + if (mounted) context.showSnackBar(libL10n.error); + } finally { + if (mounted) setState(() => _saving = false); + } + } }