feat: discover local ssh server (#921)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-09-19 23:29:01 +08:00
committed by GitHub
parent 17db393c12
commit f68c4a851b
48 changed files with 3728 additions and 1928 deletions

View File

@@ -240,7 +240,7 @@ final class _BackupPageState extends ConsumerState<BackupPage> with AutomaticKee
),
),
ListTile(
title: Text(l10n.manual),
title: Text(libL10n.manual),
trailing: webdavLoading.listenVal((loading) {
if (loading) return SizedLoading.small;
@@ -301,7 +301,7 @@ final class _BackupPageState extends ConsumerState<BackupPage> with AutomaticKee
),
),
ListTile(
title: Text(l10n.manual),
title: Text(libL10n.manual),
trailing: gistLoading.listenVal((loading) {
if (loading) return SizedLoading.small;
@@ -450,7 +450,7 @@ extension on _BackupPageState {
await Webdav.shared.upload(relativePath: bakName);
Loggers.app.info('Upload webdav backup success');
} catch (e, s) {
context.showErrDialog(e, s, l10n.upload);
context.showErrDialog(e, s, libL10n.upload);
Loggers.app.warning('Upload webdav backup failed', e, s);
} finally {
webdavLoading.value = false;
@@ -489,7 +489,7 @@ extension on _BackupPageState {
await GistRs.shared.upload(relativePath: bakName);
Loggers.app.info('Upload gist backup success');
} catch (e, s) {
context.showErrDialog(e, s, l10n.upload);
context.showErrDialog(e, s, libL10n.upload);
Loggers.app.warning('Upload gist backup failed', e, s);
} finally {
gistLoading.value = false;

View File

@@ -77,7 +77,7 @@ extension on _ContainerPageState {
Future<void> _showAddCmdPreview(String cmd) async {
await context.showRoundDialog(
title: l10n.preview,
title: libL10n.preview,
child: Text(cmd),
actions: [
TextButton(onPressed: () => context.pop(), child: Text(libL10n.cancel)),

View File

@@ -0,0 +1,218 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.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/service/ssh_discovery.dart';
import 'package:server_box/data/model/server/discovery_result.dart';
part 'widget.dart';
class SshDiscoveryPage extends ConsumerStatefulWidget {
const SshDiscoveryPage({super.key});
static const route = AppRouteNoArg<List<SshDiscoveryResult>>(
page: SshDiscoveryPage.new,
path: '/servers/discovery',
);
@override
ConsumerState<SshDiscoveryPage> createState() => _SshDiscoveryPageState();
}
class _SshDiscoveryPageState extends ConsumerState<SshDiscoveryPage> {
final _config = ValueNotifier(const SshDiscoveryConfig());
final _discoveryResults = ValueNotifier<List<SshDiscoveryResult>>([]);
final _isDiscovering = ValueNotifier(false);
final _discoveryReport = ValueNotifier<SshDiscoveryReport?>(null);
@override
void dispose() {
_config.dispose();
_discoveryResults.dispose();
_isDiscovering.dispose();
_discoveryReport.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: Text(l10n.discoverSshServers),
actions: [IconButton(icon: const Icon(Icons.settings), onPressed: _showSettings)],
),
body: _buildBody(),
floatingActionButton: _isDiscovering.listenVal((discovering) {
if (discovering) return UIs.placeholder;
return _buildFAB();
}),
);
}
Widget _buildBody() {
return Column(
children: [
_buildSummary(),
Expanded(child: _buildResultsList()),
],
);
}
Widget _buildSummary() {
return _discoveryReport.listenVal((report) {
if (report == null) {
return UIs.placeholder;
}
return Container(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.discoverySummary, style: const TextStyle(fontWeight: FontWeight.bold)),
UIs.height7,
Text('${libL10n.found}: ${report.count} ${l10n.servers}'),
Text('${libL10n.duration}: ${report.durationMs}ms'),
Text(
'${l10n.finishedAt}: ${DateTime.parse(report.generatedAt).toLocal().toString().substring(0, 16)}',
),
],
),
),
),
);
});
}
Widget _buildResultsList() {
return _discoveryResults.listenVal((results) {
if (results.isEmpty) {
return _isDiscovering.listenVal((discovering) {
if (discovering) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [CircularProgressIndicator(), UIs.height13, Text('Discovering SSH servers...')],
),
);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(BoxIcons.bx_search, size: 64, color: UIs.textGrey.color),
UIs.height13,
Text(l10n.tapToStartDiscovery, style: UIs.textGrey),
],
),
);
});
}
return ListView.builder(
itemCount: results.length,
itemBuilder: (context, index) {
final result = results[index];
return _buildResultTile(result, index);
},
);
});
}
Widget _buildResultTile(SshDiscoveryResult result, int index) {
return ListTile(
leading: Icon(
result.isSelected ? Icons.check_circle : Icons.circle_outlined,
color: result.isSelected ? Colors.green : null,
),
title: Text(result.ip),
subtitle: result.banner != null
? Text(result.banner!, style: const TextStyle(fontSize: 12))
: Text('Port ${result.port}', style: UIs.textGrey),
trailing: const Icon(BoxIcons.bx_server),
onTap: () {
final updated = result.copyWith(isSelected: !result.isSelected);
final newResults = List<SshDiscoveryResult>.from(_discoveryResults.value);
newResults[index] = updated;
_discoveryResults.value = newResults;
},
);
}
Widget _buildFAB() {
return _discoveryResults.listenVal((results) {
final selectedResults = results.where((r) => r.isSelected).toList();
return AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOutBack)),
child: FadeTransition(opacity: animation, child: child),
);
},
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerRight,
children: <Widget>[...previousChildren, if (currentChild != null) currentChild],
);
},
child: selectedResults.isNotEmpty
? FloatingActionButton.extended(
key: const ValueKey('import'),
heroTag: 'import_fab',
onPressed: () => _importSelected(),
icon: const Icon(Icons.add),
label: Text('${libL10n.import} (${selectedResults.length})'),
)
: FloatingActionButton.extended(
key: const ValueKey('discovery'),
heroTag: 'discovery_fab',
onPressed: _startDiscovery,
icon: const Icon(BoxIcons.bx_search),
label: Text(libL10n.search),
),
);
});
}
Future<void> _startDiscovery() async {
_isDiscovering.value = true;
_discoveryResults.value = [];
_discoveryReport.value = null;
try {
final report = await SshDiscoveryService.discover(_config.value);
_discoveryReport.value = report;
_discoveryResults.value = report.items;
} catch (e) {
if (mounted) {
context.showSnackBar('${l10n.discoveryFailed}: $e');
}
} finally {
_isDiscovering.value = false;
}
}
void _showSettings() {
context.showRoundDialog(
child: _DiscoverySettingsDialog(config: _config.value, onChanged: (config) => _config.value = config),
actions: Btnx.oks,
);
}
void _importSelected() {
final selected = _discoveryResults.value.where((r) => r.isSelected).toList();
if (selected.isEmpty) return;
context.pop(selected);
}
}

View File

@@ -0,0 +1,78 @@
part of 'discovery.dart';
class _DiscoverySettingsDialog extends StatefulWidget {
final SshDiscoveryConfig config;
final ValueChanged<SshDiscoveryConfig> onChanged;
const _DiscoverySettingsDialog({
required this.config,
required this.onChanged,
});
@override
State<_DiscoverySettingsDialog> createState() => _DiscoverySettingsDialogState();
}
class _DiscoverySettingsDialogState extends State<_DiscoverySettingsDialog> {
late final _timeoutController = TextEditingController(text: widget.config.timeoutMs.toString());
late final _concurrencyController = TextEditingController(text: widget.config.maxConcurrency.toString());
late bool _enableMdns = widget.config.enableMdns;
@override
void dispose() {
_timeoutController.dispose();
_concurrencyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.discoverySettings,
style: const TextStyle(fontSize: 18),
),
UIs.height13,
Input(
controller: _timeoutController,
type: TextInputType.number,
label: '${libL10n.timeout} (ms)',
onChanged: (v) {
final t = int.tryParse(v) ?? 700;
if (t > 0) {
widget.onChanged(widget.config.copyWith(timeoutMs: t));
}
},
hint: '700',
),
UIs.height7,
Input(
controller: _concurrencyController,
type: TextInputType.number,
label: l10n.maxConcurrency,
hint: '128',
onChanged: (v) {
final c = int.tryParse(v) ?? 128;
if (c > 0) {
widget.onChanged(widget.config.copyWith(maxConcurrency: c));
}
},
),
UIs.height7,
SwitchListTile(
title: Text(l10n.enableMdns),
subtitle: Text(l10n.enableMdnsDesc),
value: _enableMdns,
onChanged: (value) {
setState(() {
_enableMdns = value;
});
widget.onChanged(widget.config.copyWith(enableMdns: value));
},
),
],
);
}
}

View File

@@ -1,6 +1,100 @@
part of 'edit.dart';
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 ${l10n.server}');
} else {
// Multiple servers - show import dialog
final shouldImport = await context.showRoundDialog<bool>(
title: libL10n.import,
child: Text(libL10n.askContinue('${libL10n.found} ${discoveredServers.length} ${l10n.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} ${l10n.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 ${l10n.servers}');
if (mounted) Navigator.of(context).pop(true);
}
void _onTapSSHImport() async {
try {
final servers = await SSHConfig.parseConfig();

View File

@@ -13,6 +13,7 @@ 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';
@@ -21,6 +22,7 @@ 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';
@@ -30,17 +32,13 @@ class ServerEditPage extends ConsumerStatefulWidget {
const ServerEditPage({super.key, this.args});
static const route = AppRoute<bool, SpiRequiredArgs>(
page: ServerEditPage.new,
path: '/servers/edit',
);
static const route = AppRoute<bool, SpiRequiredArgs>(page: ServerEditPage.new, path: '/servers/edit');
@override
ConsumerState<ServerEditPage> createState() => _ServerEditPageState();
}
class _ServerEditPageState extends ConsumerState<ServerEditPage>
with AfterLayoutMixin {
class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayoutMixin {
late final spi = widget.args?.spi;
final _nameController = TextEditingController();
final _ipController = TextEditingController();
@@ -137,11 +135,12 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage>
_buildWriteScriptTip(),
if (isMobile) _buildQrScan(),
if (isDesktop) _buildSSHImport(),
_buildSSHDiscovery(),
];
final children = [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: topItems.joinWith(UIs.width13).toList(),
SizedBox(
height: 50,
child: ListView(scrollDirection: Axis.horizontal, children: topItems.joinWith(UIs.width13).toList()),
),
Input(
autoFocus: true,
@@ -186,10 +185,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage>
hint: 'root',
suggestion: false,
),
TagTile(
tags: _tags,
allTags: ref.watch(serversNotifierProvider).tags,
).cardx,
TagTile(tags: _tags, allTags: ref.watch(serversNotifierProvider).tags).cardx,
ListTile(
title: Text(l10n.autoConnect),
trailing: _autoConnect.listenVal(

View File

@@ -439,6 +439,16 @@ extension _Widgets on _ServerEditPageState {
);
}
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

@@ -36,7 +36,7 @@ final class _AppAboutPageState extends State<_AppAboutPage> with AutomaticKeepAl
),
Btn.elevated(
icon: const Icon(MingCute.question_fill),
text: l10n.license,
text: libL10n.license,
onTap: () => showLicensePage(context: context),
),
].joinWith(UIs.width13),

View File

@@ -72,7 +72,7 @@ extension _App on _AppSettingsPageState {
title: libL10n.setting,
items: List.generate(10, (idx) => idx == 1 ? null : idx),
initial: _setting.serverStatusUpdateInterval.fetch(),
display: (p0) => p0 == 0 ? l10n.manual : '$p0 ${l10n.second}',
display: (p0) => p0 == 0 ? libL10n.manual : '$p0 ${l10n.second}',
);
if (val != null) {
_setting.serverStatusUpdateInterval.put(val);

View File

@@ -42,7 +42,7 @@ extension _SFTP on _AppSettingsPageState {
return _setting.sftpEditor.listenable().listenVal((val) {
return ListTile(
leading: const Icon(MingCute.edit_fill),
title: TipText(l10n.editor, l10n.sftpEditorTip),
title: TipText(libL10n.editor, l10n.sftpEditorTip),
trailing: Text(val.isEmpty ? l10n.inner : val, style: UIs.text15),
onTap: () async {
final ctrl = TextEditingController(text: val);
@@ -57,7 +57,7 @@ extension _SFTP on _AppSettingsPageState {
child: Input(
controller: ctrl,
autoFocus: true,
label: l10n.editor,
label: libL10n.editor,
hint: '\$EDITOR / vim / nano ...',
icon: Icons.edit,
suggestion: false,

View File

@@ -38,14 +38,14 @@ extension _SSH on _AppSettingsPageState {
Widget _buildFont() {
return ListTile(
leading: const Icon(MingCute.font_fill),
title: Text(l10n.font),
title: Text(libL10n.font),
trailing: _setting.fontPath.listenable().listenVal((val) {
final fontName = val.getFileName();
return Text(fontName ?? libL10n.empty, style: UIs.text15);
}),
onTap: () {
context.showRoundDialog(
title: l10n.font,
title: libL10n.font,
actions: [
TextButton(onPressed: () async => await _pickFontFile(), child: Text(libL10n.file)),
TextButton(

View File

@@ -123,7 +123,7 @@ final class _AppSettingsPageState extends ConsumerState<AppSettingsPage> {
[const CenterGreyTitle('App'), _buildApp()],
[CenterGreyTitle(l10n.server), _buildServer()],
[const CenterGreyTitle('SSH'), _buildSSH(), const CenterGreyTitle('SFTP'), _buildSFTP()],
[CenterGreyTitle(l10n.container), _buildContainer(), CenterGreyTitle(l10n.editor), _buildEditor()],
[CenterGreyTitle(l10n.container), _buildContainer(), CenterGreyTitle(libL10n.editor), _buildEditor()],
/// Fullscreen Mode is designed for old mobile phone which can be
/// used as a status screen.

View File

@@ -259,7 +259,7 @@ extension _Actions on _LocalFilePageState {
),
Btn.tile(
icon: const Icon(Icons.upload),
text: l10n.upload,
text: libL10n.upload,
onTap: () => _onTapUpload(file, fileName),
),
Btn.tile(