Files
flutter_server_box/lib/view/page/server/edit/actions.dart
lollipopkit🏳️‍⚧️ 92a4601335 opt.: disable it on iOS
2025-10-25 20:37:14 +08:00

517 lines
17 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;
}
// Determine if this requires an executable
final parts = command.split(' ');
final executable = parts.first;
final requiresExecutable = !['ssh', 'nc', 'socat'].contains(executable);
proxyCommand = ProxyCommandConfig(
command: command,
timeout: Duration(seconds: _proxyCommandTimeout.value),
retryOnFailure: true,
requiresExecutable: requiresExecutable,
executableName: requiresExecutable ? executable : 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;
}
}
}