diff --git a/flutter/lib/common/shared_state.dart b/flutter/lib/common/shared_state.dart index 4f9373c..912f214 100644 --- a/flutter/lib/common/shared_state.dart +++ b/flutter/lib/common/shared_state.dart @@ -105,6 +105,25 @@ class ConnectionType { _direct.value != kInvalidValueStr && _stream_type.value != kInvalidValueStr; } + + /// Returns true if the connection is using P2P (direct) transport, i.e. NAT + /// punching succeeded. Returns false if going through the relay server. + /// Returns true (assume P2P) if the connection state isn't known yet, so the + /// UI doesn't briefly grey out before the connection establishes. + bool get isP2P { + if (!isValid()) return true; + return _direct.value == strDirect; + } +} + +/// Convenience function: checks the P2P/relay state of a connection by peerId. +/// Returns true (assume P2P) if state is unknown. +bool isDirectConnection(String peerId) { + try { + return ConnectionTypeState.find(peerId).isP2P; + } catch (_) { + return true; + } } class ConnectionTypeState { diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 637904c..56451a8 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -454,7 +454,13 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text(translate('Transfer file')), - onPressed: () => connectWithToken(isFileTransfer: true)), + onPressed: () { + if (!isDirectConnection(id)) { + showToast('中继模式:当前连接走服务器 21117 中转,文件传输已被禁用以避免占用过多服务器带宽。'); + return; + } + connectWithToken(isFileTransfer: true); + }), ); v.add( TTextMenu( @@ -469,7 +475,13 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { v.add( TTextMenu( child: Text(translate('TCP tunneling')), - onPressed: () => connectWithToken(isTcpTunneling: true)), + onPressed: () { + if (!isDirectConnection(id)) { + showToast('中继模式:TCP 隧道已被禁用以避免占用过多服务器带宽。'); + return; + } + connectWithToken(isTcpTunneling: true); + }), ); } // note diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index f466964..07e961d 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -22,6 +22,7 @@ import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; import '../widgets/kb_layout_type_chooser.dart'; import '../widgets/tabbar_widget.dart'; +import '../widgets/punch_status_banner.dart'; import 'package:flutter_hbb/native/custom_cursor.dart' if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart'; @@ -483,6 +484,16 @@ class _RemotePageState extends State _ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(), ], ), + // Custom fork: punch-status banner — shows P2P/relay state at the top + // so the user knows whether the connection is going through their + // 21117 server (relay) or directly between the two machines (P2P). + // See flutter/lib/desktop/widgets/punch_status_banner.dart + Positioned( + top: 0, + left: 0, + right: 0, + child: PunchStatusBanner(peerId: widget.id), + ), ], ); } diff --git a/flutter/lib/desktop/widgets/punch_status_banner.dart b/flutter/lib/desktop/widgets/punch_status_banner.dart new file mode 100644 index 0000000..fd92f19 --- /dev/null +++ b/flutter/lib/desktop/widgets/punch_status_banner.dart @@ -0,0 +1,97 @@ +// punch_status_banner.dart +// +// Top-of-connection-page banner that shows whether the current session is a +// P2P direct connection (NAT punch succeeded) or a relay fallback (NAT punch +// failed, going through the user's 21117 server). User-facing copy makes the +// implications clear: +// +// - P2P: 绿色横幅 + ⚡ icon: "P2P 直连成功 — 走的是两台机器之间的网络,可放心传文件" +// - Relay: 橙色横幅 + ⚠ icon: "中继模式 — 视频/控制走服务器 21117 中转,请勿传大文件" +// +// The status comes from ConnectionType.direct, which the Rust core sets +// when the connection is established (see src/client/io_loop.rs:184 — +// set_connection_type(secure, direct, stream_type)). +// +// The banner is reactive: it subscribes to the Rx so it updates +// automatically if the connection type changes (e.g. after a re-connect). + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../common/shared_state.dart'; + +class PunchStatusBanner extends StatelessWidget { + final String peerId; + const PunchStatusBanner({super.key, required this.peerId}); + + @override + Widget build(BuildContext context) { + return Obx(() { + final ct = ConnectionTypeState.find(peerId); + if (!ct.isValid()) { + // Not connected yet — show nothing + return const SizedBox.shrink(); + } + final isDirect = ct.direct.value == ConnectionType.strDirect; + if (isDirect) { + return _Banner( + color: const Color(0xFFE7F5EA), // light green + iconColor: const Color(0xFF1E8E3E), + icon: Icons.bolt, + text: 'P2P 直连成功 — 走的是两台机器之间的网络,可放心传文件', + ); + } else { + return _Banner( + color: const Color(0xFFFFF3E0), // light orange + iconColor: const Color(0xFFE65100), + icon: Icons.warning_amber_rounded, + text: '中继模式 — 视频/控制走服务器 21117 中转,请勿传大文件', + ); + } + }); + } +} + +class _Banner extends StatelessWidget { + final Color color; + final Color iconColor; + final IconData icon; + final String text; + const _Banner({ + required this.color, + required this.iconColor, + required this.icon, + required this.text, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: color, + elevation: 2, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: iconColor, size: 18), + const SizedBox(width: 8), + Flexible( + child: Text( + text, + style: TextStyle( + color: iconColor, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } +}