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); + } + } }