* refactor(sftp): Optimize file download logic and SSH command execution handling Replace manual chunked downloads with a more concise `sftp.download` method Consistently use `utf8.decode` to process SSH command output Remove redundant code and comments, and simplify the logic * chore: Update `dartssh2` submodule * feat (Temperature Display): Added an option to switch between degrees Celsius and millicelsius Allows users to switch temperature units in server settings, resolving the issue of incorrect temperature display on some devices * chore: Add a participnt * fix(sftp): Fixed a resource leak issue during file downloads and SSH command execution Ensured that remote and local file handles are properly closed during file downloads to prevent resource leaks. Additionally, improved error handling during SSH command execution to ensure that all streams are either successfully completed or properly handled in the event of an error.
298 lines
9.3 KiB
Dart
298 lines
9.3 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 {
|
|
bool _isInvalidJumpSelection(String? candidateJumpId) {
|
|
final currentServer = spi;
|
|
return wouldCreateJumpCycle(
|
|
currentServerId: currentServer?.id,
|
|
candidateJumpId: candidateJumpId,
|
|
serversById: ref.read(serversProvider).servers,
|
|
);
|
|
}
|
|
|
|
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} ${libL10n.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';
|
|
}
|
|
if (_isInvalidJumpSelection(_jumpServer.value)) {
|
|
context.showSnackBar('${l10n.invalid}: ${l10n.jumpServer}');
|
|
return;
|
|
}
|
|
|
|
final customCmds = _customCmds.value;
|
|
final custom = ServerCustom(
|
|
pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull,
|
|
pveIgnoreCert: _pveIgnoreCert.value,
|
|
pvePwd: _pvePwdCtrl.text.selfNotEmptyOrNull,
|
|
cmds: customCmds.isEmpty ? null : customCmds,
|
|
preferTempDev: _preferTempDevCtrl.text.selfNotEmptyOrNull,
|
|
tempIsCelsius: _tempIsCelsius.value,
|
|
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 {
|
|
Future<void> _checkSSHConfigImport() async {
|
|
final hasExistingServers = ref.read(serversProvider).servers.isNotEmpty;
|
|
if (hasExistingServers) {
|
|
Stores.setting.firstTimeReadSSHCfg.put(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final servers = await SSHConfig.parseConfig();
|
|
if (!mounted) return;
|
|
if (servers.isEmpty) {
|
|
Stores.setting.firstTimeReadSSHCfg.put(false);
|
|
return;
|
|
}
|
|
|
|
final shouldImport = await context.showRoundDialog<bool>(
|
|
title: l10n.sshConfigImport,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.sshConfigFound),
|
|
const SizedBox(height: 8),
|
|
Text(l10n.sshConfigImportPermission),
|
|
],
|
|
),
|
|
actions: Btnx.cancelOk,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
Stores.setting.firstTimeReadSSHCfg.put(false);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showCmdTypesDialog(Set<ShellCmdType> allCmdTypes) {
|
|
return context.showRoundDialog(
|
|
title: '${libL10n.disabled} ${libL10n.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;
|
|
_pvePwdCtrl.text = custom.pvePwd ?? '';
|
|
_customCmds.value = custom.cmds ?? {};
|
|
_preferTempDevCtrl.text = custom.preferTempDev ?? '';
|
|
_tempIsCelsius.value = custom.tempIsCelsius;
|
|
_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;
|
|
}
|
|
}
|