refactor(server): Move the SSH import and discovery features from the server edit page to the settings page (#1079)

* refactor(server): Move the SSH import and discovery features from the server edit page to the settings page

* feat (SSH Configuration): Added a feature to automatically import SSH configurations upon first launch

Checks for and prompts the user to import SSH configurations upon the first launch on the desktop

Optimized the SSH server import logic, adding duplicate detection and name conflict handling

Fixed an issue with mount status checks that could occur during the import process

* refactor (UI): Adjust the placement of the QR code scanning and SSH configuration import features

Move the QR code scanning feature from the server editing page to the settings page, and display different access points based on the platform

Optimize the SSH configuration import logic to ensure the status is updated correctly after the configuration is read for the first time

* refactor(ssh): Refactor server import logic and extract common methods

Extract server import logic into the `ServerDeduplication` class

Use the `importServersWithNotification` method consistently to handle imports

Remove duplicate `_importServers` and `_resolveServers` methods

Add checks for existing server IDs

* refactor(SSH): Optimized server import logic and fixed permission issues

- Moved the SSH configuration import logic from `edit.dart` to `actions.dart`
- Removed redundant checks for the `mounted` parameter
- Added handling for file permission exceptions
- Improved logic for resolving server name conflicts

* fix(ssh): Fixed an issue with message display during SSH configuration import

- Modified the format of the import success message to display the number of servers successfully imported
- Added a prompt for manual selection when permissions are denied
- Optimized the server deduplication logic to display an “already exists” message based on the original count

* fix(ssh): Fixed an issue with the count display when importing SSH configurations

Adjusted the server's deduplication logic to ensure the correct original count is used when displaying the number of imports

Removed unnecessary flag settings for the first read of SSH configurations

* fix: Fixed an issue where the “first read” flag was not updated when SSH configuration access was denied

When SSH configuration access is denied, set the “first read” flag to false to prevent repeated prompts

* fix(server): Optimized the logic for checking existing servers when importing SSH configurations

Moved the logic for checking existing servers to an earlier stage to avoid unnecessary parsing of SSH configurations
This commit is contained in:
GT610
2026-03-20 20:41:09 +08:00
committed by GitHub
parent f858b150a5
commit 1bea565c21
8 changed files with 332 additions and 310 deletions

View File

@@ -13,226 +13,6 @@ extension _Actions on _ServerEditPageState {
);
}
Future<void> _onTapSSHDiscovery() async {
try {
final result = await SshDiscoveryPage.route.go(context);
if (result != null && result.isNotEmpty) {
await _processDiscoveredServers(result);
}
} catch (e, s) {
context.showErrDialog(e, s);
}
}
Future<void> _processDiscoveredServers(
List<SshDiscoveryResult> discoveredServers,
) async {
if (discoveredServers.length == 1) {
// Single server - populate the current form
final server = discoveredServers.first;
_ipController.text = server.ip;
_portController.text = server.port.toString();
if (_nameController.text.isEmpty) {
_nameController.text = server.ip;
}
context.showSnackBar('${libL10n.found} 1 ${libL10n.server}');
} else {
// Multiple servers - show import dialog
final shouldImport = await context.showRoundDialog<bool>(
title: libL10n.import,
child: Text(
libL10n.askContinue(
'${libL10n.found} ${discoveredServers.length} ${libL10n.servers}',
),
),
actions: Btnx.cancelOk,
);
if (shouldImport == true) {
// Prompt user to configure default values before importing
final defaultUsername = 'root';
final defaultKeyId = _keyIdx.value?.toString() ?? '';
final usernameController = TextEditingController(text: defaultUsername);
final keyIdController = TextEditingController(text: defaultKeyId);
final shouldProceed = await context.showRoundDialog<bool>(
title: libL10n.import,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${libL10n.found} ${discoveredServers.length} ${libL10n.servers}.',
),
const SizedBox(height: 8),
Text(libL10n.setting),
const SizedBox(height: 8),
TextField(
controller: usernameController,
decoration: InputDecoration(labelText: libL10n.user),
),
TextField(
controller: keyIdController,
decoration: InputDecoration(labelText: l10n.privateKey),
),
],
),
actions: Btnx.cancelOk,
);
if (shouldProceed == true) {
final username = usernameController.text.isNotEmpty
? usernameController.text
: defaultUsername;
final keyId = keyIdController.text.isNotEmpty
? keyIdController.text
: null;
final servers = discoveredServers
.map(
(result) => Spi(
name: result.ip,
ip: result.ip,
port: result.port,
user: username,
keyId: keyId,
pwd: _passwordController.text.isEmpty
? null
: _passwordController.text,
),
)
.toList();
await _batchImportServers(servers);
}
usernameController.dispose();
keyIdController.dispose();
}
}
}
Future<void> _batchImportServers(List<Spi> servers) async {
final store = Stores.server;
int imported = 0;
for (final server in servers) {
try {
store.put(server);
imported++;
} catch (e) {
dprint('Failed to import server ${server.name}: $e');
}
}
context.showSnackBar('${libL10n.success}: $imported ${libL10n.servers}');
if (mounted) context.pop(true);
}
void _onTapSSHImport() async {
try {
final servers = await SSHConfig.parseConfig();
if (servers.isEmpty) {
context.showSnackBar(l10n.sshConfigNoServers);
return;
}
dprint('Parsed ${servers.length} servers from SSH config');
await _processSSHServers(servers);
dprint('Finished processing SSH config servers');
} catch (e, s) {
_handleImportSSHCfgPermissionIssue(e, s);
}
}
void _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async {
dprint('Error importing SSH config: $e');
// Check if it's a permission error and offer file picker as fallback
if (e is PathAccessException ||
e.toString().contains('Operation not permitted')) {
final useFilePicker = await context.showRoundDialog<bool>(
title: l10n.sshConfigImport,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.sshConfigPermissionDenied),
const SizedBox(height: 8),
Text(l10n.sshConfigManualSelect),
],
),
actions: Btnx.cancelOk,
);
if (useFilePicker == true) {
await _onTapSSHImportWithFilePicker();
}
} else {
context.showErrDialog(e, s);
}
}
Future<void> _processSSHServers(List<Spi> servers) async {
final deduplicated = ServerDeduplication.deduplicateServers(servers);
final resolved = ServerDeduplication.resolveNameConflicts(deduplicated);
final summary = ServerDeduplication.getImportSummary(servers, resolved);
if (!summary.hasItemsToImport) {
context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}'));
return;
}
final shouldImport = await context.showRoundDialog<bool>(
title: l10n.sshConfigImport,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.sshConfigFoundServers('${summary.total}')),
if (summary.hasDuplicates)
Text(
l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'),
style: UIs.textGrey,
),
Text(l10n.sshConfigServersToImport('${summary.toImport}')),
const SizedBox(height: 16),
...resolved.map(
(s) => Text('${s.name} (${s.user}@${s.ip}:${s.port})'),
),
],
),
),
actions: Btnx.cancelOk,
);
if (shouldImport == true) {
for (final server in resolved) {
ref.read(serversProvider.notifier).addServer(server);
}
context.showSnackBar(l10n.sshConfigImported('${resolved.length}'));
}
}
Future<void> _onTapSSHImportWithFilePicker() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: false,
dialogTitle: 'SSH ${libL10n.select}',
);
if (result?.files.single.path case final path?) {
final servers = await SSHConfig.parseConfig(path);
if (servers.isEmpty) {
context.showSnackBar(l10n.sshConfigNoServers);
return;
}
await _processSSHServers(servers);
}
} catch (e, s) {
context.showErrDialog(e, s);
}
}
void _onTapCustomItem() async {
final res = await KvEditor.route.go(
context,
@@ -358,60 +138,60 @@ extension _Actions on _ServerEditPageState {
}
extension _Utils on _ServerEditPageState {
void _checkSSHConfigImport() async {
final prop = Stores.setting.firstTimeReadSSHCfg;
// Only check if it's first time and user hasn't disabled it
if (!prop.fetch()) return;
Future<void> _checkSSHConfigImport() async {
final hasExistingServers = ref.read(serversProvider).servers.isNotEmpty;
if (hasExistingServers) {
Stores.setting.firstTimeReadSSHCfg.put(false);
return;
}
try {
// Check if SSH config exists
final (_, configExists) = SSHConfig.configExists();
if (!configExists) return;
final servers = await SSHConfig.parseConfig();
if (!mounted) return;
if (servers.isEmpty) {
Stores.setting.firstTimeReadSSHCfg.put(false);
return;
}
// Ask for permission
final hasPermission = await context.showRoundDialog<bool>(
final shouldImport = await context.showRoundDialog<bool>(
title: l10n.sshConfigImport,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.sshConfigFound),
UIs.height7,
const SizedBox(height: 8),
Text(l10n.sshConfigImportPermission),
UIs.height7,
Text(l10n.sshConfigImportHelp, style: UIs.textGrey),
],
),
actions: Btnx.cancelOk,
);
prop.put(false);
if (!mounted) return;
if (hasPermission == true) {
// Parse and import SSH config
final servers = await SSHConfig.parseConfig();
if (servers.isEmpty) {
context.showSnackBar(l10n.sshConfigNoServers);
return;
}
Stores.setting.firstTimeReadSSHCfg.put(false);
final deduplicated = ServerDeduplication.deduplicateServers(servers);
final resolved = ServerDeduplication.resolveNameConflicts(deduplicated);
final summary = ServerDeduplication.getImportSummary(servers, resolved);
if (!summary.hasItemsToImport) {
context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}'));
return;
}
// Import without asking again since user already gave permission
for (final server in resolved) {
ref.read(serversProvider.notifier).addServer(server);
}
context.showSnackBar(l10n.sshConfigImported('${resolved.length}'));
if (shouldImport == true) {
await ServerDeduplication.importServersWithNotification(
servers: servers,
ref: ref,
context: context,
allExistMessage: l10n.sshConfigAllExist,
importedMessage: l10n.sshConfigImported,
);
}
} catch (e) {
if (!mounted) return;
if (e is PathAccessException ||
e.toString().contains('Operation not permitted')) {
Stores.setting.firstTimeReadSSHCfg.put(false);
context.showSnackBar(
'${l10n.sshConfigPermissionDenied} ${l10n.sshConfigManualSelect}',
);
} else {
dprint('Error checking SSH config: $e');
Stores.setting.firstTimeReadSSHCfg.put(false);
}
} catch (e, s) {
_handleImportSSHCfgPermissionIssue(e, s);
}
}

View File

@@ -1,8 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:choice/choice.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -14,7 +12,6 @@ import 'package:server_box/core/utils/server_dedup.dart';
import 'package:server_box/core/utils/ssh_config.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/discovery_result.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/wol_cfg.dart';
@@ -23,7 +20,6 @@ import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/store/server.dart';
import 'package:server_box/view/page/private_key/edit.dart';
import 'package:server_box/view/page/server/discovery/discovery.dart';
part 'actions.dart';
part 'widget.dart';
@@ -138,9 +134,6 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage>
Widget _buildForm() {
final topItems = [
_buildWriteScriptTip(),
if (isMobile) _buildQrScan(),
if (isDesktop) _buildSSHImport(),
_buildSSHDiscovery(),
];
final children = [
SizedBox(
@@ -217,8 +210,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage>
void afterFirstLayout(BuildContext context) {
if (spi != null) {
_initWithSpi(spi!);
} else {
// Only for new servers, check SSH config import on first time
} else if (isDesktop && Stores.setting.firstTimeReadSSHCfg.fetch()) {
_checkSSHConfigImport();
}
}

View File

@@ -408,49 +408,6 @@ extension _Widgets on _ServerEditPageState {
);
}
Widget _buildQrScan() {
return Btn.tile(
text: libL10n.import,
icon: const Icon(Icons.qr_code, color: Colors.grey),
onTap: () async {
final ret = await BarcodeScannerPage.route.go(
context,
args: const BarcodeScannerPageArgs(),
);
final code = ret?.text;
if (code == null) return;
try {
final spi = Spi.fromJson(json.decode(code));
_initWithSpi(spi);
} catch (e, s) {
context.showErrDialog(e, s);
}
},
textStyle: UIs.textGrey,
mainAxisSize: MainAxisSize.min,
);
}
Widget _buildSSHImport() {
return Btn.tile(
text: l10n.sshConfigImport,
icon: const Icon(Icons.settings, color: Colors.grey),
onTap: _onTapSSHImport,
textStyle: UIs.textGrey,
mainAxisSize: MainAxisSize.min,
);
}
Widget _buildSSHDiscovery() {
return Btn.tile(
text: l10n.discoverSshServers,
icon: const Icon(BoxIcons.bx_search, color: Colors.grey),
onTap: _onTapSSHDiscovery,
textStyle: UIs.textGrey,
mainAxisSize: MainAxisSize.min,
);
}
Widget _buildDelBtn() {
return IconButton(
onPressed: () {

View File

@@ -180,7 +180,7 @@ extension _Server on _AppSettingsPageState {
_buildDoubleColumnServersPage(),
_buildUpdateInterval(),
_buildMaxRetry(),
_buildSSHConfigImport(),
if (isDesktop) _buildSSHConfigAutoImportToggle(),
],
);
}
@@ -261,7 +261,7 @@ extension _Server on _AppSettingsPageState {
);
}
Widget _buildSSHConfigImport() {
Widget _buildSSHConfigAutoImportToggle() {
return ListTile(
title: Text(l10n.sshConfigImport),
subtitle: Text(l10n.sshConfigImportTip, style: UIs.textGrey),

View File

@@ -4,6 +4,9 @@ extension _SSH on _AppSettingsPageState {
Widget _buildSSH() {
return Column(
children: [
if (isDesktop) _buildSSHConfigImport(),
if (isMobile) _buildQrScan(),
_buildSSHDiscovery(),
_buildLetterCache(),
_buildSSHWakeLock(),
_buildTermTheme(),
@@ -17,6 +20,246 @@ extension _SSH on _AppSettingsPageState {
);
}
Widget _buildSSHConfigImport() {
return ListTile(
leading: const Icon(MingCute.file_import_line),
title: Text(l10n.sshConfigImport),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: _onTapSSHConfigImport,
);
}
Widget _buildQrScan() {
return ListTile(
leading: const Icon(Icons.qr_code),
title: Text(libL10n.import),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: _onTapQrScan,
);
}
Future<void> _onTapQrScan() async {
final ret = await BarcodeScannerPage.route.go(
context,
args: const BarcodeScannerPageArgs(),
);
final code = ret?.text;
if (code == null) return;
if (!mounted) return;
try {
final spi = Spi.fromJson(json.decode(code));
final existingIds = ref.read(serversProvider).servers.keys;
if (existingIds.contains(spi.id)) {
context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}');
return;
}
final resolvedList = ServerDeduplication.resolveNameConflicts([spi]);
final resolvedSpi = resolvedList.first;
ref.read(serversProvider.notifier).addServer(resolvedSpi);
context.showSnackBar(libL10n.success);
} catch (e, s) {
context.showErrDialog(e, s);
}
}
Widget _buildSSHDiscovery() {
return ListTile(
leading: const Icon(BoxIcons.bx_search),
title: Text(l10n.discoverSshServers),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: _onTapSSHDiscovery,
);
}
Future<void> _onTapSSHDiscovery() async {
try {
final result = await SshDiscoveryPage.route.go(context);
if (!mounted) return;
if (result != null && result.isNotEmpty) {
await _processDiscoveredServers(result);
}
} catch (e, s) {
if (!mounted) return;
context.showErrDialog(e, s);
}
}
Future<void> _processDiscoveredServers(
List<SshDiscoveryResult> discoveredServers,
) async {
final defaultUsername = 'root';
final usernameController = TextEditingController(text: defaultUsername);
try {
final shouldImport = await context.showRoundDialog<bool>(
title: libL10n.import,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.sshConfigFoundServers('${discoveredServers.length}')),
const SizedBox(height: 8),
Input(
controller: usernameController,
label: libL10n.user,
),
],
),
actions: Btnx.cancelOk,
);
if (!mounted) return;
if (shouldImport == true) {
final username = usernameController.text.isNotEmpty
? usernameController.text
: defaultUsername;
final servers = discoveredServers
.map(
(result) => Spi(
id: ShortId.generate(),
name: result.ip,
ip: result.ip,
port: result.port,
user: username,
),
)
.toList();
await ServerDeduplication.importServersWithNotification(
servers: servers,
ref: ref,
context: context,
allExistMessage: l10n.sshConfigAllExist,
importedMessage: (count) => '${libL10n.success}: $count ${libL10n.servers}',
);
}
} finally {
usernameController.dispose();
}
}
Future<void> _onTapSSHConfigImport() async {
try {
final servers = await SSHConfig.parseConfig();
if (!mounted) return;
if (servers.isEmpty) {
context.showSnackBar(l10n.sshConfigNoServers);
return;
}
await _processSSHServers(servers);
} catch (e, s) {
if (!mounted) return;
await _handleImportSSHCfgPermissionIssue(e, s);
}
}
Future<void> _processSSHServers(List<Spi> servers) async {
final deduplicated = ServerDeduplication.deduplicateServers(servers);
final resolved = ServerDeduplication.resolveNameConflicts(deduplicated);
final summary = ServerDeduplication.getImportSummary(servers, resolved);
if (!summary.hasItemsToImport) {
if (!mounted) return;
context.showSnackBar(l10n.sshConfigAllExist('${summary.duplicates}'));
return;
}
final shouldImport = await context.showRoundDialog<bool>(
title: l10n.sshConfigImport,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.sshConfigFoundServers('${summary.total}')),
if (summary.hasDuplicates)
Text(
l10n.sshConfigDuplicatesSkipped('${summary.duplicates}'),
style: UIs.textGrey,
),
Text(l10n.sshConfigServersToImport('${summary.toImport}')),
const SizedBox(height: 16),
...resolved.map(
(s) => Text('${s.name} (${s.user}@${s.ip}:${s.port})'),
),
],
),
),
actions: Btnx.cancelOk,
);
if (!mounted) return;
if (shouldImport == true) {
await ServerDeduplication.importServersWithNotification(
ref: ref,
context: context,
resolvedServers: resolved,
originalCount: summary.total,
allExistMessage: l10n.sshConfigAllExist,
importedMessage: l10n.sshConfigImported,
);
}
}
Future<void> _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async {
dprint('Error importing SSH config: $e');
if (e is PathAccessException ||
e.toString().contains('Operation not permitted')) {
final useFilePicker = await context.showRoundDialog<bool>(
title: l10n.sshConfigImport,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.sshConfigPermissionDenied),
const SizedBox(height: 8),
Text(l10n.sshConfigManualSelect),
],
),
actions: Btnx.cancelOk,
);
if (!mounted) return;
if (useFilePicker == true) {
await _onTapSSHImportWithFilePicker();
}
} else {
if (!mounted) return;
context.showErrDialog(e, s);
}
}
Future<void> _onTapSSHImportWithFilePicker() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: false,
dialogTitle: l10n.sshConfigImport,
);
if (!mounted) return;
if (result?.files.single.path case final path?) {
final servers = await SSHConfig.parseConfig(path);
if (!mounted) return;
if (servers.isEmpty) {
context.showSnackBar(l10n.sshConfigNoServers);
return;
}
await _processSSHServers(servers);
}
} catch (e, s) {
if (!mounted) return;
context.showErrDialog(e, s);
}
}
Widget _buildSSHVirtKeys() {
return ListTile(
leading: const Icon(BoxIcons.bxs_keyboard),

View File

@@ -2,13 +2,18 @@ import 'dart:convert';
import 'dart:io';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_highlight/theme_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/utils/server_dedup.dart';
import 'package:server_box/core/utils/ssh_config.dart';
import 'package:server_box/data/model/app/net_view.dart';
import 'package:server_box/data/model/server/discovery_result.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/github_id.dart';
@@ -18,6 +23,7 @@ import 'package:server_box/data/store/setting.dart';
import 'package:server_box/generated/l10n/l10n.dart';
import 'package:server_box/view/page/backup.dart';
import 'package:server_box/view/page/private_key/list.dart';
import 'package:server_box/view/page/server/discovery/discovery.dart';
import 'package:server_box/view/page/setting/entries/home_tabs.dart';
import 'package:server_box/view/page/setting/platform/ios.dart';
import 'package:server_box/view/page/setting/platform/platform_pub.dart';