Files
flutter_opencode_client/lib/view/widget/server_func_btns.dart
GT610 d6e17ff58c feat: Added Port Forwarding Functionality (#1083)
* feat: Added Port Forwarding Functionality

Implemented port forwarding functionality, including the following major changes:
- Added a port forwarding configuration model and related state management
- Added a port forwarding page and interaction logic
- Implemented forwarding connections between local and remote ports
- Integrated into the server features menu
- Added necessary Hive adapters and storage support
- Updated plugin configurations across all platforms to support the new feature

* feat (Port Forwarding): Added multilingual support and optimized implementation

Added multilingual support for the port forwarding feature, including Chinese, English, and other languages

Optimized the port forwarding implementation by adding connection management and error handling

Fixed an issue with state persistence when updating port forwarding configurations

Updated related dependencies and submodules

* fix(port_forward): Fixed port forwarding error handling and redesigned the configuration dialog

Handled uncaught errors when port forwarding is disabled or during connection attempts

Extracted the configuration dialog into a standalone component and added port range validation

* fix(port_forward): Fixed issues with port forwarding connection management and UI layout

Fixed an issue where port forwarding connections were not closed properly; now uses `clientGetter` to delay the retrieval of `SSHClient`
Added cleanup logic when connections are closed to prevent memory leaks

Added a `mounted` check in `PortForwardPage` to prevent operations from executing after the component is unmounted

Wrapped the configuration dialog content in a `SingleChildScrollView` to prevent content overflow

* fix(port_forward): Fixed a concurrent modification exception that occurred when closing a port forwarding connection

Fixed a concurrent modification exception that could occur when closing a local forwarding entry by copying the connection list to prevent modifications to the collection during iteration. Also improved the UI by using theme colors and added error handling for configuration saving.

* fix(port_forward_provider): Fixed an issue where entries were not properly removed when port forwarding was stopped

When port forwarding is stopped, ensure that the corresponding entries are removed from the _forwards map. Additionally, before adding a new forwarding rule, check for and close any existing forwarding rules with the same ID to prevent resource leaks.

* refactor(l1n): Remove unused localization and remote host port translations

* fix(port_forward_provider): Handle errors when closing port forwarding

Add error handling to prevent the program from crashing due to exceptions when closing port forwarding

* refactor(port_forward): Refactor port forwarding state management to use serverId

Directly link port forwarding state management to the server ID to simplify parameter passing

Remove direct dependencies on Spi and use serverId as the core identifier instead

Update relevant providers and page logic to accommodate the new state structure

* fix(port_forward): Fixed a race condition issue in port forwarding operations

Added an _inFlight collection to prevent duplicate operations

Added a _saving state when saving configurations to prevent duplicate submissions

Automatically cleans up forwarding when changes in server connection status are detected

* refactor(port_forward_provider): Remove unnecessary concurrency control logic

Simplify the `toggleForward` method by removing concurrency control for the `_inFlight` collection, as it is not required in the current scenario
2026-03-23 19:38:58 +08:00

321 lines
10 KiB
Dart

import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/data/model/app/menu/base.dart';
import 'package:server_box/data/model/app/menu/server_func.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/container/container.dart';
import 'package:server_box/view/page/iperf.dart';
import 'package:server_box/view/page/port_forward.dart';
import 'package:server_box/view/page/process.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
import 'package:server_box/view/page/storage/sftp.dart';
import 'package:server_box/view/page/systemd.dart';
class ServerFuncBtnsTopRight extends ConsumerWidget {
final Spi spi;
const ServerFuncBtnsTopRight({super.key, required this.spi});
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenu<ServerFuncBtn>(
items: ServerFuncBtn.values.map((e) => PopMenu.build(e, e.icon, e.toStr)).toList(),
padding: const EdgeInsets.symmetric(horizontal: 10),
onSelected: (val) => _onTapMoreBtns(val, spi, context, ref),
);
}
}
class ServerFuncBtns extends StatelessWidget {
const ServerFuncBtns({super.key, required this.spi});
final Spi spi;
@override
Widget build(BuildContext context) {
final btns = this.btns;
if (btns.isEmpty) return UIs.placeholder;
return SizedBox(
height: 77,
child: ListView.builder(
itemCount: btns.length,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 13),
itemBuilder: (context, index) {
final value = btns[index];
final item = Consumer(builder: (_, ref, _) => _buildItem(context, value, ref));
return item.paddingSymmetric(horizontal: 7);
},
),
);
}
Widget _buildItem(BuildContext context, ServerFuncBtn e, WidgetRef ref) {
final move = Stores.setting.moveServerFuncs.fetch();
if (move) {
return IconButton(
onPressed: () => _onTapMoreBtns(e, spi, context, ref),
padding: EdgeInsets.zero,
tooltip: e.toStr,
icon: Icon(e.icon, size: 15),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 13),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => _onTapMoreBtns(e, spi, context, ref),
padding: EdgeInsets.zero,
icon: Icon(e.icon, size: 17),
),
Text(e.toStr, style: UIs.text11Grey),
],
),
);
}
List<ServerFuncBtn> get btns {
try {
final vals = <ServerFuncBtn>[];
final list = Stores.setting.serverFuncBtns.fetch();
for (final idx in list) {
if (idx < 0 || idx >= ServerFuncBtn.values.length) continue;
vals.add(ServerFuncBtn.values[idx]);
}
return vals;
} catch (e) {
return ServerFuncBtn.values;
}
}
}
void _onTapMoreBtns(ServerFuncBtn value, Spi spi, BuildContext context, WidgetRef ref) async {
// final isMobile = ResponsiveBreakpoints.of(context).isMobile;
switch (value) {
// case ServerFuncBtn.pkg:
// _onPkg(context, spi);
// break;
case ServerFuncBtn.sftp:
if (!_checkClient(context, spi.id, ref)) return;
final args = SftpPageArgs(spi: spi);
// if (isMobile) {
SftpPage.route.go(context, args);
// } else {
// SplitViewNavigator.of(context)?.replace(
// SftpPage.route.toWidget(args: args),
// );
// }
break;
case ServerFuncBtn.snippet:
final snippetState = ref.read(snippetProvider);
if (snippetState.snippets.isEmpty) {
context.showSnackBar(libL10n.empty);
return;
}
final snippets = await context.showPickWithTagDialog<Snippet>(
title: libL10n.snippet,
tags: snippetState.tags.vn,
itemsBuilder: (e) {
if (e == TagSwitcher.kDefaultTag) {
return snippetState.snippets;
}
return snippetState.snippets
.where((element) => element.tags?.contains(e) ?? false)
.toList();
},
display: (e) => e.name,
);
if (snippets == null || snippets.isEmpty) return;
final snippet = snippets.firstOrNull;
if (snippet == null) return;
final fmted = snippet.fmtWithSpi(spi);
final sure = await context.showRoundDialog<bool>(
title: libL10n.attention,
child: SingleChildScrollView(child: SimpleMarkdown(data: '```shell\n$fmted\n```')),
actions: [CountDownBtn(onTap: () => context.pop(true), text: libL10n.run, afterColor: Colors.red)],
);
if (sure != true) return;
if (!_checkClient(context, spi.id, ref)) return;
final args = SshPageArgs(spi: spi, initSnippet: snippet);
// if (isMobile) {
SSHPage.route.go(context, args);
// } else {
// SplitViewNavigator.of(context)?.replace(
// SSHPage.route.toWidget(args: args),
// );
// }
break;
case ServerFuncBtn.container:
if (!_checkClient(context, spi.id, ref)) return;
final args = SpiRequiredArgs(spi);
// if (isMobile) {
ContainerPage.route.go(context, args);
// } else {
// SplitViewNavigator.of(
// context,
// )?.replace(ContainerPage.route.toWidget(args: args));
// }
break;
case ServerFuncBtn.process:
if (!_checkClient(context, spi.id, ref)) return;
final args = SpiRequiredArgs(spi);
// if (isMobile) {
ProcessPage.route.go(context, args);
// } else {
// SplitViewNavigator.of(context)?.replace(
// ProcessPage.route.toWidget(args: args),
// );
// }
break;
case ServerFuncBtn.terminal:
_gotoSSH(spi, context);
break;
case ServerFuncBtn.iperf:
if (!_checkClient(context, spi.id, ref)) return;
final args = SpiRequiredArgs(spi);
// if (isMobile) {
IPerfPage.route.go(context, args);
// } else {
// SplitViewNavigator.of(context)?.replace(
// IPerfPage.route.toWidget(args: args),
// );
// }
break;
case ServerFuncBtn.systemd:
if (!_checkClient(context, spi.id, ref)) return;
final args = SpiRequiredArgs(spi);
// if (isMobile) {
SystemdPage.route.go(context, args);
// } else {
// SplitViewNavigator.of(context)?.replace(
// SystemdPage.route.toWidget(args: args),
// );
// }
break;
case ServerFuncBtn.portForward:
if (!_checkClient(context, spi.id, ref)) return;
final args = SpiRequiredArgs(spi);
PortForwardPage.route.go(context, args);
break;
}
}
void _gotoSSH(Spi spi, BuildContext context) async {
// run built-in ssh on macOS due to incompatibility
if (isMobile || isMacOS) {
final args = SshPageArgs(spi: spi);
SSHPage.route.go(context, args);
return;
}
final extraArgs = <String>[];
if (spi.port != 22) {
extraArgs.addAll(['-p', '${spi.port}']);
}
final path = await () async {
final tempKeyFileName = 'srvbox_pk_${spi.keyId}';
/// For security reason, save the private key file to app doc path
return Paths.doc.joinPath(tempKeyFileName);
}();
final file = File(path);
final shouldGenKey = spi.keyId != null;
if (shouldGenKey) {
if (await file.exists()) {
await file.delete();
}
final keyContent = getPrivateKey(spi.keyId!);
final keyContentWithNewline = keyContent.endsWith('\n') ? keyContent : '$keyContent\n';
await file.writeAsString(keyContentWithNewline);
if (!Platform.isWindows) {
await Process.run('chmod', ['600', path]);
}
extraArgs.addAll(['-i', path]);
}
final sshCommand = ['ssh'] + extraArgs + ['${spi.user}@${spi.ip}'];
final system = Pfs.type;
switch (system) {
case Pfs.windows:
await Process.start('cmd', ['/c', 'start'] + sshCommand);
break;
case Pfs.linux:
final scriptFile = File('${Directory.systemTemp.path}/srvbox_launch_term.sh');
await scriptFile.writeAsString(_runEmulatorShell);
if (Platform.isLinux || Platform.isMacOS) {
await Process.run('chmod', ['+x', scriptFile.path]);
}
try {
var terminal = Stores.setting.desktopTerminal.fetch();
if (terminal.isEmpty) terminal = 'x-terminal-emulator';
await Process.start(scriptFile.path, [terminal, ...sshCommand]);
} catch (e, s) {
context.showErrDialog(e, s, libL10n.emulator);
} finally {
await scriptFile.delete();
}
break;
default:
context.showSnackBar('Mismatch system: $system');
}
if (shouldGenKey) {
if (!await file.exists()) return;
await Future.delayed(const Duration(seconds: 2), file.delete);
}
}
bool _checkClient(BuildContext context, String id, WidgetRef ref) {
final serverState = ref.read(serverProvider(id));
if (serverState.client == null) {
context.showSnackBar(l10n.waitConnection);
return false;
}
return true;
}
const _runEmulatorShell = '''
#!/bin/sh
TERMINAL="\$1"
shift
if [ -z "\$TERMINAL" ] || [ "\$TERMINAL" = "x-terminal-emulator" ]; then
for term in kitty alacritty gnome-terminal gnome-console konsole xfce4-terminal terminator tilix wezterm foot xterm; do
command -v "\$term" >/dev/null 2>&1 && { TERMINAL="\$term"; break; }
done
[ -z "\$TERMINAL" ] && TERMINAL="x-terminal-emulator"
fi
case "\$TERMINAL" in
gnome-terminal|gnome-console) exec "\$TERMINAL" -- "\$@" ;;
alacritty)
"\$TERMINAL" --version 2>&1 | grep -q "alacritty 0\\.1[3-9]" &&
exec "\$TERMINAL" --command "\$@" || exec "\$TERMINAL" -e "\$@" ;;
kitty|foot) exec "\$TERMINAL" "\$@" ;;
wezterm) exec "\$TERMINAL" start -- "\$@" ;;
xfce4-terminal) exec "\$TERMINAL" -e "\$*" ;;
*) exec "\$TERMINAL" -e "\$@" ;;
esac
''';