From 8be9b9b10bf945c903f970022a1783acc37fa93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:42:34 +0800 Subject: [PATCH] impl: jump logic Fixes #356 --- lib/core/utils/server.dart | 178 +++++++++++++----- .../model/server/server_private_info.dart | 20 +- .../server/server_private_info.freezed.dart | 64 +++++-- .../model/server/server_private_info.g.dart | 4 + lib/data/model/sftp/req.dart | 24 ++- lib/data/provider/server/all.g.dart | 2 +- lib/data/provider/server/single.dart | 2 +- lib/data/provider/server/single.g.dart | 2 +- lib/data/store/server.dart | 15 +- lib/hive/hive_adapters.g.dart | 7 +- lib/hive/hive_adapters.g.yaml | 4 +- lib/view/page/server/edit/actions.dart | 14 +- lib/view/page/server/edit/edit.dart | 6 +- lib/view/page/server/edit/jump_chain.dart | 176 +++++++++++++++++ lib/view/page/server/edit/widget.dart | 95 +--------- 15 files changed, 411 insertions(+), 202 deletions(-) create mode 100644 lib/view/page/server/edit/jump_chain.dart diff --git a/lib/core/utils/server.dart b/lib/core/utils/server.dart index 1d511fce..300ea5c4 100644 --- a/lib/core/utils/server.dart +++ b/lib/core/utils/server.dart @@ -90,6 +90,8 @@ Future _genClientInternal( void Function(String storageKey, String fingerprintHex)? onHostKeyAccepted, Future Function(HostKeyPromptInfo info)? onHostKeyPrompt, required Set visited, + SSHSocket? socketOverride, + bool followJumpConfig = true, }) async { final identifier = _hostIdentifier(spi); if (!visited.add(identifier)) { @@ -105,46 +107,116 @@ Future _genClientInternal( String? alterUser; final socket = await () async { - // Proxy - final jumpId = spi.jumpId; - Spi? jumpSpi_; - String? jumpPrivateKey; + if (socketOverride != null) return socketOverride; - if (jumpId != null) { - if (jumpChain != null) { - final idx = jumpChain.indexWhere((e) => e.id == jumpId || e.oldId == jumpId); - if (idx == -1) { - throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $jumpId'); - } - jumpSpi_ = jumpChain[idx]; - jumpPrivateKey = jumpPrivateKeys != null && idx < jumpPrivateKeys.length ? jumpPrivateKeys[idx] : null; + if (followJumpConfig) { + final hopIds = spi.jumpChainIds; - if (jumpSpi_.keyId != null && jumpPrivateKey == null) { - throw SSHErr( - type: SSHErrType.noPrivateKey, - message: l10n.privateKeyNotFoundFmt(jumpSpi_.keyId ?? ''), - ); + // Explicit hop list on this node + if (hopIds != null && hopIds.isNotEmpty) { + SSHClient? currentClient; + + for (var i = 0; i < hopIds.length; i++) { + final hopId = hopIds[i]; + final hopSpi = jumpChain?.firstWhereOrNull((e) => e.id == hopId || e.oldId == hopId) ?? + Stores.server.box.get(hopId); + if (hopSpi == null) { + throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found: $hopId'); + } + + if (currentClient == null) { + // First hop: connect directly + final hopKeyId = hopSpi.keyId; + final hopPrivateKey = hopKeyId == null + ? null + : (jumpPrivateKeys != null && i < jumpPrivateKeys.length ? jumpPrivateKeys[i] : null) ?? + getPrivateKey(hopKeyId); + + final hopSocket = await SSHSocket.connect(hopSpi.ip, hopSpi.port, timeout: timeout); + currentClient = await _genClientInternal( + hopSpi, + privateKey: hopPrivateKey, + jumpChain: jumpChain, + jumpPrivateKeys: jumpPrivateKeys, + timeout: timeout, + onKeyboardInteractive: onKeyboardInteractive, + knownHostFingerprints: hostKeyCache, + onHostKeyAccepted: hostKeyPersist, + onHostKeyPrompt: hostKeyPrompt, + visited: visited, + socketOverride: hopSocket, + ); + } else { + final forwarded = await currentClient.forwardLocal(hopSpi.ip, hopSpi.port); + + final hopKeyId = hopSpi.keyId; + final hopPrivateKey = hopKeyId == null + ? null + : (jumpPrivateKeys != null && i < jumpPrivateKeys.length ? jumpPrivateKeys[i] : null) ?? + getPrivateKey(hopKeyId); + + currentClient = await _genClientInternal( + hopSpi, + privateKey: hopPrivateKey, + jumpChain: jumpChain, + jumpPrivateKeys: jumpPrivateKeys, + timeout: timeout, + onKeyboardInteractive: onKeyboardInteractive, + knownHostFingerprints: hostKeyCache, + onHostKeyAccepted: hostKeyPersist, + onHostKeyPrompt: hostKeyPrompt, + visited: visited, + socketOverride: forwarded, + ); + } + } + + if (currentClient != null) { + return await currentClient.forwardLocal(spi.ip, spi.port); } - } else { - jumpSpi_ = Stores.server.box.get(jumpId); } - } - if (jumpSpi_ != null) { - final jumpClient = await _genClientInternal( - jumpSpi_, - privateKey: jumpPrivateKey, - jumpChain: jumpChain, - jumpPrivateKeys: jumpPrivateKeys, - timeout: timeout, - onKeyboardInteractive: onKeyboardInteractive, - knownHostFingerprints: hostKeyCache, - onHostKeyAccepted: hostKeyPersist, - onHostKeyPrompt: hostKeyPrompt, - visited: visited, - ); + // Legacy single hop + final hopId = spi.jumpId; + Spi? hopSpi; + String? hopPrivateKey; - return await jumpClient.forwardLocal(spi.ip, spi.port); + if (hopId != null) { + if (jumpChain != null) { + final idx = jumpChain.indexWhere((e) => e.id == hopId || e.oldId == hopId); + if (idx == -1) { + throw SSHErr(type: SSHErrType.connect, message: 'Jump server not found in provided chain: $hopId'); + } + hopSpi = jumpChain[idx]; + hopPrivateKey = jumpPrivateKeys != null && idx < jumpPrivateKeys.length ? jumpPrivateKeys[idx] : null; + + if (hopSpi.keyId != null && hopPrivateKey == null) { + throw SSHErr( + type: SSHErrType.noPrivateKey, + message: l10n.privateKeyNotFoundFmt(hopSpi.keyId ?? ''), + ); + } + } else { + hopSpi = Stores.server.box.get(hopId); + } + } + + if (hopSpi != null) { + final hopClient = await _genClientInternal( + hopSpi, + privateKey: hopPrivateKey, + jumpChain: jumpChain, + jumpPrivateKeys: jumpPrivateKeys, + timeout: timeout, + onKeyboardInteractive: onKeyboardInteractive, + knownHostFingerprints: hostKeyCache, + onHostKeyAccepted: hostKeyPersist, + onHostKeyPrompt: hostKeyPrompt, + visited: visited, + ); + + return await hopClient.forwardLocal(spi.ip, spi.port); + } } // Direct @@ -171,32 +243,36 @@ Future _genClientInternal( prompt: hostKeyPrompt, ); - final keyId = spi.keyId; - if (keyId == null) { - onStatus?.call(GenSSHClientStatus.pwd); + Future buildClient(SSHSocket socket) async { + final keyId = spi.keyId; + if (keyId == null) { + onStatus?.call(GenSSHClientStatus.pwd); + return SSHClient( + socket, + username: alterUser ?? spi.user, + onPasswordRequest: () => spi.pwd, + onUserInfoRequest: onKeyboardInteractive, + onVerifyHostKey: hostKeyVerifier.call, + // printDebug: debugPrint, + // printTrace: debugPrint, + ); + } + privateKey ??= getPrivateKey(keyId); + + onStatus?.call(GenSSHClientStatus.key); return SSHClient( socket, - username: alterUser ?? spi.user, - onPasswordRequest: () => spi.pwd, + username: spi.user, + // Must use [compute] here, instead of [Computer.shared.start] + identities: await compute(loadIndentity, privateKey!), onUserInfoRequest: onKeyboardInteractive, onVerifyHostKey: hostKeyVerifier.call, // printDebug: debugPrint, // printTrace: debugPrint, ); } - privateKey ??= getPrivateKey(keyId); - onStatus?.call(GenSSHClientStatus.key); - return SSHClient( - socket, - username: spi.user, - // Must use [compute] here, instead of [Computer.shared.start] - identities: await compute(loadIndentity, privateKey), - onUserInfoRequest: onKeyboardInteractive, - onVerifyHostKey: hostKeyVerifier.call, - // printDebug: debugPrint, - // printTrace: debugPrint, - ); + return await buildClient(socket); } typedef _HostKeyPersistCallback = void Function(String storageKey, String fingerprintHex); diff --git a/lib/data/model/server/server_private_info.dart b/lib/data/model/server/server_private_info.dart index 045b34af..d9cad5b0 100644 --- a/lib/data/model/server/server_private_info.dart +++ b/lib/data/model/server/server_private_info.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/server/custom.dart'; @@ -35,8 +36,15 @@ abstract class Spi with _$Spi { String? alterUrl, @Default(true) bool autoConnect, - /// [id] of the jump server + /// [id] of the jump server (legacy, single hop) + /// + /// Migrated to [jumpChainIds]. String? jumpId, + + /// Jump chain hop ids (nearest -> farthest) + /// + /// Preferred over [jumpId]. + @JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, @@ -79,7 +87,10 @@ extension Spix on Spi { String? migrateId() { if (id.isNotEmpty) return null; ServerStore.instance.delete(oldId); - final newSpi = copyWith(id: ShortId.generate()); + final newSpi = copyWith( + id: ShortId.generate(), + jumpChainIds: jumpChainIds ?? (jumpId == null ? null : [jumpId!]), + ); newSpi.save(); return newSpi.id; } @@ -94,7 +105,8 @@ extension Spix on Spi { port == other.port && pwd == other.pwd && keyId == other.keyId && - jumpId == other.jumpId; + jumpId == other.jumpId && + listEquals(jumpChainIds, other.jumpChainIds); } /// Returns true if the connection should be re-established. @@ -137,7 +149,7 @@ extension Spix on Spi { tags: ['tag1', 'tag2'], alterUrl: 'user@ip:port', autoConnect: true, - jumpId: 'jump_server_id', + jumpChainIds: ['jump_server_id'], custom: ServerCustom( pveAddr: 'http://localhost:8006', pveIgnoreCert: false, diff --git a/lib/data/model/server/server_private_info.freezed.dart b/lib/data/model/server/server_private_info.freezed.dart index 89db2a0c..70ae6303 100644 --- a/lib/data/model/server/server_private_info.freezed.dart +++ b/lib/data/model/server/server_private_info.freezed.dart @@ -16,8 +16,13 @@ T _$identity(T value) => value; mixin _$Spi { String get name; String get ip; int get port; String get user; String? get pwd;/// [id] of private key -@JsonKey(name: 'pubKeyId') String? get keyId; List? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server - String? get jumpId; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal. +@JsonKey(name: 'pubKeyId') String? get keyId; List? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server (legacy, single hop) +/// +/// Migrated to [jumpChainIds]. + String? get jumpId;/// Jump chain hop ids (nearest -> farthest) +/// +/// Preferred over [jumpId]. +@JsonKey(includeIfNull: false) List? get jumpChainIds; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal. Map? get envs;@JsonKey(fromJson: Spi.parseId) String get id;/// Custom system type (unix or windows). If set, skip auto-detection. @JsonKey(includeIfNull: false) SystemType? get customSystemType;/// Disabled command types for this server @JsonKey(includeIfNull: false) List? get disabledCmdTypes; @@ -33,12 +38,12 @@ $SpiCopyWith get copyWith => _$SpiCopyWithImpl(this as Spi, _$identity @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&const DeepCollectionEquality().equals(other.jumpChainIds, jumpChainIds)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other.disabledCmdTypes, disabledCmdTypes)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes)); +int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,const DeepCollectionEquality().hash(jumpChainIds),custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType,const DeepCollectionEquality().hash(disabledCmdTypes)); @@ -49,7 +54,7 @@ abstract mixin class $SpiCopyWith<$Res> { factory $SpiCopyWith(Spi value, $Res Function(Spi) _then) = _$SpiCopyWithImpl; @useResult $Res call({ - String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? disabledCmdTypes + String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId,@JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? disabledCmdTypes }); @@ -66,7 +71,7 @@ class _$SpiCopyWithImpl<$Res> /// Create a copy of Spi /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? jumpChainIds = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) { return _then(_self.copyWith( name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable @@ -78,7 +83,8 @@ as String?,tags: freezed == tags ? _self.tags : tags // ignore: cast_nullable_to as List?,alterUrl: freezed == alterUrl ? _self.alterUrl : alterUrl // ignore: cast_nullable_to_non_nullable as String?,autoConnect: null == autoConnect ? _self.autoConnect : autoConnect // ignore: cast_nullable_to_non_nullable as bool,jumpId: freezed == jumpId ? _self.jumpId : jumpId // ignore: cast_nullable_to_non_nullable -as String?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable +as String?,jumpChainIds: freezed == jumpChainIds ? _self.jumpChainIds : jumpChainIds // ignore: cast_nullable_to_non_nullable +as List?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable as ServerCustom?,wolCfg: freezed == wolCfg ? _self.wolCfg : wolCfg // ignore: cast_nullable_to_non_nullable as WakeOnLanCfg?,envs: freezed == envs ? _self.envs : envs // ignore: cast_nullable_to_non_nullable as Map?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable @@ -169,10 +175,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _Spi() when $default != null: -return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: +return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: return orElse(); } @@ -190,10 +196,10 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId, /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes) $default,) {final _that = this; switch (_that) { case _Spi(): -return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: +return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: throw StateError('Unexpected subclass'); } @@ -210,10 +216,10 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId, /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, @JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List? disabledCmdTypes)? $default,) {final _that = this; switch (_that) { case _Spi() when $default != null: -return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: +return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.jumpChainIds,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _: return null; } @@ -225,7 +231,7 @@ return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId, @JsonSerializable(includeIfNull: false) class _Spi extends Spi { - const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List? disabledCmdTypes}): _tags = tags,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._(); + const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List? tags, this.alterUrl, this.autoConnect = true, this.jumpId, @JsonKey(includeIfNull: false) final List? jumpChainIds, this.custom, this.wolCfg, final Map? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType, @JsonKey(includeIfNull: false) final List? disabledCmdTypes}): _tags = tags,_jumpChainIds = jumpChainIds,_envs = envs,_disabledCmdTypes = disabledCmdTypes,super._(); factory _Spi.fromJson(Map json) => _$SpiFromJson(json); @override final String name; @@ -246,8 +252,25 @@ class _Spi extends Spi { @override final String? alterUrl; @override@JsonKey() final bool autoConnect; -/// [id] of the jump server +/// [id] of the jump server (legacy, single hop) +/// +/// Migrated to [jumpChainIds]. @override final String? jumpId; +/// Jump chain hop ids (nearest -> farthest) +/// +/// Preferred over [jumpId]. + final List? _jumpChainIds; +/// Jump chain hop ids (nearest -> farthest) +/// +/// Preferred over [jumpId]. +@override@JsonKey(includeIfNull: false) List? get jumpChainIds { + final value = _jumpChainIds; + if (value == null) return null; + if (_jumpChainIds is EqualUnmodifiableListView) return _jumpChainIds; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + @override final ServerCustom? custom; @override final WakeOnLanCfg? wolCfg; /// It only applies to SSH terminal. @@ -289,12 +312,12 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&const DeepCollectionEquality().equals(other._jumpChainIds, _jumpChainIds)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType)&&const DeepCollectionEquality().equals(other._disabledCmdTypes, _disabledCmdTypes)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes)); +int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,const DeepCollectionEquality().hash(_jumpChainIds),custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType,const DeepCollectionEquality().hash(_disabledCmdTypes)); @@ -305,7 +328,7 @@ abstract mixin class _$SpiCopyWith<$Res> implements $SpiCopyWith<$Res> { factory _$SpiCopyWith(_Spi value, $Res Function(_Spi) _then) = __$SpiCopyWithImpl; @override @useResult $Res call({ - String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? disabledCmdTypes + String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List? tags, String? alterUrl, bool autoConnect, String? jumpId,@JsonKey(includeIfNull: false) List? jumpChainIds, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType,@JsonKey(includeIfNull: false) List? disabledCmdTypes }); @@ -322,7 +345,7 @@ class __$SpiCopyWithImpl<$Res> /// Create a copy of Spi /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? jumpChainIds = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,Object? disabledCmdTypes = freezed,}) { return _then(_Spi( name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable @@ -334,7 +357,8 @@ as String?,tags: freezed == tags ? _self._tags : tags // ignore: cast_nullable_t as List?,alterUrl: freezed == alterUrl ? _self.alterUrl : alterUrl // ignore: cast_nullable_to_non_nullable as String?,autoConnect: null == autoConnect ? _self.autoConnect : autoConnect // ignore: cast_nullable_to_non_nullable as bool,jumpId: freezed == jumpId ? _self.jumpId : jumpId // ignore: cast_nullable_to_non_nullable -as String?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable +as String?,jumpChainIds: freezed == jumpChainIds ? _self._jumpChainIds : jumpChainIds // ignore: cast_nullable_to_non_nullable +as List?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable as ServerCustom?,wolCfg: freezed == wolCfg ? _self.wolCfg : wolCfg // ignore: cast_nullable_to_non_nullable as WakeOnLanCfg?,envs: freezed == envs ? _self._envs : envs // ignore: cast_nullable_to_non_nullable as Map?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable diff --git a/lib/data/model/server/server_private_info.g.dart b/lib/data/model/server/server_private_info.g.dart index a3fc1653..7f4a692f 100644 --- a/lib/data/model/server/server_private_info.g.dart +++ b/lib/data/model/server/server_private_info.g.dart @@ -17,6 +17,9 @@ _Spi _$SpiFromJson(Map json) => _Spi( alterUrl: json['alterUrl'] as String?, autoConnect: json['autoConnect'] as bool? ?? true, jumpId: json['jumpId'] as String?, + jumpChainIds: (json['jumpChainIds'] as List?) + ?.map((e) => e as String) + .toList(), custom: json['custom'] == null ? null : ServerCustom.fromJson(json['custom'] as Map), @@ -47,6 +50,7 @@ Map _$SpiToJson(_Spi instance) => { 'alterUrl': ?instance.alterUrl, 'autoConnect': instance.autoConnect, 'jumpId': ?instance.jumpId, + 'jumpChainIds': ?instance.jumpChainIds, 'custom': ?instance.custom, 'wolCfg': ?instance.wolCfg, 'envs': ?instance.envs, diff --git a/lib/data/model/sftp/req.dart b/lib/data/model/sftp/req.dart index a80370e8..15421d90 100644 --- a/lib/data/model/sftp/req.dart +++ b/lib/data/model/sftp/req.dart @@ -15,31 +15,29 @@ class SftpReq { if (keyId != null) { privateKey = getPrivateKey(keyId); } - if (spi.jumpId != null) { + if (spi.jumpChainIds != null || spi.jumpId != null) { final chain = []; final keys = []; final visited = {spi.id.isNotEmpty ? spi.id : spi.oldId}; - var currentJumpId = spi.jumpId; - while (currentJumpId != null) { - final jumpSpi = Stores.server.box.get(currentJumpId); - if (jumpSpi == null) break; + final hopIds = spi.jumpChainIds ?? (spi.jumpId == null ? const [] : [spi.jumpId!]); + for (final hopId in hopIds) { + final hopSpi = Stores.server.box.get(hopId); + if (hopSpi == null) break; - // Prevent infinite loops if user mis-configured jump servers. - final jumpId = jumpSpi.id.isNotEmpty ? jumpSpi.id : jumpSpi.oldId; - if (!visited.add(jumpId)) { + final hopKey = hopSpi.id.isNotEmpty ? hopSpi.id : hopSpi.oldId; + if (!visited.add(hopKey)) { throw SSHErr( type: SSHErrType.connect, - message: 'Jump loop detected while building SFTP chain: ${jumpSpi.name}', + message: 'Jump loop detected while building SFTP chain: ${hopSpi.name}', ); } - chain.add(jumpSpi); - keys.add(jumpSpi.keyId != null ? getPrivateKey(jumpSpi.keyId!) : null); - currentJumpId = jumpSpi.jumpId; + chain.add(hopSpi); + keys.add(hopSpi.keyId != null ? getPrivateKey(hopSpi.keyId!) : null); } - // Always set when `spi.jumpId != null` so the isolate won't fallback to Stores. + // Always set when a jump is configured so the isolate won't fallback to Stores. jumpChain = chain; jumpPrivateKeys = keys; } diff --git a/lib/data/provider/server/all.g.dart b/lib/data/provider/server/all.g.dart index 2fefa053..bf1fc413 100644 --- a/lib/data/provider/server/all.g.dart +++ b/lib/data/provider/server/all.g.dart @@ -41,7 +41,7 @@ final class ServersNotifierProvider } } -String _$serversNotifierHash() => r'dc5da44f9bd8d8dcfba3e6e932cca3e2f379e582'; +String _$serversNotifierHash() => r'277d1b219235f14bcc1b82a1e16260c2f28decdb'; abstract class _$ServersNotifier extends $Notifier { ServersState build(); diff --git a/lib/data/provider/server/single.dart b/lib/data/provider/server/single.dart index 5b7df3bb..1c8e8407 100644 --- a/lib/data/provider/server/single.dart +++ b/lib/data/provider/server/single.dart @@ -135,7 +135,7 @@ class ServerNotifier extends _$ServerNotifier { final time2 = DateTime.now(); final spentTime = time2.difference(time1).inMilliseconds; - if (spi.jumpId == null) { + if (spi.jumpChainIds == null && spi.jumpId == null) { Loggers.app.info('Connected to ${spi.name} in $spentTime ms.'); } else { Loggers.app.info('Jump to ${spi.name} in $spentTime ms.'); diff --git a/lib/data/provider/server/single.g.dart b/lib/data/provider/server/single.g.dart index bfc9f2bb..959526d9 100644 --- a/lib/data/provider/server/single.g.dart +++ b/lib/data/provider/server/single.g.dart @@ -58,7 +58,7 @@ final class ServerNotifierProvider } } -String _$serverNotifierHash() => r'04b1beef4d96242fd10d5b523c6f5f17eb774bae'; +String _$serverNotifierHash() => r'52e806bcc32a7818d1ec2b07a3c683b06885c9f8'; final class ServerNotifierFamily extends $Family with diff --git a/lib/data/store/server.dart b/lib/data/store/server.dart index 065495a8..2feae6e3 100644 --- a/lib/data/store/server.dart +++ b/lib/data/store/server.dart @@ -89,15 +89,12 @@ class ServerStore extends HiveStore { // Replace ids in jump server settings. final spi = get(newId); if (spi != null) { - final jumpId = spi.jumpId; // This could be an oldId. - // Check if this jumpId corresponds to a server that was also migrated. - if (jumpId != null && idMap.containsKey(jumpId)) { - final newJumpId = idMap[jumpId]; - if (spi.jumpId != newJumpId) { - final newSpi = spi.copyWith(jumpId: newJumpId); - update(spi, newSpi); - } - } + final jumpChainIds = spi.jumpChainIds ?? (spi.jumpId == null ? null : [spi.jumpId!]); + if (jumpChainIds == null || jumpChainIds.isEmpty) continue; + + final newChain = jumpChainIds.map((e) => idMap[e] ?? e).toList(); + final newSpi = spi.copyWith(jumpId: null, jumpChainIds: newChain); + update(spi, newSpi); } // Replace ids in [Snippet] diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index c456396b..3877b8e3 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -107,6 +107,7 @@ class SpiAdapter extends TypeAdapter { alterUrl: fields[7] as String?, autoConnect: fields[8] == null ? true : fields[8] as bool, jumpId: fields[9] as String?, + jumpChainIds: (fields[16] as List?)?.cast(), custom: fields[10] as ServerCustom?, wolCfg: fields[11] as WakeOnLanCfg?, envs: (fields[12] as Map?)?.cast(), @@ -119,7 +120,7 @@ class SpiAdapter extends TypeAdapter { @override void write(BinaryWriter writer, Spi obj) { writer - ..writeByte(16) + ..writeByte(17) ..writeByte(0) ..write(obj.name) ..writeByte(1) @@ -151,7 +152,9 @@ class SpiAdapter extends TypeAdapter { ..writeByte(14) ..write(obj.customSystemType) ..writeByte(15) - ..write(obj.disabledCmdTypes); + ..write(obj.disabledCmdTypes) + ..writeByte(16) + ..write(obj.jumpChainIds); } @override diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 94d426fe..85c24d21 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -27,7 +27,7 @@ types: index: 4 Spi: typeId: 3 - nextIndex: 16 + nextIndex: 17 fields: name: index: 0 @@ -61,6 +61,8 @@ types: index: 14 disabledCmdTypes: index: 15 + jumpChainIds: + index: 16 VirtKey: typeId: 4 nextIndex: 45 diff --git a/lib/view/page/server/edit/actions.dart b/lib/view/page/server/edit/actions.dart index 7b6d30c7..6839de01 100644 --- a/lib/view/page/server/edit/actions.dart +++ b/lib/view/page/server/edit/actions.dart @@ -222,6 +222,15 @@ extension _Actions on _ServerEditPageState { return; } + if (this.spi != null) { + final ok = await context.showRoundDialog( + title: libL10n.attention, + child: Text(libL10n.askContinue('${l10n.jumpServer} ${libL10n.setting}')), + actions: Btnx.cancelOk, + ); + if (ok != true) return; + } + if (_keyIdx.value == null && _passwordController.text.isEmpty) { final ok = await context.showRoundDialog( title: libL10n.attention, @@ -277,7 +286,8 @@ extension _Actions on _ServerEditPageState { tags: _tags.value.isEmpty ? null : _tags.value.toList(), alterUrl: _altUrlController.text.selfNotEmptyOrNull, autoConnect: _autoConnect.value, - jumpId: _jumpServer.value, + jumpId: null, + jumpChainIds: _jumpChain.value.isEmpty ? null : _jumpChain.value, custom: custom, wolCfg: wol, envs: _env.value.isEmpty ? null : _env.value, @@ -421,7 +431,7 @@ extension _Utils on _ServerEditPageState { _altUrlController.text = spi.alterUrl ?? ''; _autoConnect.value = spi.autoConnect; - _jumpServer.value = spi.jumpId; + _jumpChain.value = spi.jumpChainIds ?? (spi.jumpId == null ? const [] : [spi.jumpId!]); final custom = spi.custom; if (custom != null) { diff --git a/lib/view/page/server/edit/edit.dart b/lib/view/page/server/edit/edit.dart index 75ec62e9..ee807d95 100644 --- a/lib/view/page/server/edit/edit.dart +++ b/lib/view/page/server/edit/edit.dart @@ -25,6 +25,7 @@ import 'package:server_box/view/page/private_key/edit.dart'; import 'package:server_box/view/page/server/discovery/discovery.dart'; part 'actions.dart'; +part 'jump_chain.dart'; part 'widget.dart'; class ServerEditPage extends ConsumerStatefulWidget { @@ -66,7 +67,7 @@ class _ServerEditPageState extends ConsumerState with AfterLayou /// -1: non selected, null: password, others: index of private key final _keyIdx = ValueNotifier(null); final _autoConnect = ValueNotifier(true); - final _jumpServer = nvn(); + final _jumpChain = [].vn; final _pveIgnoreCert = ValueNotifier(false); final _env = {}.vn; final _customCmds = {}.vn; @@ -100,7 +101,7 @@ class _ServerEditPageState extends ConsumerState with AfterLayou _keyIdx.dispose(); _autoConnect.dispose(); - _jumpServer.dispose(); + _jumpChain.dispose(); _pveIgnoreCert.dispose(); _env.dispose(); _customCmds.dispose(); @@ -199,7 +200,6 @@ class _ServerEditPageState extends ConsumerState with AfterLayou ), _buildAuth(), _buildSystemType(), - _buildJumpServer(), _buildMore(), ]; return AutoMultiList(children: children); diff --git a/lib/view/page/server/edit/jump_chain.dart b/lib/view/page/server/edit/jump_chain.dart new file mode 100644 index 00000000..3423c808 --- /dev/null +++ b/lib/view/page/server/edit/jump_chain.dart @@ -0,0 +1,176 @@ +part of 'edit.dart'; + +extension _JumpChain on _ServerEditPageState { + Widget _buildJumpChain() { + final serversState = ref.watch(serversProvider); + final servers = serversState.servers; + final selfId = spi?.id; + + if (selfId == null) { + return ListTile( + leading: const Icon(Icons.map), + title: Text(l10n.jumpServer), + subtitle: Text(libL10n.empty, style: UIs.textGrey), + ).cardx; + } + + String serverNameOrId(String id) { + return servers[id]?.name ?? id; + } + + List flattenHopIds(String id, {required Set visited}) { + if (!visited.add(id)) return const []; + final spi = servers[id]; + if (spi == null) return const []; + + final hops = spi.jumpChainIds; + if (hops == null || hops.isEmpty) return const []; + + final flat = []; + for (final hopId in hops) { + flat.add(hopId); + flat.addAll(flattenHopIds(hopId, visited: visited)); + } + return flat; + } + + bool containsCycleWithCandidate(String candidateId) { + final visited = {selfId}; + final queue = [..._jumpChain.value, candidateId]; + + for (final hopId in queue) { + if (hopId == selfId) return true; + if (!visited.add(hopId)) return true; + } + + for (final hopId in queue) { + final extra = flattenHopIds(hopId, visited: visited); + for (final id in extra) { + if (id == selfId) return true; + } + } + return false; + } + + String? buildTextNearToFar() { + if (_jumpChain.value.isEmpty) return null; + final flat = []; + final visited = {selfId}; + for (final hopId in _jumpChain.value) { + flat.add(hopId); + flat.addAll(flattenHopIds(hopId, visited: visited)); + } + final names = flat.map(serverNameOrId).toList(); + if (names.isEmpty) return null; + return names.join(' → '); + } + + String? buildTextFarToNear() { + final text = buildTextNearToFar(); + if (text == null) return null; + return text.split(' → ').reversed.join(' → '); + } + + return _jumpChain.listenVal((_) { + final nearToFar2 = buildTextNearToFar(); + final farToNear2 = buildTextFarToNear(); + + return ListTile( + leading: const Icon(Icons.map), + title: Text(l10n.jumpServer), + subtitle: (nearToFar2 == null) + ? Text(libL10n.empty, style: UIs.textGrey) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('${l10n.route}: $nearToFar2', style: UIs.textGrey), + Text('${libL10n.path}: $farToNear2', style: UIs.textGrey), + ], + ), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () async { + if (serversState.serverOrder.isEmpty) { + context.showSnackBar(libL10n.empty); + return; + } + + final candidates = serversState.serverOrder.where((e) => e != selfId).toList(); + if (candidates.isEmpty) { + context.showSnackBar(libL10n.empty); + return; + } + + // Add a hop + final nextHop = await context.showPickSingleDialog( + title: '${l10n.jumpServer} (+1)', + items: candidates.where((id) => !containsCycleWithCandidate(id)).toList(), + display: serverNameOrId, + clearable: true, + ); + if (nextHop == null) return; + + _jumpChain.value = [..._jumpChain.value, nextHop]; + + // If user wants to manage order/remove, offer a simple editor dialog + await context.showRoundDialog( + title: l10n.jumpServer, + child: SizedBox( + width: 320, + child: _jumpChain.listenVal((hops) { + return ListView.builder( + shrinkWrap: true, + itemCount: hops.length, + itemBuilder: (context, index) { + final id = hops[index]; + return ListTile( + title: Text(serverNameOrId(id)), + subtitle: Text(id, style: UIs.textGrey), + trailing: Wrap( + spacing: 4, + children: [ + IconButton( + icon: const Icon(Icons.arrow_upward, size: 18), + onPressed: index == 0 + ? null + : () { + final list = [..._jumpChain.value]; + final tmp = list[index - 1]; + list[index - 1] = list[index]; + list[index] = tmp; + _jumpChain.value = list; + }, + ), + IconButton( + icon: const Icon(Icons.arrow_downward, size: 18), + onPressed: index == hops.length - 1 + ? null + : () { + final list = [..._jumpChain.value]; + final tmp = list[index + 1]; + list[index + 1] = list[index]; + list[index] = tmp; + _jumpChain.value = list; + }, + ), + IconButton( + icon: const Icon(Icons.delete, size: 18), + onPressed: () { + final list = [..._jumpChain.value]..removeAt(index); + _jumpChain.value = list; + }, + ), + ], + ), + ); + }, + ); + }), + ), + actions: Btnx.oks, + ); + }, + ).cardx; + }); + } +} diff --git a/lib/view/page/server/edit/widget.dart b/lib/view/page/server/edit/widget.dart index b1786b6e..c63b0b0a 100644 --- a/lib/view/page/server/edit/widget.dart +++ b/lib/view/page/server/edit/widget.dart @@ -132,6 +132,7 @@ extension _Widgets on _ServerEditPageState { return ExpandTile( title: Text(l10n.more), children: [ + _buildJumpChain(), Input( controller: _logoUrlCtrl, type: TextInputType.url, @@ -347,100 +348,6 @@ extension _Widgets on _ServerEditPageState { ); } - Widget _buildJumpServer() { - const padding = EdgeInsets.only(left: 13, right: 13, bottom: 7); - final servers = ref.watch(serversProvider).servers; - final selfId = spi?.id; - - bool wouldCreateCycle(Spi candidate) { - if (selfId == null) return false; - if (candidate.id == selfId) return true; - - final visited = {selfId}; - var current = candidate; - while (true) { - final jumpId = current.jumpId; - if (jumpId == null) return false; - if (jumpId == selfId) return true; - if (!visited.add(jumpId)) { - // Candidate already contains a loop; treat as invalid to avoid infinite jump. - return true; - } - final next = servers[jumpId]; - if (next == null) return false; - current = next; - } - } - - String? buildJumpChainText(String jumpId) { - final chain = []; - final visited = {}; - var currentId = jumpId; - - while (true) { - if (!visited.add(currentId)) { - break; - } - final srv = servers[currentId]; - if (srv == null) break; - chain.add(srv); - final nextId = srv.jumpId; - if (nextId == null) break; - currentId = nextId; - } - - if (chain.isEmpty) return null; - // Display as actual connection order: farthest -> ... -> nearest. - return chain.reversed.map((e) => e.name).join(' → '); - } - - final srvs = servers.values.where((e) => e.id != selfId && !wouldCreateCycle(e)).toList(); - final body = _jumpServer.listenVal((val) { - final srv = srvs.firstWhereOrNull((e) => e.id == _jumpServer.value); - final chainText = val == null ? null : buildJumpChainText(val); - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (chainText != null) - Padding( - padding: const EdgeInsets.only(bottom: 7), - child: Text(chainText, style: UIs.textGrey), - ), - Choice( - multiple: false, - clearable: true, - value: srv != null ? [srv] : [], - builder: (state, _) => Wrap( - children: List.generate(srvs.length, (index) { - final item = srvs[index]; - return ChoiceChipX( - label: item.name, - state: state, - value: item, - onSelected: (srv, on) { - if (on) { - _jumpServer.value = srv.id; - } else { - _jumpServer.value = null; - } - }, - ); - }), - ), - ), - ], - ); - }); - return ExpandTile( - leading: const Icon(Icons.map), - initiallyExpanded: _jumpServer.value != null, - childrenPadding: padding, - title: Text(l10n.jumpServer), - children: [body], - ).cardx; - } Widget _buildWriteScriptTip() { return Btn.tile(