mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-01-31 13:25:10 +01:00
* feat(localization): Add validation prompt for invalid host formats Add validation for host formats, allowing only IPv4, IPv6, and domain name formats Add regular expression validation for host format on the server editing page Update multilingual files to add the invalidHostFormat field * chore: Update dependent package versions to the latest * fix(server edit): Update the hostname regular expression to support IPv6 zone identifiers Modify the regular expression for hostname validation to add support for IPv6 zone identifiers (such as %en0)
463 lines
15 KiB
Dart
463 lines
15 KiB
Dart
part of 'edit.dart';
|
|
|
|
/// Only permit ipv4 / ipv6 / domain chars (including IPv6 zone identifier like %en0)
|
|
final _hostReg = RegExp(r'^[a-zA-Z0-9\.\-_:%;]+$');
|
|
|
|
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) 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, KvEditorArgs(data: _customCmds.value));
|
|
if (res == null) return;
|
|
_customCmds.value = res;
|
|
}
|
|
|
|
void _onTapDisabledCmdTypes() async {
|
|
final allCmdTypes = ShellCmdType.all;
|
|
|
|
// [TimeSeq] depends on the `time` cmd type, so it should be removed from the list
|
|
allCmdTypes.remove(StatusCmdType.time);
|
|
|
|
await _showCmdTypesDialog(allCmdTypes);
|
|
}
|
|
|
|
void _onSave() async {
|
|
if (_ipController.text.isEmpty) {
|
|
context.showSnackBar('${libL10n.empty} ${l10n.host}');
|
|
return;
|
|
}
|
|
|
|
if (!_hostReg.hasMatch(_ipController.text)) {
|
|
context.showSnackBar(l10n.invalidHostFormat);
|
|
return;
|
|
}
|
|
|
|
if (_keyIdx.value == null && _passwordController.text.isEmpty) {
|
|
final ok = await context.showRoundDialog<bool>(
|
|
title: libL10n.attention,
|
|
child: Text(libL10n.askContinue(l10n.useNoPwd)),
|
|
actions: Btnx.cancelRedOk,
|
|
);
|
|
if (ok != true) return;
|
|
}
|
|
|
|
// If [_pubKeyIndex] is -1, it means that the user has not selected
|
|
if (_keyIdx.value == -1) {
|
|
context.showSnackBar(libL10n.empty);
|
|
return;
|
|
}
|
|
if (_usernameController.text.isEmpty) {
|
|
_usernameController.text = 'root';
|
|
}
|
|
if (_portController.text.isEmpty) {
|
|
_portController.text = '22';
|
|
}
|
|
final customCmds = _customCmds.value;
|
|
final custom = ServerCustom(
|
|
pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull,
|
|
pveIgnoreCert: _pveIgnoreCert.value,
|
|
cmds: customCmds.isEmpty ? null : customCmds,
|
|
preferTempDev: _preferTempDevCtrl.text.selfNotEmptyOrNull,
|
|
logoUrl: _logoUrlCtrl.text.selfNotEmptyOrNull,
|
|
netDev: _netDevCtrl.text.selfNotEmptyOrNull,
|
|
scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull,
|
|
);
|
|
|
|
final wolEmpty = _wolMacCtrl.text.isEmpty && _wolIpCtrl.text.isEmpty && _wolPwdCtrl.text.isEmpty;
|
|
final wol = wolEmpty
|
|
? null
|
|
: WakeOnLanCfg(mac: _wolMacCtrl.text, ip: _wolIpCtrl.text, pwd: _wolPwdCtrl.text.selfNotEmptyOrNull);
|
|
if (wol != null) {
|
|
final wolValidation = wol.validate();
|
|
if (!wolValidation.$2) {
|
|
context.showSnackBar('${libL10n.fail}: ${wolValidation.$1}');
|
|
return;
|
|
}
|
|
}
|
|
|
|
final spi = Spi(
|
|
name: _nameController.text.isEmpty ? _ipController.text : _nameController.text,
|
|
ip: _ipController.text,
|
|
port: int.parse(_portController.text),
|
|
user: _usernameController.text,
|
|
pwd: _passwordController.text.selfNotEmptyOrNull,
|
|
keyId: _keyIdx.value != null
|
|
? ref.read(privateKeyProvider).keys.elementAt(_keyIdx.value!).id
|
|
: null,
|
|
tags: _tags.value.isEmpty ? null : _tags.value.toList(),
|
|
alterUrl: _altUrlController.text.selfNotEmptyOrNull,
|
|
autoConnect: _autoConnect.value,
|
|
jumpId: _jumpServer.value,
|
|
custom: custom,
|
|
wolCfg: wol,
|
|
envs: _env.value.isEmpty ? null : _env.value,
|
|
id: widget.args?.spi.id ?? ShortId.generate(),
|
|
customSystemType: _systemType.value,
|
|
disabledCmdTypes: _disabledCmdTypes.value.isEmpty ? null : _disabledCmdTypes.value.toList(),
|
|
);
|
|
|
|
if (this.spi == null) {
|
|
final existsIds = ServerStore.instance.box.keys;
|
|
if (existsIds.contains(spi.id)) {
|
|
context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}');
|
|
return;
|
|
}
|
|
ref.read(serversProvider.notifier).addServer(spi);
|
|
} else {
|
|
ref.read(serversProvider.notifier).updateServer(this.spi!, spi);
|
|
}
|
|
|
|
context.pop();
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
try {
|
|
// Check if SSH config exists
|
|
final (_, configExists) = SSHConfig.configExists();
|
|
if (!configExists) return;
|
|
|
|
// Ask for permission
|
|
final hasPermission = await context.showRoundDialog<bool>(
|
|
title: l10n.sshConfigImport,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.sshConfigFound),
|
|
UIs.height7,
|
|
Text(l10n.sshConfigImportPermission),
|
|
UIs.height7,
|
|
Text(l10n.sshConfigImportHelp, style: UIs.textGrey),
|
|
],
|
|
),
|
|
actions: Btnx.cancelOk,
|
|
);
|
|
|
|
prop.put(false);
|
|
|
|
if (hasPermission == true) {
|
|
// Parse and import SSH config
|
|
final servers = await SSHConfig.parseConfig();
|
|
if (servers.isEmpty) {
|
|
context.showSnackBar(l10n.sshConfigNoServers);
|
|
return;
|
|
}
|
|
|
|
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}'));
|
|
}
|
|
} catch (e, s) {
|
|
_handleImportSSHCfgPermissionIssue(e, s);
|
|
}
|
|
}
|
|
|
|
Future<void> _showCmdTypesDialog(Set<ShellCmdType> allCmdTypes) {
|
|
return context.showRoundDialog(
|
|
title: '${libL10n.disabled} ${l10n.cmd}',
|
|
child: SizedBox(
|
|
width: 270,
|
|
child: _disabledCmdTypes.listenVal((disabled) {
|
|
return ListView.builder(
|
|
itemCount: allCmdTypes.length,
|
|
itemExtent: 50,
|
|
itemBuilder: (context, index) {
|
|
final cmdType = allCmdTypes.elementAtOrNull(index);
|
|
if (cmdType == null) return UIs.placeholder;
|
|
final display = cmdType.displayName;
|
|
return ListTile(
|
|
leading: Icon(cmdType.sysType.icon, size: 20),
|
|
title: Text(cmdType.name, style: const TextStyle(fontSize: 16)),
|
|
trailing: Checkbox(
|
|
value: disabled.contains(display),
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
if (value) {
|
|
_disabledCmdTypes.value.add(display);
|
|
} else {
|
|
_disabledCmdTypes.value.remove(display);
|
|
}
|
|
_disabledCmdTypes.notify();
|
|
},
|
|
),
|
|
onTap: () {
|
|
final isDisabled = disabled.contains(display);
|
|
if (isDisabled) {
|
|
_disabledCmdTypes.value.remove(display);
|
|
} else {
|
|
_disabledCmdTypes.value.add(display);
|
|
}
|
|
_disabledCmdTypes.notify();
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
),
|
|
actions: Btnx.oks,
|
|
);
|
|
}
|
|
|
|
void _initWithSpi(Spi spi) {
|
|
_nameController.text = spi.name;
|
|
_ipController.text = spi.ip;
|
|
_portController.text = spi.port.toString();
|
|
_usernameController.text = spi.user;
|
|
if (spi.keyId == null) {
|
|
_passwordController.text = spi.pwd ?? '';
|
|
} else {
|
|
_keyIdx.value = ref.read(privateKeyProvider).keys.indexWhere((e) => e.id == spi.keyId);
|
|
}
|
|
|
|
/// List in dart is passed by pointer, so you need to copy it here
|
|
_tags.value = spi.tags?.toSet() ?? {};
|
|
|
|
_altUrlController.text = spi.alterUrl ?? '';
|
|
_autoConnect.value = spi.autoConnect;
|
|
_jumpServer.value = spi.jumpId;
|
|
|
|
final custom = spi.custom;
|
|
if (custom != null) {
|
|
_pveAddrCtrl.text = custom.pveAddr ?? '';
|
|
_pveIgnoreCert.value = custom.pveIgnoreCert;
|
|
_customCmds.value = custom.cmds ?? {};
|
|
_preferTempDevCtrl.text = custom.preferTempDev ?? '';
|
|
_logoUrlCtrl.text = custom.logoUrl ?? '';
|
|
}
|
|
|
|
final wol = spi.wolCfg;
|
|
if (wol != null) {
|
|
_wolMacCtrl.text = wol.mac;
|
|
_wolIpCtrl.text = wol.ip;
|
|
_wolPwdCtrl.text = wol.pwd ?? '';
|
|
}
|
|
|
|
_env.value = spi.envs ?? {};
|
|
|
|
_netDevCtrl.text = spi.custom?.netDev ?? '';
|
|
_scriptDirCtrl.text = spi.custom?.scriptDir ?? '';
|
|
|
|
_systemType.value = spi.customSystemType;
|
|
|
|
final disabledCmdTypes = spi.disabledCmdTypes?.toSet() ?? {};
|
|
final allAvailableCmdTypes = ShellCmdType.all.map((e) => e.displayName);
|
|
disabledCmdTypes.removeWhere((e) => !allAvailableCmdTypes.contains(e));
|
|
_disabledCmdTypes.value = disabledCmdTypes;
|
|
}
|
|
}
|