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,613 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'host_screen.dart';
|
||||
import 'rust/api.dart' as nd;
|
||||
import 'session_screen.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Default NetDesk rendezvous / signaling endpoint (the Frankfurt VPS).
|
||||
/// A LAN host is reachable by overriding this with `ws://<PC-IP>:8901/ws`
|
||||
/// from the Advanced panel.
|
||||
const String kDefaultServer = 'wss://netdesk.space/ws';
|
||||
|
||||
/// The landing / connect screen.
|
||||
///
|
||||
/// Mirrors the mental model of TeamViewer / RustDesk: the brand mark, a big
|
||||
/// Partner ID field, a session password, and a primary Connect action. Connect
|
||||
/// calls into the Rust core via flutter_rust_bridge ([nd.connect]) and hands
|
||||
/// the returned session handle to the [SessionScreen].
|
||||
class ConnectScreen extends StatefulWidget {
|
||||
const ConnectScreen({super.key, this.initError});
|
||||
|
||||
/// Non-null when the native engine failed to load (shown as a banner).
|
||||
final String? initError;
|
||||
|
||||
@override
|
||||
State<ConnectScreen> createState() => _ConnectScreenState();
|
||||
}
|
||||
|
||||
class _ConnectScreenState extends State<ConnectScreen> {
|
||||
final TextEditingController _idController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _serverController =
|
||||
TextEditingController(text: kDefaultServer);
|
||||
|
||||
bool _obscurePassword = true;
|
||||
bool _showAdvanced = false;
|
||||
bool _connecting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_idController.dispose();
|
||||
_passwordController.dispose();
|
||||
_serverController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _digits => _idController.text.replaceAll(RegExp(r'\s'), '');
|
||||
|
||||
bool get _canConnect =>
|
||||
!_connecting && _digits.length >= 6 && widget.initError == null;
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (!_canConnect) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
final hostId = _digits;
|
||||
final server = _serverController.text.trim().isEmpty
|
||||
? kDefaultServer
|
||||
: _serverController.text.trim();
|
||||
|
||||
setState(() => _connecting = true);
|
||||
try {
|
||||
final handle = await nd.connect(
|
||||
wsUrl: server,
|
||||
hostId: hostId,
|
||||
password: _passwordController.text,
|
||||
totp: '',
|
||||
);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => SessionScreen(
|
||||
partnerId: _idController.text.trim(),
|
||||
handle: handle,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: NetDeskColors.surface,
|
||||
content: Text('Could not start session: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _connecting = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The form half of the screen: error banner, connect card, device card and
|
||||
/// the host-mode button. Shared by the narrow and the wide (two-pane) layouts.
|
||||
List<Widget> _formChildren(BuildContext context) {
|
||||
return [
|
||||
if (widget.initError != null) ...[
|
||||
_EngineErrorBanner(message: widget.initError!),
|
||||
const SizedBox(height: NetDeskSpacing.md),
|
||||
],
|
||||
_ConnectCard(
|
||||
idController: _idController,
|
||||
passwordController: _passwordController,
|
||||
serverController: _serverController,
|
||||
obscurePassword: _obscurePassword,
|
||||
canConnect: _canConnect,
|
||||
connecting: _connecting,
|
||||
showAdvanced: _showAdvanced,
|
||||
onToggleObscure: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
onToggleAdvanced: () => setState(() => _showAdvanced = !_showAdvanced),
|
||||
onChanged: () => setState(() {}),
|
||||
onConnect: _connect,
|
||||
),
|
||||
const SizedBox(height: NetDeskSpacing.md),
|
||||
const _ThisDeviceCard(),
|
||||
const SizedBox(height: NetDeskSpacing.md),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const HostScreen()),
|
||||
),
|
||||
icon: const Icon(Icons.phonelink_setup, size: 18),
|
||||
label: const Text('Allow remote control of this device'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: NetDeskColors.textSecondary,
|
||||
side: const BorderSide(color: NetDeskColors.border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(NetDeskRadius.md),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Responsive: the screen must FIT the window on phones, tablets (either
|
||||
// orientation), split-screen and desktop/DeX windows.
|
||||
// * wide windows -> brand beside the form (two panes, half the height)
|
||||
// * short windows -> compact brand + tighter spacing
|
||||
// * scrolling only remains as a last-resort safety for tiny windows.
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final wide = box.maxWidth >= 720;
|
||||
final compact = box.maxHeight < 700;
|
||||
|
||||
if (wide) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
_Wordmark(),
|
||||
SizedBox(height: NetDeskSpacing.lg),
|
||||
_FooterNote(),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(NetDeskSpacing.lg),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _formChildren(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(
|
||||
compact ? NetDeskSpacing.md : NetDeskSpacing.lg),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Wordmark(compact: compact),
|
||||
SizedBox(
|
||||
height: compact
|
||||
? NetDeskSpacing.md
|
||||
: NetDeskSpacing.xl),
|
||||
..._formChildren(context),
|
||||
SizedBox(
|
||||
height: compact
|
||||
? NetDeskSpacing.md
|
||||
: NetDeskSpacing.lg),
|
||||
const _FooterNote(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand mark: the NetDesk logo, wordmark and the Akronlogic byline.
|
||||
/// `compact` shrinks it so short windows (landscape phones, small desktop
|
||||
/// windows) still fit the whole screen without scrolling.
|
||||
class _Wordmark extends StatelessWidget {
|
||||
const _Wordmark({this.compact = false});
|
||||
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final logo = compact ? 54.0 : 76.0;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: logo,
|
||||
height: logo,
|
||||
padding: EdgeInsets.all(compact ? 7 : 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(NetDeskRadius.lg),
|
||||
color: NetDeskColors.surface,
|
||||
border: Border.all(color: NetDeskColors.border),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: NetDeskColors.accentGlow,
|
||||
blurRadius: 36,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/icon.png',
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? NetDeskSpacing.sm : NetDeskSpacing.md),
|
||||
Text(
|
||||
'NetDesk',
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 28 : 40,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
color: NetDeskColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'by Akronlogic',
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 12 : 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 2.4,
|
||||
color: NetDeskColors.textSecondary.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The main card: Partner ID + password + Connect (+ optional Advanced server).
|
||||
class _ConnectCard extends StatelessWidget {
|
||||
const _ConnectCard({
|
||||
required this.idController,
|
||||
required this.passwordController,
|
||||
required this.serverController,
|
||||
required this.obscurePassword,
|
||||
required this.canConnect,
|
||||
required this.connecting,
|
||||
required this.showAdvanced,
|
||||
required this.onToggleObscure,
|
||||
required this.onToggleAdvanced,
|
||||
required this.onChanged,
|
||||
required this.onConnect,
|
||||
});
|
||||
|
||||
final TextEditingController idController;
|
||||
final TextEditingController passwordController;
|
||||
final TextEditingController serverController;
|
||||
final bool obscurePassword;
|
||||
final bool canConnect;
|
||||
final bool connecting;
|
||||
final bool showAdvanced;
|
||||
final VoidCallback onToggleObscure;
|
||||
final VoidCallback onToggleAdvanced;
|
||||
final VoidCallback onChanged;
|
||||
final VoidCallback onConnect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(NetDeskSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _FieldLabel('Partner ID'),
|
||||
const SizedBox(height: NetDeskSpacing.xs),
|
||||
TextField(
|
||||
controller: idController,
|
||||
onChanged: (_) => onChanged(),
|
||||
enabled: !connecting,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
_PartnerIdFormatter(),
|
||||
],
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 3,
|
||||
color: NetDeskColors.textPrimary,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '000 000 000',
|
||||
prefixIcon:
|
||||
Icon(Icons.badge_outlined, color: NetDeskColors.textMuted),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: NetDeskSpacing.md),
|
||||
const _FieldLabel('Password'),
|
||||
const SizedBox(height: NetDeskSpacing.xs),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
onChanged: (_) => onChanged(),
|
||||
enabled: !connecting,
|
||||
obscureText: obscurePassword,
|
||||
onSubmitted: (_) => canConnect ? onConnect() : null,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Session password',
|
||||
prefixIcon: const Icon(Icons.lock_outline,
|
||||
color: NetDeskColors.textMuted),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: obscurePassword ? 'Show' : 'Hide',
|
||||
onPressed: onToggleObscure,
|
||||
icon: Icon(
|
||||
obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
crossFadeState: showAdvanced
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: const SizedBox(width: double.infinity),
|
||||
secondChild: Padding(
|
||||
padding: const EdgeInsets.only(top: NetDeskSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _FieldLabel('Server'),
|
||||
const SizedBox(height: NetDeskSpacing.xs),
|
||||
TextField(
|
||||
controller: serverController,
|
||||
enabled: !connecting,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: NetDeskColors.textPrimary,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'wss://netdesk.space/ws',
|
||||
prefixIcon: Icon(Icons.dns_outlined,
|
||||
color: NetDeskColors.textMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: NetDeskSpacing.lg),
|
||||
FilledButton.icon(
|
||||
onPressed: canConnect ? onConnect : null,
|
||||
icon: connecting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.2,
|
||||
color: Color(0xFF04210F),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.bolt_rounded, size: 20),
|
||||
label: Text(connecting ? 'Connecting…' : 'Connect'),
|
||||
),
|
||||
const SizedBox(height: NetDeskSpacing.xs),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: TextButton.icon(
|
||||
onPressed: onToggleAdvanced,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: NetDeskColors.textMuted,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
showAdvanced
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.tune_rounded,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(showAdvanced ? 'Hide advanced' : 'Advanced'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small card standing in for *this* device's own shareable identity.
|
||||
class _ThisDeviceCard extends StatelessWidget {
|
||||
const _ThisDeviceCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NetDeskSpacing.lg,
|
||||
vertical: NetDeskSpacing.md,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: NetDeskColors.accentGlow,
|
||||
borderRadius: BorderRadius.circular(NetDeskRadius.sm),
|
||||
),
|
||||
child: const Icon(Icons.smartphone_rounded,
|
||||
color: NetDeskColors.accent, size: 20),
|
||||
),
|
||||
const SizedBox(width: NetDeskSpacing.md),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'This device',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.4,
|
||||
color: NetDeskColors.textMuted,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Incoming control — tap “Allow” below',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: NetDeskColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: NetDeskColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: NetDeskColors.border),
|
||||
),
|
||||
child: const Text(
|
||||
'BETA',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
color: NetDeskColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EngineErrorBanner extends StatelessWidget {
|
||||
const _EngineErrorBanner({required this.message});
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(NetDeskSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: NetDeskColors.danger.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(NetDeskRadius.md),
|
||||
border: Border.all(color: NetDeskColors.danger.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded,
|
||||
color: NetDeskColors.danger, size: 20),
|
||||
const SizedBox(width: NetDeskSpacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Engine not loaded',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: NetDeskColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: NetDeskColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FieldLabel extends StatelessWidget {
|
||||
const _FieldLabel(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
color: NetDeskColors.textSecondary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FooterNote extends StatelessWidget {
|
||||
const _FooterNote();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, size: 14, color: NetDeskColors.textMuted),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'End-to-end encrypted · v0.1.0',
|
||||
style: TextStyle(fontSize: 12, color: NetDeskColors.textMuted),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats up to 12 digits as "123 456 789" while typing.
|
||||
class _PartnerIdFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
var digits = newValue.text.replaceAll(RegExp(r'\s'), '');
|
||||
if (digits.length > 12) digits = digits.substring(0, 12);
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < digits.length; i++) {
|
||||
if (i != 0 && i % 3 == 0) buffer.write(' ');
|
||||
buffer.write(digits[i]);
|
||||
}
|
||||
final formatted = buffer.toString();
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user