* refactor(server): Move the SSH import and discovery features from the server edit page to the settings page * feat (SSH Configuration): Added a feature to automatically import SSH configurations upon first launch Checks for and prompts the user to import SSH configurations upon the first launch on the desktop Optimized the SSH server import logic, adding duplicate detection and name conflict handling Fixed an issue with mount status checks that could occur during the import process * refactor (UI): Adjust the placement of the QR code scanning and SSH configuration import features Move the QR code scanning feature from the server editing page to the settings page, and display different access points based on the platform Optimize the SSH configuration import logic to ensure the status is updated correctly after the configuration is read for the first time * refactor(ssh): Refactor server import logic and extract common methods Extract server import logic into the `ServerDeduplication` class Use the `importServersWithNotification` method consistently to handle imports Remove duplicate `_importServers` and `_resolveServers` methods Add checks for existing server IDs * refactor(SSH): Optimized server import logic and fixed permission issues - Moved the SSH configuration import logic from `edit.dart` to `actions.dart` - Removed redundant checks for the `mounted` parameter - Added handling for file permission exceptions - Improved logic for resolving server name conflicts * fix(ssh): Fixed an issue with message display during SSH configuration import - Modified the format of the import success message to display the number of servers successfully imported - Added a prompt for manual selection when permissions are denied - Optimized the server deduplication logic to display an “already exists” message based on the original count * fix(ssh): Fixed an issue with the count display when importing SSH configurations Adjusted the server's deduplication logic to ensure the correct original count is used when displaying the number of imports Removed unnecessary flag settings for the first read of SSH configurations * fix: Fixed an issue where the “first read” flag was not updated when SSH configuration access was denied When SSH configuration access is denied, set the “first read” flag to false to prevent repeated prompts * fix(server): Optimized the logic for checking existing servers when importing SSH configurations Moved the logic for checking existing servers to an earlier stage to avoid unnecessary parsing of SSH configurations
553 lines
16 KiB
Dart
553 lines
16 KiB
Dart
part of '../entry.dart';
|
|
|
|
extension _SSH on _AppSettingsPageState {
|
|
Widget _buildSSH() {
|
|
return Column(
|
|
children: [
|
|
if (isDesktop) _buildSSHConfigImport(),
|
|
if (isMobile) _buildQrScan(),
|
|
_buildSSHDiscovery(),
|
|
_buildLetterCache(),
|
|
_buildSSHWakeLock(),
|
|
_buildTermTheme(),
|
|
_buildFont(),
|
|
_buildTermFontSize(),
|
|
_buildSshBg(),
|
|
if (isDesktop) _buildDesktopTerminal(),
|
|
_buildSSHVirtualKeyAutoOff(),
|
|
if (isMobile) _buildSSHVirtKeys(),
|
|
].map((e) => CardX(child: e)).toList(),
|
|
);
|
|
}
|
|
|
|
Widget _buildSSHConfigImport() {
|
|
return ListTile(
|
|
leading: const Icon(MingCute.file_import_line),
|
|
title: Text(l10n.sshConfigImport),
|
|
trailing: const Icon(Icons.keyboard_arrow_right),
|
|
onTap: _onTapSSHConfigImport,
|
|
);
|
|
}
|
|
|
|
Widget _buildQrScan() {
|
|
return ListTile(
|
|
leading: const Icon(Icons.qr_code),
|
|
title: Text(libL10n.import),
|
|
trailing: const Icon(Icons.keyboard_arrow_right),
|
|
onTap: _onTapQrScan,
|
|
);
|
|
}
|
|
|
|
Future<void> _onTapQrScan() async {
|
|
final ret = await BarcodeScannerPage.route.go(
|
|
context,
|
|
args: const BarcodeScannerPageArgs(),
|
|
);
|
|
final code = ret?.text;
|
|
if (code == null) return;
|
|
if (!mounted) return;
|
|
|
|
try {
|
|
final spi = Spi.fromJson(json.decode(code));
|
|
final existingIds = ref.read(serversProvider).servers.keys;
|
|
if (existingIds.contains(spi.id)) {
|
|
context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}');
|
|
return;
|
|
}
|
|
final resolvedList = ServerDeduplication.resolveNameConflicts([spi]);
|
|
final resolvedSpi = resolvedList.first;
|
|
ref.read(serversProvider.notifier).addServer(resolvedSpi);
|
|
context.showSnackBar(libL10n.success);
|
|
} catch (e, s) {
|
|
context.showErrDialog(e, s);
|
|
}
|
|
}
|
|
|
|
Widget _buildSSHDiscovery() {
|
|
return ListTile(
|
|
leading: const Icon(BoxIcons.bx_search),
|
|
title: Text(l10n.discoverSshServers),
|
|
trailing: const Icon(Icons.keyboard_arrow_right),
|
|
onTap: _onTapSSHDiscovery,
|
|
);
|
|
}
|
|
|
|
Future<void> _onTapSSHDiscovery() async {
|
|
try {
|
|
final result = await SshDiscoveryPage.route.go(context);
|
|
if (!mounted) return;
|
|
|
|
if (result != null && result.isNotEmpty) {
|
|
await _processDiscoveredServers(result);
|
|
}
|
|
} catch (e, s) {
|
|
if (!mounted) return;
|
|
context.showErrDialog(e, s);
|
|
}
|
|
}
|
|
|
|
Future<void> _processDiscoveredServers(
|
|
List<SshDiscoveryResult> discoveredServers,
|
|
) async {
|
|
final defaultUsername = 'root';
|
|
final usernameController = TextEditingController(text: defaultUsername);
|
|
|
|
try {
|
|
final shouldImport = await context.showRoundDialog<bool>(
|
|
title: libL10n.import,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(l10n.sshConfigFoundServers('${discoveredServers.length}')),
|
|
const SizedBox(height: 8),
|
|
Input(
|
|
controller: usernameController,
|
|
label: libL10n.user,
|
|
),
|
|
],
|
|
),
|
|
actions: Btnx.cancelOk,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (shouldImport == true) {
|
|
final username = usernameController.text.isNotEmpty
|
|
? usernameController.text
|
|
: defaultUsername;
|
|
final servers = discoveredServers
|
|
.map(
|
|
(result) => Spi(
|
|
id: ShortId.generate(),
|
|
name: result.ip,
|
|
ip: result.ip,
|
|
port: result.port,
|
|
user: username,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
await ServerDeduplication.importServersWithNotification(
|
|
servers: servers,
|
|
ref: ref,
|
|
context: context,
|
|
allExistMessage: l10n.sshConfigAllExist,
|
|
importedMessage: (count) => '${libL10n.success}: $count ${libL10n.servers}',
|
|
);
|
|
}
|
|
} finally {
|
|
usernameController.dispose();
|
|
}
|
|
}
|
|
|
|
Future<void> _onTapSSHConfigImport() async {
|
|
try {
|
|
final servers = await SSHConfig.parseConfig();
|
|
if (!mounted) return;
|
|
if (servers.isEmpty) {
|
|
context.showSnackBar(l10n.sshConfigNoServers);
|
|
return;
|
|
}
|
|
|
|
await _processSSHServers(servers);
|
|
} catch (e, s) {
|
|
if (!mounted) return;
|
|
await _handleImportSSHCfgPermissionIssue(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) {
|
|
if (!mounted) return;
|
|
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 (!mounted) return;
|
|
|
|
if (shouldImport == true) {
|
|
await ServerDeduplication.importServersWithNotification(
|
|
ref: ref,
|
|
context: context,
|
|
resolvedServers: resolved,
|
|
originalCount: summary.total,
|
|
allExistMessage: l10n.sshConfigAllExist,
|
|
importedMessage: l10n.sshConfigImported,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleImportSSHCfgPermissionIssue(Object e, StackTrace s) async {
|
|
dprint('Error importing SSH config: $e');
|
|
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 (!mounted) return;
|
|
|
|
if (useFilePicker == true) {
|
|
await _onTapSSHImportWithFilePicker();
|
|
}
|
|
} else {
|
|
if (!mounted) return;
|
|
context.showErrDialog(e, s);
|
|
}
|
|
}
|
|
|
|
Future<void> _onTapSSHImportWithFilePicker() async {
|
|
try {
|
|
final result = await FilePicker.platform.pickFiles(
|
|
type: FileType.any,
|
|
allowMultiple: false,
|
|
dialogTitle: l10n.sshConfigImport,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (result?.files.single.path case final path?) {
|
|
final servers = await SSHConfig.parseConfig(path);
|
|
if (!mounted) return;
|
|
if (servers.isEmpty) {
|
|
context.showSnackBar(l10n.sshConfigNoServers);
|
|
return;
|
|
}
|
|
|
|
await _processSSHServers(servers);
|
|
}
|
|
} catch (e, s) {
|
|
if (!mounted) return;
|
|
context.showErrDialog(e, s);
|
|
}
|
|
}
|
|
|
|
Widget _buildSSHVirtKeys() {
|
|
return ListTile(
|
|
leading: const Icon(BoxIcons.bxs_keyboard),
|
|
title: Text(l10n.editVirtKeys),
|
|
trailing: const Icon(Icons.keyboard_arrow_right),
|
|
onTap: () => SSHVirtKeySettingPage.route.go(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildSSHVirtualKeyAutoOff() {
|
|
return ListTile(
|
|
leading: const Icon(MingCute.hotkey_fill),
|
|
title: Text(l10n.sshVirtualKeyAutoOff),
|
|
subtitle: const Text('Ctrl & Alt', style: UIs.textGrey),
|
|
trailing: StoreSwitch(prop: _setting.sshVirtualKeyAutoOff),
|
|
);
|
|
}
|
|
|
|
Widget _buildFont() {
|
|
return ListTile(
|
|
leading: const Icon(MingCute.font_fill),
|
|
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: libL10n.font,
|
|
actions: [
|
|
TextButton(onPressed: () async => await _pickFontFile(), child: Text(libL10n.file)),
|
|
TextButton(
|
|
onPressed: () {
|
|
_setting.fontPath.delete();
|
|
context.pop();
|
|
RNodes.app.notify();
|
|
},
|
|
child: Text(libL10n.clear),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _pickFontFile() async {
|
|
final path = await Pfs.pickFilePath();
|
|
if (path == null) return;
|
|
|
|
// iOS can't copy file to app dir, so we need to use the original path
|
|
if (isIOS) {
|
|
_setting.fontPath.put(path);
|
|
await FontUtils.loadFrom(path);
|
|
} else {
|
|
final fontFile = File(path);
|
|
await fontFile.copy(Paths.font);
|
|
_setting.fontPath.put(Paths.font);
|
|
await FontUtils.loadFrom(Paths.font);
|
|
}
|
|
|
|
context.pop();
|
|
RNodes.app.notify();
|
|
}
|
|
|
|
Widget _buildTermFontSize() {
|
|
return ListTile(
|
|
leading: const Icon(MingCute.font_size_line),
|
|
title: TipText(l10n.fontSize, l10n.termFontSizeTip),
|
|
trailing: ValBuilder(
|
|
listenable: _setting.termFontSize.listenable(),
|
|
builder: (val) => Text(val.toString(), style: UIs.text15),
|
|
),
|
|
onTap: () => _showFontSizeDialog(_setting.termFontSize),
|
|
);
|
|
}
|
|
|
|
Future<void> _pickBgImage() async {
|
|
final path = await Pfs.pickFilePath();
|
|
if (path == null) return;
|
|
|
|
final file = File(path);
|
|
final extIndex = path.lastIndexOf('.');
|
|
final ext = extIndex != -1 ? path.substring(extIndex) : '';
|
|
final newPath = Paths.img.joinPath('ssh_bg$ext');
|
|
final destFile = File(newPath);
|
|
if (await destFile.exists()) {
|
|
await destFile.delete();
|
|
}
|
|
await file.copy(newPath);
|
|
_setting.sshBgImage.put(newPath);
|
|
|
|
context.pop();
|
|
RNodes.app.notify();
|
|
}
|
|
|
|
Widget _buildDesktopTerminal() {
|
|
return _setting.desktopTerminal.listenable().listenVal((val) {
|
|
return ListTile(
|
|
leading: const Icon(Icons.terminal),
|
|
title: TipText(libL10n.terminal, l10n.desktopTerminalTip),
|
|
trailing: Text(val, style: UIs.text15, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
onTap: () {
|
|
withTextFieldController((ctrl) async {
|
|
ctrl.text = val;
|
|
void onSave() {
|
|
_setting.desktopTerminal.put(ctrl.text.trim());
|
|
context.pop();
|
|
}
|
|
|
|
await context.showRoundDialog<bool>(
|
|
title: libL10n.select,
|
|
child: Input(
|
|
controller: ctrl,
|
|
autoFocus: true,
|
|
label: libL10n.terminal,
|
|
hint: 'x-terminal-emulator / gnome-terminal',
|
|
icon: Icons.edit,
|
|
suggestion: false,
|
|
onSubmitted: (_) => onSave(),
|
|
),
|
|
actions: Btn.ok(onTap: onSave).toList,
|
|
);
|
|
});
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildTermTheme() {
|
|
String index2Str(int index) {
|
|
switch (index) {
|
|
case 0:
|
|
return l10n.system;
|
|
case 1:
|
|
return libL10n.bright;
|
|
case 2:
|
|
return libL10n.dark;
|
|
default:
|
|
return libL10n.error;
|
|
}
|
|
}
|
|
|
|
return ListTile(
|
|
leading: const Icon(MingCute.moon_stars_fill, size: _kIconSize),
|
|
title: Text(libL10n.theme),
|
|
trailing: ValBuilder(
|
|
listenable: _setting.termTheme.listenable(),
|
|
builder: (val) => Text(index2Str(val), style: UIs.text15),
|
|
),
|
|
onTap: () async {
|
|
final selected = await context.showPickSingleDialog(
|
|
title: libL10n.theme,
|
|
items: List.generate(3, (index) => index),
|
|
display: (p0) => index2Str(p0),
|
|
initial: _setting.termTheme.fetch(),
|
|
);
|
|
if (selected != null) {
|
|
_setting.termTheme.put(selected);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildSSHWakeLock() {
|
|
return ListTile(
|
|
leading: const Icon(MingCute.lock_fill),
|
|
title: Text(l10n.wakeLock),
|
|
trailing: StoreSwitch(prop: _setting.sshWakeLock),
|
|
);
|
|
}
|
|
|
|
Widget _buildLetterCache() {
|
|
return ListTile(
|
|
leading: const Icon(Bootstrap.alphabet),
|
|
// title: Text(l10n.letterCache),
|
|
// subtitle: Text(
|
|
// '${l10n.letterCacheTip}\n${l10n.needRestart}',
|
|
// style: UIs.textGrey,
|
|
// ),
|
|
title: TipText(l10n.letterCache, '${l10n.letterCacheTip}\n${l10n.needRestart}'),
|
|
trailing: StoreSwitch(prop: _setting.letterCache),
|
|
);
|
|
}
|
|
|
|
Widget _buildSshBg() {
|
|
return ExpandTile(
|
|
leading: const Icon(MingCute.background_fill),
|
|
title: Text(libL10n.background),
|
|
children: [_buildSshBgImage(), _buildSshBgOpacity(), _buildSshBlurRadius()],
|
|
);
|
|
}
|
|
|
|
Widget _buildSshBgImage() {
|
|
return ListTile(
|
|
leading: const Icon(Icons.image),
|
|
title: Text(libL10n.image),
|
|
trailing: _setting.sshBgImage.listenable().listenVal((val) {
|
|
final name = val.getFileName();
|
|
return Text(name ?? libL10n.empty, style: UIs.text15);
|
|
}),
|
|
onTap: () {
|
|
context.showRoundDialog(
|
|
title: libL10n.image,
|
|
actions: [
|
|
TextButton(onPressed: () async => await _pickBgImage(), child: Text(libL10n.file)),
|
|
TextButton(
|
|
onPressed: () {
|
|
_setting.sshBgImage.delete();
|
|
context.pop();
|
|
RNodes.app.notify();
|
|
},
|
|
child: Text(libL10n.clear),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildSshBgOpacity() {
|
|
void onSave(String s) {
|
|
final val = double.tryParse(s);
|
|
if (val == null) {
|
|
context.showSnackBar(libL10n.fail);
|
|
return;
|
|
}
|
|
_setting.sshBgOpacity.put(val.clamp(0.0, 1.0));
|
|
context.pop();
|
|
}
|
|
|
|
return ListTile(
|
|
leading: const Icon(Icons.opacity),
|
|
title: Text(libL10n.opacity),
|
|
trailing: ValBuilder(
|
|
listenable: _setting.sshBgOpacity.listenable(),
|
|
builder: (val) => Text(val.toString(), style: UIs.text15),
|
|
),
|
|
onTap: () => context.showRoundDialog(
|
|
title: libL10n.opacity,
|
|
child: Input(
|
|
controller: _sshOpacityCtrl,
|
|
autoFocus: true,
|
|
type: TextInputType.number,
|
|
hint: '0.3',
|
|
icon: Icons.opacity,
|
|
suggestion: false,
|
|
onSubmitted: onSave,
|
|
),
|
|
actions: Btn.ok(onTap: () => onSave(_sshOpacityCtrl.text)).toList,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSshBlurRadius() {
|
|
void onSave(String s) {
|
|
final val = double.tryParse(s);
|
|
if (val == null) {
|
|
context.showSnackBar(libL10n.fail);
|
|
return;
|
|
}
|
|
const minRadius = 0.0;
|
|
const maxBlur = 50.0;
|
|
final clampedVal = val.clamp(minRadius, maxBlur);
|
|
_setting.sshBlurRadius.put(clampedVal);
|
|
context.pop();
|
|
}
|
|
|
|
return ListTile(
|
|
leading: const Icon(Icons.blur_on),
|
|
title: Text(libL10n.blurRadius),
|
|
trailing: ValBuilder(
|
|
listenable: _setting.sshBlurRadius.listenable(),
|
|
builder: (val) => Text(val.toString(), style: UIs.text15),
|
|
),
|
|
onTap: () => context.showRoundDialog(
|
|
title: libL10n.blurRadius,
|
|
child: Input(
|
|
controller: _sshBlurCtrl,
|
|
autoFocus: true,
|
|
type: TextInputType.number,
|
|
hint: '0',
|
|
icon: Icons.blur_on,
|
|
suggestion: false,
|
|
onSubmitted: onSave,
|
|
),
|
|
actions: Btn.ok(onTap: () => onSave(_sshBlurCtrl.text)).toList,
|
|
),
|
|
);
|
|
}
|
|
}
|