mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 23:34:24 +01:00
533 lines
18 KiB
Dart
533 lines
18 KiB
Dart
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) 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 (_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;
|
|
}
|
|
}
|
|
|
|
// ProxyCommand configuration
|
|
ProxyCommandConfig? proxyCommand;
|
|
if (!Platform.isIOS && _proxyCommandEnabled.value) {
|
|
final command = _proxyCommandController.text.trim();
|
|
if (command.isEmpty) {
|
|
context.showSnackBar('ProxyCommand is enabled but command is empty');
|
|
return;
|
|
}
|
|
|
|
// Check if command contains required placeholders
|
|
if (!command.contains('%h')) {
|
|
context.showSnackBar('ProxyCommand must contain %h (hostname) placeholder');
|
|
return;
|
|
}
|
|
|
|
List<String> tokens;
|
|
try {
|
|
tokens = ProxyCommandExecutor.tokenizeCommand(command);
|
|
} on ProxyCommandException catch (e) {
|
|
context.showSnackBar(e.message);
|
|
return;
|
|
}
|
|
if (tokens.isEmpty) {
|
|
context.showSnackBar('ProxyCommand must not be empty');
|
|
return;
|
|
}
|
|
|
|
// Determine if this requires an executable
|
|
final executableToken = tokens.first;
|
|
var normalized = p.basename(executableToken).toLowerCase();
|
|
if (normalized.endsWith('.exe')) {
|
|
normalized = normalized.substring(0, normalized.length - 4);
|
|
}
|
|
const builtinExecutables = {'ssh', 'nc', 'socat'};
|
|
final requiresExecutable = !builtinExecutables.contains(normalized);
|
|
|
|
proxyCommand = ProxyCommandConfig(
|
|
command: command,
|
|
timeout: Duration(seconds: _proxyCommandTimeout.value),
|
|
retryOnFailure: true,
|
|
requiresExecutable: requiresExecutable,
|
|
executableName: requiresExecutable ? executableToken : null,
|
|
);
|
|
} else if (Platform.isIOS && _proxyCommandEnabled.value) {
|
|
context.showSnackBar('ProxyCommand is not supported on iOS');
|
|
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(),
|
|
proxyCommand: proxyCommand,
|
|
);
|
|
|
|
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;
|
|
|
|
// Load ProxyCommand configuration
|
|
final proxyCommand = spi.proxyCommand;
|
|
if (proxyCommand != null && !Platform.isIOS) {
|
|
_proxyCommandEnabled.value = true;
|
|
_proxyCommandController.text = proxyCommand.command;
|
|
_proxyCommandTimeout.value = proxyCommand.timeout.inSeconds;
|
|
|
|
// Try to match with a preset
|
|
final presets = ProxyCommandExecutor.getPresets();
|
|
for (final entry in presets.entries) {
|
|
if (entry.value.command == proxyCommand.command) {
|
|
_proxyCommandPreset.value = entry.key;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
_proxyCommandEnabled.value = false;
|
|
_proxyCommandController.text = '';
|
|
_proxyCommandTimeout.value = 30;
|
|
_proxyCommandPreset.value = null;
|
|
}
|
|
|
|
if (Platform.isIOS) {
|
|
_proxyCommandEnabled.value = false;
|
|
_proxyCommandController.text = '';
|
|
_proxyCommandTimeout.value = 30;
|
|
_proxyCommandPreset.value = null;
|
|
}
|
|
}
|
|
}
|