NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
@@ -0,0 +1,539 @@
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user