Files
netdesk/native/ui/lib/host_screen.dart
T

540 lines
17 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'rust/api.dart' as nd;
import 'theme.dart';
/// "Allow remote control" — the Android HOST role.
///
/// Turns this device into a NetDesk host (like RustDesk): once the user grants
/// screen capture and enables the accessibility service, a remote viewer can see
/// and control it. Talks to the native side over the `space.netdesk/host`
/// MethodChannel (see MainActivity.kt / HostService.kt).
///
/// Preview: screen capture + the controls are live; the remote-connection wiring
/// (register by Partner ID, send the encrypted H.264 to a viewer) lands next.
class HostScreen extends StatefulWidget {
const HostScreen({super.key});
@override
State<HostScreen> createState() => _HostScreenState();
}
class _HostScreenState extends State<HostScreen> with WidgetsBindingObserver {
static const _host = MethodChannel('space.netdesk/host');
static const _frameChannel = EventChannel('space.netdesk/frames');
static const _wsUrl = 'wss://netdesk.space/ws';
bool _running = false;
bool _accessibility = false;
bool _hideIcon = false;
bool _unattended = false;
int _frames = 0;
bool _busy = false;
// Rust host session.
BigInt? _handle;
String _id = '';
String _password = '';
bool _viewerConnected = false;
StreamSubscription<dynamic>? _frameSub;
StreamSubscription<nd.NdHostEvent>? _eventSub;
// Viewer-mouse -> touch gesture interpretation (coords are in capture space;
// MainActivity scales to the real display before injecting).
int _btns = 0;
double _downX = 0, _downY = 0, _lastX = 0, _lastY = 0;
int _downMs = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_refresh();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_frameSub?.cancel();
_eventSub?.cancel();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _refresh();
}
Future<T?> _call<T>(String method, [Map<String, dynamic>? args]) async {
try {
return await _host.invokeMethod<T>(method, args);
} catch (_) {
return null;
}
}
Future<void> _refresh() async {
final running = await _call<bool>('isHostRunning') ?? false;
final acc = await _call<bool>('hasAccessibility') ?? false;
final hide = await _call<bool>('isIconHidden') ?? false;
final un = await _call<bool>('isUnattended') ?? false;
final frames = await _call<int>('lastFrameCount') ?? 0;
if (mounted) {
setState(() {
_running = running;
_accessibility = acc;
_hideIcon = hide;
_unattended = un;
_frames = frames;
});
}
}
Future<void> _toggleHost() async {
setState(() => _busy = true);
if (_running) {
await _stopHostFull();
} else {
await _startHostFull();
}
if (mounted) setState(() => _busy = false);
}
/// Load the persisted host id + unattended password, generating them once.
Future<void> _loadCreds() async {
var id = await _call<String>('getHostId') ?? '';
var pw = await _call<String>('getHostPassword') ?? '';
if (id.isEmpty || pw.isEmpty) {
id = nd.hostGenId();
pw = nd.hostGenPassword();
await _call('setHostCreds', {'id': id, 'password': pw});
}
if (mounted) {
setState(() {
_id = id;
_password = pw;
});
}
}
Future<void> _startHostFull() async {
await _loadCreds();
// 1) screen-capture consent -> HostService starts encoding.
final ok = await _call<bool>('startHost') ?? false;
if (!ok) {
await _refresh();
return;
}
// 2) wait for the encoder to report a capture size.
int w = 0, h = 0;
for (var i = 0; i < 30; i++) {
await Future.delayed(const Duration(milliseconds: 200));
final s = await _call<Map<dynamic, dynamic>>('hostScreenSize');
if (s != null && (s['w'] as int? ?? 0) > 0) {
w = s['w'] as int;
h = s['h'] as int;
break;
}
}
if (w == 0) {
await _refresh();
return;
}
// 3) register the Rust host + wire frames out and input in.
final handle = await nd.hostStart(
wsUrl: _wsUrl,
hostId: _id,
name: 'Android',
password: _password,
width: w,
height: h,
);
_handle = handle;
_frameSub = _frameChannel.receiveBroadcastStream().listen(_onFrame);
_eventSub = nd.hostEvents(handle: handle).listen(_onHostEvent);
await _refresh();
}
Future<void> _stopHostFull() async {
await _frameSub?.cancel();
_frameSub = null;
await _eventSub?.cancel();
_eventSub = null;
final h = _handle;
_handle = null;
if (h != null) nd.hostStop(handle: h);
await _call('stopHost');
if (mounted) setState(() => _viewerConnected = false);
await Future.delayed(const Duration(milliseconds: 300));
await _refresh();
}
void _onFrame(dynamic payload) {
final h = _handle;
if (h == null || payload is! Uint8List || payload.isEmpty) return;
final keyframe = payload[0] == 1;
final data = Uint8List.sublistView(payload, 1);
nd.hostPushFrame(handle: h, data: data, keyframe: keyframe);
}
void _onHostEvent(nd.NdHostEvent ev) {
switch (ev) {
case nd.NdHostEvent_Mouse(:final x, :final y, :final buttons, :final wheel):
_onMouse(x, y, buttons, wheel);
case nd.NdHostEvent_Key(:final code, :final down):
_call('injectKey', {'code': code, 'down': down});
case nd.NdHostEvent_KeyframeRequested():
_call('requestKeyframe');
case nd.NdHostEvent_ViewerConnected():
if (mounted) setState(() => _viewerConnected = true);
case nd.NdHostEvent_ViewerLeft():
if (mounted) setState(() => _viewerConnected = false);
default:
break;
}
}
/// Translate the viewer's absolute pointer into Android gestures: a quick
/// press+release = tap, a moved drag = swipe, wheel = scroll.
void _onMouse(int x, int y, int buttons, double wheel) {
if (wheel != 0) {
final dy = (wheel.clamp(-3.0, 3.0) * 140).round();
_call('injectSwipe', {
'x1': x.toDouble(), 'y1': y.toDouble(),
'x2': x.toDouble(), 'y2': (y + dy).toDouble(), 'dur': 120,
});
return;
}
final leftNow = (buttons & 1) != 0;
final leftWas = (_btns & 1) != 0;
_btns = buttons;
if (leftNow && !leftWas) {
_downX = x.toDouble();
_downY = y.toDouble();
_lastX = _downX;
_lastY = _downY;
_downMs = DateTime.now().millisecondsSinceEpoch;
} else if (leftNow && leftWas) {
_lastX = x.toDouble();
_lastY = y.toDouble();
} else if (!leftNow && leftWas) {
_lastX = x.toDouble();
_lastY = y.toDouble();
final dist = (_lastX - _downX).abs() + (_lastY - _downY).abs();
if (dist < 12) {
_call('injectTap', {'x': _downX, 'y': _downY});
} else {
final dur = (DateTime.now().millisecondsSinceEpoch - _downMs)
.clamp(40, 800);
_call('injectSwipe', {
'x1': _downX, 'y1': _downY, 'x2': _lastX, 'y2': _lastY, 'dur': dur,
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: NetDeskColors.background,
appBar: AppBar(
backgroundColor: NetDeskColors.background,
elevation: 0,
title: const Text('Allow remote control'),
),
body: SafeArea(
child: RefreshIndicator(
color: NetDeskColors.accent,
onRefresh: _refresh,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_statusCard(),
if (_running && _id.isNotEmpty) ...[
const SizedBox(height: 14),
_credsCard(),
],
const SizedBox(height: 14),
_startStopButton(),
const SizedBox(height: 18),
_tile(
icon: Icons.touch_app_outlined,
title: 'Remote input (accessibility)',
subtitle: _accessibility
? 'Enabled — a viewer can tap and swipe this device.'
: 'Required so a viewer can tap and swipe. Tap to enable.',
trailing: _accessibility
? const Icon(Icons.check_circle, color: NetDeskColors.accent)
: const Icon(Icons.chevron_right,
color: NetDeskColors.textMuted),
onTap: _accessibility
? null
: () async {
await _call('openAccessibilitySettings');
},
),
_switchTile(
icon: Icons.schedule,
title: 'Unattended access',
subtitle: 'Start the host automatically after the device reboots.',
value: _unattended,
onChanged: (v) async {
await _call('setUnattended', {'on': v});
await _refresh();
},
),
_switchTile(
icon: Icons.visibility_off_outlined,
title: 'Hide app icon',
subtitle:
'Remove NetDesk from the app drawer (it keeps running). '
'Reopen it from your browser at netdesk.space.',
value: _hideIcon,
onChanged: (v) async {
await _call('setHideIcon', {'hide': v});
await _refresh();
},
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Text(
'Enter the ID and password above in any NetDesk viewer '
'(Windows, web or another phone) to see and control this '
'device. Keep the screen-capture notification on. '
'Developed by Akronlogic.',
style: TextStyle(
color: NetDeskColors.textMuted, fontSize: 12, height: 1.4),
),
),
],
),
),
),
);
}
Widget _statusCard() {
final on = _running;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: NetDeskColors.surface,
borderRadius: BorderRadius.circular(NetDeskRadius.lg),
border: Border.all(color: NetDeskColors.border),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: on ? NetDeskColors.accentGlow : NetDeskColors.surfaceAlt,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
on ? Icons.cast_connected : Icons.cast,
color: on ? NetDeskColors.accent : NetDeskColors.textSecondary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
on ? 'Host is active' : 'Host is off',
style: const TextStyle(
color: NetDeskColors.textPrimary,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
on
? 'Capturing the screen · $_frames frames encoded'
: 'This device cannot be controlled yet',
style: const TextStyle(
color: NetDeskColors.textSecondary, fontSize: 13),
),
],
),
),
],
),
);
}
Widget _credsCard() {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: NetDeskColors.surface,
borderRadius: BorderRadius.circular(NetDeskRadius.lg),
border: Border.all(
color: _viewerConnected ? NetDeskColors.accent : NetDeskColors.border,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_viewerConnected ? Icons.lock_person : Icons.vpn_key_outlined,
size: 18,
color: _viewerConnected
? NetDeskColors.accent
: NetDeskColors.textSecondary,
),
const SizedBox(width: 8),
Text(
_viewerConnected ? 'A viewer is connected' : 'Ready for a viewer',
style: TextStyle(
color: _viewerConnected
? NetDeskColors.accent
: NetDeskColors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 14),
_credRow('Your ID', _id),
const SizedBox(height: 10),
_credRow('Password', _password),
],
),
);
}
Widget _credRow(String label, String value) {
return Row(
children: [
SizedBox(
width: 86,
child: Text(label,
style: const TextStyle(
color: NetDeskColors.textMuted, fontSize: 13)),
),
Expanded(
child: SelectableText(
value,
style: const TextStyle(
color: NetDeskColors.textPrimary,
fontSize: 19,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
),
),
IconButton(
icon: const Icon(Icons.copy_rounded,
size: 18, color: NetDeskColors.textSecondary),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label copied'),
duration: const Duration(seconds: 1),
),
);
},
),
],
);
}
Widget _startStopButton() {
return SizedBox(
height: 52,
child: ElevatedButton(
onPressed: _busy ? null : _toggleHost,
style: ElevatedButton.styleFrom(
backgroundColor:
_running ? NetDeskColors.danger : NetDeskColors.accent,
foregroundColor: _running ? Colors.white : const Color(0xFF04210F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.md),
),
),
child: _busy
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.4),
)
: Text(
_running ? 'Stop hosting' : 'Start hosting this device',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700),
),
),
);
}
Widget _tile({
required IconData icon,
required String title,
required String subtitle,
Widget? trailing,
VoidCallback? onTap,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: NetDeskColors.surface,
borderRadius: BorderRadius.circular(NetDeskRadius.md),
border: Border.all(color: NetDeskColors.border),
),
child: ListTile(
onTap: onTap,
leading: Icon(icon, color: NetDeskColors.textSecondary),
title: Text(title,
style: const TextStyle(
color: NetDeskColors.textPrimary,
fontWeight: FontWeight.w600)),
subtitle: Text(subtitle,
style: const TextStyle(
color: NetDeskColors.textMuted, fontSize: 12.5)),
trailing: trailing,
),
);
}
Widget _switchTile({
required IconData icon,
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: NetDeskColors.surface,
borderRadius: BorderRadius.circular(NetDeskRadius.md),
border: Border.all(color: NetDeskColors.border),
),
child: SwitchListTile(
value: value,
onChanged: onChanged,
activeThumbColor: NetDeskColors.accent,
secondary: Icon(icon, color: NetDeskColors.textSecondary),
title: Text(title,
style: const TextStyle(
color: NetDeskColors.textPrimary,
fontWeight: FontWeight.w600)),
subtitle: Text(subtitle,
style: const TextStyle(
color: NetDeskColors.textMuted, fontSize: 12.5)),
),
);
}
}