mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
feat: term session mgr (#846)
This commit is contained in:
@@ -5,11 +5,28 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class ForegroundService : Service() {
|
class ForegroundService : Service() {
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
var isRunning: Boolean = false
|
||||||
|
}
|
||||||
private val chanId = "ForegroundServiceChannel"
|
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<Int>()
|
||||||
|
// Stable mapping from session-id -> notification-id to avoid hash collisions
|
||||||
|
private val notificationIdMap = mutableMapOf<String, Int>()
|
||||||
|
private val nextNotificationId = java.util.concurrent.atomic.AtomicInteger(2001)
|
||||||
|
|
||||||
private fun logError(message: String, error: Throwable? = null) {
|
private fun logError(message: String, error: Throwable? = null) {
|
||||||
Log.e("ForegroundService", message, error)
|
Log.e("ForegroundService", message, error)
|
||||||
@@ -26,6 +43,7 @@ class ForegroundService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.d("ForegroundService", "Service onCreate")
|
Log.d("ForegroundService", "Service onCreate")
|
||||||
|
isRunning = true
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,24 +68,20 @@ class ForegroundService : Service() {
|
|||||||
val action = intent.action
|
val action = intent.action
|
||||||
Log.d("ForegroundService", "onStartCommand action=$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) {
|
return when (action) {
|
||||||
"ACTION_STOP_FOREGROUND" -> {
|
ACTION_STOP_FOREGROUND -> {
|
||||||
|
clearAll()
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
START_NOT_STICKY
|
START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
ACTION_UPDATE_SESSIONS -> {
|
||||||
|
val payload = intent.getStringExtra("payload") ?: "{}"
|
||||||
|
handleUpdateSessions(payload)
|
||||||
|
START_STICKY
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
// Default bring up foreground with placeholder
|
||||||
|
ensureForeground(createSummaryNotification(0, emptyList()))
|
||||||
START_STICKY
|
START_STICKY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,24 +115,99 @@ class ForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(): Notification {
|
private fun ensureForeground(notification: Notification) {
|
||||||
try {
|
try {
|
||||||
val notificationIntent = Intent(this, MainActivity::class.java)
|
if (!isFgStarted) {
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
startForeground(SUMMARY_ID, notification)
|
||||||
this,
|
isFgStarted = true
|
||||||
0,
|
} else {
|
||||||
notificationIntent,
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
nm?.notify(SUMMARY_ID, notification)
|
||||||
)
|
|
||||||
|
|
||||||
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
|
|
||||||
action = "ACTION_STOP_FOREGROUND"
|
|
||||||
}
|
}
|
||||||
val deletePendingIntent = PendingIntent.getService(
|
} catch (e: Exception) {
|
||||||
this,
|
logError("Failed to start/update foreground", e)
|
||||||
0,
|
}
|
||||||
deleteIntent,
|
}
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
|
||||||
|
private fun createSummaryNotification(count: Int, lines: List<String>): 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<SessionItem>()
|
||||||
|
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<Int>()
|
||||||
|
val summaryLines = mutableListOf<String>()
|
||||||
|
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) {
|
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
@@ -127,23 +216,60 @@ class ForegroundService : Service() {
|
|||||||
Notification.Builder(this)
|
Notification.Builder(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder
|
val noti = builder
|
||||||
.setContentTitle("Server Box")
|
.setContentTitle(s.title)
|
||||||
.setContentText("Running in background")
|
.setContentText("${s.subtitle} · ${s.status}")
|
||||||
.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")
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.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()
|
.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() {
|
private fun stopForegroundService() {
|
||||||
try {
|
try {
|
||||||
stopForeground(true)
|
stopForeground(true)
|
||||||
@@ -157,5 +283,6 @@ class ForegroundService : Service() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
Log.d("ForegroundService", "Service onDestroy")
|
Log.d("ForegroundService", "Service onDestroy")
|
||||||
|
isRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,32 @@ import android.appwidget.AppWidgetManager
|
|||||||
import tech.lolli.toolbox.widget.HomeWidget
|
import tech.lolli.toolbox.widget.HomeWidget
|
||||||
|
|
||||||
class MainActivity: FlutterFragmentActivity() {
|
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) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
|
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
|
|
||||||
MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan").apply {
|
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
|
||||||
setMethodCallHandler { method, result ->
|
channel.setMethodCallHandler { method, result ->
|
||||||
when (method.method) {
|
when (method.method) {
|
||||||
"sendToBackground" -> {
|
"sendToBackground" -> {
|
||||||
moveTaskToBack(true)
|
moveTaskToBack(true)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"isServiceRunning" -> {
|
||||||
|
result.success(ForegroundService.isRunning)
|
||||||
|
}
|
||||||
"startService" -> {
|
"startService" -> {
|
||||||
try {
|
try {
|
||||||
reqPerm()
|
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)
|
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(serviceIntent)
|
startForegroundService(serviceIntent)
|
||||||
@@ -51,12 +63,35 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
sendBroadcast(intent)
|
sendBroadcast(intent)
|
||||||
result.success(null)
|
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 -> {
|
else -> {
|
||||||
result.notImplemented()
|
result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle intent if launched via notification action
|
||||||
|
handleActionIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reqPerm() {
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
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 */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
|
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
|
||||||
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* 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 */; };
|
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
|
||||||
E3AE8AEC2AB601DB000A6459 /* 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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -95,6 +101,10 @@
|
|||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivity.swift; sourceTree = "<group>"; };
|
||||||
|
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
@@ -156,6 +166,26 @@
|
|||||||
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
|
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
|
||||||
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
|
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
|
||||||
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F0009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F000C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F1009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
|
F0A1B2C31A2B3C4D5E6F100C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -233,6 +263,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */,
|
||||||
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
|
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
|
||||||
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
|
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@@ -242,6 +273,8 @@
|
|||||||
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
|
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
|
||||||
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
|
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
|
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */,
|
||||||
|
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
|
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
@@ -263,8 +296,11 @@
|
|||||||
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
|
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */,
|
||||||
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
|
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
|
||||||
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
|
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
|
||||||
|
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */,
|
||||||
|
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */,
|
||||||
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
|
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
|
||||||
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
|
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
|
||||||
E33A3E442A626DD0009744AB /* Info.plist */,
|
E33A3E442A626DD0009744AB /* Info.plist */,
|
||||||
@@ -412,6 +448,7 @@
|
|||||||
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
|
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
|
||||||
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -420,6 +457,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -516,6 +554,8 @@
|
|||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
||||||
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
|
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
|
||||||
|
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */,
|
||||||
|
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -525,6 +565,8 @@
|
|||||||
files = (
|
files = (
|
||||||
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
|
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
|
||||||
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
|
||||||
|
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */,
|
||||||
|
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
|
||||||
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
|
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
|
||||||
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
|
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -610,6 +652,40 @@
|
|||||||
name = LaunchScreen.storyboard;
|
name = LaunchScreen.storyboard;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
/* End PBXVariantGroup section */
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import ActivityKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -11,14 +12,48 @@ import Flutter
|
|||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
let methodChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
|
// Home widget channel (legacy)
|
||||||
methodChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
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 call.method == "update" {
|
||||||
if #available(iOS 14.0, *) {
|
if #available(iOS 14.0, *) {
|
||||||
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
|
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)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,4 +65,11 @@ import Flutter
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
// Stop Live Activity when app is about to terminate
|
||||||
|
if #available(iOS 16.2, *) {
|
||||||
|
LiveActivityManager.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>ConfigurationIntent</string>
|
<string>ConfigurationIntent</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true />
|
<true />
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
@@ -78,4 +80,4 @@
|
|||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Get QR code and etc.</string>
|
<string>Get QR code and etc.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<string>en</string>
|
<string>en</string>
|
||||||
<string>zh</string>
|
<string>zh</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>ServerBox</string>
|
<string>ServerBox</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<string>en</string>
|
<string>en</string>
|
||||||
<string>zh</string>
|
<string>zh</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>ServerBox</string>
|
<string>ServerBox</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
@@ -68,4 +70,4 @@
|
|||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Get QR code and etc.</string>
|
<string>Get QR code and etc.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
95
ios/Runner/LiveActivityManager.swift
Normal file
95
ios/Runner/LiveActivityManager.swift
Normal file
@@ -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<TerminalAttributes>?
|
||||||
|
|
||||||
|
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<TerminalAttributes>.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
ios/Runner/TerminalLiveActivityAttributes.swift
Normal file
39
ios/Runner/TerminalLiveActivityAttributes.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
ios/Runner/de.lproj/Localizable.strings
Normal file
8
ios/Runner/de.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/Runner/en.lproj/Localizable.strings
Normal file
8
ios/Runner/en.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/Runner/es.lproj/Localizable.strings
Normal file
8
ios/Runner/es.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/Runner/fr.lproj/Localizable.strings
Normal file
8
ios/Runner/fr.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/Runner/id.lproj/Localizable.strings
Normal file
8
ios/Runner/id.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/Runner/ja.lproj/Localizable.strings
Normal file
8
ios/Runner/ja.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "ターミナル";
|
||||||
|
"Connected" = "接続済み";
|
||||||
|
"Connecting" = "接続中";
|
||||||
|
"Disconnected" = "切断";
|
||||||
|
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
||||||
|
"1 connection" = "1 件の接続";
|
||||||
|
"%d connections" = "%d 件の接続";
|
||||||
|
|
||||||
8
ios/Runner/pt-BR.lproj/Localizable.strings
Normal file
8
ios/Runner/pt-BR.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/Runner/ru.lproj/Localizable.strings
Normal file
8
ios/Runner/ru.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Терминал";
|
||||||
|
"Connected" = "Подключено";
|
||||||
|
"Connecting" = "Подключение";
|
||||||
|
"Disconnected" = "Отключено";
|
||||||
|
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
||||||
|
"1 connection" = "1 подключение";
|
||||||
|
"%d connections" = "%d подключений";
|
||||||
|
|
||||||
8
ios/Runner/zh-Hans.lproj/Localizable.strings
Normal file
8
ios/Runner/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "终端";
|
||||||
|
"Connected" = "已连接";
|
||||||
|
"Connecting" = "连接中";
|
||||||
|
"Disconnected" = "已断开连接";
|
||||||
|
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
||||||
|
"1 connection" = "1 个连接";
|
||||||
|
"%d connections" = "%d 个连接";
|
||||||
|
|
||||||
8
ios/Runner/zh-Hant.lproj/Localizable.strings
Normal file
8
ios/Runner/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "終端機";
|
||||||
|
"Connected" = "已連線";
|
||||||
|
"Connecting" = "連線中";
|
||||||
|
"Disconnected" = "已中斷連線";
|
||||||
|
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
||||||
|
"1 connection" = "1 個連線";
|
||||||
|
"%d connections" = "%d 個連線";
|
||||||
|
|
||||||
@@ -4,6 +4,15 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>IntentsSupportedIntents</key>
|
||||||
|
<array>
|
||||||
|
<string>ConfigurationIntent</string>
|
||||||
|
</array>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
<string>com.apple.widgetkit-extension</string>
|
<string>com.apple.widgetkit-extension</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ import SwiftUI
|
|||||||
struct StatusWidgetBundle: WidgetBundle {
|
struct StatusWidgetBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
StatusWidget()
|
StatusWidget()
|
||||||
|
if #available(iOSApplicationExtension 16.1, *) {
|
||||||
|
TerminalLiveActivity()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
185
ios/StatusWidget/TerminalLiveActivity.swift
Normal file
185
ios/StatusWidget/TerminalLiveActivity.swift
Normal file
@@ -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
|
||||||
39
ios/StatusWidget/TerminalLiveActivityAttributes.swift
Normal file
39
ios/StatusWidget/TerminalLiveActivityAttributes.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
ios/StatusWidget/de.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/de.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/StatusWidget/en.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/en.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/StatusWidget/es.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/es.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/StatusWidget/fr.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/fr.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/StatusWidget/id.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/id.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/StatusWidget/ja.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/ja.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "ターミナル";
|
||||||
|
"Connected" = "接続済み";
|
||||||
|
"Connecting" = "接続中";
|
||||||
|
"Disconnected" = "切断";
|
||||||
|
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
||||||
|
"1 connection" = "1 件の接続";
|
||||||
|
"%d connections" = "%d 件の接続";
|
||||||
|
|
||||||
8
ios/StatusWidget/pt-BR.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/pt-BR.lproj/Localizable.strings
Normal file
@@ -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";
|
||||||
|
|
||||||
8
ios/StatusWidget/ru.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/ru.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "Терминал";
|
||||||
|
"Connected" = "Подключено";
|
||||||
|
"Connecting" = "Подключение";
|
||||||
|
"Disconnected" = "Отключено";
|
||||||
|
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
||||||
|
"1 connection" = "1 подключение";
|
||||||
|
"%d connections" = "%d подключений";
|
||||||
|
|
||||||
8
ios/StatusWidget/zh-Hans.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "终端";
|
||||||
|
"Connected" = "已连接";
|
||||||
|
"Connecting" = "连接中";
|
||||||
|
"Disconnected" = "已断开连接";
|
||||||
|
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
||||||
|
"1 connection" = "1 个连接";
|
||||||
|
"%d connections" = "%d 个连接";
|
||||||
|
|
||||||
8
ios/StatusWidget/zh-Hant.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"Terminal" = "終端機";
|
||||||
|
"Connected" = "已連線";
|
||||||
|
"Connecting" = "連線中";
|
||||||
|
"Disconnected" = "已中斷連線";
|
||||||
|
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
||||||
|
"1 connection" = "1 個連線";
|
||||||
|
"%d connections" = "%d 個連線";
|
||||||
|
|
||||||
@@ -12,19 +12,85 @@ abstract final class MethodChans {
|
|||||||
|
|
||||||
/// Issue #662
|
/// Issue #662
|
||||||
static void startService() {
|
static void startService() {
|
||||||
// if (Stores.setting.fgService.fetch() != true) return;
|
if (Stores.setting.fgService.fetch() != true) return;
|
||||||
// _channel.invokeMethod('startService');
|
_channel.invokeMethod('startService');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue #662
|
/// Issue #662
|
||||||
static void stopService() {
|
static void stopService() {
|
||||||
// if (Stores.setting.fgService.fetch() != true) return;
|
if (Stores.setting.fgService.fetch() != true) return;
|
||||||
// _channel.invokeMethod('stopService');
|
_channel.invokeMethod('stopService');
|
||||||
}
|
}
|
||||||
|
|
||||||
static void updateHomeWidget() async {
|
static void updateHomeWidget() async {
|
||||||
if (!isIOS || !isAndroid) return;
|
if (!isIOS && !isAndroid) return;
|
||||||
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
|
||||||
await _channel.invokeMethod('updateHomeWidget');
|
await _channel.invokeMethod('updateHomeWidget');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update Android foreground service notifications for SSH sessions
|
||||||
|
/// The [payload] is a JSON string describing sessions list.
|
||||||
|
static Future<void> 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<bool> 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<void> startLiveActivity(String payload) async {
|
||||||
|
if (!isIOS) return;
|
||||||
|
try {
|
||||||
|
Loggers.app.info('Starting iOS Live Activity: $payload');
|
||||||
|
await _channel.invokeMethod('startLiveActivity', payload);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> updateLiveActivity(String payload) async {
|
||||||
|
if (!isIOS) return;
|
||||||
|
try {
|
||||||
|
Loggers.app.info('Updating iOS Live Activity: $payload');
|
||||||
|
await _channel.invokeMethod('updateLiveActivity', payload);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> 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<void> 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/model/server/try_limiter.dart';
|
||||||
import 'package:server_box/data/res/status.dart';
|
import 'package:server_box/data/res/status.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
|
import 'package:server_box/data/ssh/session_manager.dart';
|
||||||
|
|
||||||
class ServerProvider extends Provider {
|
class ServerProvider extends Provider {
|
||||||
const ServerProvider._();
|
const ServerProvider._();
|
||||||
@@ -183,6 +184,10 @@ class ServerProvider extends Provider {
|
|||||||
for (final s in servers.values) {
|
for (final s in servers.values) {
|
||||||
s.value.conn = ServerConn.disconnected;
|
s.value.conn = ServerConn.disconnected;
|
||||||
s.notify();
|
s.notify();
|
||||||
|
|
||||||
|
// Update SSH session status to disconnected
|
||||||
|
final sessionId = 'ssh_${s.value.spi.id}';
|
||||||
|
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||||
}
|
}
|
||||||
//TryLimiter.clear();
|
//TryLimiter.clear();
|
||||||
}
|
}
|
||||||
@@ -209,6 +214,10 @@ class ServerProvider extends Provider {
|
|||||||
item.conn = ServerConn.disconnected;
|
item.conn = ServerConn.disconnected;
|
||||||
_manualDisconnectedIds.add(id);
|
_manualDisconnectedIds.add(id);
|
||||||
s.notify();
|
s.notify();
|
||||||
|
|
||||||
|
// Remove SSH session when server is manually closed
|
||||||
|
final sessionId = 'ssh_$id';
|
||||||
|
TermSessionManager.remove(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void addServer(Spi spi) {
|
static void addServer(Spi spi) {
|
||||||
@@ -229,10 +238,21 @@ class ServerProvider extends Provider {
|
|||||||
Stores.setting.serverOrder.put(serverOrder.value);
|
Stores.setting.serverOrder.put(serverOrder.value);
|
||||||
Stores.server.delete(id);
|
Stores.server.delete(id);
|
||||||
_updateTags();
|
_updateTags();
|
||||||
|
|
||||||
|
// Remove SSH session when server is deleted
|
||||||
|
final sessionId = 'ssh_$id';
|
||||||
|
TermSessionManager.remove(sessionId);
|
||||||
|
|
||||||
bakSync.sync(milliDelay: 1000);
|
bakSync.sync(milliDelay: 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void deleteAll() {
|
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();
|
servers.clear();
|
||||||
serverOrder.value.clear();
|
serverOrder.value.clear();
|
||||||
serverOrder.notify();
|
serverOrder.notify();
|
||||||
@@ -253,6 +273,11 @@ class ServerProvider extends Provider {
|
|||||||
serverOrder.value.update(old.id, newSpi.id);
|
serverOrder.value.update(old.id, newSpi.id);
|
||||||
Stores.setting.serverOrder.put(serverOrder.value);
|
Stores.setting.serverOrder.put(serverOrder.value);
|
||||||
serverOrder.notify();
|
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
|
// Only reconnect if neccessary
|
||||||
@@ -320,11 +345,26 @@ class ServerProvider extends Provider {
|
|||||||
} else {
|
} else {
|
||||||
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
|
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) {
|
} catch (e) {
|
||||||
TryLimiter.inc(sid);
|
TryLimiter.inc(sid);
|
||||||
sv.status.err = SSHErr(type: SSHErrType.connect, message: e.toString());
|
sv.status.err = SSHErr(type: SSHErrType.connect, message: e.toString());
|
||||||
_setServerState(s, ServerConn.failed);
|
_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]
|
/// In order to keep privacy, print [spi.name] instead of [spi.id]
|
||||||
Loggers.app.warning('Connect to ${spi.name} failed', e);
|
Loggers.app.warning('Connect to ${spi.name} failed', e);
|
||||||
return;
|
return;
|
||||||
@@ -332,6 +372,10 @@ class ServerProvider extends Provider {
|
|||||||
|
|
||||||
_setServerState(s, ServerConn.connected);
|
_setServerState(s, ServerConn.connected);
|
||||||
|
|
||||||
|
// Update SSH session status to connected
|
||||||
|
final sessionId = 'ssh_${spi.id}';
|
||||||
|
TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Detect system type using helper
|
// Detect system type using helper
|
||||||
final detectedSystemType = await SystemDetector.detect(sv.client!, spi);
|
final detectedSystemType = await SystemDetector.detect(sv.client!, spi);
|
||||||
@@ -352,6 +396,10 @@ class ServerProvider extends Provider {
|
|||||||
sv.status.err = err;
|
sv.status.err = err;
|
||||||
Loggers.app.warning(err);
|
Loggers.app.warning(err);
|
||||||
_setServerState(s, ServerConn.failed);
|
_setServerState(s, ServerConn.failed);
|
||||||
|
|
||||||
|
// Update SSH session status to disconnected
|
||||||
|
final sessionId = 'ssh_${spi.id}';
|
||||||
|
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||||
return;
|
return;
|
||||||
} on SSHAuthFailError catch (e) {
|
} on SSHAuthFailError catch (e) {
|
||||||
TryLimiter.inc(sid);
|
TryLimiter.inc(sid);
|
||||||
@@ -359,6 +407,10 @@ class ServerProvider extends Provider {
|
|||||||
sv.status.err = err;
|
sv.status.err = err;
|
||||||
Loggers.app.warning(err);
|
Loggers.app.warning(err);
|
||||||
_setServerState(s, ServerConn.failed);
|
_setServerState(s, ServerConn.failed);
|
||||||
|
|
||||||
|
// Update SSH session status to disconnected
|
||||||
|
final sessionId = 'ssh_${spi.id}';
|
||||||
|
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If max try times < 2 and can't write script, this will stop the status getting and etc.
|
// 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;
|
sv.status.err = err;
|
||||||
Loggers.app.warning(err);
|
Loggers.app.warning(err);
|
||||||
_setServerState(s, ServerConn.failed);
|
_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);
|
TryLimiter.inc(sid);
|
||||||
sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
|
sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
|
||||||
_setServerState(s, ServerConn.failed);
|
_setServerState(s, ServerConn.failed);
|
||||||
|
|
||||||
|
// Update SSH session status to disconnected on segments error
|
||||||
|
final sessionId = 'ssh_${spi.id}';
|
||||||
|
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -403,6 +463,10 @@ class ServerProvider extends Provider {
|
|||||||
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
|
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
|
||||||
_setServerState(s, ServerConn.failed);
|
_setServerState(s, ServerConn.failed);
|
||||||
Loggers.app.warning('Get status from ${spi.name} failed', e);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +486,10 @@ class ServerProvider extends Provider {
|
|||||||
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
|
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
|
||||||
_setServerState(s, ServerConn.failed);
|
_setServerState(s, ServerConn.failed);
|
||||||
Loggers.app.warning('Server status', e, trace);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
200
lib/data/ssh/session_manager.dart
Normal file
200
lib/data/ssh/session_manager.dart
Normal file
@@ -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<String, Object> 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<String, _Entry> _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<void> _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<void> _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<void> 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});
|
||||||
|
}
|
||||||
@@ -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/provider/snippet.dart';
|
||||||
import 'package:server_box/data/res/build_data.dart';
|
import 'package:server_box/data/res/build_data.dart';
|
||||||
import 'package:server_box/data/res/store.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/data/store/server.dart';
|
||||||
import 'package:server_box/hive/hive_registrar.g.dart';
|
import 'package:server_box/hive/hive_registrar.g.dart';
|
||||||
|
|
||||||
@@ -46,6 +47,9 @@ Future<void> _initApp() async {
|
|||||||
await _initWindow();
|
await _initWindow();
|
||||||
|
|
||||||
_doPlatformRelated();
|
_doPlatformRelated();
|
||||||
|
|
||||||
|
// Initialize Android session notification channel/handler
|
||||||
|
TermSessionManager.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initData() async {
|
Future<void> _initData() async {
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ extension _Init on SSHPageState {
|
|||||||
_listen(session.stdout);
|
_listen(session.stdout);
|
||||||
_listen(session.stderr);
|
_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) {
|
for (final snippet in SnippetProvider.snippets.value) {
|
||||||
if (snippet.autoRunOn?.contains(widget.args.spi.id) == true) {
|
if (snippet.autoRunOn?.contains(widget.args.spi.id) == true) {
|
||||||
snippet.runInTerm(_terminal, widget.args.spi);
|
snippet.runInTerm(_terminal, widget.args.spi);
|
||||||
@@ -85,6 +90,7 @@ extension _Init on SSHPageState {
|
|||||||
context.pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
widget.args.onSessionEnd?.call();
|
widget.args.onSessionEnd?.call();
|
||||||
|
TermSessionManager.remove(_sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _listen(Stream<Uint8List>? stream) {
|
void _listen(Stream<Uint8List>? stream) {
|
||||||
@@ -122,6 +128,7 @@ extension _Init on SSHPageState {
|
|||||||
_discontinuityTimer?.cancel();
|
_discontinuityTimer?.cancel();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_writeLn('\n\nConnection lost\r\n');
|
_writeLn('\n\nConnection lost\r\n');
|
||||||
|
TermSessionManager.updateStatus(_sessionId, TermSessionStatus.disconnected);
|
||||||
context.showRoundDialog(
|
context.showRoundDialog(
|
||||||
title: libL10n.attention,
|
title: libL10n.attention,
|
||||||
child: Text('${l10n.disconnected}\n${l10n.goBackQ}'),
|
child: Text('${l10n.disconnected}\n${l10n.goBackQ}'),
|
||||||
@@ -139,3 +146,17 @@ extension _Init on SSHPageState {
|
|||||||
_terminal.write('$p0\r\n');
|
_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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/provider/virtual_keyboard.dart';
|
||||||
import 'package:server_box/data/res/store.dart';
|
import 'package:server_box/data/res/store.dart';
|
||||||
import 'package:server_box/data/res/terminal.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:server_box/view/page/storage/sftp.dart';
|
||||||
|
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
@@ -81,10 +82,13 @@ class SSHPageState extends State<SSHPage>
|
|||||||
bool _isDark = false;
|
bool _isDark = false;
|
||||||
Timer? _virtKeyLongPressTimer;
|
Timer? _virtKeyLongPressTimer;
|
||||||
late SSHClient? _client = widget.args.spi.server?.value.client;
|
late SSHClient? _client = widget.args.spi.server?.value.client;
|
||||||
|
SSHSession? _session;
|
||||||
Timer? _discontinuityTimer;
|
Timer? _discontinuityTimer;
|
||||||
|
|
||||||
/// Used for (de)activate the wake lock and forground service
|
/// Used for (de)activate the wake lock and forground service
|
||||||
static var _sshConnCount = 0;
|
static var _sshConnCount = 0;
|
||||||
|
late final String _sessionId = ShortId.generate();
|
||||||
|
late final int _sessionStartMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -101,6 +105,9 @@ class SSHPageState extends State<SSHPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove session entry
|
||||||
|
TermSessionManager.remove(_sessionId);
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +124,16 @@ class SSHPageState extends State<SSHPage>
|
|||||||
MethodChans.startService();
|
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
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user