mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 12:48:28 +08:00
feat: SFTP连接功能
This commit is contained in:
@@ -8,6 +8,7 @@ import '../../core/crypto/aes_gcm_crypto.dart';
|
||||
import '../../core/crypto/crypto_js_aes.dart';
|
||||
import '../../core/crypto/rsa_crypto.dart';
|
||||
import '../terminal/ssh_connection_config.dart';
|
||||
import '../shell/sftp_session_manager.dart';
|
||||
import 'server_form_data.dart';
|
||||
import 'server_group_model.dart';
|
||||
import 'server_model.dart';
|
||||
@@ -21,6 +22,7 @@ abstract class ServerRepository {
|
||||
Future<String> updateHost(ServerFormData form);
|
||||
Future<String> deleteHost(String hostId);
|
||||
Future<SshConnectionConfig> fetchSshConfig(String hostId);
|
||||
Future<List<SftpFavorite>> fetchSftpFavorites(String hostId);
|
||||
}
|
||||
|
||||
/// Default [ServerRepository] backed by [ApiClient] and the RSA public key
|
||||
@@ -31,10 +33,10 @@ class ApiServerRepository implements ServerRepository {
|
||||
required String publicKeyPem,
|
||||
RsaCrypto? rsa,
|
||||
Random? random,
|
||||
}) : _api = apiClient,
|
||||
_publicKeyPem = publicKeyPem,
|
||||
_rsa = rsa ?? RsaCrypto(),
|
||||
_random = random ?? Random.secure();
|
||||
}) : _api = apiClient,
|
||||
_publicKeyPem = publicKeyPem,
|
||||
_rsa = rsa ?? RsaCrypto(),
|
||||
_random = random ?? Random.secure();
|
||||
|
||||
final ApiClient _api;
|
||||
final String _publicKeyPem;
|
||||
@@ -69,13 +71,19 @@ class ApiServerRepository implements ServerRepository {
|
||||
|
||||
@override
|
||||
Future<String> createHost(ServerFormData form) async {
|
||||
final response = await _api.postJson('/host-save', _prepareHostPayload(form));
|
||||
final response = await _api.postJson(
|
||||
'/host-save',
|
||||
_prepareHostPayload(form),
|
||||
);
|
||||
return response['msg']?.toString() ?? 'success';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> updateHost(ServerFormData form) async {
|
||||
final response = await _api.putJson('/host-save', _prepareHostPayload(form));
|
||||
final response = await _api.putJson(
|
||||
'/host-save',
|
||||
_prepareHostPayload(form),
|
||||
);
|
||||
return response['msg']?.toString() ?? 'success';
|
||||
}
|
||||
|
||||
@@ -147,6 +155,17 @@ class ApiServerRepository implements ServerRepository {
|
||||
return SshConnectionConfig.fromJson(plaintext);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SftpFavorite>> fetchSftpFavorites(String hostId) async {
|
||||
final response = await _api.getJson('/mobile/sftp-favorites/$hostId');
|
||||
final raw = response['data'];
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(SftpFavorite.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Uint8List _randomBytes(int length) {
|
||||
final bytes = Uint8List(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../servers/server_model.dart';
|
||||
import '../terminal/ssh_connection_config.dart';
|
||||
import '../terminal/ssh_transport.dart';
|
||||
|
||||
enum SftpConnectionStatus { disconnected, connecting, connected, error }
|
||||
|
||||
class SftpFileEntry {
|
||||
const SftpFileEntry({
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.size,
|
||||
required this.modifyTime,
|
||||
required this.permissions,
|
||||
required this.ownerName,
|
||||
required this.groupName,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String type;
|
||||
final int? size;
|
||||
final DateTime? modifyTime;
|
||||
final String permissions;
|
||||
final String ownerName;
|
||||
final String groupName;
|
||||
|
||||
bool get isDirectory => type == 'd';
|
||||
bool get isLink => type == 'l';
|
||||
}
|
||||
|
||||
class SftpFavorite {
|
||||
const SftpFavorite({
|
||||
required this.hostId,
|
||||
required this.path,
|
||||
required this.name,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final String hostId;
|
||||
final String path;
|
||||
final String name;
|
||||
final String type;
|
||||
|
||||
factory SftpFavorite.fromJson(Map<String, dynamic> json) {
|
||||
return SftpFavorite(
|
||||
hostId: (json['hostId'] ?? '').toString(),
|
||||
path: (json['path'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
type: (json['type'] ?? '').toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SftpSessionState extends ChangeNotifier {
|
||||
SftpSessionState({required this.server});
|
||||
|
||||
final ServerModel server;
|
||||
SftpConnectionStatus status = SftpConnectionStatus.disconnected;
|
||||
String currentPath = '/';
|
||||
List<SftpFileEntry> entries = const [];
|
||||
List<SftpFavorite> favorites = const [];
|
||||
Set<String> selectedNames = <String>{};
|
||||
bool loadingDirectory = false;
|
||||
bool showHidden = false;
|
||||
String? lastError;
|
||||
|
||||
bool get isConnected => status == SftpConnectionStatus.connected;
|
||||
bool get hasSelection => selectedNames.isNotEmpty;
|
||||
bool get allVisibleSelected =>
|
||||
entries.isNotEmpty &&
|
||||
entries.every((entry) => selectedNames.contains(entry.name));
|
||||
|
||||
void update({
|
||||
SftpConnectionStatus? status,
|
||||
String? currentPath,
|
||||
List<SftpFileEntry>? entries,
|
||||
List<SftpFavorite>? favorites,
|
||||
Set<String>? selectedNames,
|
||||
bool? loadingDirectory,
|
||||
bool? showHidden,
|
||||
String? lastError,
|
||||
}) {
|
||||
if (status != null) this.status = status;
|
||||
if (currentPath != null) this.currentPath = currentPath;
|
||||
if (entries != null) this.entries = entries;
|
||||
if (favorites != null) this.favorites = favorites;
|
||||
if (selectedNames != null) this.selectedNames = selectedNames;
|
||||
if (loadingDirectory != null) this.loadingDirectory = loadingDirectory;
|
||||
if (showHidden != null) this.showHidden = showHidden;
|
||||
this.lastError = lastError;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleSelection(String name) {
|
||||
final next = Set<String>.from(selectedNames);
|
||||
if (!next.add(name)) next.remove(name);
|
||||
selectedNames = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addSelection(String name) {
|
||||
if (selectedNames.contains(name)) return;
|
||||
selectedNames = {...selectedNames, name};
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectOnly(String name) {
|
||||
selectedNames = {name};
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleAllVisible() {
|
||||
if (allVisibleSelected) {
|
||||
selectedNames = <String>{};
|
||||
} else {
|
||||
selectedNames = entries.map((entry) => entry.name).toSet();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
if (selectedNames.isEmpty) return;
|
||||
selectedNames = <String>{};
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class SftpSessionManager extends ChangeNotifier {
|
||||
SftpSessionManager({SshTransportFactory? transportFactory})
|
||||
: _transportFactory = transportFactory ?? SshTransportFactory();
|
||||
|
||||
final SshTransportFactory _transportFactory;
|
||||
final Map<String, _SftpConnection> _connections = {};
|
||||
final Map<String, SftpSessionState> _states = {};
|
||||
String? _activeHostId;
|
||||
|
||||
Iterable<SftpSessionState> get sessions => List.unmodifiable(_states.values);
|
||||
String? get activeHostId => _activeHostId;
|
||||
SftpSessionState? get activeSession {
|
||||
final id = _activeHostId;
|
||||
return id == null ? null : _states[id];
|
||||
}
|
||||
|
||||
bool isConnected(String hostId) {
|
||||
return _states[hostId]?.status == SftpConnectionStatus.connected;
|
||||
}
|
||||
|
||||
SftpSessionState? sessionFor(String hostId) => _states[hostId];
|
||||
|
||||
Future<SftpSessionState> connect({
|
||||
required ServerModel server,
|
||||
required SshConnectionConfig config,
|
||||
Future<List<SftpFavorite>> Function(String hostId)? loadFavorites,
|
||||
}) async {
|
||||
final existing = _states[server.id];
|
||||
if (existing?.status == SftpConnectionStatus.connected) {
|
||||
_activeHostId = server.id;
|
||||
notifyListeners();
|
||||
return existing!;
|
||||
}
|
||||
|
||||
final state = existing ?? SftpSessionState(server: server);
|
||||
_states[server.id] = state;
|
||||
_activeHostId = server.id;
|
||||
state.update(
|
||||
status: SftpConnectionStatus.connecting,
|
||||
loadingDirectory: true,
|
||||
lastError: null,
|
||||
selectedNames: <String>{},
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _connections.remove(server.id)?.close();
|
||||
final transport = await _transportFactory.open(config);
|
||||
final identities = config.authType == 'privateKey'
|
||||
? SSHKeyPair.fromPem(config.privateKey, config.privateKeyPassphrase)
|
||||
: null;
|
||||
final client = SSHClient(
|
||||
transport.socket,
|
||||
username: config.username,
|
||||
onPasswordRequest: config.authType == 'password'
|
||||
? () => config.password
|
||||
: null,
|
||||
identities: identities,
|
||||
);
|
||||
final sftp = await client.sftp();
|
||||
_connections[server.id] = _SftpConnection(
|
||||
client: client,
|
||||
sftp: sftp,
|
||||
transport: transport,
|
||||
);
|
||||
|
||||
final initial = await _resolveInitialPath(sftp, config.username);
|
||||
final entries = await _listDirectory(sftp, initial, state.showHidden);
|
||||
final favorites = await loadFavorites?.call(server.id) ?? const [];
|
||||
state.update(
|
||||
status: SftpConnectionStatus.connected,
|
||||
currentPath: initial,
|
||||
entries: entries,
|
||||
favorites: favorites,
|
||||
loadingDirectory: false,
|
||||
lastError: null,
|
||||
selectedNames: <String>{},
|
||||
);
|
||||
notifyListeners();
|
||||
return state;
|
||||
} catch (error) {
|
||||
await _connections.remove(server.id)?.close();
|
||||
state.update(
|
||||
status: SftpConnectionStatus.error,
|
||||
loadingDirectory: false,
|
||||
lastError: error.toString(),
|
||||
);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void activate(String hostId) {
|
||||
if (_states[hostId] == null) return;
|
||||
_activeHostId = hostId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> disconnectActive() async {
|
||||
final hostId = _activeHostId;
|
||||
if (hostId == null) return;
|
||||
final connection = _connections.remove(hostId);
|
||||
final state = _states.remove(hostId);
|
||||
await connection?.close();
|
||||
state?.dispose();
|
||||
_activeHostId = _states.isEmpty ? null : _states.keys.first;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> openPath(String path) async {
|
||||
final state = activeSession;
|
||||
if (state == null) return;
|
||||
final connection = _connections[state.server.id];
|
||||
if (connection == null) return;
|
||||
state.update(loadingDirectory: true, lastError: null);
|
||||
try {
|
||||
final entries = await _listDirectory(
|
||||
connection.sftp,
|
||||
path,
|
||||
state.showHidden,
|
||||
);
|
||||
state.update(
|
||||
currentPath: path,
|
||||
entries: entries,
|
||||
loadingDirectory: false,
|
||||
selectedNames: <String>{},
|
||||
);
|
||||
} catch (error) {
|
||||
state.update(loadingDirectory: false, lastError: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshActive() async {
|
||||
final path = activeSession?.currentPath;
|
||||
if (path == null) return;
|
||||
await openPath(path);
|
||||
}
|
||||
|
||||
Future<void> createDirectory(String name) async {
|
||||
final state = activeSession;
|
||||
if (state == null) return;
|
||||
final connection = _connections[state.server.id];
|
||||
if (connection == null) return;
|
||||
final targetPath = joinPath(state.currentPath, name.trim());
|
||||
await connection.sftp.mkdir(targetPath);
|
||||
await refreshActive();
|
||||
}
|
||||
|
||||
Future<void> createFile(String name) async {
|
||||
final state = activeSession;
|
||||
if (state == null) return;
|
||||
final connection = _connections[state.server.id];
|
||||
if (connection == null) return;
|
||||
final targetPath = joinPath(state.currentPath, name.trim());
|
||||
final file = await connection.sftp.open(
|
||||
targetPath,
|
||||
mode:
|
||||
SftpFileOpenMode.write |
|
||||
SftpFileOpenMode.create |
|
||||
SftpFileOpenMode.exclusive,
|
||||
);
|
||||
try {
|
||||
await file.writeBytes(Uint8List(0));
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
await refreshActive();
|
||||
}
|
||||
|
||||
Future<void> goParent() async {
|
||||
final state = activeSession;
|
||||
if (state == null) return;
|
||||
await openPath(parentPath(state.currentPath));
|
||||
}
|
||||
|
||||
Future<void> setShowHidden(bool value) async {
|
||||
final state = activeSession;
|
||||
if (state == null || state.showHidden == value) return;
|
||||
state.update(showHidden: value);
|
||||
await refreshActive();
|
||||
}
|
||||
|
||||
String entryPath(SftpSessionState state, SftpFileEntry entry) {
|
||||
return joinPath(state.currentPath, entry.name);
|
||||
}
|
||||
|
||||
static String joinPath(String base, String name) {
|
||||
if (base == '/' || base == '~') return '$base/$name'.replaceAll('//', '/');
|
||||
if (base.endsWith('/')) return '$base$name';
|
||||
return '$base/$name';
|
||||
}
|
||||
|
||||
static String parentPath(String path) {
|
||||
if (path == '/' || path == '~') return path;
|
||||
final trimmed = path.endsWith('/') && path.length > 1
|
||||
? path.substring(0, path.length - 1)
|
||||
: path;
|
||||
final index = trimmed.lastIndexOf('/');
|
||||
if (index <= 0) return '/';
|
||||
return trimmed.substring(0, index);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final connection in _connections.values) {
|
||||
unawaited(connection.close());
|
||||
}
|
||||
for (final state in _states.values) {
|
||||
state.dispose();
|
||||
}
|
||||
_connections.clear();
|
||||
_states.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<String> _resolveInitialPath(SftpClient sftp, String username) async {
|
||||
if (username == 'root') {
|
||||
try {
|
||||
await sftp.listdir('/root');
|
||||
return '/root';
|
||||
} catch (_) {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
try {
|
||||
await sftp.listdir('/');
|
||||
return '/';
|
||||
} catch (_) {
|
||||
try {
|
||||
return await sftp.absolute('.');
|
||||
} catch (_) {
|
||||
return '~';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SftpFileEntry>> _listDirectory(
|
||||
SftpClient sftp,
|
||||
String path,
|
||||
bool showHidden,
|
||||
) async {
|
||||
final names = await sftp.listdir(path == '~' ? '.' : path);
|
||||
final entries = names
|
||||
.where((item) => item.filename != '.' && item.filename != '..')
|
||||
.where((item) => showHidden || !item.filename.startsWith('.'))
|
||||
.map(_entryFromName)
|
||||
.toList(growable: false);
|
||||
entries.sort((a, b) {
|
||||
if (a.isDirectory != b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
SftpFileEntry _entryFromName(SftpName name) {
|
||||
final type = _typeFromLongName(name.longname);
|
||||
final parts = name.longname.trim().split(RegExp(r'\s+'));
|
||||
final permissions = parts.isNotEmpty ? parts.first.skip(1) : '';
|
||||
final ownerName = parts.length >= 3 ? parts[2] : '';
|
||||
final groupName = parts.length >= 4 ? parts[3] : '';
|
||||
return SftpFileEntry(
|
||||
name: name.filename,
|
||||
type: type,
|
||||
size: name.attr.size,
|
||||
modifyTime: _dateFromSeconds(name.attr.modifyTime),
|
||||
permissions: permissions,
|
||||
ownerName: ownerName,
|
||||
groupName: groupName,
|
||||
);
|
||||
}
|
||||
|
||||
String _typeFromLongName(String longName) {
|
||||
if (longName.startsWith('d')) return 'd';
|
||||
if (longName.startsWith('l')) return 'l';
|
||||
return '-';
|
||||
}
|
||||
|
||||
DateTime? _dateFromSeconds(int? seconds) {
|
||||
if (seconds == null || seconds <= 0) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
class _SftpConnection {
|
||||
_SftpConnection({
|
||||
required this.client,
|
||||
required this.sftp,
|
||||
required this.transport,
|
||||
});
|
||||
|
||||
final SSHClient client;
|
||||
final SftpClient sftp;
|
||||
final SshTransportHandle transport;
|
||||
|
||||
Future<void> close() async {
|
||||
sftp.close();
|
||||
client.close();
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
String skip(int count) {
|
||||
if (length <= count) return '';
|
||||
return substring(count);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,7 +60,8 @@ const Map<String, String> stringsEn = {
|
||||
'servers.addServer': 'Add server',
|
||||
'servers.editServer': 'Edit server',
|
||||
'servers.searchHint': 'Search by name, host, user, tag, or group',
|
||||
'servers.emptyHint': 'No servers yet. Pull to refresh after adding hosts on web.',
|
||||
'servers.emptyHint':
|
||||
'No servers yet. Pull to refresh after adding hosts on web.',
|
||||
'servers.emptyFiltered': 'No matching servers.',
|
||||
'servers.notConfigured': 'Not configured',
|
||||
'servers.fetchSshFailed': 'Failed to get SSH config: {0}',
|
||||
@@ -113,12 +114,17 @@ const Map<String, String> stringsEn = {
|
||||
'servers.proxy.proxyServerShort': 'Proxy',
|
||||
'servers.proxy.socksShort': 'Proxy',
|
||||
'servers.jumpHosts.empty': 'No configured servers can be used as jump hosts.',
|
||||
'servers.jumpHosts.orderHint': 'Connection order follows the selection order.',
|
||||
'servers.jumpHosts.orderHint':
|
||||
'Connection order follows the selection order.',
|
||||
'servers.jumpHosts.placeholder': 'Select jump hosts',
|
||||
'servers.proxyServerUnsupported': 'Proxy server selection is reserved for later.',
|
||||
'servers.proxy.empty': 'No Socks service data. Manage proxy services on web first.',
|
||||
'servers.credentials.empty': 'No credential data. Manage credentials on web first.',
|
||||
'servers.credentials.hint': 'Credentials are maintained centrally in credential management.',
|
||||
'servers.proxyServerUnsupported':
|
||||
'Proxy server selection is reserved for later.',
|
||||
'servers.proxy.empty':
|
||||
'No Socks service data. Manage proxy services on web first.',
|
||||
'servers.credentials.empty':
|
||||
'No credential data. Manage credentials on web first.',
|
||||
'servers.credentials.hint':
|
||||
'Credentials are maintained centrally in credential management.',
|
||||
'servers.validation.group': 'Select a group',
|
||||
'servers.validation.name': 'Enter a server name',
|
||||
'servers.validation.host': 'Enter an IP address or domain',
|
||||
@@ -130,6 +136,22 @@ const Map<String, String> stringsEn = {
|
||||
|
||||
// SFTP / Scripts placeholders
|
||||
'sftp.placeholder': 'Coming soon: browse and manage remote files',
|
||||
'sftp.emptyTitle': 'No server connected',
|
||||
'sftp.emptyBody': 'Choose a server to start browsing remote files over SFTP.',
|
||||
'sftp.chooseServer': 'Choose server',
|
||||
'sftp.switchServer': 'Switch server',
|
||||
'sftp.sheetTitle': 'Select server',
|
||||
'sftp.sheetSubtitle': 'Connected servers are shown first.',
|
||||
'sftp.searchHint': 'Search by name, host, or user',
|
||||
'sftp.connectedSection': 'Connected',
|
||||
'sftp.disconnectedSection': 'Not connected',
|
||||
'sftp.statusConnected': 'Connected',
|
||||
'sftp.statusDisconnected': 'Not connected',
|
||||
'sftp.connecting': 'Connecting SFTP...',
|
||||
'sftp.emptyDirectory': 'This directory is empty.',
|
||||
'sftp.upload': 'Upload',
|
||||
'sftp.newItem': 'New',
|
||||
'sftp.favorites': 'Favorites',
|
||||
'scripts.placeholder': 'Coming soon: script library',
|
||||
|
||||
// Settings
|
||||
|
||||
@@ -126,6 +126,22 @@ const Map<String, String> stringsZh = {
|
||||
|
||||
// Placeholders
|
||||
'sftp.placeholder': '即将上线:浏览和管理远程文件',
|
||||
'sftp.emptyTitle': '还没有选择服务器',
|
||||
'sftp.emptyBody': '请选择一台服务器,开始使用 SFTP 浏览远程文件。',
|
||||
'sftp.chooseServer': '选择服务器',
|
||||
'sftp.switchServer': '切换服务器',
|
||||
'sftp.sheetTitle': '选择服务器',
|
||||
'sftp.sheetSubtitle': '已连接的服务器会优先显示。',
|
||||
'sftp.searchHint': '按名称 / 主机 / 用户名搜索',
|
||||
'sftp.connectedSection': '已连接',
|
||||
'sftp.disconnectedSection': '未连接',
|
||||
'sftp.statusConnected': '已连接',
|
||||
'sftp.statusDisconnected': '未连接',
|
||||
'sftp.connecting': '正在连接 SFTP...',
|
||||
'sftp.emptyDirectory': '当前目录为空',
|
||||
'sftp.upload': '上传',
|
||||
'sftp.newItem': '新建',
|
||||
'sftp.favorites': '收藏',
|
||||
'scripts.placeholder': '即将上线:脚本库',
|
||||
|
||||
// Settings
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/shell/sftp_session_manager.dart';
|
||||
import '../features/terminal/terminal_session_manager.dart';
|
||||
|
||||
/// Single TerminalSessionManager for the whole app. Used to be passed
|
||||
@@ -10,3 +11,9 @@ final terminalSessionManagerProvider = Provider<TerminalSessionManager>((ref) {
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
});
|
||||
|
||||
final sftpSessionManagerProvider = Provider<SftpSessionManager>((ref) {
|
||||
final manager = SftpSessionManager();
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:mobile/features/servers/server_form_page.dart';
|
||||
import 'package:mobile/features/servers/server_group_model.dart';
|
||||
import 'package:mobile/features/servers/server_model.dart';
|
||||
import 'package:mobile/features/servers/server_repository.dart';
|
||||
import 'package:mobile/features/shell/sftp_session_manager.dart';
|
||||
import 'package:mobile/features/terminal/ssh_connection_config.dart';
|
||||
import 'package:mobile/l10n/app_localizations.dart';
|
||||
import 'package:mobile/state/api_providers.dart';
|
||||
@@ -19,12 +20,12 @@ class _FakeRepository implements ServerRepository {
|
||||
|
||||
@override
|
||||
Future<List<ServerGroupModel>> fetchGroups() async => [
|
||||
ServerGroupModel.fromJson({
|
||||
'id': 'default',
|
||||
'name': 'Default group',
|
||||
'index': 1,
|
||||
}),
|
||||
];
|
||||
ServerGroupModel.fromJson({
|
||||
'id': 'default',
|
||||
'name': 'Default group',
|
||||
'index': 1,
|
||||
}),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> createHost(ServerFormData form) async {
|
||||
@@ -42,6 +43,10 @@ class _FakeRepository implements ServerRepository {
|
||||
Future<SshConnectionConfig> fetchSshConfig(String hostId) async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SftpFavorite>> fetchSftpFavorites(String hostId) async =>
|
||||
const [];
|
||||
}
|
||||
|
||||
Widget _wrap(ServerRepository repo) {
|
||||
@@ -77,7 +82,9 @@ Future<void> _fillRequiredFields(WidgetTester tester) async {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('requires credential when auth type is credential', (tester) async {
|
||||
testWidgets('requires credential when auth type is credential', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = _FakeRepository();
|
||||
await _pumpForm(tester, repo);
|
||||
await _fillRequiredFields(tester);
|
||||
@@ -91,7 +98,9 @@ void main() {
|
||||
expect(repo.createCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('requires proxy target when proxy type is not none', (tester) async {
|
||||
testWidgets('requires proxy target when proxy type is not none', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = _FakeRepository();
|
||||
await _pumpForm(tester, repo);
|
||||
await _fillRequiredFields(tester);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:mobile/features/servers/server_form_data.dart';
|
||||
import 'package:mobile/features/servers/server_model.dart';
|
||||
import 'package:mobile/features/servers/server_group_model.dart';
|
||||
import 'package:mobile/features/servers/server_repository.dart';
|
||||
import 'package:mobile/features/shell/sftp_session_manager.dart';
|
||||
import 'package:mobile/features/servers/servers_tab.dart';
|
||||
import 'package:mobile/features/terminal/ssh_connection_config.dart';
|
||||
import 'package:mobile/l10n/app_localizations.dart';
|
||||
@@ -84,6 +85,10 @@ class _FakeRepository implements ServerRepository {
|
||||
if (sshError != null) throw sshError!;
|
||||
return config;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SftpFavorite>> fetchSftpFavorites(String hostId) async =>
|
||||
const [];
|
||||
}
|
||||
|
||||
class _RecordingAuthNotifier extends AuthNotifier {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
const { RSADecryptAsync } = require('../utils/encrypt')
|
||||
const { encryptJsonForMobile } = require('../utils/mobile-crypto')
|
||||
const { HostListDB } = require('../utils/db-class')
|
||||
const { HostListDB, FavoriteSftpDB } = require('../utils/db-class')
|
||||
|
||||
// `getConnectionOptions` and `getProxyConfig` are lazily required inside
|
||||
// functions. Loading `../socket/terminal` at module scope pulls in
|
||||
// `terminal-session`, which expects `global.logger` to exist after app boot.
|
||||
const hostListDB = new HostListDB().getInstance()
|
||||
const favoriteSftpDB = new FavoriteSftpDB().getInstance()
|
||||
|
||||
function normalizePort(port) {
|
||||
const numericPort = Number(port)
|
||||
@@ -154,8 +155,26 @@ async function getMobileSshConnection({ request, res }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getMobileSftpFavorites({ params, request, res }) {
|
||||
try {
|
||||
const hostId = params?.hostId || request.query?.hostId
|
||||
if (!hostId) {
|
||||
return res.fail({ msg: 'missing hostId' })
|
||||
}
|
||||
const favorites = await favoriteSftpDB.findAsync(
|
||||
{ hostId },
|
||||
{ sort: { createTime: -1 } }
|
||||
)
|
||||
return res.success({ data: favorites, msg: 'success' })
|
||||
} catch (error) {
|
||||
logger.error('getMobileSftpFavorites error:', error.message)
|
||||
return res.fail({ msg: error.message || 'mobile sftp favorites failed' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMobileSshConnection,
|
||||
getMobileSftpFavorites,
|
||||
getMobileConnectionTopology,
|
||||
normalizePort,
|
||||
normalizeMobileAuthPayload,
|
||||
|
||||
@@ -12,7 +12,7 @@ const { getProxyList, addProxy, updateProxy, removeProxy } = require('../control
|
||||
const { getTerminalConfig, saveTerminalConfig } = require('../controller/terminal-config')
|
||||
const { getServerListConfig, saveServerListConfig } = require('../controller/server-list-config')
|
||||
const { getSuspendedSessions, getTerminalSessionConfig, updateTerminalSessionConfig } = require('../controller/terminal')
|
||||
const { getMobileSshConnection } = require('../controller/mobile')
|
||||
const { getMobileSshConnection, getMobileSftpFavorites } = require('../controller/mobile')
|
||||
|
||||
const ssh = [
|
||||
{
|
||||
@@ -398,6 +398,11 @@ const mobile = [
|
||||
method: 'post',
|
||||
path: '/mobile/ssh-connection',
|
||||
controller: getMobileSshConnection
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/mobile/sftp-favorites/:hostId',
|
||||
controller: getMobileSftpFavorites
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user