540 lines
17 KiB
Dart
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)),
|
|
),
|
|
);
|
|
}
|
|
}
|