Add P2P/relay status banner + gate file-transfer menu on relay
Custom fork feature: surface NAT-punch result to the user so they know
whether the connection is going through their 21117 server (relay) or
directly between the two machines (P2P).
Three changes:
1. flutter/lib/common/shared_state.dart: add ConnectionType.isP2P getter
(true = direct, false = relay) and a top-level isDirectConnection(peerId)
helper that returns true when state is unknown (so UI doesn't briefly
grey out before the connection establishes).
2. flutter/lib/desktop/widgets/punch_status_banner.dart (new): a small
Positioned banner at the top of the connection page.
- P2P success: green ⚡ banner: 'P2P 直连成功 — 走的是两台机器之间的网络,可放心传文件'
- Relay fallback: orange ⚠ banner: '中继模式 — 视频/控制走服务器 21117 中转,请勿传大文件'
The banner is reactive (Obx on ConnectionType.direct Rx<String>).
3. flutter/lib/common/widgets/toolbar.dart: wrap the 'Transfer file' and
'TCP tunneling' menu items' onPressed with an isDirectConnection() guard.
On relay mode, the menu still shows but the click is intercepted with a
toast explaining why the action is blocked.
4. flutter/lib/desktop/pages/remote_page.dart: add the banner via
Positioned(top: 0, left: 0, right: 0) at the end of bodyWidget's Stack.
Why this matters: user has 4 users on 5 Mbps server (114.55.133.123).
When NAT punch fails and the session goes through relay, file transfers
would saturate the server bandwidth. Showing the relay state + blocking
the file-transfer menu protects the server without requiring the user
to remember the technical detail.
This commit is contained in:
@@ -105,6 +105,25 @@ class ConnectionType {
|
|||||||
_direct.value != kInvalidValueStr &&
|
_direct.value != kInvalidValueStr &&
|
||||||
_stream_type.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 {
|
class ConnectionTypeState {
|
||||||
|
|||||||
@@ -454,7 +454,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
v.add(
|
v.add(
|
||||||
TTextMenu(
|
TTextMenu(
|
||||||
child: Text(translate('Transfer file')),
|
child: Text(translate('Transfer file')),
|
||||||
onPressed: () => connectWithToken(isFileTransfer: true)),
|
onPressed: () {
|
||||||
|
if (!isDirectConnection(id)) {
|
||||||
|
showToast('中继模式:当前连接走服务器 21117 中转,文件传输已被禁用以避免占用过多服务器带宽。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectWithToken(isFileTransfer: true);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
v.add(
|
v.add(
|
||||||
TTextMenu(
|
TTextMenu(
|
||||||
@@ -469,7 +475,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
v.add(
|
v.add(
|
||||||
TTextMenu(
|
TTextMenu(
|
||||||
child: Text(translate('TCP tunneling')),
|
child: Text(translate('TCP tunneling')),
|
||||||
onPressed: () => connectWithToken(isTcpTunneling: true)),
|
onPressed: () {
|
||||||
|
if (!isDirectConnection(id)) {
|
||||||
|
showToast('中继模式:TCP 隧道已被禁用以避免占用过多服务器带宽。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectWithToken(isTcpTunneling: true);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// note
|
// note
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import '../../utils/image.dart';
|
|||||||
import '../widgets/remote_toolbar.dart';
|
import '../widgets/remote_toolbar.dart';
|
||||||
import '../widgets/kb_layout_type_chooser.dart';
|
import '../widgets/kb_layout_type_chooser.dart';
|
||||||
import '../widgets/tabbar_widget.dart';
|
import '../widgets/tabbar_widget.dart';
|
||||||
|
import '../widgets/punch_status_banner.dart';
|
||||||
|
|
||||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||||
@@ -483,6 +484,16 @@ class _RemotePageState extends State<RemotePage>
|
|||||||
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
_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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user