diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4d798e98..b0187f37 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -27,7 +27,7 @@
+ android:value="true"/>
Unit)? = null
private lateinit var workManager: WorkManager
private val refreshReceiver: BroadcastReceiver = RefreshReceiver()
@@ -66,6 +68,7 @@ class MainActivity: FlutterActivity() {
when(call.method) {
"android.registerActiveSite" -> registerActiveSite(result)
"android.deviceHasCamera" -> deviceHasCamera(result)
+ "android.openVpnSettings" -> openVpnSettings(result)
"nebula.parseCerts" -> nebulaParseCerts(call, result)
"nebula.generateKeyPair" -> nebulaGenerateKeyPair(result)
@@ -134,6 +137,12 @@ class MainActivity: FlutterActivity() {
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY))
}
+ private fun openVpnSettings(result: MethodChannel.Result) {
+ val intent = Intent(Settings.ACTION_VPN_SETTINGS)
+ startActivity(intent)
+ result.success(null)
+ }
+
private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) {
val certs = call.argument("certs")
if (certs == "") {
@@ -233,7 +242,16 @@ class MainActivity: FlutterActivity() {
return result.error("failure", "Site config was incomplete, please review and try again", null)
}
- sites?.refreshSites()
+ if (site.alwaysOn == true) {
+ if (activeSiteId != null && activeSiteId != site.id) {
+ stopSite { sites?.getSite(site.id)?.let { startSiteDirectly(it) } }
+ } else if (activeSiteId != site.id) {
+ sites?.getSite(site.id)?.let { startSiteDirectly(it) }
+ }
+ }
+
+ sites?.refreshSites(activeSiteId)
+ sites?.updateAll()
result.success(null)
}
@@ -253,6 +271,22 @@ class MainActivity: FlutterActivity() {
return true
}
+ private fun startSiteDirectly(siteContainer: SiteContainer) {
+ if (VpnService.prepare(this) != null) {
+ // VPN permission not granted; cannot start without user interaction
+ return
+ }
+ siteContainer.updater.setState(true, "Initializing...")
+ val intent = Intent(this, NebulaVpnService::class.java).apply {
+ putExtra("path", siteContainer.site.path)
+ putExtra("id", siteContainer.site.id)
+ }
+ startService(intent)
+ if (outMessenger == null) {
+ bindService(intent, connection, 0)
+ }
+ }
+
private fun startSite(call: MethodCall, result: MethodChannel.Result) {
val id = call.argument("id")
if (id == "") {
@@ -271,7 +305,8 @@ class MainActivity: FlutterActivity() {
}
}
- private fun stopSite() {
+ private fun stopSite(onStopped: (() -> Unit)? = null) {
+ onStopCallback = onStopped
val intent = Intent(this, NebulaVpnService::class.java).apply {
action = NebulaVpnService.ACTION_STOP
}
@@ -499,9 +534,13 @@ class MainActivity: FlutterActivity() {
inner class IncomingHandler: Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
val id = msg.data.getString("id")
+ if (id == null) {
+ Log.i(TAG, "got a message without an id from the vpn service")
+ return
+ }
//TODO: If the elvis hits then we had a deleted site running, which shouldn't happen
- val site = sites!!.getSite(id!!) ?: return
+ val site = sites!!.getSite(id) ?: return
when (msg.what) {
NebulaVpnService.MSG_IS_RUNNING -> isRunning(site, msg)
@@ -530,6 +569,8 @@ class MainActivity: FlutterActivity() {
// a site while another is actively running.
activeSiteId = null
unbindVpnService()
+ onStopCallback?.invoke()
+ onStopCallback = null
}
}
}
diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt
index fa0e648f..1ccb7ab3 100644
--- a/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt
+++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/NebulaVpnService.kt
@@ -12,7 +12,6 @@ import android.system.OsConstants
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.work.*
-import mobileNebula.CIDR
import java.io.File
import java.net.Inet4Address
import java.net.Inet6Address
@@ -64,45 +63,82 @@ class NebulaVpnService : VpnService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopVpn()
- return Service.START_NOT_STICKY
+ return START_NOT_STICKY
}
- val id = intent?.getStringExtra("id")
+ var autoStart = false
+ var sitePath: String? = null
+ try {
+ sitePath = intent?.getStringExtra("path")
+ if (sitePath == null) {
+ // If the UI is starting us we expect a path, if there is no path then we expect the system is starting us
+ autoStart = true
+ sitePath = filesDir.resolve("always-on-site").readText()
+ }
+ } catch (_: Exception) {
+ // Ignore errors
+ }
+ if (sitePath.isNullOrEmpty()) {
+ Log.e(TAG, "Could not find site path in intent or always-on-site file")
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+
+ var startSite: Site? = null
+ try {
+ startSite = Site(this, File(sitePath))
+ } catch (err: Exception) {
+ // Ignore errors
+ }
+ if (startSite == null) {
+ Log.e(TAG, "Could not get site details from: $sitePath")
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
if (running) {
// if the UI triggers this twice, check if we are already running the requested site. if not, return an error.
// otherwise, just ignore the request since we handled it the first time.
- if (site!!.id != id) {
- announceExit(id, "Trying to run nebula but it is already running")
+ if (site!!.id != startSite.id) {
+ announceExit(startSite.id, "Trying to run nebula but it is already running")
}
- return super.onStartCommand(intent, flags, startId)
+
+ return START_NOT_STICKY
}
// Make sure we don't accept commands for a different site id
- if (site != null && site!!.id != id) {
- announceExit(id, "Command received for a site id that is different from the current active site")
- return super.onStartCommand(intent, flags, startId)
+ if (site != null && site!!.id != startSite.id) {
+ announceExit(startSite.id, "Command received for a site id that is different from the current active site")
+ stopSelf(startId)
+ return START_NOT_STICKY
}
- path = intent!!.getStringExtra("path")!!
- //TODO: if we fail to start, android will attempt a restart lacking all the intent data we need.
- // Link active site config in Main to avoid this
- site = Site(this, File(path!!))
-
- if (site!!.cert == null) {
- announceExit(id, "Site is missing a certificate")
- return super.onStartCommand(intent, flags, startId)
+ if (startSite.cert == null) {
+ announceExit(startSite.id, "Site is missing a certificate")
+ stopSelf(startId)
+ return START_NOT_STICKY
}
// Kick off a site update
val workRequest = OneTimeWorkRequestBuilder().build()
workManager!!.enqueue(workRequest)
+ site = startSite
+ path = sitePath
+
+ if (autoStart) {
+ startVpn()
+ }
// We don't actually start here. In order to properly capture boot errors we wait until an IPC connection is made
return super.onStartCommand(intent, flags, startId)
}
private fun startVpn() {
+ if (site == null) {
+ Log.e(TAG, "Got a start command but we don't have a site to run")
+ return
+ }
+
val builder = Builder()
.setMtu(site!!.mtu)
.setSession(TAG)
diff --git a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt
index 088c883a..e94ff67f 100644
--- a/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt
+++ b/android/app/src/main/kotlin/net/defined/mobile_nebula/Sites.kt
@@ -1,6 +1,7 @@
package net.defined.mobile_nebula
import android.content.Context
+import android.provider.Settings
import android.util.Log
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
@@ -48,10 +49,19 @@ class Sites(private var engine: FlutterEngine) {
return containers.mapValues { it.value.site }
}
+ fun updateAll() {
+ containers.values.forEach { it.updater.notifyChanged() }
+ }
+
fun deleteSite(id: String) {
val context = MainActivity.getContext()!!
val site = containers[id]!!.site
+ val alwaysOnFile = context.filesDir.resolve("always-on-site")
+ if (alwaysOnFile.exists() && alwaysOnFile.readText() == site.path) {
+ alwaysOnFile.delete()
+ }
+
val baseDir = if(site.managed) context.noBackupFilesDir else context.filesDir
val siteDir = baseDir.resolve("sites").resolve(id)
siteDir.deleteRecursively()
@@ -132,6 +142,10 @@ class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.S
}
}
+ fun notifyChanged() {
+ eventSink?.success(gson.toJson(site))
+ }
+
init {
eventChannel.setStreamHandler(this)
}
@@ -213,6 +227,7 @@ class Site(context: Context, siteDir: File) {
// The following fields are present when managed = true
val rawConfig: String?
val lastManagedUpdate: String?
+ val alwaysOn: Boolean
// Path to this site on disk
@Transient
@@ -247,6 +262,9 @@ class Site(context: Context, siteDir: File) {
connected = false
status = "Disconnected"
+ val alwaysOnPath = try { context.filesDir.resolve("always-on-site").readText() } catch (_: Exception) { null }
+ alwaysOn = alwaysOnPath == path
+
try {
val rawDetails = mobileNebula.MobileNebula.parseCerts(incomingSite.cert)
val certs = gson.fromJson(rawDetails, Array::class.java)
@@ -352,6 +370,7 @@ class IncomingSite(
val lastManagedUpdate: String?,
val rawConfig: String?,
var dnCredentials: DNCredentials?,
+ val alwaysOn: Boolean?,
) {
fun save(context: Context): File {
// Don't allow backups of DN-managed sites
@@ -373,6 +392,15 @@ class IncomingSite(
dnCredentials?.save(context, siteDir)
dnCredentials = null
+ val alwaysOnFile = context.filesDir.resolve("always-on-site")
+ when (alwaysOn) {
+ true -> alwaysOnFile.writeText(siteDir.absolutePath)
+ false -> if (alwaysOnFile.exists() && alwaysOnFile.readText() == siteDir.absolutePath) {
+ alwaysOnFile.delete()
+ }
+ null -> {}
+ }
+
val confFile = siteDir.resolve("config.json")
confFile.writeText(Gson().toJson(this))
diff --git a/ios/NebulaNetworkExtension/Site.swift b/ios/NebulaNetworkExtension/Site.swift
index 97a64a0a..31a9bada 100644
--- a/ios/NebulaNetworkExtension/Site.swift
+++ b/ios/NebulaNetworkExtension/Site.swift
@@ -164,6 +164,7 @@ class Site: Codable {
var connected: Bool? //TODO: active is a better name
var status: String?
var logFile: String?
+ var alwaysOn: Bool
var managed: Bool
var dnsResolvers: [String]
// The following fields are present if managed = true
@@ -188,6 +189,7 @@ class Site: Codable {
self.manager = manager
self.connected = statusMap[manager.connection.status]
self.status = statusString[manager.connection.status]
+ self.alwaysOn = manager.isOnDemandEnabled
}
convenience init(proto: NETunnelProviderProtocol) throws {
@@ -237,6 +239,7 @@ class Site: Codable {
lastManagedUpdate = incoming.lastManagedUpdate
dnsResolvers = incoming.dnsResolvers ?? []
rawConfig = incoming.rawConfig
+ alwaysOn = incoming.alwaysOn ?? false
// Default these to disconnected for the UI
status = statusString[.disconnected]
@@ -385,6 +388,7 @@ class Site: Codable {
case lastManagedUpdate
case dnsResolvers
case rawConfig
+ case alwaysOn
}
}
@@ -444,6 +448,7 @@ struct IncomingSite: Codable {
var key: String?
var managed: Bool?
var dnsResolvers: [String]?
+ var alwaysOn: Bool?
// The following fields are present if managed = true
var dnCredentials: DNCredentials?
var lastManagedUpdate: String?
@@ -546,6 +551,11 @@ struct IncomingSite: Codable {
manager.localizedDescription = self.name
manager.isEnabled = true
+ manager.isOnDemandEnabled = self.alwaysOn == true
+ let rule = NEOnDemandRuleConnect()
+ rule.interfaceTypeMatch = .any
+ manager.onDemandRules = [rule]
+
manager.saveToPreferences { error in
return callback(error)
}
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 5ea07705..1224bb4a 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -262,11 +262,15 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
updater?.update(connected: false)
#else
- let manager = self.sites?.getSite(id: id)?.manager
+ let container = self.sites?.getContainer(id: id)
+ let manager = container?.site.manager
+
manager?.loadFromPreferences { error in
//TODO: Handle load error
-
- manager?.connection.stopVPNTunnel()
+ manager?.isOnDemandEnabled = false
+ manager?.saveToPreferences { _ in
+ manager?.connection.stopVPNTunnel()
+ }
return result(nil)
}
#endif
diff --git a/ios/Runner/Sites.swift b/ios/Runner/Sites.swift
index ed7b198d..2686b01d 100644
--- a/ios/Runner/Sites.swift
+++ b/ios/Runner/Sites.swift
@@ -82,7 +82,6 @@ class SiteUpdater: NSObject, FlutterStreamHandler {
private var eventSink: FlutterEventSink?
private var eventChannel: FlutterEventChannel
private var site: Site
- private var notification: Any?
public var startFunc: (() -> Void)?
private var configFd: Int32? = nil
private var configObserver: (any DispatchSourceFileSystemObject)? = nil
@@ -137,31 +136,38 @@ class SiteUpdater: NSObject, FlutterStreamHandler {
code: "Internal Error", message: "Flutter manager was not present", details: nil)
}
- self.notification = NotificationCenter.default.addObserver(
- forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection,
- queue: nil
- ) { n in
- let oldConnected = self.site.connected
- self.site.status = statusString[self.site.manager!.connection.status]
- self.site.connected = statusMap[self.site.manager!.connection.status]
-
- // Check to see if we just moved to connected and if we have a start function to call when that happens
- if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil {
- self.startFunc!()
- self.startFunc = nil
- }
-
- self.update(connected: self.site.connected!)
- }
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(onNotification),
+ name: NSNotification.Name.NEVPNConfigurationChange, object: site.manager)
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(onNotification), name: NSNotification.Name.NEVPNStatusDidChange,
+ object: site.manager!.connection)
#endif
return nil
}
+ @objc func onNotification(n: Notification) {
+ let oldConnected = self.site.connected
+
+ self.site.status = statusString[self.site.manager!.connection.status]
+ self.site.connected = statusMap[self.site.manager!.connection.status]
+ self.site.alwaysOn = self.site.manager?.isOnDemandEnabled == true
+
+ // Check to see if we just moved to connected and if we have a start function to call when that happens
+ if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil {
+ self.startFunc!()
+ self.startFunc = nil
+ }
+
+ self.update(connected: self.site.connected!)
+ }
+
/// onCancel is called when the flutter listener stops listening
func onCancel(withArguments arguments: Any?) -> FlutterError? {
- if self.notification != nil {
- NotificationCenter.default.removeObserver(self.notification!)
- }
+ NotificationCenter.default.removeObserver(
+ self, name: NSNotification.Name.NEVPNConfigurationChange, object: nil)
+ NotificationCenter.default.removeObserver(
+ self, name: NSNotification.Name.NEVPNStatusDidChange, object: nil)
return nil
}
diff --git a/lib/components/site_item.dart b/lib/components/site_item.dart
index f38c9a95..818d5dd6 100644
--- a/lib/components/site_item.dart
+++ b/lib/components/site_item.dart
@@ -6,12 +6,17 @@ import 'config/config_item.dart';
import 'config/config_page_item.dart';
import 'config/config_section.dart';
-class SiteItem extends StatelessWidget {
+class SiteItem extends StatefulWidget {
const SiteItem({super.key, required this.site, this.onPressed});
final Site site;
final void Function()? onPressed;
+ @override
+ State createState() => _SiteItemState();
+}
+
+class _SiteItemState extends State {
@override
Widget build(BuildContext context) {
return _buildContent(context);
@@ -23,9 +28,9 @@ class SiteItem extends StatelessWidget {
List children = [];
// Add the name
- children.add(TextSpan(text: site.name, style: nameStyle));
+ children.add(TextSpan(text: widget.site.name, style: nameStyle));
- if (site.managed) {
+ if (widget.site.managed) {
// Toss some space in
children.add(TextSpan(text: ' ', style: nameStyle));
@@ -50,7 +55,7 @@ class SiteItem extends StatelessWidget {
Widget _siteStatusWidget(BuildContext context) {
final grayTextColor = Theme.of(context).colorScheme.onSecondaryContainer;
var fontStyle = TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500);
- if (site.errors.isNotEmpty) {
+ if (widget.site.errors.isNotEmpty) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -61,56 +66,61 @@ class SiteItem extends StatelessWidget {
);
}
- return Text(site.status, style: fontStyle);
+ return Text(widget.site.status, style: fontStyle);
}
Widget _buildContent(BuildContext context) {
- void handleChange(v) async {
- try {
- if (v) {
- await site.start();
- } else {
- await site.stop();
- }
- } catch (error) {
- var action = v ? 'start' : 'stop';
- Utils.popError('Failed to $action the site', error.toString());
- }
- }
-
final grayTextColor = Theme.of(context).colorScheme.onSecondaryContainer;
- return ConfigSection(
- children: [
- ConfigItem(
- labelWidth: 0,
- padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16),
- content: Row(
- mainAxisAlignment: MainAxisAlignment.start,
- children: [
- Flexible(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [_siteNameWidget(context), Container(height: 4), _siteStatusWidget(context)],
- ),
- ),
- Switch.adaptive(
- value: site.connected,
- materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
- onChanged: site.errors.isNotEmpty && !site.connected ? null : handleChange,
+ List children = [];
+ children.add(
+ ConfigItem(
+ labelWidth: 0,
+ padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16),
+ content: Row(
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ Flexible(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [_siteNameWidget(context), Container(height: 4), _siteStatusWidget(context)],
),
- ],
- ),
+ ),
+ Switch.adaptive(
+ value: widget.site.connected,
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ onChanged: widget.site.errors.isNotEmpty && !widget.site.connected ? null : toggleSite,
+ ),
+ ],
),
- ConfigPageItem(
- padding: EdgeInsets.fromLTRB(16, 8, 16, 16),
- label: Text(
- 'Details',
- style: TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500),
- ),
- onPressed: onPressed,
+ ),
+ );
+
+ children.add(
+ ConfigPageItem(
+ padding: EdgeInsets.fromLTRB(16, 8, 16, 16),
+ label: Text(
+ 'Details',
+ style: TextStyle(color: grayTextColor, fontSize: 14, fontWeight: FontWeight.w500),
),
- ],
+ onPressed: widget.onPressed,
+ ),
);
+
+ return ConfigSection(children: children);
+ }
+
+ void toggleSite(bool val) async {
+ try {
+ if (val) {
+ await widget.site.start();
+ } else {
+ await widget.site.stop();
+ }
+ setState(() {});
+ } catch (error) {
+ var action = val ? 'start' : 'stop';
+ Utils.popError('Failed to $action the site', error.toString());
+ }
}
}
diff --git a/lib/models/site.dart b/lib/models/site.dart
index 2538926b..dbc5c830 100644
--- a/lib/models/site.dart
+++ b/lib/models/site.dart
@@ -55,6 +55,7 @@ class Site {
late String status;
late String logFile;
late String logVerbosity;
+ late bool alwaysOn;
late List dnsResolvers;
late bool managed;
@@ -85,6 +86,7 @@ class Site {
this.managed = false,
this.rawConfig,
this.lastManagedUpdate,
+ this.alwaysOn = false,
List? dnsResolvers,
}) {
this.id = id ?? uuid.v4();
@@ -141,6 +143,7 @@ class Site {
rawConfig: decoded['rawConfig'],
lastManagedUpdate: decoded['lastManagedUpdate'],
dnsResolvers: decoded['dnsResolvers'],
+ alwaysOn: decoded['alwaysOn'],
);
}
@@ -186,6 +189,7 @@ class Site {
rawConfig = decoded['rawConfig'];
lastManagedUpdate = decoded['lastManagedUpdate'];
dnsResolvers = decoded['dnsResolvers'];
+ alwaysOn = decoded['alwaysOn'];
}
static Map _fromJson(Map json) {
@@ -245,6 +249,7 @@ class Site {
"rawConfig": json['rawConfig'],
"lastManagedUpdate": json["lastManagedUpdate"] == null ? null : DateTime.parse(json["lastManagedUpdate"]),
"dnsResolvers": dnsResolvers,
+ "alwaysOn": json['alwaysOn'] ?? false,
};
}
@@ -274,6 +279,7 @@ class Site {
'managed': managed,
'rawConfig': rawConfig,
'dnsResolvers': dnsResolvers,
+ 'alwaysOn': alwaysOn,
};
}
diff --git a/lib/screens/site_detail_screen.dart b/lib/screens/site_detail_screen.dart
index adbd0b3c..89c50cb0 100644
--- a/lib/screens/site_detail_screen.dart
+++ b/lib/screens/site_detail_screen.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@@ -105,6 +106,7 @@ class SiteDetailScreenState extends State {
_buildConfig(),
site.connected ? _buildHosts() : Container(),
_buildSiteDetails(),
+ _buildAlwaysOn(),
_buildDelete(),
],
),
@@ -271,6 +273,69 @@ class SiteDetailScreenState extends State {
);
}
+ Widget _buildAlwaysOn() {
+ if (Platform.isAndroid) {
+ return ConfigSection(
+ children: [
+ ConfigItem(
+ label: Text('Always-on'),
+ content: Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ Switch.adaptive(
+ value: widget.site.alwaysOn,
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ onChanged: (val) async {
+ widget.site.alwaysOn = val;
+ try {
+ await widget.site.save();
+ } catch (e) {
+ Utils.popError('Failed to update always-on', e.toString());
+ widget.site.alwaysOn = !val;
+ setState(() {});
+ return;
+ }
+ setState(() {});
+ },
+ ),
+ ],
+ ),
+ ),
+ ConfigPageItem(
+ content: Text('Verify system always-on is enabled'),
+ onPressed: () {
+ platform.invokeMethod('android.openVpnSettings');
+ },
+ ),
+ ],
+ );
+ } else if (Platform.isIOS || Platform.isMacOS) {
+ return ConfigSection(
+ children: [
+ ConfigItem(
+ label: Text('Always-on'),
+ content: Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ Switch.adaptive(
+ value: widget.site.alwaysOn,
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ onChanged: (val) async {
+ widget.site.alwaysOn = val;
+ await widget.site.save();
+ setState(() {});
+ },
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+
+ return Container();
+ }
+
Widget _buildDelete() {
final outerContext = context;
return Padding(