Skip to content

Commit 5b90fc0

Browse files
committed
Add tray icon and close-to-tray behavior in macOS/Windows, keep VPN running when UI closes and resync UI on relaunch in android, keep tunnel running when UI terminates and resync UI on relaunch in ios, sync UI with native tunnel status on init/resume in flutter
1 parent b8364bd commit 5b90fc0

9 files changed

Lines changed: 277 additions & 16 deletions

File tree

android/app/src/main/kotlin/tech/threefold/mycelium/MainActivity.kt

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.app.Activity
55
import android.content.BroadcastReceiver
66
import android.content.Context
77
import android.content.Intent
8+
import android.content.SharedPreferences
89
import android.content.IntentFilter
910
import android.net.VpnService
1011
import android.os.Build
@@ -27,9 +28,11 @@ class MainActivity: FlutterActivity() {
2728
private var vpnPermissionSecretKey: ByteArray? = null
2829

2930
private lateinit var channel : MethodChannel
31+
private lateinit var prefs: SharedPreferences
3032

3133
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
3234
super.configureFlutterEngine(flutterEngine)
35+
prefs = getSharedPreferences("mycelium_prefs", MODE_PRIVATE)
3336
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
3437
channel.setMethodCallHandler {
3538
// This method is invoked on the main thread.
@@ -56,6 +59,17 @@ class MainActivity: FlutterActivity() {
5659
Log.d(tag, "stopping VPN")
5760
result.success(stopCmdSent)
5861
}
62+
"queryStatus" -> {
63+
// Immediately report last known state while also querying the service
64+
val running = prefs.getBoolean("mycelium_running", false)
65+
if (running) {
66+
channel.invokeMethod("notifyMyceliumStarted","")
67+
} else {
68+
channel.invokeMethod("notifyMyceliumFinished","")
69+
}
70+
queryStatus()
71+
result.success(true)
72+
}
5973
else -> result.notImplemented()
6074
}
6175
}
@@ -69,6 +83,9 @@ class MainActivity: FlutterActivity() {
6983
TunService.EVENT_MYCELIUM_FINISHED -> {
7084
channel.invokeMethod("notifyMyceliumFinished","")
7185
}
86+
TunService.EVENT_MYCELIUM_RUNNING -> {
87+
channel.invokeMethod("notifyMyceliumStarted", "")
88+
}
7289
TunService.EVENT_MYCELIUM_FAILED -> {
7390
channel.invokeMethod("notifyMyceliumFailed", "")
7491
}
@@ -129,6 +146,12 @@ class MainActivity: FlutterActivity() {
129146
return true
130147
}
131148

149+
private fun queryStatus() {
150+
val intent = Intent(this, TunService::class.java)
151+
intent.action = TunService.ACTION_QUERY
152+
startService(intent)
153+
}
154+
132155
@SuppressLint("UnspecifiedRegisterReceiverFlag", "WrongConstant")
133156
override fun onCreate(savedInstanceState: Bundle?) {
134157
super.onCreate(savedInstanceState)
@@ -177,10 +200,6 @@ class MainActivity: FlutterActivity() {
177200

178201
override fun onDestroy() {
179202
Log.e(tag, "onDestroy")
180-
181-
Log.i(tag, "onDestroy:Stopping VPN service")
182-
stopVpn()
183-
184203
super.onDestroy()
185204

186205
// Activity is about to be destroyed.

android/app/src/main/kotlin/tech/threefold/mycelium/TunService.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package tech.threefold.mycelium
22

33
import android.content.Intent
4+
import android.content.Context
5+
import android.content.SharedPreferences
46
import android.net.VpnService
57
import android.os.ParcelFileDescriptor
68
import android.system.OsConstants
@@ -20,13 +22,16 @@ class TunService : VpnService(), CoroutineScope {
2022
companion object {
2123
const val ACTION_START = "tech.threefold.mycelium.TunService.START"
2224
const val ACTION_STOP = "tech.threefold.mycelium.TunService.STOP"
25+
const val ACTION_QUERY = "tech.threefold.mycelium.TunService.QUERY"
2326
const val EVENT_INTENT = "tech.threefold.mycelium.TunService.EVENT"
2427
const val EVENT_MYCELIUM_FAILED = "mycelium_failed"
2528
const val EVENT_MYCELIUM_FINISHED = "mycelium_finished"
29+
const val EVENT_MYCELIUM_RUNNING = "mycelium_running"
2630
}
2731

2832
private var started = AtomicBoolean()
2933
private var parcel: ParcelFileDescriptor? = null
34+
private lateinit var prefs: SharedPreferences
3035

3136
private val job = Job()
3237

@@ -36,6 +41,7 @@ class TunService : VpnService(), CoroutineScope {
3641
override fun onCreate() {
3742
Log.d(tag, "tun service created")
3843
super.onCreate()
44+
prefs = getSharedPreferences("mycelium_prefs", Context.MODE_PRIVATE)
3945
}
4046

4147
override fun onDestroy() {
@@ -63,6 +69,14 @@ class TunService : VpnService(), CoroutineScope {
6369
start(peers.toList(), secretKey)
6470
START_STICKY
6571
}
72+
ACTION_QUERY -> {
73+
if (started.get()) {
74+
sendMyceliumEvent(EVENT_MYCELIUM_RUNNING)
75+
} else {
76+
sendMyceliumEvent(EVENT_MYCELIUM_FINISHED)
77+
}
78+
START_NOT_STICKY
79+
}
6680
else -> {
6781
Log.e(tag, "unknown command")
6882

@@ -75,6 +89,7 @@ class TunService : VpnService(), CoroutineScope {
7589
if (!started.compareAndSet(false, true)) {
7690
return 0
7791
}
92+
prefs.edit().putBoolean("mycelium_running", true).apply()
7893
val nodeAddress = addressFromSecretKey(secretKey)
7994
Log.i(tag, "creating TUN device with node address: $nodeAddress")
8095

@@ -127,6 +142,7 @@ class TunService : VpnService(), CoroutineScope {
127142
Log.d(tag, "got stop when not started")
128143
return
129144
}
145+
prefs.edit().putBoolean("mycelium_running", false).apply()
130146
if (stopMycelium) {
131147
stopMycelium()
132148
}

ios/Runner/AppDelegate.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ import OSLog
6060
self.flutterTunnelStatus = .stopped
6161
self.stopMycelium()
6262
result(true)
63+
case "queryStatus":
64+
self.queryStatus()
65+
result(true)
6366
default:
6467
result(FlutterMethodNotImplemented)
6568
}
@@ -76,7 +79,6 @@ import OSLog
7679
override func applicationWillTerminate(_ application: UIApplication) {
7780
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
7881
// Insert code here to handle when the app is about to terminate
79-
self.stopMycelium()
8082
super.applicationWillTerminate(application)
8183
}
8284

@@ -227,6 +229,31 @@ import OSLog
227229
self.vpnManager?.connection.stopVPNTunnel()
228230
}
229231

232+
func queryStatus() {
233+
NETunnelProviderManager.loadAllFromPreferences { (providers: [NETunnelProviderManager]?, error: Error?) in
234+
if let error = error {
235+
errlog("queryStatus loadAllFromPref failed:" + error.localizedDescription)
236+
self.flutterChannel?.invokeMethod("notifyMyceliumFinished", arguments: nil)
237+
return
238+
}
239+
guard let providers = providers else {
240+
self.flutterChannel?.invokeMethod("notifyMyceliumFinished", arguments: nil)
241+
return
242+
}
243+
let myProvider = providers.first(where: { $0.protocolConfiguration?.serverAddress==self.vpnServerAddress })
244+
if let vpnManager = myProvider {
245+
switch vpnManager.connection.status {
246+
case .connected, .connecting, .reasserting:
247+
self.flutterChannel?.invokeMethod("notifyMyceliumStarted", arguments: nil)
248+
default:
249+
self.flutterChannel?.invokeMethod("notifyMyceliumFinished", arguments: nil)
250+
}
251+
} else {
252+
self.flutterChannel?.invokeMethod("notifyMyceliumFinished", arguments: nil)
253+
}
254+
}
255+
}
256+
230257
/*
231258
TODO: add some handlers to below func
232259
func applicationWillResignActive(_ application: UIApplication) {

lib/main.dart

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import 'package:flutter/services.dart';
77
import 'package:path_provider/path_provider.dart';
88
import 'package:logging/logging.dart';
99
import 'package:flutter_desktop_sleep/flutter_desktop_sleep.dart';
10-
import 'package:flutter_window_close/flutter_window_close.dart';
10+
import 'package:tray_manager/tray_manager.dart';
11+
import 'package:window_manager/window_manager.dart';
1112

1213
import 'myceliumflut_ffi_binding.dart';
1314

@@ -45,7 +46,7 @@ class MyApp extends StatefulWidget {
4546
State<MyApp> createState() => _MyAppState();
4647
}
4748

48-
class _MyAppState extends State<MyApp> {
49+
class _MyAppState extends State<MyApp> with TrayListener, WindowListener, WidgetsBindingObserver {
4950
static const platform = MethodChannel("tech.threefold.mycelium/tun");
5051
String _nodeAddr = '';
5152
var privKey = Uint8List(0);
@@ -58,6 +59,7 @@ class _MyAppState extends State<MyApp> {
5859
void initState() {
5960
textEditController = TextEditingController(text: '');
6061
super.initState();
62+
WidgetsBinding.instance.addObserver(this);
6163
initPlatformState();
6264
platform.setMethodCallHandler((MethodCall call) async {
6365
methodHandler(call.method);
@@ -87,16 +89,32 @@ class _MyAppState extends State<MyApp> {
8789
}
8890
});
8991

90-
// only for windows because macos already has own handler
91-
if (Platform.isWindows) {
92-
FlutterWindowClose.setWindowShouldCloseHandler(() async {
93-
_logger.info("Window close handler");
94-
if (_isStarted) {
95-
stopMycelium();
96-
}
97-
return true;
98-
});
92+
_initDesktopLifecycle();
93+
}
94+
95+
Future<void> _initDesktopLifecycle() async {
96+
if (!(Platform.isMacOS || Platform.isWindows)) {
97+
return;
9998
}
99+
100+
await windowManager.ensureInitialized();
101+
windowManager.addListener(this);
102+
await windowManager.setPreventClose(true);
103+
104+
trayManager.addListener(this);
105+
await trayManager.setIcon('assets/images/mycelium_icon.png');
106+
final items = [
107+
MenuItem(key: 'show', label: 'Show Window'),
108+
MenuItem(key: 'hide', label: 'Hide Window'),
109+
MenuItem.separator(),
110+
MenuItem(key: 'toggle', label: 'Start/Stop Mycelium'),
111+
MenuItem.separator(),
112+
MenuItem(key: 'quit', label: 'Quit'),
113+
];
114+
await trayManager.setContextMenu(Menu(items: items));
115+
116+
// Optional: start minimized to tray when app launches on desktop
117+
// await windowManager.hide();
100118
}
101119

102120
void methodHandler(String methodName) {
@@ -172,6 +190,15 @@ class _MyAppState extends State<MyApp> {
172190
setState(() {
173191
_nodeAddr = nodeAddr;
174192
});
193+
194+
// Query native layer for current VPN status to sync UI
195+
if (!isUseDylib()) {
196+
try {
197+
await platform.invokeMethod('queryStatus');
198+
} catch (e) {
199+
_logger.warning("queryStatus not implemented: $e");
200+
}
201+
}
175202
}
176203

177204
// start/stop mycelium button variables
@@ -187,6 +214,11 @@ class _MyAppState extends State<MyApp> {
187214
void dispose() {
188215
// Clean up the controller when the widget is disposed.
189216
textEditController.dispose();
217+
if (Platform.isMacOS || Platform.isWindows) {
218+
windowManager.removeListener(this);
219+
trayManager.removeListener(this);
220+
}
221+
WidgetsBinding.instance.removeObserver(this);
190222
super.dispose();
191223
}
192224

@@ -374,6 +406,17 @@ class _MyAppState extends State<MyApp> {
374406
);
375407
}
376408

409+
@override
410+
void didChangeAppLifecycleState(AppLifecycleState state) {
411+
if (state == AppLifecycleState.resumed) {
412+
if (!isUseDylib()) {
413+
platform.invokeMethod('queryStatus').catchError((e) {
414+
_logger.warning("queryStatus on resume failed: $e");
415+
});
416+
}
417+
}
418+
}
419+
377420
void startMycelium() {
378421
if (_isStarted) {
379422
_logger.warning("Mycelium already started");
@@ -487,6 +530,61 @@ class _MyAppState extends State<MyApp> {
487530
isRestartVisible = true;
488531
});
489532
}
533+
// Window lifecycle handlers
534+
@override
535+
void onWindowClose() async {
536+
// Intercept close to keep app running in tray
537+
await windowManager.hide();
538+
}
539+
540+
// Tray handlers
541+
@override
542+
void onTrayIconMouseDown() async {
543+
if (await windowManager.isVisible()) {
544+
await windowManager.hide();
545+
} else {
546+
await windowManager.show();
547+
await windowManager.focus();
548+
}
549+
}
550+
551+
@override
552+
void onTrayMenuItemClick(MenuItem menuItem) async {
553+
switch (menuItem.key) {
554+
case 'show':
555+
await windowManager.show();
556+
await windowManager.focus();
557+
break;
558+
case 'hide':
559+
await windowManager.hide();
560+
break;
561+
case 'toggle':
562+
if (!_isStarted) {
563+
startMycelium();
564+
} else {
565+
stopMycelium();
566+
}
567+
break;
568+
case 'quit':
569+
if (_isStarted) {
570+
stopMycelium();
571+
}
572+
// Give a brief moment for stop to propagate
573+
await Future.delayed(const Duration(milliseconds: 200));
574+
// Terminate application
575+
if (Platform.isMacOS) {
576+
// Use existing plugin to terminate if available
577+
try {
578+
_flutterDesktopSleepPlugin.terminateApp();
579+
} catch (_) {
580+
exit(0);
581+
}
582+
} else {
583+
exit(0);
584+
}
585+
break;
586+
}
587+
}
490588
}
491589

492590
double physicalPxToLogicalPx(BuildContext context, double physicalPx) {

macos/Flutter/GeneratedPluginRegistrant.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ import Foundation
88
import flutter_desktop_sleep
99
import flutter_window_close
1010
import path_provider_foundation
11+
import screen_retriever_macos
12+
import tray_manager
13+
import window_manager
1114

1215
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
1316
FlutterDesktopSleepPlugin.register(with: registry.registrar(forPlugin: "FlutterDesktopSleepPlugin"))
1417
FlutterWindowClosePlugin.register(with: registry.registrar(forPlugin: "FlutterWindowClosePlugin"))
1518
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
19+
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
20+
TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin"))
21+
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
1622
}

0 commit comments

Comments
 (0)