diff --git a/android/app/src/main/kotlin/tech/lolli/toolbox/ForegroundService.kt b/android/app/src/main/kotlin/tech/lolli/toolbox/ForegroundService.kt index 3b14e287..f5b662c6 100644 --- a/android/app/src/main/kotlin/tech/lolli/toolbox/ForegroundService.kt +++ b/android/app/src/main/kotlin/tech/lolli/toolbox/ForegroundService.kt @@ -5,11 +5,28 @@ import android.content.Intent import android.os.Build import android.os.IBinder import android.util.Log +import org.json.JSONArray +import org.json.JSONObject import java.io.File import java.util.* class ForegroundService : Service() { + companion object { + @Volatile + var isRunning: Boolean = false + } private val chanId = "ForegroundServiceChannel" + private val GROUP_KEY = "ssh_sessions_group" + private val SUMMARY_ID = 1000 + private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND" + private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS" + private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION" + + private var isFgStarted = false + private val postedIds = mutableSetOf() + // Stable mapping from session-id -> notification-id to avoid hash collisions + private val notificationIdMap = mutableMapOf() + private val nextNotificationId = java.util.concurrent.atomic.AtomicInteger(2001) private fun logError(message: String, error: Throwable? = null) { Log.e("ForegroundService", message, error) @@ -26,6 +43,7 @@ class ForegroundService : Service() { override fun onCreate() { super.onCreate() Log.d("ForegroundService", "Service onCreate") + isRunning = true createNotificationChannel() } @@ -50,24 +68,20 @@ class ForegroundService : Service() { val action = intent.action Log.d("ForegroundService", "onStartCommand action=$action") - // Create notification before starting foreground - val notification = createNotification() - - // Use try-catch for startForeground - try { - startForeground(1, notification) - } catch (e: Exception) { - logError("Failed to start foreground", e) - stopSelf() - return START_NOT_STICKY - } - return when (action) { - "ACTION_STOP_FOREGROUND" -> { + ACTION_STOP_FOREGROUND -> { + clearAll() stopForegroundService() START_NOT_STICKY } + ACTION_UPDATE_SESSIONS -> { + val payload = intent.getStringExtra("payload") ?: "{}" + handleUpdateSessions(payload) + START_STICKY + } else -> { + // Default bring up foreground with placeholder + ensureForeground(createSummaryNotification(0, emptyList())) START_STICKY } } @@ -101,24 +115,99 @@ class ForegroundService : Service() { } } - private fun createNotification(): Notification { + private fun ensureForeground(notification: Notification) { try { - val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_IMMUTABLE - ) - - val deleteIntent = Intent(this, ForegroundService::class.java).apply { - action = "ACTION_STOP_FOREGROUND" + if (!isFgStarted) { + startForeground(SUMMARY_ID, notification) + isFgStarted = true + } else { + val nm = getSystemService(NotificationManager::class.java) + nm?.notify(SUMMARY_ID, notification) } - val deletePendingIntent = PendingIntent.getService( - this, - 0, - deleteIntent, - PendingIntent.FLAG_IMMUTABLE + } catch (e: Exception) { + logError("Failed to start/update foreground", e) + } + } + + private fun createSummaryNotification(count: Int, lines: List): Notification { + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE + ) + val stopIntent = Intent(this, ForegroundService::class.java).apply { action = ACTION_STOP_FOREGROUND } + val stopPending = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE) + + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, chanId) + } else { + Notification.Builder(this) + } + + val inbox = Notification.InboxStyle() + lines.forEach { inbox.addLine(it) } + + return builder + .setContentTitle("SSH sessions: $count active") + .setContentText(if (lines.isNotEmpty()) lines.first() else "Running") + .setSmallIcon(R.mipmap.ic_launcher) + .setStyle(inbox) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setContentIntent(pendingIntent) + .addAction(android.R.drawable.ic_delete, "Stop", stopPending) + .build() + } + + private fun handleUpdateSessions(payload: String) { + val nm = getSystemService(NotificationManager::class.java) + if (nm == null) { + logError("NotificationManager null") + return + } + + val sessions = mutableListOf() + try { + val obj = JSONObject(payload) + val arr: JSONArray = obj.optJSONArray("sessions") ?: JSONArray() + for (i in 0 until arr.length()) { + val s = arr.optJSONObject(i) ?: continue + val id = s.optString("id") + val title = s.optString("title") + val sub = s.optString("subtitle") + val whenMs = s.optLong("startTimeMs", System.currentTimeMillis()) + val status = s.optString("status", "connected") + if (id.isNotEmpty()) { + sessions.add(SessionItem(id, title, sub, whenMs, status)) + } + } + } catch (e: Exception) { + logError("Failed to parse payload", e) + } + + // Clear if empty + if (sessions.isEmpty()) { + clearAll() + return + } + + // Build per-session notifications + val currentIds = mutableSetOf() + val summaryLines = mutableListOf() + sessions.forEach { s -> + // Assign a stable, collision-resistant id per session for this service lifecycle + val nid = notificationIdMap.getOrPut(s.id) { nextNotificationId.getAndIncrement() } + currentIds.add(nid) + summaryLines.add("${s.title}: ${s.status}") + + val disconnectIntent = Intent(this, MainActivity::class.java).apply { + action = ACTION_DISCONNECT_SESSION + putExtra("session_id", s.id) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + val disconnectPending = PendingIntent.getActivity( + this, nid, disconnectIntent, PendingIntent.FLAG_IMMUTABLE ) val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -127,23 +216,60 @@ class ForegroundService : Service() { Notification.Builder(this) } - return builder - .setContentTitle("Server Box") - .setContentText("Running in background") - .setSmallIcon(R.mipmap.ic_launcher) - .setContentIntent(pendingIntent) - .addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent) - .build() - } catch (e: Exception) { - logError("Error creating notification", e) - // Return a basic notification as fallback - return Notification.Builder(this) - .setContentTitle("Server Box") + val noti = builder + .setContentTitle(s.title) + .setContentText("${s.subtitle} · ${s.status}") .setSmallIcon(R.mipmap.ic_launcher) + .setWhen(s.startWhen) + .setUsesChronometer(true) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setGroup(GROUP_KEY) + .addAction(android.R.drawable.ic_media_pause, "Disconnect", disconnectPending) .build() + + nm.notify(nid, noti) } + + // Cancel stale ones + val toCancel = postedIds - currentIds + toCancel.forEach { nm.cancel(it) } + // Clean up id mappings for canceled notifications to prevent growth + if (toCancel.isNotEmpty()) { + val keysToRemove = notificationIdMap.filterValues { it in toCancel }.keys + keysToRemove.forEach { notificationIdMap.remove(it) } + } + postedIds.clear() + postedIds.addAll(currentIds) + + // Post/update summary and ensure foreground + val maxSummaryLines = 5 + val truncated = summaryLines.size > maxSummaryLines + val displaySummaryLines = if (truncated) { + summaryLines.take(maxSummaryLines) + "...and ${summaryLines.size - maxSummaryLines} more" + } else { + summaryLines + } + val summary = createSummaryNotification(sessions.size, displaySummaryLines) + ensureForeground(summary) } + private fun clearAll() { + val nm = getSystemService(NotificationManager::class.java) + nm?.cancel(SUMMARY_ID) + postedIds.forEach { id -> nm?.cancel(id) } + postedIds.clear() + isFgStarted = false + } + + data class SessionItem( + val id: String, + val title: String, + val subtitle: String, + val startWhen: Long, + val status: String, + ) + private fun stopForegroundService() { try { stopForeground(true) @@ -157,5 +283,6 @@ class ForegroundService : Service() { override fun onDestroy() { super.onDestroy() Log.d("ForegroundService", "Service onDestroy") + isRunning = false } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/tech/lolli/toolbox/MainActivity.kt b/android/app/src/main/kotlin/tech/lolli/toolbox/MainActivity.kt index 3dd4ed07..3e8f714f 100644 --- a/android/app/src/main/kotlin/tech/lolli/toolbox/MainActivity.kt +++ b/android/app/src/main/kotlin/tech/lolli/toolbox/MainActivity.kt @@ -13,20 +13,32 @@ import android.appwidget.AppWidgetManager import tech.lolli.toolbox.widget.HomeWidget class MainActivity: FlutterFragmentActivity() { + private lateinit var channel: MethodChannel + private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS" + private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION" + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger - MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan").apply { - setMethodCallHandler { method, result -> + channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan") + channel.setMethodCallHandler { method, result -> when (method.method) { "sendToBackground" -> { moveTaskToBack(true) result.success(null) } + "isServiceRunning" -> { + result.success(ForegroundService.isRunning) + } "startService" -> { try { reqPerm() + if (!notificationsAllowed()) { + // Don't start foreground service without notification permission on API 33+ + result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null) + return@setMethodCallHandler + } val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(serviceIntent) @@ -51,12 +63,35 @@ class MainActivity: FlutterFragmentActivity() { sendBroadcast(intent) result.success(null) } + "updateSessions" -> { + try { + if (!notificationsAllowed()) { + // Avoid starting/continuing service updates when notifications are blocked + result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null) + return@setMethodCallHandler + } + val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java) + serviceIntent.action = ACTION_UPDATE_SESSIONS + serviceIntent.putExtra("payload", method.arguments as String) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + result.success(null) + } catch (e: Exception) { + android.util.Log.e("MainActivity", "Failed to update sessions: ${e.message}") + result.error("SERVICE_ERROR", e.message, null) + } + } else -> { result.notImplemented() } } - } } + + // Handle intent if launched via notification action + handleActionIntent(intent) } private fun reqPerm() { @@ -77,5 +112,33 @@ class MainActivity: FlutterFragmentActivity() { } } } -} + private fun notificationsAllowed(): Boolean { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + true + } else { + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleActionIntent(intent) + } + + private fun handleActionIntent(intent: Intent?) { + if (intent == null) return + when (intent.action) { + ACTION_DISCONNECT_SESSION -> { + val sessionId = intent.getStringExtra("session_id") + if (sessionId != null && ::channel.isInitialized) { + try { + channel.invokeMethod("disconnectSession", mapOf("id" to sessionId)) + } catch (e: Exception) { + android.util.Log.e("MainActivity", "Failed to invoke disconnect: ${e.message}") + } + } + } + } + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e151a35c..078a8a19 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; }; + 4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */; }; + 4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */; }; + 4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; }; 7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; }; @@ -36,6 +40,8 @@ E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; }; E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; }; E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; }; + F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */; }; + F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -95,6 +101,10 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = ""; }; + 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivity.swift; sourceTree = ""; }; + 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = ""; }; 5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -156,6 +166,26 @@ E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = ""; }; E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F0002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F0006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F0007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F0008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F0009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F000A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F000B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F000C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F1002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F1006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F1007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F1008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F1009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F100A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F100B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F100C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -233,6 +263,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */, 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */, E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, @@ -242,6 +273,8 @@ E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */, E39A76AC2AB9A2F70067C641 /* Info-Release.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */, + 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, E3AE8AE92AB601DB000A6459 /* Utils.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -263,8 +296,11 @@ E33A3E3A2A626DCE009744AB /* StatusWidget */ = { isa = PBXGroup; children = ( + F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */, 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */, E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */, + 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */, + 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */, E33A3E3F2A626DCE009744AB /* StatusWidget.swift */, E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */, E33A3E442A626DD0009744AB /* Info.plist */, @@ -412,6 +448,7 @@ E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */, 7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -420,6 +457,7 @@ buildActionMask = 2147483647; files = ( 7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */, + F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -516,6 +554,8 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */, E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */, + 4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */, + 4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -525,6 +565,8 @@ files = ( E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */, E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */, + 4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */, + 4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */, E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */, E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */, ); @@ -610,6 +652,40 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; + F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + F0A1B2C31A2B3C4D5E6F0002 /* en */, + F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */, + F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */, + F0A1B2C31A2B3C4D5E6F0006 /* fr */, + F0A1B2C31A2B3C4D5E6F0007 /* ru */, + F0A1B2C31A2B3C4D5E6F0008 /* es */, + F0A1B2C31A2B3C4D5E6F0009 /* de */, + F0A1B2C31A2B3C4D5E6F000A /* pt-BR */, + F0A1B2C31A2B3C4D5E6F000B /* id */, + F0A1B2C31A2B3C4D5E6F000C /* ja */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + F0A1B2C31A2B3C4D5E6F1002 /* en */, + F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */, + F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */, + F0A1B2C31A2B3C4D5E6F1006 /* fr */, + F0A1B2C31A2B3C4D5E6F1007 /* ru */, + F0A1B2C31A2B3C4D5E6F1008 /* es */, + F0A1B2C31A2B3C4D5E6F1009 /* de */, + F0A1B2C31A2B3C4D5E6F100A /* pt-BR */, + F0A1B2C31A2B3C4D5E6F100B /* id */, + F0A1B2C31A2B3C4D5E6F100C /* ja */, + ); + name = Localizable.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 94eb8385..86dce037 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,6 +1,7 @@ import UIKit import WidgetKit import Flutter +import ActivityKit @main @objc class AppDelegate: FlutterAppDelegate { @@ -11,14 +12,48 @@ import Flutter GeneratedPluginRegistrant.register(with: self) let controller : FlutterViewController = window?.rootViewController as! FlutterViewController - let methodChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger) - methodChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + // Home widget channel (legacy) + let homeWidgetChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger) + homeWidgetChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in if call.method == "update" { if #available(iOS 14.0, *) { WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget") } } }) + + // Main channel for cross-platform calls (incl. Live Activities) + let mainChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/main_chan", binaryMessenger: controller.binaryMessenger) + mainChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + switch call.method { + case "updateHomeWidget": + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget") + } + result(nil) + case "startLiveActivity": + if #available(iOS 16.2, *) { + if let payload = call.arguments as? String { + LiveActivityManager.start(json: payload) + } + } + result(nil) + case "updateLiveActivity": + if #available(iOS 16.2, *) { + if let payload = call.arguments as? String { + LiveActivityManager.update(json: payload) + } + } + result(nil) + case "stopLiveActivity": + if #available(iOS 16.2, *) { + LiveActivityManager.stop() + } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + }) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -30,4 +65,11 @@ import Flutter } return true } + + override func applicationWillTerminate(_ application: UIApplication) { + // Stop Live Activity when app is about to terminate + if #available(iOS 16.2, *) { + LiveActivityManager.stop() + } + } } diff --git a/ios/Runner/Info-Debug.plist b/ios/Runner/Info-Debug.plist index 39a0fa5e..4e589262 100644 --- a/ios/Runner/Info-Debug.plist +++ b/ios/Runner/Info-Debug.plist @@ -41,6 +41,8 @@ ConfigurationIntent + NSSupportsLiveActivities + UIApplicationSupportsIndirectInputEvents UIBackgroundModes @@ -78,4 +80,4 @@ NSPhotoLibraryUsageDescription Get QR code and etc. - \ No newline at end of file + diff --git a/ios/Runner/Info-Profile.plist b/ios/Runner/Info-Profile.plist index ab4251da..a1fabb70 100644 --- a/ios/Runner/Info-Profile.plist +++ b/ios/Runner/Info-Profile.plist @@ -17,6 +17,8 @@ en zh + NSSupportsLiveActivities + CFBundleName ServerBox CFBundlePackageType diff --git a/ios/Runner/Info-Release.plist b/ios/Runner/Info-Release.plist index 1b326c8e..c37501af 100644 --- a/ios/Runner/Info-Release.plist +++ b/ios/Runner/Info-Release.plist @@ -17,6 +17,8 @@ en zh + NSSupportsLiveActivities + CFBundleName ServerBox CFBundlePackageType @@ -68,4 +70,4 @@ NSPhotoLibraryUsageDescription Get QR code and etc. - \ No newline at end of file + diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift new file mode 100644 index 00000000..cd951409 --- /dev/null +++ b/ios/Runner/LiveActivityManager.swift @@ -0,0 +1,95 @@ +// +// LiveActivityManager.swift +// Runner +// +// Handles starting/updating/stopping Terminal Live Activities from Flutter via MethodChannel. +// + +import Foundation +import ActivityKit + +@available(iOS 16.2, *) +class LiveActivityManager { + static var current: Activity? + + struct Payload: Decodable { + let id: String + let title: String + let subtitle: String + let startTimeMs: Int + let status: String + let hasTerminal: Bool? + let connectionCount: Int? + } + + private static func parse(_ json: String) -> Payload? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(Payload.self, from: data) + } + + static func start(json: String) { + guard #available(iOS 16.2, *) else { return } + guard let p = parse(json) else { return } + let attributes = TerminalAttributes(id: p.id) + let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0) + // Localize multi-connection title/subtitle on iOS side + let isMulti = (p.id == "multi_connections") + let title = isMulti + ? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1) + : p.title + let subtitle = isMulti + ? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections") + : p.subtitle + let state = TerminalAttributes.ContentState( + id: p.id, + title: title, + subtitle: subtitle, + status: p.status, + startTime: date, + hasTerminal: p.hasTerminal ?? true, + connectionCount: p.connectionCount ?? 1 + ) + let content = ActivityContent(state: state, staleDate: nil) + do { + current = try Activity.request(attributes: attributes, content: content, pushType: nil) + } catch { + // ignore + } + } + + static func update(json: String) { + guard #available(iOS 16.2, *) else { return } + guard let p = parse(json) else { return } + let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0) + // Localize multi-connection title/subtitle on iOS side + let isMulti = (p.id == "multi_connections") + let title = isMulti + ? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1) + : p.title + let subtitle = isMulti + ? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections") + : p.subtitle + let state = TerminalAttributes.ContentState( + id: p.id, + title: title, + subtitle: subtitle, + status: p.status, + startTime: date, + hasTerminal: p.hasTerminal ?? true, + connectionCount: p.connectionCount ?? 1 + ) + if let activity = current { + Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } + } else { + start(json: json) + } + } + + static func stop() { + guard #available(iOS 16.2, *) else { return } + if let activity = current { + Task { await activity.end(dismissalPolicy: .immediate) } + current = nil + } + } +} diff --git a/ios/Runner/TerminalLiveActivityAttributes.swift b/ios/Runner/TerminalLiveActivityAttributes.swift new file mode 100644 index 00000000..3f3579f7 --- /dev/null +++ b/ios/Runner/TerminalLiveActivityAttributes.swift @@ -0,0 +1,39 @@ +// +// TerminalLiveActivityAttributes.swift +// Runner +// +// Mirror of the ActivityKit attributes used in the extension. +// + +import Foundation +import ActivityKit + +@available(iOS 16.1, *) +public struct TerminalAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + public var id: String + public var title: String + public var subtitle: String + public var status: String + public var startTime: Date + public var hasTerminal: Bool + public var connectionCount: Int + + public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) { + self.id = id + self.title = title + self.subtitle = subtitle + self.status = status + self.startTime = startTime + self.hasTerminal = hasTerminal + self.connectionCount = connectionCount + } + } + + public var id: String + + public init(id: String) { + self.id = id + } +} + diff --git a/ios/Runner/de.lproj/Localizable.strings b/ios/Runner/de.lproj/Localizable.strings new file mode 100644 index 00000000..1d6474e3 --- /dev/null +++ b/ios/Runner/de.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Verbunden"; +"Connecting" = "Verbindung wird hergestellt"; +"Disconnected" = "Getrennt"; +"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen"; +"1 connection" = "1 Verbindung"; +"%d connections" = "%d Verbindungen"; + diff --git a/ios/Runner/en.lproj/Localizable.strings b/ios/Runner/en.lproj/Localizable.strings new file mode 100644 index 00000000..8ac57902 --- /dev/null +++ b/ios/Runner/en.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Connected"; +"Connecting" = "Connecting"; +"Disconnected" = "Disconnected"; +"Multiple SSH sessions active" = "Multiple SSH sessions active"; +"1 connection" = "1 connection"; +"%d connections" = "%d connections"; + diff --git a/ios/Runner/es.lproj/Localizable.strings b/ios/Runner/es.lproj/Localizable.strings new file mode 100644 index 00000000..d9c6c7f4 --- /dev/null +++ b/ios/Runner/es.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Conectado"; +"Connecting" = "Conectando"; +"Disconnected" = "Desconectado"; +"Multiple SSH sessions active" = "Varias sesiones SSH activas"; +"1 connection" = "1 conexión"; +"%d connections" = "%d conexiones"; + diff --git a/ios/Runner/fr.lproj/Localizable.strings b/ios/Runner/fr.lproj/Localizable.strings new file mode 100644 index 00000000..5822311e --- /dev/null +++ b/ios/Runner/fr.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Connecté"; +"Connecting" = "Connexion en cours"; +"Disconnected" = "Déconnecté"; +"Multiple SSH sessions active" = "Plusieurs sessions SSH actives"; +"1 connection" = "1 connexion"; +"%d connections" = "%d connexions"; + diff --git a/ios/Runner/id.lproj/Localizable.strings b/ios/Runner/id.lproj/Localizable.strings new file mode 100644 index 00000000..21f635be --- /dev/null +++ b/ios/Runner/id.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Terhubung"; +"Connecting" = "Menghubungkan"; +"Disconnected" = "Terputus"; +"Multiple SSH sessions active" = "Beberapa sesi SSH aktif"; +"1 connection" = "1 koneksi"; +"%d connections" = "%d koneksi"; + diff --git a/ios/Runner/ja.lproj/Localizable.strings b/ios/Runner/ja.lproj/Localizable.strings new file mode 100644 index 00000000..0f5af0be --- /dev/null +++ b/ios/Runner/ja.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "ターミナル"; +"Connected" = "接続済み"; +"Connecting" = "接続中"; +"Disconnected" = "切断"; +"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ"; +"1 connection" = "1 件の接続"; +"%d connections" = "%d 件の接続"; + diff --git a/ios/Runner/pt-BR.lproj/Localizable.strings b/ios/Runner/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..b9b6c409 --- /dev/null +++ b/ios/Runner/pt-BR.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Conectado"; +"Connecting" = "Conectando"; +"Disconnected" = "Desconectado"; +"Multiple SSH sessions active" = "Várias sessões SSH ativas"; +"1 connection" = "1 conexão"; +"%d connections" = "%d conexões"; + diff --git a/ios/Runner/ru.lproj/Localizable.strings b/ios/Runner/ru.lproj/Localizable.strings new file mode 100644 index 00000000..e4f9c9b4 --- /dev/null +++ b/ios/Runner/ru.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Терминал"; +"Connected" = "Подключено"; +"Connecting" = "Подключение"; +"Disconnected" = "Отключено"; +"Multiple SSH sessions active" = "Несколько активных сеансов SSH"; +"1 connection" = "1 подключение"; +"%d connections" = "%d подключений"; + diff --git a/ios/Runner/zh-Hans.lproj/Localizable.strings b/ios/Runner/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..1e4bbed6 --- /dev/null +++ b/ios/Runner/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "终端"; +"Connected" = "已连接"; +"Connecting" = "连接中"; +"Disconnected" = "已断开连接"; +"Multiple SSH sessions active" = "多个 SSH 会话正在活动"; +"1 connection" = "1 个连接"; +"%d connections" = "%d 个连接"; + diff --git a/ios/Runner/zh-Hant.lproj/Localizable.strings b/ios/Runner/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..999bdc34 --- /dev/null +++ b/ios/Runner/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "終端機"; +"Connected" = "已連線"; +"Connecting" = "連線中"; +"Disconnected" = "已中斷連線"; +"Multiple SSH sessions active" = "多個 SSH 連線運行中"; +"1 connection" = "1 個連線"; +"%d connections" = "%d 個連線"; + diff --git a/ios/StatusWidget/Info.plist b/ios/StatusWidget/Info.plist index 0f118fb7..9b998bb6 100644 --- a/ios/StatusWidget/Info.plist +++ b/ios/StatusWidget/Info.plist @@ -4,6 +4,15 @@ NSExtension + NSExtensionAttributes + + IntentsSupportedIntents + + ConfigurationIntent + + NSSupportsLiveActivities + + NSExtensionPointIdentifier com.apple.widgetkit-extension diff --git a/ios/StatusWidget/StatusWidgetBundle.swift b/ios/StatusWidget/StatusWidgetBundle.swift index 0c82f5ab..8a243613 100644 --- a/ios/StatusWidget/StatusWidgetBundle.swift +++ b/ios/StatusWidget/StatusWidgetBundle.swift @@ -12,5 +12,8 @@ import SwiftUI struct StatusWidgetBundle: WidgetBundle { var body: some Widget { StatusWidget() + if #available(iOSApplicationExtension 16.1, *) { + TerminalLiveActivity() + } } } diff --git a/ios/StatusWidget/TerminalLiveActivity.swift b/ios/StatusWidget/TerminalLiveActivity.swift new file mode 100644 index 00000000..6081a863 --- /dev/null +++ b/ios/StatusWidget/TerminalLiveActivity.swift @@ -0,0 +1,185 @@ +// +// TerminalLiveActivity.swift +// StatusWidget +// +// Renders the Live Activity UI for SSH/Terminal sessions. +// + +import SwiftUI +import WidgetKit +import ActivityKit + +// Helper to map status strings to a color dot (case-insensitive). +@inline(__always) +private func getStatusDotColor(_ status: String) -> Color { + switch status.lowercased() { + case "connected": + return .green + case "connecting": + return .yellow + case "disconnected": + return .red + default: + return .secondary + } +} + +// Normalize status for display: capitalize first letter only. +@inline(__always) +private func formatStatus(_ status: String) -> String { + let trimmed = status.trimmingCharacters(in: .whitespacesAndNewlines) + guard let first = trimmed.first else { return status } + let head = String(first).uppercased() + let tail = String(trimmed.dropFirst()).lowercased() + return head + tail +} + +// Localize known statuses; fall back to formatted original. +@inline(__always) +private func localizedStatus(_ status: String) -> String { + switch status.lowercased() { + case "connected": + return NSLocalizedString("Connected", comment: "Session connected status") + case "connecting": + return NSLocalizedString("Connecting", comment: "Session connecting status") + case "disconnected": + return NSLocalizedString("Disconnected", comment: "Session disconnected status") + default: + return formatStatus(status) + } +} + +@available(iOS 16.1, *) +struct TerminalLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: TerminalAttributes.self) { context in + let state = context.state + + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH") + .font(.caption) + .foregroundStyle(.secondary) + if state.connectionCount > 1 { + Text("(\(state.connectionCount))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Text(state.title) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + Text(state.subtitle) + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + Circle() + .fill(getStatusDotColor(state.status)) + .frame(width: 6, height: 6) + Text(localizedStatus(state.status)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + Image(systemName: state.hasTerminal ? "terminal" : "bolt.horizontal.circle") + .font(.title3) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 10) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(context.state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH") + .font(.caption2) + .foregroundStyle(.secondary) + if context.state.connectionCount > 1 { + Text("(\(context.state.connectionCount))") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Text(context.state.title) + .font(.subheadline) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.vertical, 8) + .padding(.horizontal, 8) + } + DynamicIslandExpandedRegion(.trailing) { + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { + Circle() + .fill(getStatusDotColor(context.state.status)) + .frame(width: 6, height: 6) + Text(localizedStatus(context.state.status)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 8) + } + DynamicIslandExpandedRegion(.bottom) { + Text(context.state.subtitle) + .font(.caption) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } compactLeading: { + Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle") + } compactTrailing: { + EmptyView() + } minimal: { + Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle") + } + } + } +} + +#if DEBUG +@available(iOS 16.2, *) +struct TerminalLiveActivity_Previews: PreviewProvider { + static let attributes = TerminalAttributes(id: "preview") + static let contentState = TerminalAttributes.ContentState( + id: "preview", + title: "root@server-01", + subtitle: "CPU 37% • Mem 1.3G/2.0G", + status: "Connected", + startTime: Date().addingTimeInterval(-1234), + hasTerminal: true, + connectionCount: 2 + ) + + static var previews: some View { + Group { + // 锁屏 / 通知样式预览 + attributes + .previewContext(contentState, viewKind: .content) + .previewDisplayName("Lock Screen") + + // 岛屿展开态预览 + attributes + .previewContext(contentState, viewKind: .dynamicIsland(.expanded)) + .previewDisplayName("Dynamic Island • Expanded") + + // 岛屿紧凑态预览 + attributes + .previewContext(contentState, viewKind: .dynamicIsland(.compact)) + .previewDisplayName("Dynamic Island • Compact") + + // 岛屿最小态预览 + attributes + .previewContext(contentState, viewKind: .dynamicIsland(.minimal)) + .previewDisplayName("Dynamic Island • Minimal") + } + } +} +#endif diff --git a/ios/StatusWidget/TerminalLiveActivityAttributes.swift b/ios/StatusWidget/TerminalLiveActivityAttributes.swift new file mode 100644 index 00000000..86f104ee --- /dev/null +++ b/ios/StatusWidget/TerminalLiveActivityAttributes.swift @@ -0,0 +1,39 @@ +// +// TerminalLiveActivityAttributes.swift +// StatusWidget +// +// Defines ActivityKit attributes and content state for SSH/Terminal Live Activities. +// + +import Foundation +import ActivityKit + +@available(iOS 16.1, *) +public struct TerminalAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + public var id: String + public var title: String + public var subtitle: String + public var status: String + public var startTime: Date + public var hasTerminal: Bool + public var connectionCount: Int + + public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) { + self.id = id + self.title = title + self.subtitle = subtitle + self.status = status + self.startTime = startTime + self.hasTerminal = hasTerminal + self.connectionCount = connectionCount + } + } + + public var id: String + + public init(id: String) { + self.id = id + } +} + diff --git a/ios/StatusWidget/de.lproj/Localizable.strings b/ios/StatusWidget/de.lproj/Localizable.strings new file mode 100644 index 00000000..1d6474e3 --- /dev/null +++ b/ios/StatusWidget/de.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Verbunden"; +"Connecting" = "Verbindung wird hergestellt"; +"Disconnected" = "Getrennt"; +"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen"; +"1 connection" = "1 Verbindung"; +"%d connections" = "%d Verbindungen"; + diff --git a/ios/StatusWidget/en.lproj/Localizable.strings b/ios/StatusWidget/en.lproj/Localizable.strings new file mode 100644 index 00000000..8ac57902 --- /dev/null +++ b/ios/StatusWidget/en.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Connected"; +"Connecting" = "Connecting"; +"Disconnected" = "Disconnected"; +"Multiple SSH sessions active" = "Multiple SSH sessions active"; +"1 connection" = "1 connection"; +"%d connections" = "%d connections"; + diff --git a/ios/StatusWidget/es.lproj/Localizable.strings b/ios/StatusWidget/es.lproj/Localizable.strings new file mode 100644 index 00000000..d9c6c7f4 --- /dev/null +++ b/ios/StatusWidget/es.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Conectado"; +"Connecting" = "Conectando"; +"Disconnected" = "Desconectado"; +"Multiple SSH sessions active" = "Varias sesiones SSH activas"; +"1 connection" = "1 conexión"; +"%d connections" = "%d conexiones"; + diff --git a/ios/StatusWidget/fr.lproj/Localizable.strings b/ios/StatusWidget/fr.lproj/Localizable.strings new file mode 100644 index 00000000..5822311e --- /dev/null +++ b/ios/StatusWidget/fr.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Connecté"; +"Connecting" = "Connexion en cours"; +"Disconnected" = "Déconnecté"; +"Multiple SSH sessions active" = "Plusieurs sessions SSH actives"; +"1 connection" = "1 connexion"; +"%d connections" = "%d connexions"; + diff --git a/ios/StatusWidget/id.lproj/Localizable.strings b/ios/StatusWidget/id.lproj/Localizable.strings new file mode 100644 index 00000000..21f635be --- /dev/null +++ b/ios/StatusWidget/id.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Terhubung"; +"Connecting" = "Menghubungkan"; +"Disconnected" = "Terputus"; +"Multiple SSH sessions active" = "Beberapa sesi SSH aktif"; +"1 connection" = "1 koneksi"; +"%d connections" = "%d koneksi"; + diff --git a/ios/StatusWidget/ja.lproj/Localizable.strings b/ios/StatusWidget/ja.lproj/Localizable.strings new file mode 100644 index 00000000..0f5af0be --- /dev/null +++ b/ios/StatusWidget/ja.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "ターミナル"; +"Connected" = "接続済み"; +"Connecting" = "接続中"; +"Disconnected" = "切断"; +"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ"; +"1 connection" = "1 件の接続"; +"%d connections" = "%d 件の接続"; + diff --git a/ios/StatusWidget/pt-BR.lproj/Localizable.strings b/ios/StatusWidget/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..b9b6c409 --- /dev/null +++ b/ios/StatusWidget/pt-BR.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Terminal"; +"Connected" = "Conectado"; +"Connecting" = "Conectando"; +"Disconnected" = "Desconectado"; +"Multiple SSH sessions active" = "Várias sessões SSH ativas"; +"1 connection" = "1 conexão"; +"%d connections" = "%d conexões"; + diff --git a/ios/StatusWidget/ru.lproj/Localizable.strings b/ios/StatusWidget/ru.lproj/Localizable.strings new file mode 100644 index 00000000..e4f9c9b4 --- /dev/null +++ b/ios/StatusWidget/ru.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "Терминал"; +"Connected" = "Подключено"; +"Connecting" = "Подключение"; +"Disconnected" = "Отключено"; +"Multiple SSH sessions active" = "Несколько активных сеансов SSH"; +"1 connection" = "1 подключение"; +"%d connections" = "%d подключений"; + diff --git a/ios/StatusWidget/zh-Hans.lproj/Localizable.strings b/ios/StatusWidget/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..1e4bbed6 --- /dev/null +++ b/ios/StatusWidget/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "终端"; +"Connected" = "已连接"; +"Connecting" = "连接中"; +"Disconnected" = "已断开连接"; +"Multiple SSH sessions active" = "多个 SSH 会话正在活动"; +"1 connection" = "1 个连接"; +"%d connections" = "%d 个连接"; + diff --git a/ios/StatusWidget/zh-Hant.lproj/Localizable.strings b/ios/StatusWidget/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..999bdc34 --- /dev/null +++ b/ios/StatusWidget/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,8 @@ +"Terminal" = "終端機"; +"Connected" = "已連線"; +"Connecting" = "連線中"; +"Disconnected" = "已中斷連線"; +"Multiple SSH sessions active" = "多個 SSH 連線運行中"; +"1 connection" = "1 個連線"; +"%d connections" = "%d 個連線"; + diff --git a/lib/core/chan.dart b/lib/core/chan.dart index 1707bc04..a86770e5 100644 --- a/lib/core/chan.dart +++ b/lib/core/chan.dart @@ -12,19 +12,85 @@ abstract final class MethodChans { /// Issue #662 static void startService() { - // if (Stores.setting.fgService.fetch() != true) return; - // _channel.invokeMethod('startService'); + if (Stores.setting.fgService.fetch() != true) return; + _channel.invokeMethod('startService'); } /// Issue #662 static void stopService() { - // if (Stores.setting.fgService.fetch() != true) return; - // _channel.invokeMethod('stopService'); + if (Stores.setting.fgService.fetch() != true) return; + _channel.invokeMethod('stopService'); } static void updateHomeWidget() async { - if (!isIOS || !isAndroid) return; + if (!isIOS && !isAndroid) return; if (!Stores.setting.autoUpdateHomeWidget.fetch()) return; await _channel.invokeMethod('updateHomeWidget'); } + + /// Update Android foreground service notifications for SSH sessions + /// The [payload] is a JSON string describing sessions list. + static Future updateSessions(String payload) async { + if (!isAndroid) return; + try { + Loggers.app.info('Updating Android sessions: $payload'); + await _channel.invokeMethod('updateSessions', payload); + } catch (_) { + // ignore + } + } + + /// Query whether the Android foreground service is currently running. + static Future isServiceRunning() async { + if (!isAndroid) return false; + try { + final res = await _channel.invokeMethod('isServiceRunning'); + return res == true; + } catch (_) { + return false; + } + } + + // iOS Live Activities controls + static Future startLiveActivity(String payload) async { + if (!isIOS) return; + try { + Loggers.app.info('Starting iOS Live Activity: $payload'); + await _channel.invokeMethod('startLiveActivity', payload); + } catch (_) {} + } + + static Future updateLiveActivity(String payload) async { + if (!isIOS) return; + try { + Loggers.app.info('Updating iOS Live Activity: $payload'); + await _channel.invokeMethod('updateLiveActivity', payload); + } catch (_) {} + } + + static Future stopLiveActivity() async { + if (!isIOS) return; + try { + Loggers.app.info('Stopping iOS Live Activity'); + await _channel.invokeMethod('stopLiveActivity'); + } catch (_) {} + } + + /// Register a handler for native -> Flutter callbacks. + /// Currently handles: `disconnectSession` with argument map {id: string} + static void registerHandler(Future Function(String id) onDisconnect) { + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'disconnectSession': + final args = call.arguments; + final id = args is Map ? args['id'] as String? : args as String?; + if (id != null && id.isNotEmpty) { + await onDisconnect(id); + } + return; + default: + return; + } + }); + } } diff --git a/lib/data/provider/server.dart b/lib/data/provider/server.dart index 8a3f8a76..cec54ef5 100644 --- a/lib/data/provider/server.dart +++ b/lib/data/provider/server.dart @@ -20,6 +20,7 @@ import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/model/server/try_limiter.dart'; import 'package:server_box/data/res/status.dart'; import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/ssh/session_manager.dart'; class ServerProvider extends Provider { const ServerProvider._(); @@ -183,6 +184,10 @@ class ServerProvider extends Provider { for (final s in servers.values) { s.value.conn = ServerConn.disconnected; s.notify(); + + // Update SSH session status to disconnected + final sessionId = 'ssh_${s.value.spi.id}'; + TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); } //TryLimiter.clear(); } @@ -209,6 +214,10 @@ class ServerProvider extends Provider { item.conn = ServerConn.disconnected; _manualDisconnectedIds.add(id); s.notify(); + + // Remove SSH session when server is manually closed + final sessionId = 'ssh_$id'; + TermSessionManager.remove(sessionId); } static void addServer(Spi spi) { @@ -229,10 +238,21 @@ class ServerProvider extends Provider { Stores.setting.serverOrder.put(serverOrder.value); Stores.server.delete(id); _updateTags(); + + // Remove SSH session when server is deleted + final sessionId = 'ssh_$id'; + TermSessionManager.remove(sessionId); + bakSync.sync(milliDelay: 1000); } static void deleteAll() { + // Remove all SSH sessions before clearing servers + for (final id in servers.keys) { + final sessionId = 'ssh_$id'; + TermSessionManager.remove(sessionId); + } + servers.clear(); serverOrder.value.clear(); serverOrder.notify(); @@ -253,6 +273,11 @@ class ServerProvider extends Provider { serverOrder.value.update(old.id, newSpi.id); Stores.setting.serverOrder.put(serverOrder.value); serverOrder.notify(); + + // Update SSH session ID when server ID changes + final oldSessionId = 'ssh_${old.id}'; + TermSessionManager.remove(oldSessionId); + // Session will be re-added when reconnecting if necessary } // Only reconnect if neccessary @@ -320,11 +345,26 @@ class ServerProvider extends Provider { } else { Loggers.app.info('Jump to ${spi.name} in $spentTime ms.'); } + + // Add SSH session to TermSessionManager + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.add( + id: sessionId, + spi: spi, + startTimeMs: time1.millisecondsSinceEpoch, + disconnect: () => _closeOneServer(spi.id), + status: TermSessionStatus.connecting, + ); + TermSessionManager.setActive(sessionId, hasTerminal: false); } catch (e) { TryLimiter.inc(sid); sv.status.err = SSHErr(type: SSHErrType.connect, message: e.toString()); _setServerState(s, ServerConn.failed); + // Remove SSH session on connection failure + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.remove(sessionId); + /// In order to keep privacy, print [spi.name] instead of [spi.id] Loggers.app.warning('Connect to ${spi.name} failed', e); return; @@ -332,6 +372,10 @@ class ServerProvider extends Provider { _setServerState(s, ServerConn.connected); + // Update SSH session status to connected + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected); + try { // Detect system type using helper final detectedSystemType = await SystemDetector.detect(sv.client!, spi); @@ -352,6 +396,10 @@ class ServerProvider extends Provider { sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); + + // Update SSH session status to disconnected + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } on SSHAuthFailError catch (e) { TryLimiter.inc(sid); @@ -359,6 +407,10 @@ class ServerProvider extends Provider { sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); + + // Update SSH session status to disconnected + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } catch (e) { // If max try times < 2 and can't write script, this will stop the status getting and etc. @@ -367,6 +419,10 @@ class ServerProvider extends Provider { sv.status.err = err; Loggers.app.warning(err); _setServerState(s, ServerConn.failed); + + // Update SSH session status to disconnected + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); } } @@ -396,6 +452,10 @@ class ServerProvider extends Provider { TryLimiter.inc(sid); sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw'); _setServerState(s, ServerConn.failed); + + // Update SSH session status to disconnected on segments error + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } } catch (e) { @@ -403,6 +463,10 @@ class ServerProvider extends Provider { sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString()); _setServerState(s, ServerConn.failed); Loggers.app.warning('Get status from ${spi.name} failed', e); + + // Update SSH session status to disconnected on status error + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } @@ -422,6 +486,10 @@ class ServerProvider extends Provider { sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw'); _setServerState(s, ServerConn.failed); Loggers.app.warning('Server status', e, trace); + + // Update SSH session status to disconnected on parse error + final sessionId = 'ssh_${spi.id}'; + TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); return; } diff --git a/lib/data/ssh/session_manager.dart b/lib/data/ssh/session_manager.dart new file mode 100644 index 00000000..044baa5b --- /dev/null +++ b/lib/data/ssh/session_manager.dart @@ -0,0 +1,200 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/foundation.dart'; +import 'package:server_box/core/chan.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; + +enum TermSessionStatus { + connecting, + connected, + disconnected; + + @override + String toString() { + return name.capitalize; + } +} + +/// Represents a running SSH terminal session for Android notifications and iOS Live Activities. +class TermSessionInfo { + final String id; + final String title; // e.g. server name + final String subtitle; // e.g. user@ip:port + final int startTimeMs; + final TermSessionStatus status; + + TermSessionInfo({ + required this.id, + required this.title, + required this.subtitle, + required this.startTimeMs, + required this.status, + }); + + Map toJson() => { + 'id': id, + 'title': title, + 'subtitle': subtitle, + 'startTimeMs': startTimeMs, + 'status': status.toString(), + }; +} + +/// Singleton to track active SSH sessions and sync to Android notifications. +abstract final class TermSessionManager { + static final Map _entries = {}; + static String? _activeId; // For iOS Live Activity + static Timer? _updateTimer; // Timer for iOS Live Activity updates + static const _updateInterval = Duration(seconds: 5); // 5-second update interval + + static void init() { + if (isAndroid) { + MethodChans.registerHandler((id) async { + _entries[id]?.disconnect?.call(); + }); + } + } + + /// Add a session record and push update to Android. + static void add({ + required String id, + required Spi spi, + required int startTimeMs, + required VoidCallback disconnect, + TermSessionStatus status = TermSessionStatus.connecting, + }) { + final info = TermSessionInfo( + id: id, + title: spi.name, + subtitle: spi.oldId, + startTimeMs: startTimeMs, + status: status, + ); + _entries[id] = _Entry(info, disconnect, hasTerminalUI: true); + _activeId = id; // most recent as active + _sync(); + } + + static void updateStatus(String id, TermSessionStatus status) { + final old = _entries[id]; + if (old == null) return; + _entries[id] = _Entry( + TermSessionInfo( + id: old.info.id, + title: old.info.title, + subtitle: old.info.subtitle, + startTimeMs: old.info.startTimeMs, + status: status, + ), + old.disconnect, + hasTerminalUI: old.hasTerminalUI, + ); + _sync(); + } + + static void remove(String id) { + _entries.remove(id); + if (_activeId == id) { + _activeId = _entries.keys.firstOrNull; + } + _sync(); + } + + static Future _sync() async { + // Android: update foreground service notifications + if (isAndroid) { + final isRunning = await MethodChans.isServiceRunning(); + if (_entries.isEmpty) { + if (isRunning) { + MethodChans.stopService(); + } + await MethodChans.updateSessions(jsonEncode({'sessions': []})); + } else { + if (!isRunning) { + MethodChans.startService(); + } + final payload = jsonEncode({'sessions': _entries.values.map((e) => e.info.toJson()).toList()}); + await MethodChans.updateSessions(payload); + } + } + + // iOS: manage Live Activity timer + if (isIOS) { + if (_entries.isEmpty) { + _updateTimer?.cancel(); + _updateTimer = null; + await MethodChans.stopLiveActivity(); + } else { + // Start timer if not already running + _updateTimer ??= Timer.periodic(_updateInterval, (_) => _updateLiveActivity()); + // Immediately update for immediate feedback + await _updateLiveActivity(); + } + } + } + + static Future _updateLiveActivity() async { + if (!isIOS || _entries.isEmpty) return; + + final connectionCount = _entries.length; + + if (connectionCount == 1) { + // Single connection: show hostname + final id = _activeId ?? _entries.keys.first; + final entry = _entries[id]; + if (entry == null) return; + final payload = jsonEncode({ + ...entry.info.toJson(), + 'hasTerminal': entry.hasTerminalUI, + 'connectionCount': connectionCount, + }); + await MethodChans.updateLiveActivity(payload); + } else { + // Multiple connections: show connection count + final id = _activeId ?? _entries.keys.first; + final entry = _entries[id]; + if (entry == null) return; + final payload = jsonEncode({ + 'id': 'multi_connections', + 'title': '$connectionCount connections', + 'subtitle': 'Multiple SSH sessions active', + 'startTimeMs': entry.info.startTimeMs, + 'status': TermSessionStatus.connected.toString(), + 'hasTerminal': entry.hasTerminalUI, + 'connectionCount': connectionCount, + }); + await MethodChans.updateLiveActivity(payload); + } + } + + /// Mark which session is actively displayed in UI (for iOS Live Activity). + static void setActive(String id, {bool hasTerminal = true}) { + _activeId = id; + final old = _entries[id]; + if (old != null) { + _entries[id] = _Entry(old.info, old.disconnect, hasTerminalUI: hasTerminal); + _sync(); + } + } + + /// Stop Live Activity when app is closed/terminated (iOS only). + static Future stopLiveActivityOnAppClose() async { + if (!isIOS) return; + + // Cancel any running timers + _updateTimer?.cancel(); + _updateTimer = null; + + // Stop the Live Activity + await MethodChans.stopLiveActivity(); + } +} + +class _Entry { + final TermSessionInfo info; + final VoidCallback? disconnect; + final bool hasTerminalUI; + _Entry(this.info, this.disconnect, {this.hasTerminalUI = true}); +} diff --git a/lib/main.dart b/lib/main.dart index 1eeceed2..438a73e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,7 @@ import 'package:server_box/data/provider/sftp.dart'; import 'package:server_box/data/provider/snippet.dart'; import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/ssh/session_manager.dart'; import 'package:server_box/data/store/server.dart'; import 'package:server_box/hive/hive_registrar.g.dart'; @@ -46,6 +47,9 @@ Future _initApp() async { await _initWindow(); _doPlatformRelated(); + + // Initialize Android session notification channel/handler + TermSessionManager.init(); } Future _initData() async { diff --git a/lib/view/page/ssh/page/init.dart b/lib/view/page/ssh/page/init.dart index 9a533dba..b9929441 100644 --- a/lib/view/page/ssh/page/init.dart +++ b/lib/view/page/ssh/page/init.dart @@ -61,6 +61,11 @@ extension _Init on SSHPageState { _listen(session.stdout); _listen(session.stderr); + // Hold the session for external control (disconnect) + _session = session; + // Mark status connected for notifications / live activities + TermSessionManager.updateStatus(_sessionId, TermSessionStatus.connected); + for (final snippet in SnippetProvider.snippets.value) { if (snippet.autoRunOn?.contains(widget.args.spi.id) == true) { snippet.runInTerm(_terminal, widget.args.spi); @@ -85,6 +90,7 @@ extension _Init on SSHPageState { context.pop(); } widget.args.onSessionEnd?.call(); + TermSessionManager.remove(_sessionId); } void _listen(Stream? stream) { @@ -122,6 +128,7 @@ extension _Init on SSHPageState { _discontinuityTimer?.cancel(); if (!mounted) return; _writeLn('\n\nConnection lost\r\n'); + TermSessionManager.updateStatus(_sessionId, TermSessionStatus.disconnected); context.showRoundDialog( title: libL10n.attention, child: Text('${l10n.disconnected}\n${l10n.goBackQ}'), @@ -139,3 +146,17 @@ extension _Init on SSHPageState { _terminal.write('$p0\r\n'); } } + +extension on SSHPageState { + void _disconnectFromNotification() { + // Mark as disconnected in session manager for immediate UI/notification feedback + TermSessionManager.updateStatus(_sessionId, TermSessionStatus.disconnected); + + // Try to close the running SSH session, if any + try { + _session?.close(); + } catch (e, stackTrace) { + Loggers.app.warning('Error closing SSH session: $e\n$stackTrace'); + } + } +} diff --git a/lib/view/page/ssh/page/page.dart b/lib/view/page/ssh/page/page.dart index e2ebc51c..ed1d136b 100644 --- a/lib/view/page/ssh/page/page.dart +++ b/lib/view/page/ssh/page/page.dart @@ -21,6 +21,7 @@ import 'package:server_box/data/provider/snippet.dart'; import 'package:server_box/data/provider/virtual_keyboard.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/terminal.dart'; +import 'package:server_box/data/ssh/session_manager.dart'; import 'package:server_box/view/page/storage/sftp.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -81,10 +82,13 @@ class SSHPageState extends State bool _isDark = false; Timer? _virtKeyLongPressTimer; late SSHClient? _client = widget.args.spi.server?.value.client; + SSHSession? _session; Timer? _discontinuityTimer; /// Used for (de)activate the wake lock and forground service static var _sshConnCount = 0; + late final String _sessionId = ShortId.generate(); + late final int _sessionStartMs = DateTime.now().millisecondsSinceEpoch; @override void dispose() { @@ -101,6 +105,9 @@ class SSHPageState extends State } } + // Remove session entry + TermSessionManager.remove(_sessionId); + super.dispose(); } @@ -117,6 +124,16 @@ class SSHPageState extends State MethodChans.startService(); } } + + // Add session entry (for Android notifications & iOS Live Activities) + TermSessionManager.add( + id: _sessionId, + spi: widget.args.spi, + startTimeMs: _sessionStartMs, + disconnect: _disconnectFromNotification, + status: TermSessionStatus.connecting, + ); + TermSessionManager.setActive(_sessionId, hasTerminal: true); } @override