import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:mobile_nebula/components/SimplePage.dart'; import 'package:mobile_nebula/components/SiteItem.dart'; import 'package:mobile_nebula/models/Certificate.dart'; import 'package:mobile_nebula/models/IPAndPort.dart'; import 'package:mobile_nebula/models/Site.dart'; import 'package:mobile_nebula/models/StaticHosts.dart'; import 'package:mobile_nebula/models/UnsafeRoute.dart'; import 'package:mobile_nebula/screens/SettingsScreen.dart'; import 'package:mobile_nebula/screens/SiteDetailScreen.dart'; import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart'; import 'package:mobile_nebula/services/utils.dart'; import 'package:uuid/uuid.dart'; //TODO: add refresh class MainScreen extends StatefulWidget { const MainScreen({Key? key}) : super(key: key); @override _MainScreenState createState() => _MainScreenState(); } class _MainScreenState extends State { bool ready = false; List? sites; // A set of widgets to display in a column that represents an error blocking us from moving forward entirely List? error; static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); @override void initState() { _loadSites(); super.initState(); } @override Widget build(BuildContext context) { return SimplePage( title: 'Nebula', scrollable: SimpleScrollable.none, leadingAction: PlatformIconButton( padding: EdgeInsets.zero, icon: Icon(Icons.add, size: 28.0), onPressed: () => Utils.openPage(context, (context) { return SiteConfigScreen(onSave: (_) { _loadSites(); }); }), ), trailingActions: [ PlatformIconButton( padding: EdgeInsets.zero, icon: Icon(Icons.menu, size: 28.0), onPressed: () => Utils.openPage(context, (_) => SettingsScreen()), ), ], bottomBar: kDebugMode ? _debugSave() : null, child: _buildBody(), ); } Widget _buildBody() { if (error != null) { return Center( child: Padding( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: error!, ), padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10))); } if (!ready) { return Center( child: PlatformCircularProgressIndicator(cupertino: (_, __) { return CupertinoProgressIndicatorData(radius: 50); }), ); } return _buildSites(); } Widget _buildNoSites() { return Padding( padding: const EdgeInsets.all(8.0), child: Center( child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0), child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), ), Text('You don\'t have any site configurations installed yet. Hit the plus button above to get started.', textAlign: TextAlign.center), ], ), )); } Widget _buildSites() { if (sites == null || sites!.length == 0) { return _buildNoSites(); } List items = []; sites!.forEach((site) { items.add(SiteItem( key: Key(site.id), site: site, onPressed: () { Utils.openPage(context, (context) { return SiteDetailScreen(site: site, onChanged: () => _loadSites()); }); })); }); Widget child = ReorderableListView( padding: EdgeInsets.symmetric(vertical: 5), children: items, onReorder: (oldI, newI) async { if (oldI < newI) { // removing the item at oldIndex will shorten the list by 1. newI -= 1; } setState(() { final Site moved = sites!.removeAt(oldI); sites!.insert(newI, moved); }); for (var i = min(oldI, newI); i <= max(oldI, newI); i++) { sites![i].sortKey = i; try { await sites![i].save(); } catch (err) { //TODO: display error at the end print('ERR ${sites![i].name} - $err'); } } _loadSites(); }); if (Platform.isIOS) { child = CupertinoTheme(child: child, data: CupertinoTheme.of(context)); } // The theme here is to remove the hardcoded canvas border reordering forces on us return Theme(data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: child); } Widget _debugSave() { return CupertinoButton( key: Key('debug-save'), child: Text("DEBUG SAVE"), onPressed: () async { var uuid = Uuid(); var cert = '''-----BEGIN NEBULA CERTIFICATE----- CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ== -----END NEBULA CERTIFICATE-----'''; var ca = '''-----BEGIN NEBULA CERTIFICATE----- CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT 4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc= -----END NEBULA CERTIFICATE-----'''; var s = Site( name: "DEBUG TEST", id: uuid.v4(), staticHostmap: { "10.1.0.1": StaticHost( lighthouse: true, destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)]) }, ca: [CertificateInfo.debug(rawCert: ca)], certInfo: CertificateInfo.debug(rawCert: cert), unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]); s.key = '''-----BEGIN NEBULA X25519 PRIVATE KEY----- rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg= -----END NEBULA X25519 PRIVATE KEY-----'''; var err = await s.save(); if (err != null) { Utils.popError(context, "Failed to save the site", err); } else { _loadSites(); } }, ); } _loadSites() async { if (Platform.isAndroid) { try { await platform.invokeMethod("android.requestPermissions"); } on PlatformException catch (err) { if (err.code == "PERMISSIONS") { setState(() { error = [ Text("Permissions Required", style: TextStyle(fontWeight: FontWeight.bold)), Text( "VPN permissions are required for nebula to run, click the button below request and accept the appropriate permissions.", textAlign: TextAlign.center), ElevatedButton( onPressed: () { error = null; _loadSites(); }, child: Text("Request Permissions")), ]; }); } else { setState(() { error = [ Text("Unknown Error", style: TextStyle(fontWeight: FontWeight.bold)), Text(err.message ?? 'An unknown error occurred', textAlign: TextAlign.center) ]; }); } } catch (err) { setState(() { error = [ Text("Unknown Error", style: TextStyle(fontWeight: FontWeight.bold)), Text(err.toString(), textAlign: TextAlign.center) ]; }); } } //TODO: This can throw, we need to show an error dialog Map rawSites = jsonDecode(await platform.invokeMethod('listSites')); bool hasErrors = false; sites = []; rawSites.forEach((id, rawSite) { try { var site = Site.fromJson(rawSite); if (site.errors.length > 0) { hasErrors = true; } //TODO: we need to cancel change listeners when we rebuild site.onChange().listen((_) { setState(() {}); }, onError: (err) { setState(() {}); if (ModalRoute.of(context)!.isCurrent) { Utils.popError(context, "${site.name} Error", err); } }); sites!.add(site); } catch (err) { //TODO: handle error print("$err site config: $rawSite"); // Sometimes it is helpful to just nuke these is dev // platform.invokeMethod('deleteSite', id); } }); if (Platform.isAndroid) { // Android suffers from a race to discover the active site and attach site specific event listeners platform.invokeMethod("android.registerActiveSite"); } if (hasErrors) { Utils.popError(context, "Site Error(s)", "1 or more sites have errors and need your attention, problem sites have a red border."); } sites!.sort((a, b) { return a.sortKey - b.sortKey; }); setState(() { ready = true; }); } }