Compare commits

...

14 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
fcb3d7e2b3 bump: v1220 2025-08-18 12:21:52 +08:00
lollipopkit🏳️‍⚧️
f5634d6e88 bump: v1218 2025-08-18 12:08:29 +08:00
lollipopkit🏳️‍⚧️
5497ad83e0 opt.: bio auth settings 2025-08-17 17:56:26 +08:00
dsvf
4a7827f41a Delay bio auth (#642) 2025-08-17 14:06:24 +08:00
lollipopkit🏳️‍⚧️
60671fe461 feat: native widget url settings dialog (#856) 2025-08-16 23:07:19 +08:00
lollipopkit🏳️‍⚧️
bc1b6e5a4a feat: GitHub Gist sync (#854) 2025-08-14 23:21:33 +08:00
lollipopkit🏳️‍⚧️
1d553eccd5 rm: claude pr review 2025-08-13 23:33:03 +08:00
lollipopkit🏳️‍⚧️
68734a9e52 fix: disable command menu doesnt work (#852) 2025-08-13 23:32:22 +08:00
lollipopkit🏳️‍⚧️
ed8a1d18b9 opt.: systemd page (#851) 2025-08-13 22:16:55 +08:00
shamnad-sherief
e4a9875620 fix: Systemd shows nothing (#850) 2025-08-13 20:19:28 +08:00
lollipopkit🏳️‍⚧️
6f9aa2ece9 add: Claude Code GitHub Workflow (#849)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2025-08-13 15:23:30 +08:00
lollipopkit🏳️‍⚧️
13e28675af opt.: watchOS & iOS widget (#847) 2025-08-13 01:44:02 +08:00
lollipopkit🏳️‍⚧️
8c0e0f89d5 fix: term opening on Linux (#845) 2025-08-12 23:55:31 +08:00
lollipopkit🏳️‍⚧️
9b01da5a23 feat: term session mgr (#846) 2025-08-12 23:43:42 +08:00
104 changed files with 4533 additions and 956 deletions

66
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
github.actor == 'lollipopkit' && (
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
# model: "claude-opus-4-1-20250805"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View File

@@ -20,7 +20,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.32.2"
flutter-version: "3.35.1"
- uses: actions/setup-java@v4
with:
distribution: "zulu"
@@ -58,24 +58,17 @@ jobs:
run: |
sudo apt update
# Basic
sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev libvulkan-dev desktop-file-utils wget
sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev mesa-utils libvulkan-dev desktop-file-utils wget
# App Specific
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev
# Packaging
sudo wget https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage -O /bin/appimagetool
sudo chmod +x /bin/appimagetool
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev libsecret-1-dev
- name: Build
run: |
dart run fl_build -p linux
- name: Rename artifacts
run: |
appimage_name=$(ls dist/*/*.AppImage)
mv $appimage_name ${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.appimage
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.appimage
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -46,6 +46,15 @@
android:name="flutterEmbedding"
android:value="2" />
<activity
android:name=".widget.WidgetConfigureActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.Dialog">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver
android:name=".widget.HomeWidget"
android:exported="false"

View File

@@ -5,11 +5,28 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.*
class ForegroundService : Service() {
companion object {
@Volatile
var isRunning: Boolean = false
}
private val chanId = "ForegroundServiceChannel"
private val GROUP_KEY = "ssh_sessions_group"
private val SUMMARY_ID = 1000
private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND"
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
private var isFgStarted = false
private val postedIds = mutableSetOf<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) {
Log.e("ForegroundService", message, error)
@@ -26,6 +43,7 @@ class ForegroundService : Service() {
override fun onCreate() {
super.onCreate()
Log.d("ForegroundService", "Service onCreate")
isRunning = true
createNotificationChannel()
}
@@ -50,24 +68,20 @@ class ForegroundService : Service() {
val action = intent.action
Log.d("ForegroundService", "onStartCommand action=$action")
// Create notification before starting foreground
val notification = createNotification()
// Use try-catch for startForeground
try {
startForeground(1, notification)
} catch (e: Exception) {
logError("Failed to start foreground", e)
stopSelf()
return START_NOT_STICKY
}
return when (action) {
"ACTION_STOP_FOREGROUND" -> {
ACTION_STOP_FOREGROUND -> {
clearAll()
stopForegroundService()
START_NOT_STICKY
}
ACTION_UPDATE_SESSIONS -> {
val payload = intent.getStringExtra("payload") ?: "{}"
handleUpdateSessions(payload)
START_STICKY
}
else -> {
// Default bring up foreground with placeholder
ensureForeground(createSummaryNotification(0, emptyList()))
START_STICKY
}
}
@@ -101,24 +115,99 @@ class ForegroundService : Service() {
}
}
private fun createNotification(): Notification {
private fun ensureForeground(notification: Notification) {
try {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE
)
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
action = "ACTION_STOP_FOREGROUND"
if (!isFgStarted) {
startForeground(SUMMARY_ID, notification)
isFgStarted = true
} else {
val nm = getSystemService(NotificationManager::class.java)
nm?.notify(SUMMARY_ID, notification)
}
val deletePendingIntent = PendingIntent.getService(
this,
0,
deleteIntent,
PendingIntent.FLAG_IMMUTABLE
} catch (e: Exception) {
logError("Failed to start/update foreground", e)
}
}
private fun createSummaryNotification(count: Int, lines: List<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) {
@@ -127,23 +216,60 @@ class ForegroundService : Service() {
Notification.Builder(this)
}
return builder
.setContentTitle("Server Box")
.setContentText("Running in background")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
.build()
} catch (e: Exception) {
logError("Error creating notification", e)
// Return a basic notification as fallback
return Notification.Builder(this)
.setContentTitle("Server Box")
val noti = builder
.setContentTitle(s.title)
.setContentText("${s.subtitle} · ${s.status}")
.setSmallIcon(R.mipmap.ic_launcher)
.setWhen(s.startWhen)
.setUsesChronometer(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.addAction(android.R.drawable.ic_media_pause, "Disconnect", disconnectPending)
.build()
nm.notify(nid, noti)
}
// Cancel stale ones
val toCancel = postedIds - currentIds
toCancel.forEach { nm.cancel(it) }
// Clean up id mappings for canceled notifications to prevent growth
if (toCancel.isNotEmpty()) {
val keysToRemove = notificationIdMap.filterValues { it in toCancel }.keys
keysToRemove.forEach { notificationIdMap.remove(it) }
}
postedIds.clear()
postedIds.addAll(currentIds)
// Post/update summary and ensure foreground
val maxSummaryLines = 5
val truncated = summaryLines.size > maxSummaryLines
val displaySummaryLines = if (truncated) {
summaryLines.take(maxSummaryLines) + "...and ${summaryLines.size - maxSummaryLines} more"
} else {
summaryLines
}
val summary = createSummaryNotification(sessions.size, displaySummaryLines)
ensureForeground(summary)
}
private fun clearAll() {
val nm = getSystemService(NotificationManager::class.java)
nm?.cancel(SUMMARY_ID)
postedIds.forEach { id -> nm?.cancel(id) }
postedIds.clear()
isFgStarted = false
}
data class SessionItem(
val id: String,
val title: String,
val subtitle: String,
val startWhen: Long,
val status: String,
)
private fun stopForegroundService() {
try {
stopForeground(true)
@@ -157,5 +283,6 @@ class ForegroundService : Service() {
override fun onDestroy() {
super.onDestroy()
Log.d("ForegroundService", "Service onDestroy")
isRunning = false
}
}
}

View File

@@ -13,20 +13,32 @@ import android.appwidget.AppWidgetManager
import tech.lolli.toolbox.widget.HomeWidget
class MainActivity: FlutterFragmentActivity() {
private lateinit var channel: MethodChannel
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan").apply {
setMethodCallHandler { method, result ->
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
channel.setMethodCallHandler { method, result ->
when (method.method) {
"sendToBackground" -> {
moveTaskToBack(true)
result.success(null)
}
"isServiceRunning" -> {
result.success(ForegroundService.isRunning)
}
"startService" -> {
try {
reqPerm()
if (!notificationsAllowed()) {
// Don't start foreground service without notification permission on API 33+
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
return@setMethodCallHandler
}
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
@@ -51,12 +63,35 @@ class MainActivity: FlutterFragmentActivity() {
sendBroadcast(intent)
result.success(null)
}
"updateSessions" -> {
try {
if (!notificationsAllowed()) {
// Avoid starting/continuing service updates when notifications are blocked
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
return@setMethodCallHandler
}
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
serviceIntent.action = ACTION_UPDATE_SESSIONS
serviceIntent.putExtra("payload", method.arguments as String)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
result.success(null)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to update sessions: ${e.message}")
result.error("SERVICE_ERROR", e.message, null)
}
}
else -> {
result.notImplemented()
}
}
}
}
// Handle intent if launched via notification action
handleActionIntent(intent)
}
private fun reqPerm() {
@@ -77,5 +112,33 @@ class MainActivity: FlutterFragmentActivity() {
}
}
}
}
private fun notificationsAllowed(): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
true
} else {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleActionIntent(intent)
}
private fun handleActionIntent(intent: Intent?) {
if (intent == null) return
when (intent.action) {
ACTION_DISCONNECT_SESSION -> {
val sessionId = intent.getStringExtra("session_id")
if (sessionId != null && ::channel.isInitialized) {
try {
channel.invokeMethod("disconnectSession", mapOf("id" to sessionId))
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to invoke disconnect: ${e.message}")
}
}
}
}
}
}

View File

@@ -13,13 +13,24 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
import org.json.JSONException
import tech.lolli.toolbox.R
import java.net.URL
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.io.FileNotFoundException
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
class HomeWidget : AppWidgetProvider() {
companion object {
private const val TAG = "HomeWidget"
private const val NETWORK_TIMEOUT = 10_000L // 10 seconds
private const val COROUTINE_TIMEOUT = 15_000L // 15 seconds
private val activeUpdates = ConcurrentHashMap<Int, Boolean>()
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
@@ -27,105 +38,184 @@ class HomeWidget : AppWidgetProvider() {
}
private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
val views = RemoteViews(context.packageName, R.layout.home_widget)
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
var url = sp.getString("widget_$appWidgetId", null)
if (url.isNullOrEmpty()) {
url = sp.getString("$appWidgetId", null)
}
if (url.isNullOrEmpty()) {
val gUrl = sp.getString("widget_*", null)
url = gUrl
}
if (url.isNullOrEmpty()) {
Log.e("HomeWidget", "URL not found")
}
val intentUpdate = Intent(context, HomeWidget::class.java)
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val ids = intArrayOf(appWidgetId)
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
var flag = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
flag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
}
val pendingUpdate: PendingIntent = PendingIntent.getBroadcast(
context,
appWidgetId,
intentUpdate,
flag)
views.setOnClickPendingIntent(R.id.widget_container, pendingUpdate)
if (url.isNullOrEmpty()) {
views.setTextViewText(R.id.widget_name, "No URL")
// Update the widget to display a message for missing URL
views.setViewVisibility(R.id.error_message, View.VISIBLE)
views.setTextViewText(R.id.error_message, "Please configure the widget URL.")
views.setViewVisibility(R.id.widget_content, View.GONE)
views.setFloat(R.id.widget_name, "setAlpha", 1f)
views.setFloat(R.id.error_message, "setAlpha", 1f)
appWidgetManager.updateAppWidget(appWidgetId, views)
// Prevent concurrent updates for the same widget
if (activeUpdates.putIfAbsent(appWidgetId, true) == true) {
Log.d(TAG, "Widget $appWidgetId is already updating, skipping")
return
} else {
views.setViewVisibility(R.id.widget_cpu_label, View.VISIBLE)
views.setViewVisibility(R.id.widget_mem_label, View.VISIBLE)
views.setViewVisibility(R.id.widget_disk_label, View.VISIBLE)
views.setViewVisibility(R.id.widget_net_label, View.VISIBLE)
}
val views = RemoteViews(context.packageName, R.layout.home_widget)
val url = getWidgetUrl(context, appWidgetId)
if (url.isNullOrEmpty()) {
Log.w(TAG, "URL not found for widget $appWidgetId")
showErrorState(views, appWidgetManager, appWidgetId, "Please configure the widget URL.")
activeUpdates.remove(appWidgetId)
return
}
setupClickIntent(context, views, appWidgetId)
showLoadingState(views, appWidgetManager, appWidgetId)
CoroutineScope(Dispatchers.IO).launch {
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
val jsonStr = connection.inputStream.bufferedReader().use { it.readText() }
val jsonObject = JSONObject(jsonStr)
val data = jsonObject.getJSONObject("data")
val server = data.getString("name")
val cpu = data.getString("cpu")
val mem = data.getString("mem")
val disk = data.getString("disk")
val net = data.getString("net")
withContext(Dispatchers.Main) {
if (mem.isEmpty() || disk.isEmpty()) {
Log.e("HomeWidget", "Failed to retrieve status: Memory or disk information is empty")
return@withContext
withTimeoutOrNull(COROUTINE_TIMEOUT) {
try {
val serverData = fetchServerData(url)
if (serverData != null) {
withContext(Dispatchers.Main) {
showSuccessState(views, appWidgetManager, appWidgetId, serverData)
}
} else {
withContext(Dispatchers.Main) {
showErrorState(views, appWidgetManager, appWidgetId, "Invalid server data received.")
}
views.setTextViewText(R.id.widget_name, server)
views.setTextViewText(R.id.widget_cpu, cpu)
views.setTextViewText(R.id.widget_mem, mem)
views.setTextViewText(R.id.widget_disk, disk)
views.setTextViewText(R.id.widget_net, net)
val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString()
views.setTextViewText(R.id.widget_time, timeStr)
views.setFloat(R.id.widget_name, "setAlpha", 1f)
views.setFloat(R.id.widget_cpu_label, "setAlpha", 1f)
views.setFloat(R.id.widget_mem_label, "setAlpha", 1f)
views.setFloat(R.id.widget_disk_label, "setAlpha", 1f)
views.setFloat(R.id.widget_net_label, "setAlpha", 1f)
views.setFloat(R.id.widget_time, "setAlpha", 1f)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
} else {
throw FileNotFoundException("HTTP response code: $responseCode")
} catch (e: Exception) {
Log.e(TAG, "Error updating widget $appWidgetId: ${e.message}", e)
withContext(Dispatchers.Main) {
val errorMessage = when (e) {
is SocketTimeoutException -> "Connection timeout. Please check your network."
is IOException -> "Network error. Please check your connection."
is JSONException -> "Invalid data format received from server."
else -> "Failed to retrieve data: ${e.message}"
}
showErrorState(views, appWidgetManager, appWidgetId, errorMessage)
}
}
} catch (e: Exception) {
Log.e("HomeWidget", "Error updating widget: ${e.localizedMessage}", e)
} ?: run {
Log.w(TAG, "Widget update timed out for widget $appWidgetId")
withContext(Dispatchers.Main) {
views.setTextViewText(R.id.widget_name, "Error")
// Update the widget to display a message for data retrieval failure
views.setViewVisibility(R.id.error_message, View.VISIBLE)
views.setTextViewText(R.id.error_message, "Failed to retrieve data.")
views.setViewVisibility(R.id.widget_content, View.GONE)
views.setFloat(R.id.widget_name, "setAlpha", 1f)
views.setFloat(R.id.error_message, "setAlpha", 1f)
appWidgetManager.updateAppWidget(appWidgetId, views)
showErrorState(views, appWidgetManager, appWidgetId, "Update timed out. Please try again.")
}
}
activeUpdates.remove(appWidgetId)
}
}
private fun getWidgetUrl(context: Context, appWidgetId: Int): String? {
val sp = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
return sp.getString("widget_$appWidgetId", null)
?: sp.getString("$appWidgetId", null)
?: sp.getString("widget_*", null)
}
private fun setupClickIntent(context: Context, views: RemoteViews, appWidgetId: Int) {
val intentConfigure = Intent(context, WidgetConfigureActivity::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingConfigure = PendingIntent.getActivity(context, appWidgetId, intentConfigure, flag)
views.setOnClickPendingIntent(R.id.widget_container, pendingConfigure)
}
private suspend fun fetchServerData(url: String): ServerData? = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = NETWORK_TIMEOUT.toInt()
readTimeout = NETWORK_TIMEOUT.toInt()
setRequestProperty("User-Agent", "ServerBox-Widget/1.0")
setRequestProperty("Accept", "application/json")
}
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("HTTP ${connection.responseCode}: ${connection.responseMessage}")
}
val jsonStr = connection.inputStream.bufferedReader().use { it.readText() }
parseServerData(jsonStr)
} finally {
connection?.disconnect()
}
}
private fun parseServerData(jsonStr: String): ServerData? {
return try {
val jsonObject = JSONObject(jsonStr)
val data = jsonObject.getJSONObject("data")
val server = data.optString("name", "Unknown Server")
val cpu = data.optString("cpu", "").takeIf { it.isNotBlank() } ?: "N/A"
val mem = data.optString("mem", "").takeIf { it.isNotBlank() } ?: "N/A"
val disk = data.optString("disk", "").takeIf { it.isNotBlank() } ?: "N/A"
val net = data.optString("net", "").takeIf { it.isNotBlank() } ?: "N/A"
// Return data even if some fields are missing, providing defaults
// Only reject if we can't parse the JSON structure properly
ServerData(server, cpu, mem, disk, net)
} catch (e: JSONException) {
Log.e(TAG, "JSON parsing error: ${e.message}", e)
null
}
}
private fun showLoadingState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int) {
views.apply {
setTextViewText(R.id.widget_name, "Loading...")
setViewVisibility(R.id.error_message, View.GONE)
setViewVisibility(R.id.widget_content, View.VISIBLE)
setViewVisibility(R.id.widget_cpu_label, View.VISIBLE)
setViewVisibility(R.id.widget_mem_label, View.VISIBLE)
setViewVisibility(R.id.widget_disk_label, View.VISIBLE)
setViewVisibility(R.id.widget_net_label, View.VISIBLE)
setViewVisibility(R.id.widget_progress, View.VISIBLE)
setFloat(R.id.widget_name, "setAlpha", 0.7f)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private fun showSuccessState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, data: ServerData) {
views.apply {
setTextViewText(R.id.widget_name, data.name)
setTextViewText(R.id.widget_cpu, data.cpu)
setTextViewText(R.id.widget_mem, data.mem)
setTextViewText(R.id.widget_disk, data.disk)
setTextViewText(R.id.widget_net, data.net)
val timeStr = android.text.format.DateFormat.format("HH:mm", java.util.Date()).toString()
setTextViewText(R.id.widget_time, timeStr)
setViewVisibility(R.id.error_message, View.GONE)
setViewVisibility(R.id.widget_content, View.VISIBLE)
setViewVisibility(R.id.widget_progress, View.GONE)
// Smooth fade-in animation
setFloat(R.id.widget_name, "setAlpha", 1f)
setFloat(R.id.widget_cpu_label, "setAlpha", 1f)
setFloat(R.id.widget_mem_label, "setAlpha", 1f)
setFloat(R.id.widget_disk_label, "setAlpha", 1f)
setFloat(R.id.widget_net_label, "setAlpha", 1f)
setFloat(R.id.widget_time, "setAlpha", 1f)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private fun showErrorState(views: RemoteViews, appWidgetManager: AppWidgetManager, appWidgetId: Int, errorMessage: String) {
views.apply {
setTextViewText(R.id.widget_name, "Error")
setViewVisibility(R.id.error_message, View.VISIBLE)
setTextViewText(R.id.error_message, errorMessage)
setViewVisibility(R.id.widget_content, View.GONE)
setViewVisibility(R.id.widget_progress, View.GONE)
setFloat(R.id.widget_name, "setAlpha", 1f)
setFloat(R.id.error_message, "setAlpha", 1f)
}
appWidgetManager.updateAppWidget(appWidgetId, views)
}
data class ServerData(
val name: String,
val cpu: String,
val mem: String,
val disk: String,
val net: String
)
}

View File

@@ -0,0 +1,82 @@
package tech.lolli.toolbox.widget
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.util.Patterns
import android.widget.Button
import android.widget.EditText
import tech.lolli.toolbox.R
class WidgetConfigureActivity : Activity() {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
private lateinit var urlEditText: EditText
private lateinit var saveButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.widget_configure)
// 设置结果为取消,以防用户在完成配置前退出
setResult(RESULT_CANCELED)
// 获取 widget ID
val extras = intent.extras
if (extras != null) {
appWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
)
}
// 如果没有有效的 widget ID完成 activity
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
// 初始化 UI 元素
urlEditText = findViewById(R.id.url_edit_text)
saveButton = findViewById(R.id.save_button)
// 从 SharedPreferences 加载现有配置
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val existingUrl = sp.getString("widget_$appWidgetId", "")
urlEditText.setText(existingUrl)
// 设置保存按钮点击事件
saveButton.setOnClickListener {
val url = urlEditText.text.toString().trim()
if (url.isEmpty()) {
urlEditText.error = "Please enter a URL"
return@setOnClickListener
}
// 验证 URL 格式
if (!Patterns.WEB_URL.matcher(url).matches()) {
urlEditText.error = "Please enter a valid URL"
return@setOnClickListener
}
// 保存 URL 到 SharedPreferences
val editor = sp.edit()
editor.putString("widget_$appWidgetId", url)
editor.apply()
// 更新 widget 使用 AppWidgetManager
val appWidgetManager = AppWidgetManager.getInstance(this)
val updateIntent = Intent(this, HomeWidget::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
}
sendBroadcast(updateIntent)
// 设置结果并结束 activity
val resultValue = Intent()
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, resultValue)
finish()
}
}
}

View File

@@ -10,14 +10,17 @@
<TextView
android:id="@+id/widget_name"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/widgetText"
android:textSize="23sp"
android:textSize="20sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
android:alpha="0"
android:animateLayoutChanges="true"
android:fadingEdge="horizontal"
android:singleLine="true"
tools:text="Server Name" />
<!-- Wrap the content in a LinearLayout for easy visibility management -->
@@ -27,121 +30,138 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_below="@id/widget_name"
android:paddingTop="13dp">
android:layout_marginTop="8dp">
<RelativeLayout
android:id="@+id/widget_container_inner"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:paddingTop="13dp"
android:layout_height="wrap_content"
android:animateLayoutChanges="true">
<LinearLayout
android:id="@+id/widget_cpu_label"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="2.7dp"
android:layout_marginBottom="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
android:orientation="horizontal"
android:alpha="0"
android:animateLayoutChanges="true">
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:src="@drawable/speed_24">
</ImageView>
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/speed_24"
android:layout_gravity="center_vertical"
android:contentDescription="CPU usage" />
<TextView
android:id="@+id/widget_cpu"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="11dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:singleLine="true"
android:ellipsize = "marquee"
android:ellipsize="end"
android:textColor="@color/widgetSummaryText"
android:textSize="12.7sp"
tools:text="CPU" />
android:textSize="12sp"
tools:text="CPU: 25.6%" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_mem_label"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="2.7dp"
android:layout_marginBottom="4dp"
android:layout_below="@id/widget_cpu_label"
android:gravity="center_vertical"
android:orientation="horizontal">
android:orientation="horizontal"
android:alpha="0"
android:animateLayoutChanges="true">
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:src="@drawable/memory_24">
</ImageView>
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/memory_24"
android:layout_gravity="center_vertical"
android:contentDescription="Memory usage" />
<TextView
android:id="@+id/widget_mem"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="11dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:maxLines="1"
android:ellipsize="end"
android:textColor="@color/widgetSummaryText"
android:textSize="12.7sp"
tools:text="Mem" />
android:textSize="12sp"
tools:text="Memory: 4.2GB / 8GB" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_disk_label"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="2.7dp"
android:layout_marginBottom="4dp"
android:layout_below="@id/widget_mem_label"
android:gravity="center_vertical"
android:orientation="horizontal">
android:orientation="horizontal"
android:alpha="0"
android:animateLayoutChanges="true">
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:src="@drawable/storage_24">
</ImageView>
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/storage_24"
android:layout_gravity="center_vertical"
android:contentDescription="Disk usage" />
<TextView
android:id="@+id/widget_disk"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="11dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:maxLines="1"
android:ellipsize="end"
android:textColor="@color/widgetSummaryText"
android:textSize="12.7sp"
tools:text="Disk" />
android:textSize="12sp"
tools:text="Disk: 125GB / 250GB" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_net_label"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/widget_disk_label"
android:gravity="center_vertical"
android:orientation="horizontal">
android:orientation="horizontal"
android:alpha="0"
android:animateLayoutChanges="true">
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:src="@drawable/net_24">
</ImageView>
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/net_24"
android:layout_gravity="center_vertical"
android:contentDescription="Network usage" />
<TextView
android:id="@+id/widget_net"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="11dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:maxLines="1"
android:ellipsize="end"
android:textColor="@color/widgetSummaryText"
android:textSize="12.7sp"
tools:text="Net" />
android:textSize="12sp"
tools:text="Network: 15MB/s ↓ 8MB/s ↑" />
</LinearLayout>
@@ -149,29 +169,45 @@
</LinearLayout>
<!-- Add a TextView for error messages -->
<!-- Error message display -->
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/widget_name"
android:layout_marginTop="8dp"
android:textColor="@color/widgetSummaryText"
android:textSize="12sp"
android:textSize="11sp"
android:visibility="gone"
android:alpha="0"
android:animateLayoutChanges="true"
tools:text="Error message" />
android:lineSpacingMultiplier="1.2"
android:maxLines="3"
android:ellipsize="end"
tools:text="Error message text that might be longer than usual" />
<TextView
android:id="@+id/widget_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:maxLines="2"
android:layout_alignParentEnd="true"
android:maxLines="1"
android:textColor="@color/widgetSummaryText"
android:textSize="11sp"
android:textSize="10sp"
android:alpha="0"
android:animateLayoutChanges="true"
tools:text="UpdateTime" />
android:fontFamily="monospace"
tools:text="12:34" />
<!-- Progress indicator for loading state -->
<ProgressBar
android:id="@+id/widget_progress"
style="?android:attr/progressBarStyleLarge"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_centerInParent="true"
android:visibility="gone"
android:indeterminate="true" />
</RelativeLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Widget URL"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="16dp"
android:textColor="@android:color/black" />
<EditText
android:id="@+id/url_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="https://server/status"
android:inputType="textUri"
android:layout_marginBottom="16dp"
android:background="@android:drawable/edit_text"
android:padding="12dp"
android:textColor="@android:color/black"
android:textColorHint="@android:color/darker_gray" />
<Button
android:id="@+id/save_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save"
android:background="#8b2252"
android:textColor="@android:color/white"
android:padding="12dp" />
</LinearLayout>

View File

@@ -6,6 +6,7 @@
android:minHeight="110dp"
android:updatePeriodMillis="1800001"
android:initialLayout="@layout/home_widget"
android:configure="tech.lolli.toolbox.widget.WidgetConfigureActivity"
android:resizeMode="none"
android:widgetCategory="home_screen">
</appwidget-provider>

View File

@@ -1 +0,0 @@
extensions:

View File

@@ -1,13 +0,0 @@
variables:
output: dist/
releases:
- name: linux
jobs:
- name: release-linux-deb
package:
platform: linux
target: deb
- name: release-linux-rpm
package:
platform: linux
target: rpm

View File

@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<string>13.0</string>
</dict>
</plist>

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -1,5 +1,5 @@
PODS:
- app_links (0.0.2):
- app_links (6.4.1):
- Flutter
- camera_avfoundation (0.0.1):
- Flutter
@@ -87,14 +87,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/watch_connectivity/ios"
SPEC CHECKSUMS:
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436
file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
@@ -104,6 +104,6 @@ SPEC CHECKSUMS:
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6
PODFILE CHECKSUM: ec6ef69056f066e8b21a3391082f23b5ad2d37f8
PODFILE CHECKSUM: 5a0fb6438066e44ab2c77bd223668d351b8d8461
COCOAPODS: 1.16.2

View File

@@ -9,6 +9,10 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; };
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */; };
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; };
@@ -36,6 +40,8 @@
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; };
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */; };
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -95,6 +101,10 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
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>"; };
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>"; };
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>"; };
@@ -156,6 +166,26 @@
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>"; };
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 */
/* Begin PBXFrameworksBuildPhase section */
@@ -233,6 +263,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */,
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -242,6 +273,8 @@
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */,
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
E3AE8AE92AB601DB000A6459 /* Utils.swift */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -263,8 +296,11 @@
E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
isa = PBXGroup;
children = (
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */,
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */,
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */,
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
E33A3E442A626DD0009744AB /* Info.plist */,
@@ -412,6 +448,7 @@
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -420,6 +457,7 @@
buildActionMask = 2147483647;
files = (
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -516,6 +554,8 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */,
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -525,6 +565,8 @@
files = (
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */,
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
);
@@ -610,6 +652,40 @@
name = LaunchScreen.storyboard;
sourceTree = "<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 */
/* Begin XCBuildConfiguration section */
@@ -655,7 +731,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -672,17 +748,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -739,7 +815,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -789,7 +865,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -808,17 +884,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -836,17 +912,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -867,7 +943,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -880,7 +956,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
@@ -906,7 +982,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -919,7 +995,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -942,7 +1018,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -955,7 +1031,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -978,7 +1054,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -990,7 +1066,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
@@ -1019,7 +1095,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1031,7 +1107,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;
@@ -1057,7 +1133,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206;
CURRENT_PROJECT_VERSION = 1220;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1069,7 +1145,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1206;
MARKETING_VERSION = 1.0.1220;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;

View File

@@ -1,6 +1,7 @@
import UIKit
import WidgetKit
import Flutter
import ActivityKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -11,14 +12,48 @@ import Flutter
GeneratedPluginRegistrant.register(with: self)
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let methodChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
methodChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
// Home widget channel (legacy)
let homeWidgetChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: controller.binaryMessenger)
homeWidgetChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
if call.method == "update" {
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
}
}
})
// Main channel for cross-platform calls (incl. Live Activities)
let mainChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/main_chan", binaryMessenger: controller.binaryMessenger)
mainChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
case "updateHomeWidget":
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadTimelines(ofKind: "StatusWidget")
}
result(nil)
case "startLiveActivity":
if #available(iOS 16.2, *) {
if let payload = call.arguments as? String {
LiveActivityManager.start(json: payload)
}
}
result(nil)
case "updateLiveActivity":
if #available(iOS 16.2, *) {
if let payload = call.arguments as? String {
LiveActivityManager.update(json: payload)
}
}
result(nil)
case "stopLiveActivity":
if #available(iOS 16.2, *) {
LiveActivityManager.stop()
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
@@ -30,4 +65,11 @@ import Flutter
}
return true
}
override func applicationWillTerminate(_ application: UIApplication) {
// Stop Live Activity when app is about to terminate
if #available(iOS 16.2, *) {
LiveActivityManager.stop()
}
}
}

View File

@@ -41,6 +41,8 @@
<array>
<string>ConfigurationIntent</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
@@ -78,4 +80,4 @@
<key>NSPhotoLibraryUsageDescription</key>
<string>Get QR code and etc.</string>
</dict>
</plist>
</plist>

View File

@@ -17,6 +17,8 @@
<string>en</string>
<string>zh</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>

View File

@@ -17,6 +17,8 @@
<string>en</string>
<string>zh</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>CFBundleName</key>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
@@ -68,4 +70,4 @@
<key>NSPhotoLibraryUsageDescription</key>
<string>Get QR code and etc.</string>
</dict>
</plist>
</plist>

View 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
}
}
}

View 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
}
}

View 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";

View 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";

View 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";

View 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";

View 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";

View File

@@ -0,0 +1,8 @@
"Terminal" = "ターミナル";
"Connected" = "接続済み";
"Connecting" = "接続中";
"Disconnected" = "切断";
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
"1 connection" = "1 件の接続";
"%d connections" = "%d 件の接続";

View 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";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Терминал";
"Connected" = "Подключено";
"Connecting" = "Подключение";
"Disconnected" = "Отключено";
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
"1 connection" = "1 подключение";
"%d connections" = "%d подключений";

View File

@@ -0,0 +1,8 @@
"Terminal" = "终端";
"Connected" = "已连接";
"Connecting" = "连接中";
"Disconnected" = "已断开连接";
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
"1 connection" = "1 个连接";
"%d connections" = "%d 个连接";

View File

@@ -0,0 +1,8 @@
"Terminal" = "終端機";
"Connected" = "已連線";
"Connecting" = "連線中";
"Disconnected" = "已中斷連線";
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
"1 connection" = "1 個連線";
"%d connections" = "%d 個連線";

View File

@@ -4,6 +4,15 @@
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupportedIntents</key>
<array>
<string>ConfigurationIntent</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>

View File

@@ -15,6 +15,142 @@ let demoStatus = Status(name: "Server", cpu: "31.7%", mem: "1.3g / 1.9g", disk:
let domain = "com.lollipopkit.toolbox"
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white)
// Widget-specific constants
enum WidgetConstants {
enum Dimensions {
static let smallGauge: CGFloat = 56
static let mediumGauge: CGFloat = 64
static let largeGauge: CGFloat = 76
static let refreshIconSmall: CGFloat = 12
static let refreshIconLarge: CGFloat = 14
static let cornerRadius: CGFloat = 12
static let shadowRadius: CGFloat = 2
}
enum Thresholds {
static let warningThreshold: Double = 0.6
static let criticalThreshold: Double = 0.85
}
enum Spacing {
static let tight: CGFloat = 4
static let normal: CGFloat = 8
static let loose: CGFloat = 12
static let extraLoose: CGFloat = 16
}
enum Colors {
static let cardBackground = Color(.systemBackground)
static let secondaryText = Color(.secondaryLabel)
static let success = Color(.systemGreen)
static let warning = Color(.systemOrange)
static let critical = Color(.systemRed)
static let accent = Color(.systemBlue)
}
static let appGroupId = "group.com.lollipopkit.toolbox"
}
// Performance optimization: cache parsed values
struct ParseCache {
private static var percentCache: [String: Double] = [:]
private static var usagePercentCache: [String: Double] = [:]
static func parsePercent(_ text: String) -> Double {
if let cached = percentCache[text] { return cached }
let trimmed = text.trimmingCharacters(in: CharacterSet(charactersIn: "% "))
let result = Double(trimmed).map { max(0, min(1, $0 / 100.0)) } ?? 0
percentCache[text] = result
return result
}
static func parseUsagePercent(_ text: String) -> Double {
if let cached = usagePercentCache[text] { return cached }
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count == 2 else { return 0 }
let used = PerformanceUtils.parseSizeToBytes(parts[0])
let total = PerformanceUtils.parseSizeToBytes(parts[1])
let result = total <= 0 ? 0 : max(0, min(1, used / total))
usagePercentCache[text] = result
return result
}
static func parseNetworkTotal(_ text: String) -> (totalBytes: Double, displayText: String) {
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count == 2 else { return (0, "0 B") }
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
let download = PerformanceUtils.parseSizeToBytes(parts[1])
let total = upload + download
let displayText = PerformanceUtils.formatSize(total)
return (total, displayText)
}
static func parseNetworkPercent(_ text: String) -> Double {
let parts = text.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count == 2 else { return 0 }
let upload = PerformanceUtils.parseSizeToBytes(parts[0])
let download = PerformanceUtils.parseSizeToBytes(parts[1])
let total = upload + download
// Return upload percentage of total traffic
return total <= 0 ? 0 : max(0, min(1, upload / total))
}
}
struct PerformanceUtils {
// Precomputed multipliers for performance
private static let sizeMultipliers: [Character: Double] = [
"k": 1024,
"m": pow(1024, 2),
"g": pow(1024, 3),
"t": pow(1024, 4),
"p": pow(1024, 5)
]
static func parseSizeToBytes(_ text: String) -> Double {
let lower = text.lowercased().replacingOccurrences(of: "b", with: "")
let unitChar = lower.trimmingCharacters(in: .whitespaces).last
let numberPart: String
let multiplier: Double
if let u = unitChar, let mult = sizeMultipliers[u] {
multiplier = mult
numberPart = String(lower.dropLast())
} else {
multiplier = 1.0
numberPart = lower
}
let value = Double(numberPart.trimmingCharacters(in: .whitespaces)) ?? 0
return value * multiplier
}
static func percentStr(_ value: Double) -> String {
let pct = max(0, min(1, value)) * 100
let rounded = (pct * 10).rounded() / 10
return rounded.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f%%", rounded)
: String(format: "%.1f%%", rounded)
}
static func thresholdColor(_ value: Double) -> Color {
let v = max(0, min(1, value))
switch v {
case ..<WidgetConstants.Thresholds.warningThreshold: return WidgetConstants.Colors.success
case ..<WidgetConstants.Thresholds.criticalThreshold: return WidgetConstants.Colors.warning
default: return WidgetConstants.Colors.critical
}
}
static func formatSize(_ bytes: Double) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var size = bytes
var unitIndex = 0
while size >= 1024 && unitIndex < units.count - 1 {
size /= 1024
unitIndex += 1
}
return String(format: "%.1f %@", size, units[unitIndex])
}
}
struct Provider: IntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
@@ -29,11 +165,13 @@ struct Provider: IntentTimelineProvider {
var url = configuration.url
let family = context.family
#if os(iOS)
if #available(iOSApplicationExtension 16.0, *) {
if family == .accessoryInline || family == .accessoryRectangular {
url = UserDefaults.standard.string(forKey: accessoryKey)
url = UserDefaults(suiteName: WidgetConstants.appGroupId)?.string(forKey: "accessory_widget_url")
}
}
#endif
let currentDate = Date()
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
@@ -111,7 +249,7 @@ struct StatusWidgetEntryView : View {
Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise")
.resizable()
.frame(width: 10, height: 12.7)
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
}.tint(.gray)
}
}
@@ -123,6 +261,37 @@ struct StatusWidgetEntryView : View {
case .normal(let data):
let sumColor: Color = .primary.opacity(0.7)
switch family {
case .systemMedium:
VStack(alignment: .leading, spacing: WidgetConstants.Spacing.normal) {
// Title + refresh
if #available(iOS 17.0, *) {
HStack {
Text(data.name).font(.system(.title3, design: .monospaced))
Spacer()
Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise")
.resizable()
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
}.tint(.gray)
}
} else {
Text(data.name).font(.system(.title3, design: .monospaced))
}
Spacer(minLength: WidgetConstants.Spacing.normal)
// Gauges row
HStack(spacing: WidgetConstants.Spacing.tight) {
GaugeTile(label: "CPU", value: ParseCache.parsePercent(data.cpu), display: data.cpu, diameter: WidgetConstants.Dimensions.smallGauge)
GaugeTile(label: "MEM", value: ParseCache.parseUsagePercent(data.mem), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.mem)), diameter: WidgetConstants.Dimensions.smallGauge)
GaugeTile(label: "DISK", value: ParseCache.parseUsagePercent(data.disk), display: PerformanceUtils.percentStr(ParseCache.parseUsagePercent(data.disk)), diameter: WidgetConstants.Dimensions.smallGauge)
GaugeTile(label: "NET", value: ParseCache.parseNetworkPercent(data.net), display: ParseCache.parseNetworkTotal(data.net).displayText, diameter: WidgetConstants.Dimensions.smallGauge)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.autoPadding()
.widgetBackground()
#if os(iOS)
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
HStack {
@@ -142,6 +311,7 @@ struct StatusWidgetEntryView : View {
.widgetBackground()
case .accessoryInline:
Text("\(data.name) \(data.cpu)").widgetBackground()
#endif
default:
VStack(alignment: .leading, spacing: 3.7) {
if #available(iOS 17.0, *) {
@@ -151,7 +321,7 @@ struct StatusWidgetEntryView : View {
Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise")
.resizable()
.frame(width: 10, height: 12.7)
.frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
}.tint(.gray)
}
} else {
@@ -162,9 +332,6 @@ struct StatusWidgetEntryView : View {
DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
DetailItem(icon: "network", text: data.net, color: sumColor)
Spacer()
DetailItem(icon: "clock", text: entry.date.toStr(), color: sumColor)
.padding(.bottom, 3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.autoPadding()
@@ -177,8 +344,16 @@ struct StatusWidgetEntryView : View {
extension View {
@ViewBuilder
func widgetBackground() -> some View {
// Set bg to black in Night, white in Day
let backgroundView = Color(bgColor.resolve())
// Modern card-style background with subtle effects
let backgroundView = LinearGradient(
gradient: Gradient(colors: [
Color(bgColor.resolve()),
Color(bgColor.resolve()).opacity(0.95)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
if #available(iOS 17.0, *) {
containerBackground(for: .widget) {
backgroundView
@@ -188,14 +363,29 @@ extension View {
}
}
// iOS 17 will auto add a SafeArea, so when iOS < 17, add .padding(.all, 17)
// Enhanced padding with improved spacing
func autoPadding() -> some View {
if #available(iOS 17.0, *) {
return self
return self.padding(.all, WidgetConstants.Spacing.tight)
} else {
return self.padding(.all, 17)
return self.padding(.all, WidgetConstants.Spacing.extraLoose + 1)
}
}
// Modern card container with shadow and rounded corners
func modernCard(cornerRadius: CGFloat = WidgetConstants.Dimensions.cornerRadius) -> some View {
self
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(WidgetConstants.Colors.cardBackground)
.shadow(
color: .black.opacity(0.08),
radius: WidgetConstants.Dimensions.shadowRadius,
x: 0,
y: 1
)
)
}
}
struct StatusWidget: Widget {
@@ -207,11 +397,15 @@ struct StatusWidget: Widget {
}
.configurationDisplayName("Status")
.description("Status of your servers.")
if #available(iOSApplicationExtension 16.0, *) {
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline])
#if os(iOS)
if #available(iOSApplicationExtension 16.0, *) {
return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
} else {
return cfg.supportedFamilies([.systemSmall])
return cfg.supportedFamilies([.systemSmall, .systemMedium])
}
#else
return cfg.supportedFamilies([.systemSmall, .systemMedium])
#endif
}
}
@@ -228,31 +422,176 @@ struct DetailItem: View {
let color: Color
var body: some View {
HStack(spacing: 6.7) {
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center)
HStack(spacing: WidgetConstants.Spacing.normal) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(color.opacity(0.8))
.frame(width: 12, height: 12)
.background(
Circle()
.fill(color.opacity(0.1))
.frame(width: 20, height: 20)
)
Text(text)
.font(.system(size: 11, design: .monospaced))
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundColor(color)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.horizontal, WidgetConstants.Spacing.tight)
.padding(.vertical, 2)
}
}
// Enhanced circular progress indicator
struct CirclePercent: View {
// eg: 31.7%
let percent: String
@State private var animatedProgress: Double = 0
var body: some View {
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
let progress = (percentD ?? 0) / 100
ZStack {
// Background circle
Circle()
.stroke(Color.primary.opacity(0.15), lineWidth: 2.5)
// Progress circle with gradient
Circle()
.trim(from: 0, to: CGFloat(max(0, min(1, animatedProgress))))
.stroke(
AngularGradient(
gradient: Gradient(colors: [
PerformanceUtils.thresholdColor(progress).opacity(0.7),
PerformanceUtils.thresholdColor(progress)
]),
center: .center
),
style: StrokeStyle(lineWidth: 3, lineCap: .round)
)
.rotationEffect(.degrees(-90))
// Percentage text
Text(percent)
.font(.system(size: 8, weight: .bold, design: .rounded))
.foregroundColor(.primary.opacity(0.8))
}
.frame(width: 24, height: 24)
.onAppear {
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
animatedProgress = progress
}
}
.onChange(of: progress) { newProgress in
withAnimation(.easeInOut(duration: 0.5)) {
animatedProgress = newProgress
}
}
}
}
//
struct CirclePercent: View {
// eg: 31.7%
let percent: String
// Modern gauge tile with enhanced visual design
struct GaugeTile: View {
let label: String
// 0..1
let value: Double
// eg: "31.7%"
let display: String
let diameter: CGFloat
@State private var animatedValue: Double = 0
var body: some View {
// 31.7% -> 0.317
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%")))
let double = (percentD ?? 0) / 100
Circle()
.trim(from: 0, to: CGFloat(double))
.stroke(Color.primary, lineWidth: 3)
.animation(.easeInOut(duration: 0.5))
VStack(spacing: WidgetConstants.Spacing.normal) {
ZStack {
// Background circle with subtle shadow effect
Circle()
.stroke(Color.primary.opacity(0.1), lineWidth: 4)
.background(
Circle()
.fill(WidgetConstants.Colors.cardBackground)
.shadow(color: .black.opacity(0.05), radius: WidgetConstants.Dimensions.shadowRadius, x: 0, y: 1)
)
// Progress arc with gradient effect
Circle()
.trim(from: 0, to: CGFloat(max(0, min(1, animatedValue))))
.stroke(
AngularGradient(
gradient: Gradient(colors: [
PerformanceUtils.thresholdColor(value).opacity(0.8),
PerformanceUtils.thresholdColor(value)
]),
center: .center,
startAngle: .degrees(-90),
endAngle: .degrees(270)
),
style: StrokeStyle(lineWidth: 5, lineCap: .round)
)
.rotationEffect(.degrees(-90))
// Center value text with improved typography
Text(display)
.font(.system(size: diameter < 60 ? 11 : 13, weight: .bold, design: .rounded))
.foregroundColor(.primary)
.minimumScaleFactor(0.8)
.lineLimit(1)
}
.frame(width: diameter, height: diameter)
.onAppear {
withAnimation(.easeOut(duration: 0.8).delay(0.1)) {
animatedValue = value
}
}
.onChange(of: value) { newValue in
withAnimation(.easeInOut(duration: 0.6)) {
animatedValue = newValue
}
}
// Label with enhanced styling
if #available(iOS 16.0, *) {
Text(label)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundColor(WidgetConstants.Colors.secondaryText)
.textCase(.uppercase)
.tracking(0.5)
} else {
Text(label)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundColor(WidgetConstants.Colors.secondaryText)
.textCase(.uppercase)
}
}
.frame(maxWidth: .infinity)
}
}
// Legacy functions maintained for compatibility - now delegate to optimized versions
func parsePercent(_ text: String) -> Double {
return ParseCache.parsePercent(text)
}
func parseUsagePercent(_ text: String) -> Double {
return ParseCache.parseUsagePercent(text)
}
func parseSizeToBytes(_ text: String) -> Double {
return PerformanceUtils.parseSizeToBytes(text)
}
func percentStr(_ value: Double) -> String {
return PerformanceUtils.percentStr(value)
}
func thresholdColor(_ value: Double) -> Color {
return PerformanceUtils.thresholdColor(value)
}
struct DynamicColor {
let dark: UIColor
let light: UIColor

View File

@@ -12,5 +12,8 @@ import SwiftUI
struct StatusWidgetBundle: WidgetBundle {
var body: some Widget {
StatusWidget()
if #available(iOSApplicationExtension 16.1, *) {
TerminalLiveActivity()
}
}
}

View 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

View 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
}
}

View 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";

View 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";

View 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";

View 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";

View 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";

View File

@@ -0,0 +1,8 @@
"Terminal" = "ターミナル";
"Connected" = "接続済み";
"Connecting" = "接続中";
"Disconnected" = "切断";
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
"1 connection" = "1 件の接続";
"%d connections" = "%d 件の接続";

View 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";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Терминал";
"Connected" = "Подключено";
"Connecting" = "Подключение";
"Disconnected" = "Отключено";
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
"1 connection" = "1 подключение";
"%d connections" = "%d подключений";

View File

@@ -0,0 +1,8 @@
"Terminal" = "终端";
"Connected" = "已连接";
"Connecting" = "连接中";
"Disconnected" = "已断开连接";
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
"1 connection" = "1 个连接";
"%d connections" = "%d 个连接";

View File

@@ -0,0 +1,8 @@
"Terminal" = "終端機";
"Connected" = "已連線";
"Connecting" = "連線中";
"Disconnected" = "已中斷連線";
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
"1 connection" = "1 個連線";
"%d connections" = "%d 個連線";

View File

@@ -9,22 +9,62 @@ import SwiftUI
struct ContentView: View {
@ObservedObject var _mgr = PhoneConnMgr()
@State private var selection: Int = 0
@State private var refreshAllCounter: Int = 0
var body: some View {
let _count = _mgr.urls.count == 0 ? 1 : _mgr.urls.count
TabView {
ForEach(0 ..< _count, id:\.self) { index in
let url = _count == 1 && _mgr.urls.count == 0 ? nil : _mgr.urls[index]
PageView(url: url, state: .loading)
let hasServers = !_mgr.urls.isEmpty
let pagesCount = hasServers ? _mgr.urls.count : 1
TabView(selection: $selection) {
ForEach(0 ..< pagesCount, id:\.self) { index in
let url = hasServers ? _mgr.urls[index] : nil
PageView(
url: url,
state: .loading,
refreshAllCounter: refreshAllCounter,
onRefreshAll: { refreshAllCounter += 1 }
)
.tag(index)
}
}
.tabViewStyle(PageTabViewStyle())
// URL
.onChange(of: _mgr.urls) { newValue in
let newCount = newValue.count
//
if newCount == 0 {
selection = 0
} else if selection >= newCount {
//
selection = max(0, newCount - 1)
}
}
// Widget 使
.onChange(of: selection) { newIndex in
let appGroupId = "group.com.lollipopkit.toolbox"
if let defaults = UserDefaults(suiteName: appGroupId) {
defaults.set(newIndex, forKey: "watch_shared_selected_index")
}
}
.onAppear {
//
let appGroupId = "group.com.lollipopkit.toolbox"
let saved = UserDefaults(suiteName: appGroupId)?.integer(forKey: "watch_shared_selected_index") ?? 0
if !_mgr.urls.isEmpty {
selection = min(max(0, saved), _mgr.urls.count - 1)
} else {
selection = 0
}
}
}
}
struct PageView: View {
let url: String?
@State var state: ContentState
//
let refreshAllCounter: Int
let onRefreshAll: () -> Void
var body: some View {
if url == nil {
@@ -36,35 +76,50 @@ struct PageView: View {
Spacer()
}
} else {
switch state {
case .loading:
ProgressView().padding().onAppear {
getStatus(url: url!)
}
case .error(let err):
Group {
switch state {
case .loading:
ProgressView().padding().onAppear {
getStatus(url: url!)
}
case .error(let err):
switch err {
case .http(let description):
VStack(alignment: .center) {
Text(description)
Button(action: {
state = .loading
}){
Image(systemName: "arrow.clockwise")
}.buttonStyle(.plain)
HStack(spacing: 10) {
Button(action: {
state = .loading
}){
Image(systemName: "arrow.clockwise")
}.buttonStyle(.plain)
Button(action: {
onRefreshAll()
}){
Image(systemName: "arrow.triangle.2.circlepath")
}.buttonStyle(.plain)
}
}
case .url(_):
Link("View help", destination: helpUrl)
}
case .normal(let status):
VStack(alignment: .leading) {
case .normal(let status):
VStack(alignment: .leading) {
HStack {
Text(status.name).font(.system(.title, design: .monospaced))
Spacer()
Button(action: {
state = .loading
}){
Image(systemName: "arrow.clockwise")
}.buttonStyle(.plain)
HStack(spacing: 10) {
Button(action: {
state = .loading
}){
Image(systemName: "arrow.clockwise")
}.buttonStyle(.plain)
Button(action: {
onRefreshAll()
}){
Image(systemName: "arrow.triangle.2.circlepath")
}.buttonStyle(.plain)
}
}
Spacer()
DetailItem(icon: "cpu", text: status.cpu)
@@ -72,6 +127,12 @@ struct PageView: View {
DetailItem(icon: "externaldrive", text: status.disk)
DetailItem(icon: "network", text: status.net)
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding([.horizontal], 11)
}
}
.onChange(of: refreshAllCounter) { _ in
if let url = url {
getStatus(url: url)
}
}
}
}
@@ -87,25 +148,32 @@ struct PageView: View {
return
}
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
guard error == nil else {
state = .error(.http(error!.localizedDescription))
// UI 线 TabView
func setStateOnMain(_ newState: ContentState) {
DispatchQueue.main.async {
self.state = newState
}
}
if let error = error {
setStateOnMain(.error(.http(error.localizedDescription)))
return
}
guard let data = data else {
state = .error(.http("empty data"))
setStateOnMain(.error(.http("empty data")))
return
}
guard let jsonAll = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
state = .error(.http("json parse fail"))
setStateOnMain(.error(.http("json parse fail")))
return
}
guard let code = jsonAll["code"] as? Int else {
state = .error(.http("code is nil"))
setStateOnMain(.error(.http("code is nil")))
return
}
if (code != 0) {
let msg = jsonAll["msg"] as? String ?? ""
state = .error(.http(msg))
setStateOnMain(.error(.http(msg)))
return
}
@@ -115,10 +183,35 @@ struct PageView: View {
let cpu = json["cpu"] as? String ?? ""
let mem = json["mem"] as? String ?? ""
let net = json["net"] as? String ?? ""
state = .normal(Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net))
let status = Status(name: name, cpu: cpu, mem: mem, disk: disk, net: net)
setStateOnMain(.normal(status))
// App Group/ Widget 使
let appGroupId = "group.com.lollipopkit.toolbox"
if let defaults = UserDefaults(suiteName: appGroupId) {
var statusMap = (defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]]) ?? [:]
statusMap[url.absoluteString] = [
"name": status.name,
"cpu": status.cpu,
"mem": status.mem,
"disk": status.disk,
"net": status.net
]
defaults.set(statusMap, forKey: "watch_shared_status_by_url")
}
}
task.resume()
}
//
@ViewBuilder
var _onRefreshAllHook: some View {
EmptyView()
.onChange(of: refreshAllCounter) { _ in
if let url = url {
getStatus(url: url)
}
}
}
}
struct ContentView_Previews: PreviewProvider {

View File

@@ -44,7 +44,13 @@ class PhoneConnMgr: NSObject, WCSessionDelegate, ObservableObject {
func updateUrls(_ val: [String: Any]) {
if let urls = val["urls"] as? [String] {
DispatchQueue.main.async {
self.urls = urls.filter { !$0.isEmpty }
let list = urls.filter { !$0.isEmpty }
self.urls = list
// Save URLs to App Group for widget access
let appGroupId = "group.com.lollipopkit.toolbox"
if let defaults = UserDefaults(suiteName: appGroupId) {
defaults.set(list, forKey: "watch_shared_urls")
}
}
}
}

View File

@@ -0,0 +1,141 @@
//
// WatchStatusWidget.swift
// WatchStatusWidget Extension
//
// Created by AI Assistant
//
import WidgetKit
import SwiftUI
import Foundation
// Simple model, independent from Runner target
struct Status: Hashable {
let name: String
let cpu: String
let mem: String
let disk: String
let net: String
}
struct WatchProvider: TimelineProvider {
func placeholder(in context: Context) -> WatchEntry {
WatchEntry(date: Date(), status: Status(name: "Server", cpu: "32%", mem: "1.3g/1.9g", disk: "7.1g/30g", net: "712k/1.2m"))
}
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
completion(loadEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
let entry = loadEntry()
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900)
completion(Timeline(entries: [entry], policy: .after(next)))
}
private func loadEntry() -> WatchEntry {
let appGroupId = "group.com.lollipopkit.toolbox"
guard let defaults = UserDefaults(suiteName: appGroupId) else {
return WatchEntry(date: Date(), status: Status(name: "Server", cpu: "--%", mem: "-", disk: "-", net: "-"))
}
let urls = (defaults.array(forKey: "watch_shared_urls") as? [String]) ?? []
let idx = defaults.integer(forKey: "watch_shared_selected_index")
var status: Status? = nil
if !urls.isEmpty {
let i = min(max(0, idx), urls.count - 1)
let url = urls[i]
// Load status from shared defaults
if let statusMap = defaults.dictionary(forKey: "watch_shared_status_by_url") as? [String: [String: String]],
let statusDict = statusMap[url] {
status = Status(
name: statusDict["name"] ?? "",
cpu: statusDict["cpu"] ?? "",
mem: statusDict["mem"] ?? "",
disk: statusDict["disk"] ?? "",
net: statusDict["net"] ?? ""
)
}
}
return WatchEntry(
date: Date(),
status: status ?? Status(name: "Server", cpu: "--%", mem: "-", disk: "-", net: "-")
)
}
}
struct WatchEntry: TimelineEntry {
let date: Date
let status: Status
}
struct WatchStatusWidgetEntryView: View {
var entry: WatchProvider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .accessoryCircular:
ZStack {
Circle().stroke(Color.primary.opacity(0.15), lineWidth: 4)
CirclePercent(percent: entry.status.cpu)
Text(entry.status.cpu.replacingOccurrences(of: "%", with: "")).font(.system(size: 10, weight: .bold, design: .monospaced))
}
.padding(2)
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(entry.status.name).font(.system(size: 12, weight: .semibold, design: .monospaced))
Spacer()
}
HStack(spacing: 6) {
Label(entry.status.cpu, systemImage: "cpu").font(.system(size: 11, design: .monospaced))
}
}
case .accessoryInline:
Text("\(entry.status.name) \(entry.status.cpu)")
default:
VStack {
Text(entry.status.name)
Text(entry.status.cpu)
}
}
}
}
struct WatchStatusWidget: Widget {
let kind: String = "WatchStatusWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
WatchStatusWidgetEntryView(entry: entry)
}
.configurationDisplayName("Server Status")
.description("Shows the selected server status.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
struct WatchStatusWidget_Previews: PreviewProvider {
static var previews: some View {
WatchStatusWidgetEntryView(entry: WatchEntry(date: Date(), status: Status(name: "Server", cpu: "37%", mem: "1.3g/1.9g", disk: "7.1g/30g", net: "712k/1.2m")))
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
}
}
// Helpers reused from iOS widget with lightweight versions
struct CirclePercent: View {
let percent: String
var body: some View {
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "% "))) ?? 0
let p = max(0, min(100, percentD)) / 100.0
Circle()
.trim(from: 0, to: CGFloat(p))
.stroke(Color.primary, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
}
}

View File

@@ -0,0 +1,17 @@
//
// WatchStatusWidgetBundle.swift
// WatchStatusWidget Extension
//
// Created by AI Assistant
//
import WidgetKit
import SwiftUI
@main
struct WatchStatusWidgetBundle: WidgetBundle {
var body: some Widget {
WatchStatusWidget()
}
}

View File

@@ -2,5 +2,4 @@ arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: l10n.dart
output-dir: lib/generated/l10n
synthetic-package: false
untranslated-messages-file: untranlated.json

View File

@@ -3,7 +3,6 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:fl_lib/generated/l10n/lib_l10n.dart';
import 'package:flutter/material.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
@@ -79,14 +78,7 @@ class MyApp extends StatelessWidget {
return MaterialApp(
key: ValueKey(locale),
builder: (context, child) => ResponsiveBreakpoints.builder(
child: child ?? UIs.placeholder,
breakpoints: const [
Breakpoint(start: 0, end: 600, name: MOBILE),
Breakpoint(start: 600, end: 1199, name: TABLET),
Breakpoint(start: 1199, end: 3840, name: DESKTOP),
],
),
builder: ResponsivePoints.builder,
locale: locale,
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
supportedLocales: AppLocalizations.supportedLocales,
@@ -96,20 +88,25 @@ class MyApp extends StatelessWidget {
themeMode: themeMode,
theme: light.fixWindowsFont,
darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont,
home: Builder(
builder: (context) {
home: FutureBuilder<List<IntroPageBuilder>>(
future: _IntroPage.builders,
builder: (context, snapshot) {
context.setLibL10n();
final appL10n = AppLocalizations.of(context);
if (appL10n != null) l10n = appL10n;
Widget child;
final intros = _IntroPage.builders;
if (intros.isNotEmpty) {
child = _IntroPage(intros);
if (snapshot.connectionState == ConnectionState.waiting) {
child = const Scaffold(body: Center(child: CircularProgressIndicator()));
} else {
final intros = snapshot.data ?? [];
if (intros.isNotEmpty) {
child = _IntroPage(intros);
} else {
child = const HomePage();
}
}
child = const HomePage();
return VirtualWindowFrame(title: BuildData.name, child: child);
},
),

View File

@@ -12,19 +12,85 @@ abstract final class MethodChans {
/// Issue #662
static void startService() {
// if (Stores.setting.fgService.fetch() != true) return;
// _channel.invokeMethod('startService');
if (Stores.setting.fgService.fetch() != true) return;
_channel.invokeMethod('startService');
}
/// Issue #662
static void stopService() {
// if (Stores.setting.fgService.fetch() != true) return;
// _channel.invokeMethod('stopService');
if (Stores.setting.fgService.fetch() != true) return;
_channel.invokeMethod('stopService');
}
static void updateHomeWidget() async {
if (!isIOS || !isAndroid) return;
if (!isIOS && !isAndroid) return;
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
await _channel.invokeMethod('updateHomeWidget');
}
/// Update Android foreground service notifications for SSH sessions
/// The [payload] is a JSON string describing sessions list.
static Future<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;
}
});
}
}

View File

@@ -132,8 +132,9 @@ extension SSHClientX on SSHClient {
if (data.contains('[sudo] password for ')) {
isRequestingPwd = true;
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
if (context == null) return;
final pwd = context.mounted ? await context.showPwdDialog(title: user, id: id) : null;
final ctx = context ?? WidgetsBinding.instance.focusManager.primaryFocus?.context;
if (ctx == null) return;
final pwd = ctx.mounted ? await ctx.showPwdDialog(title: user, id: id) : null;
if (pwd == null || pwd.isEmpty) {
session.stdin.close();
} else {

View File

@@ -12,12 +12,28 @@ final class BakSyncer extends SyncIface {
const BakSyncer._() : super();
@override
Future<void> saveToFile() => BackupV2.backup();
Future<void> saveToFile() async {
final pwd = await SecureStoreProps.bakPwd.read();
if (pwd == null || pwd.isEmpty) {
// Enforce password for non-clipboard backups
throw Exception('Backup password not set');
}
await BackupV2.backup(null, pwd);
}
@override
Future<Mergeable> fromFile(String path) async {
final content = await File(path).readAsString();
return MergeableUtils.fromJsonString(content).$1;
final pwd = await SecureStoreProps.bakPwd.read();
try {
if (Cryptor.isEncrypted(content)) {
return MergeableUtils.fromJsonString(content, pwd).$1;
}
return MergeableUtils.fromJsonString(content).$1;
} catch (_) {
// Fallback: try without password if detection failed
return MergeableUtils.fromJsonString(content).$1;
}
}
@override
@@ -28,6 +44,9 @@ final class BakSyncer extends SyncIface {
final webdavEnabled = PrefProps.webdavSync.get();
if (webdavEnabled) return Webdav.shared;
final gistEnabled = PrefProps.gistSync.get();
if (gistEnabled) return GistRs.shared;
return null;
}
}

View File

@@ -51,7 +51,7 @@ class ChainComparator<T> {
}
ChainComparator<T> reversed() {
return ChainComparator._create(null, (a, b) => this.compare(b, a));
return ChainComparator._create(null, (a, b) => compare(b, a));
}
}

View File

@@ -3,15 +3,11 @@ import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/app.dart';
abstract final class KeybordInteractive {
static FutureOr<List<String>?> defaultHandle(
Spi spi, {
BuildContext? ctx,
}) async {
static FutureOr<List<String>?> defaultHandle(Spi spi, {BuildContext? ctx}) async {
try {
final res = await (ctx ?? AppProvider.ctx)?.showPwdDialog(
final res = await (ctx ?? WidgetsBinding.instance.focusManager.primaryFocus?.context)?.showPwdDialog(
title: libL10n.pwd,
id: spi.id,
label: spi.id,

View File

@@ -6,17 +6,14 @@ import 'package:server_box/data/model/server/system.dart';
/// Helper class for detecting remote system types
class SystemDetector {
/// Detects the system type of a remote server
///
///
/// First checks if a custom system type is configured in [spi].
/// If not, attempts to detect the system by running commands:
/// 1. 'ver' command to detect Windows
/// 2. 'uname -a' command to detect Linux/BSD/Darwin
///
///
/// Returns [SystemType.linux] as default if detection fails.
static Future<SystemType> detect(
SSHClient client,
Spi spi,
) async {
static Future<SystemType> detect(SSHClient client, Spi spi) async {
// First, check if custom system type is defined
SystemType? detectedSystemType = spi.customSystemType;
if (detectedSystemType != null) {
@@ -54,4 +51,4 @@ class SystemDetector {
dprint('Defaulting to Linux system type for ${spi.oldId}');
return detectedSystemType;
}
}
}

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
@@ -83,6 +82,136 @@ as Map<String, Object?>,
}
/// Adds pattern-matching-related methods to [BackupV2].
extension BackupV2Patterns on BackupV2 {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _BackupV2 value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _BackupV2() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _BackupV2 value) $default,){
final _that = this;
switch (_that) {
case _BackupV2():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _BackupV2 value)? $default,){
final _that = this;
switch (_that) {
case _BackupV2() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _BackupV2() when $default != null:
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings) $default,) {final _that = this;
switch (_that) {
case _BackupV2():
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int version, int date, Map<String, Object?> spis, Map<String, Object?> snippets, Map<String, Object?> keys, Map<String, Object?> container, Map<String, Object?> history, Map<String, Object?> settings)? $default,) {final _that = this;
switch (_that) {
case _BackupV2() when $default != null:
return $default(_that.version,_that.date,_that.spis,_that.snippets,_that.keys,_that.container,_that.history,_that.settings);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()

View File

@@ -5,20 +5,35 @@ import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/bak/backup2.dart';
import 'package:server_box/data/model/app/bak/backup_source.dart';
import 'package:server_box/data/model/app/bak/utils.dart';
import 'package:server_box/data/res/store.dart';
/// Service class for handling backup operations
class BackupService {
/// Perform backup operation with the given source
static Future<void> backup(BuildContext context, BackupSource source) async {
final password = await _getBackupPassword(context);
if (password == null) return;
try {
String? password;
if (source is ClipboardBackupSource) {
// Clipboard backup: allow optional password
password = await _getClipboardPassword(context);
if (password == null) return; // canceled
} else {
// All other backups require pre-set bakPwd (SecureStore)
final saved = await SecureStoreProps.bakPwd.read();
if (saved == null || saved.isEmpty) {
// Prompt to set before proceeding
password = await _showPasswordDialog(context, hint: l10n.backupPasswordTip);
if (password == null || password.isEmpty) return; // Not set
await SecureStoreProps.bakPwd.write(password);
context.showSnackBar(l10n.backupPasswordSet);
} else {
password = saved;
}
}
final path = await BackupV2.backup(null, password.isEmpty ? null : password);
await source.saveContent(path);
// Show success message for clipboard source
if (source is ClipboardBackupSource) {
context.showSnackBar(libL10n.success);
}
@@ -42,12 +57,12 @@ class BackupService {
}
/// Handle password dialog for backup operations
static Future<String?> _getBackupPassword(BuildContext context) async {
final savedPassword = await Stores.setting.backupasswd.read();
static Future<String?> _getClipboardPassword(BuildContext context) async {
// Use saved bakPwd as default for clipboard flow if exists, but allow empty/custom
final savedPassword = await SecureStoreProps.bakPwd.read();
String? password;
if (savedPassword != null && savedPassword.isNotEmpty) {
// Use saved password or ask for custom password
final useCustom = await context.showRoundDialog<bool>(
title: l10n.backupPassword,
child: Text(l10n.backupPasswordTip),
@@ -57,19 +72,15 @@ class BackupService {
TextButton(onPressed: () => context.pop(true), child: Text(libL10n.custom)),
],
);
if (useCustom == null) return null;
if (useCustom) {
password = await _showPasswordDialog(context, initial: savedPassword);
} else {
password = savedPassword;
}
} else {
// No saved password, ask if user wants to set one
password = await _showPasswordDialog(context);
}
return password;
}
@@ -95,7 +106,7 @@ class BackupService {
}
// Try with saved password first
final savedPassword = await Stores.setting.backupasswd.read();
final savedPassword = await SecureStoreProps.bakPwd.read();
if (savedPassword != null && savedPassword.isNotEmpty) {
try {
final (backup, err) = await context.showLoadingDialog(

View File

@@ -1,20 +1,52 @@
import 'package:flutter/material.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart';
import 'package:server_box/data/model/server/system.dart';
/// Enum representing different command types for various systems
enum CmdTypeSys {
linux('Linux'),
bsd('BSD'),
windows('Windows');
final String sign;
const CmdTypeSys(this.sign);
IconData get icon {
return switch (this) {
CmdTypeSys.linux => MingCute.linux_line,
CmdTypeSys.bsd => LineAwesome.freebsd,
CmdTypeSys.windows => MingCute.windows_line,
};
}
}
/// Base class for all command type enums
abstract class CommandType implements Enum {
sealed class ShellCmdType implements Enum {
String get cmd;
/// Get command-specific separator
String get separator;
/// Get command-specific divider (separator with echo and formatting)
String get divider;
/// Get corresponding system type
CmdTypeSys get sysType;
static Set<ShellCmdType> get all {
return {...StatusCmdType.values, ...BSDStatusCmdType.values, ...WindowsStatusCmdType.values};
}
}
extension ShellCmdTypeX on ShellCmdType {
/// Display name of the command type
String get displayName => '${sysType.sign}.$name';
}
/// Linux/Unix status commands
enum StatusCmdType implements CommandType {
enum StatusCmdType implements ShellCmdType {
echo('echo ${SystemType.linuxSign}'),
time('date +%s'),
net('cat /proc/net/dev'),
@@ -90,16 +122,19 @@ enum StatusCmdType implements CommandType {
final String cmd;
const StatusCmdType(this.cmd);
@override
String get separator => ScriptConstants.getCmdSeparator(name);
@override
String get divider => ScriptConstants.getCmdDivider(name);
@override
CmdTypeSys get sysType => CmdTypeSys.linux;
}
/// BSD/macOS status commands
enum BSDStatusCmdType implements CommandType {
enum BSDStatusCmdType implements ShellCmdType {
echo('echo ${SystemType.bsdSign}'),
time('date +%s'),
net('netstat -ibn'),
@@ -115,16 +150,19 @@ enum BSDStatusCmdType implements CommandType {
final String cmd;
const BSDStatusCmdType(this.cmd);
@override
String get separator => ScriptConstants.getCmdSeparator(name);
@override
String get divider => ScriptConstants.getCmdDivider(name);
@override
CmdTypeSys get sysType => CmdTypeSys.bsd;
}
/// Windows PowerShell status commands
enum WindowsStatusCmdType implements CommandType {
enum WindowsStatusCmdType implements ShellCmdType {
echo('echo ${SystemType.windowsSign}'),
time('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()'),
@@ -244,12 +282,15 @@ enum WindowsStatusCmdType implements CommandType {
final String cmd;
const WindowsStatusCmdType(this.cmd);
@override
String get separator => ScriptConstants.getCmdSeparator(name);
@override
String get divider => ScriptConstants.getCmdDivider(name);
@override
CmdTypeSys get sysType => CmdTypeSys.windows;
}
/// Extensions for StatusCmdType
@@ -266,7 +307,7 @@ extension StatusCmdTypeX on StatusCmdType {
}
/// Extension for CommandType to find content in parsed map
extension CommandTypeX on CommandType {
extension CommandTypeX on ShellCmdType {
/// Find the command output from the parsed script output map
String findInMap(Map<String, String> parsedOutput) {
return parsedOutput[name] ?? '';

View File

@@ -106,7 +106,7 @@ switch (\$args[0]) {
/// Get Windows status command with command-specific separators
String _getWindowsStatusCommand({required List<String> disabledCmdTypes}) {
final cmdTypes = WindowsStatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.name));
final cmdTypes = WindowsStatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.displayName));
return cmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight(); // Remove trailing divider
}
}
@@ -196,10 +196,14 @@ esac''');
/// Get Unix status command with OS detection
String _getUnixStatusCommand({required List<String> disabledCmdTypes}) {
// Generate command lists with command-specific separators, filtering disabled commands
final filteredLinuxCmdTypes = StatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.name));
final filteredLinuxCmdTypes = StatusCmdType.values.where(
(e) => !disabledCmdTypes.contains(e.displayName),
);
final linuxCommands = filteredLinuxCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
final filteredBsdCmdTypes = BSDStatusCmdType.values.where((e) => !disabledCmdTypes.contains(e.name));
final filteredBsdCmdTypes = BSDStatusCmdType.values.where(
(e) => !disabledCmdTypes.contains(e.displayName),
);
final bsdCommands = filteredBsdCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
return '''

View File

@@ -32,14 +32,14 @@ class ScriptConstants {
/// Parse script output into command-specific map
static Map<String, String> parseScriptOutput(String raw) {
final result = <String, String>{};
if (raw.isEmpty) return result;
// Parse line by line to properly handle command-specific separators
final lines = raw.split('\n');
String? currentCmd;
final buffer = StringBuffer();
for (final line in lines) {
if (line.startsWith('$separator.')) {
// Save previous command content
@@ -61,12 +61,12 @@ class ScriptConstants {
buffer.writeln(line);
}
}
// Don't forget the last command
if (currentCmd != null) {
result[currentCmd] = buffer.toString().trim();
}
return result;
}

View File

@@ -93,7 +93,11 @@ class ShellFuncManager {
}
/// Generate complete script based on system type
static String allScript(Map<String, String>? customCmds, {SystemType? systemType, List<String>? disabledCmdTypes}) {
static String allScript(
Map<String, String>? customCmds, {
SystemType? systemType,
List<String>? disabledCmdTypes,
}) {
final isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows);

View File

@@ -142,16 +142,7 @@ class SingleCpuCore extends TimeSeqIface<SingleCpuCore> {
final int irq;
final int softirq;
SingleCpuCore(
this.id,
this.user,
this.sys,
this.nice,
this.idle,
this.iowait,
this.irq,
this.softirq,
);
SingleCpuCore(this.id, this.user, this.sys, this.nice, this.idle, this.iowait, this.irq, this.softirq);
int get total => user + sys + nice + idle + iowait + irq + softirq;
@@ -200,11 +191,11 @@ final class CpuBrand {
}
final _bsdCpuPercentReg = RegExp(r'(\d+\.\d+)%');
final _macCpuPercentReg = RegExp(
r'CPU usage: ([\d.]+)% user, ([\d.]+)% sys, ([\d.]+)% idle');
final _macCpuPercentReg = RegExp(r'CPU usage: ([\d.]+)% user, ([\d.]+)% sys, ([\d.]+)% idle');
final _freebsdCpuPercentReg = RegExp(
r'CPU: ([\d.]+)% user, ([\d.]+)% nice, ([\d.]+)% system, '
r'([\d.]+)% interrupt, ([\d.]+)% idle');
r'CPU: ([\d.]+)% user, ([\d.]+)% nice, ([\d.]+)% system, '
r'([\d.]+)% interrupt, ([\d.]+)% idle',
);
/// Parse CPU status on BSD system with support for different BSD variants
///
@@ -214,14 +205,14 @@ final _freebsdCpuPercentReg = RegExp(
/// - Generic BSD: fallback to percentage extraction
Cpus parseBsdCpu(String raw) {
final init = InitStatus.cpus;
// Try macOS format first
final macMatch = _macCpuPercentReg.firstMatch(raw);
if (macMatch != null) {
final userPercent = double.parse(macMatch.group(1)!).toInt();
final sysPercent = double.parse(macMatch.group(2)!).toInt();
final idlePercent = double.parse(macMatch.group(3)!).toInt();
init.add([
SingleCpuCore(
'cpu0',
@@ -236,7 +227,7 @@ Cpus parseBsdCpu(String raw) {
]);
return init;
}
// Try FreeBSD format
final freebsdMatch = _freebsdCpuPercentReg.firstMatch(raw);
if (freebsdMatch != null) {
@@ -245,7 +236,7 @@ Cpus parseBsdCpu(String raw) {
final sysPercent = double.parse(freebsdMatch.group(3)!).toInt();
final irqPercent = double.parse(freebsdMatch.group(4)!).toInt();
final idlePercent = double.parse(freebsdMatch.group(5)!).toInt();
init.add([
SingleCpuCore(
'cpu0',
@@ -260,20 +251,28 @@ Cpus parseBsdCpu(String raw) {
]);
return init;
}
// Fallback to generic percentage extraction
final percents = _bsdCpuPercentReg
.allMatches(raw)
.map((e) => double.parse(e.group(1) ?? '0'))
.map((e) {
final valueStr = e.group(1) ?? '0';
final value = double.tryParse(valueStr);
if (value == null) {
dprint('Warning: Failed to parse CPU percentage from "$valueStr"');
return 0.0;
}
return value;
})
.toList();
if (percents.length >= 3) {
// Validate that percentages are reasonable (0-100 range)
final validPercents = percents.where((p) => p >= 0 && p <= 100).toList();
if (validPercents.length != percents.length) {
Loggers.app.warning('BSD CPU fallback parsing found invalid percentages in: $raw');
}
init.add([
SingleCpuCore(
'cpu0',
@@ -288,10 +287,12 @@ Cpus parseBsdCpu(String raw) {
]);
return init;
} else if (percents.isNotEmpty) {
Loggers.app.warning('BSD CPU fallback parsing found ${percents.length} percentages (expected at least 3) in: $raw');
Loggers.app.warning(
'BSD CPU fallback parsing found ${percents.length} percentages (expected at least 3) in: $raw',
);
} else {
Loggers.app.warning('BSD CPU fallback parsing found no percentages in: $raw');
}
return init;
}

View File

@@ -20,11 +20,11 @@ ServerCustom _$ServerCustomFromJson(Map<String, dynamic> json) => ServerCustom(
Map<String, dynamic> _$ServerCustomToJson(ServerCustom instance) =>
<String, dynamic>{
if (instance.pveAddr case final value?) 'pveAddr': value,
'pveAddr': ?instance.pveAddr,
'pveIgnoreCert': instance.pveIgnoreCert,
if (instance.cmds case final value?) 'cmds': value,
if (instance.preferTempDev case final value?) 'preferTempDev': value,
if (instance.logoUrl case final value?) 'logoUrl': value,
if (instance.netDev case final value?) 'netDev': value,
if (instance.scriptDir case final value?) 'scriptDir': value,
'cmds': ?instance.cmds,
'preferTempDev': ?instance.preferTempDev,
'logoUrl': ?instance.logoUrl,
'netDev': ?instance.netDev,
'scriptDir': ?instance.scriptDir,
};

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
@@ -80,6 +79,136 @@ as Map<String, SmartAttribute>,
}
/// Adds pattern-matching-related methods to [DiskSmart].
extension DiskSmartPatterns on DiskSmart {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _DiskSmart value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _DiskSmart() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _DiskSmart value) $default,){
final _that = this;
switch (_that) {
case _DiskSmart():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _DiskSmart value)? $default,){
final _that = this;
switch (_that) {
case _DiskSmart() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String device, bool? healthy, double? temperature, String? model, String? serial, int? powerOnHours, int? powerCycleCount, Map<String, dynamic> rawData, Map<String, SmartAttribute> smartAttributes)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _DiskSmart() when $default != null:
return $default(_that.device,_that.healthy,_that.temperature,_that.model,_that.serial,_that.powerOnHours,_that.powerCycleCount,_that.rawData,_that.smartAttributes);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String device, bool? healthy, double? temperature, String? model, String? serial, int? powerOnHours, int? powerCycleCount, Map<String, dynamic> rawData, Map<String, SmartAttribute> smartAttributes) $default,) {final _that = this;
switch (_that) {
case _DiskSmart():
return $default(_that.device,_that.healthy,_that.temperature,_that.model,_that.serial,_that.powerOnHours,_that.powerCycleCount,_that.rawData,_that.smartAttributes);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String device, bool? healthy, double? temperature, String? model, String? serial, int? powerOnHours, int? powerCycleCount, Map<String, dynamic> rawData, Map<String, SmartAttribute> smartAttributes)? $default,) {final _that = this;
switch (_that) {
case _DiskSmart() when $default != null:
return $default(_that.device,_that.healthy,_that.temperature,_that.model,_that.serial,_that.powerOnHours,_that.powerCycleCount,_that.rawData,_that.smartAttributes);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
@@ -250,6 +379,136 @@ $SmartAttributeFlagsCopyWith<$Res> get flags {
}
/// Adds pattern-matching-related methods to [SmartAttribute].
extension SmartAttributePatterns on SmartAttribute {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SmartAttribute value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SmartAttribute() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SmartAttribute value) $default,){
final _that = this;
switch (_that) {
case _SmartAttribute():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SmartAttribute value)? $default,){
final _that = this;
switch (_that) {
case _SmartAttribute() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? id, String name, int? value, int? worst, int? thresh, String? whenFailed, dynamic rawValue, String? rawString, SmartAttributeFlags flags)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SmartAttribute() when $default != null:
return $default(_that.id,_that.name,_that.value,_that.worst,_that.thresh,_that.whenFailed,_that.rawValue,_that.rawString,_that.flags);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? id, String name, int? value, int? worst, int? thresh, String? whenFailed, dynamic rawValue, String? rawString, SmartAttributeFlags flags) $default,) {final _that = this;
switch (_that) {
case _SmartAttribute():
return $default(_that.id,_that.name,_that.value,_that.worst,_that.thresh,_that.whenFailed,_that.rawValue,_that.rawString,_that.flags);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? id, String name, int? value, int? worst, int? thresh, String? whenFailed, dynamic rawValue, String? rawString, SmartAttributeFlags flags)? $default,) {final _that = this;
switch (_that) {
case _SmartAttribute() when $default != null:
return $default(_that.id,_that.name,_that.value,_that.worst,_that.thresh,_that.whenFailed,_that.rawValue,_that.rawString,_that.flags);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
@@ -407,6 +666,136 @@ as bool,
}
/// Adds pattern-matching-related methods to [SmartAttributeFlags].
extension SmartAttributeFlagsPatterns on SmartAttributeFlags {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SmartAttributeFlags value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SmartAttributeFlags() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SmartAttributeFlags value) $default,){
final _that = this;
switch (_that) {
case _SmartAttributeFlags():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SmartAttributeFlags value)? $default,){
final _that = this;
switch (_that) {
case _SmartAttributeFlags() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? value, String? string, bool prefailure, bool updatedOnline, bool performance, bool errorRate, bool eventCount, bool autoKeep)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SmartAttributeFlags() when $default != null:
return $default(_that.value,_that.string,_that.prefailure,_that.updatedOnline,_that.performance,_that.errorRate,_that.eventCount,_that.autoKeep);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? value, String? string, bool prefailure, bool updatedOnline, bool performance, bool errorRate, bool eventCount, bool autoKeep) $default,) {final _that = this;
switch (_that) {
case _SmartAttributeFlags():
return $default(_that.value,_that.string,_that.prefailure,_that.updatedOnline,_that.performance,_that.errorRate,_that.eventCount,_that.autoKeep);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? value, String? string, bool prefailure, bool updatedOnline, bool performance, bool errorRate, bool eventCount, bool autoKeep)? $default,) {final _that = this;
switch (_that) {
case _SmartAttributeFlags() when $default != null:
return $default(_that.value,_that.string,_that.prefailure,_that.updatedOnline,_that.performance,_that.errorRate,_that.eventCount,_that.autoKeep);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()

View File

@@ -19,15 +19,11 @@ class Memory {
static Memory parse(String raw) {
final items = raw.split('\n').map((e) => memItemReg.firstMatch(e)).toList();
final total = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'MemTotal:')
?.group(2) ?? '1') ?? 1;
final free = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'MemFree:')
?.group(2) ?? '0') ?? 0;
final available = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'MemAvailable:')
?.group(2) ?? '0') ?? 0;
final total =
int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'MemTotal:')?.group(2) ?? '1') ?? 1;
final free = int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'MemFree:')?.group(2) ?? '0') ?? 0;
final available =
int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'MemAvailable:')?.group(2) ?? '0') ?? 0;
return Memory(total: total, free: free, avail: available);
}
@@ -36,14 +32,13 @@ class Memory {
final memItemReg = RegExp(r'([A-Z].+:)\s+([0-9]+) kB');
/// Parse BSD/macOS memory from top output
///
///
/// Supports formats like:
/// - macOS: "PhysMem: 32G used (1536M wired), 64G unused."
/// - FreeBSD: "Mem: 456M Active, 2918M Inact, 1127M Wired, 187M Cache, 829M Buf, 3535M Free"
Memory parseBsdMemory(String raw) {
// Try macOS format first: "PhysMem: 32G used (1536M wired), 64G unused."
final macMemReg = RegExp(
r'PhysMem:\s*([\d.]+)([KMGT])\s*used.*?,\s*([\d.]+)([KMGT])\s*unused');
final macMemReg = RegExp(r'PhysMem:\s*([\d.]+)([KMGT])\s*used.*?,\s*([\d.]+)([KMGT])\s*unused');
final macMatch = macMemReg.firstMatch(raw);
if (macMatch != null) {
@@ -58,8 +53,7 @@ Memory parseBsdMemory(String raw) {
}
// Try FreeBSD format: "Mem: 456M Active, 2918M Inact, 1127M Wired, 187M Cache, 829M Buf, 3535M Free"
final freeBsdReg = RegExp(
r'(\d+)([KMGT])\s+(Active|Inact|Wired|Cache|Buf|Free)', caseSensitive: false);
final freeBsdReg = RegExp(r'(\d+)([KMGT])\s+(Active|Inact|Wired|Cache|Buf|Free)', caseSensitive: false);
final matches = freeBsdReg.allMatches(raw);
if (matches.isNotEmpty) {
@@ -72,7 +66,11 @@ Memory parseBsdMemory(String raw) {
final kb = _convertToKB(amount, unit);
// Only sum known keywords
if (keyword == 'active' || keyword == 'inact' || keyword == 'wired' || keyword == 'cache' || keyword == 'buf') {
if (keyword == 'active' ||
keyword == 'inact' ||
keyword == 'wired' ||
keyword == 'cache' ||
keyword == 'buf') {
usedKB += kb;
} else if (keyword == 'free') {
freeKB += kb;
@@ -121,15 +119,12 @@ class Swap {
static Swap parse(String raw) {
final items = raw.split('\n').map((e) => memItemReg.firstMatch(e)).toList();
final total = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'SwapTotal:')
?.group(2) ?? '1') ?? 0;
final free = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'SwapFree:')
?.group(2) ?? '1') ?? 0;
final cached = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'SwapCached:')
?.group(2) ?? '0') ?? 0;
final total =
int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'SwapTotal:')?.group(2) ?? '1') ?? 0;
final free =
int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'SwapFree:')?.group(2) ?? '1') ?? 0;
final cached =
int.tryParse(items.firstWhereOrNull((e) => e?.group(1) == 'SwapCached:')?.group(2) ?? '0') ?? 0;
return Swap(total: total, free: free, cached: cached);
}

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
@@ -92,6 +91,136 @@ as List<String>?,
}
/// Adds pattern-matching-related methods to [Spi].
extension SpiPatterns on Spi {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Spi value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Spi() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Spi value) $default,){
final _that = this;
switch (_that) {
case _Spi():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Spi value)? $default,){
final _that = this;
switch (_that) {
case _Spi() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Spi() when $default != null:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes) $default,) {final _that = this;
switch (_that) {
case _Spi():
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String ip, int port, String user, String? pwd, @JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) String id, @JsonKey(includeIfNull: false) SystemType? customSystemType, @JsonKey(includeIfNull: false) List<String>? disabledCmdTypes)? $default,) {final _that = this;
switch (_that) {
case _Spi() when $default != null:
return $default(_that.name,_that.ip,_that.port,_that.user,_that.pwd,_that.keyId,_that.tags,_that.alterUrl,_that.autoConnect,_that.jumpId,_that.custom,_that.wolCfg,_that.envs,_that.id,_that.customSystemType,_that.disabledCmdTypes);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable(includeIfNull: false)

View File

@@ -41,19 +41,18 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
'ip': instance.ip,
'port': instance.port,
'user': instance.user,
if (instance.pwd case final value?) 'pwd': value,
if (instance.keyId case final value?) 'pubKeyId': value,
if (instance.tags case final value?) 'tags': value,
if (instance.alterUrl case final value?) 'alterUrl': value,
'pwd': ?instance.pwd,
'pubKeyId': ?instance.keyId,
'tags': ?instance.tags,
'alterUrl': ?instance.alterUrl,
'autoConnect': instance.autoConnect,
if (instance.jumpId case final value?) 'jumpId': value,
if (instance.custom case final value?) 'custom': value,
if (instance.wolCfg case final value?) 'wolCfg': value,
if (instance.envs case final value?) 'envs': value,
'jumpId': ?instance.jumpId,
'custom': ?instance.custom,
'wolCfg': ?instance.wolCfg,
'envs': ?instance.envs,
'id': instance.id,
if (_$SystemTypeEnumMap[instance.customSystemType] case final value?)
'customSystemType': value,
if (instance.disabledCmdTypes case final value?) 'disabledCmdTypes': value,
'customSystemType': ?_$SystemTypeEnumMap[instance.customSystemType],
'disabledCmdTypes': ?instance.disabledCmdTypes,
};
const _$SystemTypeEnumMap = {

View File

@@ -45,7 +45,8 @@ Future<ServerStatus> getStatus(ServerStatusUpdateReq req) async {
Future<ServerStatus> _getLinuxStatus(ServerStatusUpdateReq req) async {
final parsedOutput = req.parsedOutput;
final time = int.tryParse(StatusCmdType.time.findInMap(parsedOutput)) ??
final time =
int.tryParse(StatusCmdType.time.findInMap(parsedOutput)) ??
DateTime.now().millisecondsSinceEpoch ~/ 1000;
try {
@@ -259,9 +260,7 @@ String? _parseUpTime(String raw) {
if (splitedComma.length >= 2) {
final timePart = splitedComma[1].trim();
// Check if it's in HH:MM format
if (timePart.contains(':') &&
!timePart.contains('user') &&
!timePart.contains('load')) {
if (timePart.contains(':') && !timePart.contains('user') && !timePart.contains('load')) {
return '$firstPart, $timePart';
}
}
@@ -269,9 +268,7 @@ String? _parseUpTime(String raw) {
}
// Case 2: "2:34" (hours:minutes) - already in good format
if (firstPart.contains(':') &&
!firstPart.contains('user') &&
!firstPart.contains('load')) {
if (firstPart.contains(':') && !firstPart.contains('user') && !firstPart.contains('load')) {
return firstPart;
}
@@ -303,7 +300,8 @@ String? _parseHostName(String raw) {
// Windows status parsing implementation
Future<ServerStatus> _getWindowsStatus(ServerStatusUpdateReq req) async {
final parsedOutput = req.parsedOutput;
final time = int.tryParse(WindowsStatusCmdType.time.findInMap(parsedOutput)) ??
final time =
int.tryParse(WindowsStatusCmdType.time.findInMap(parsedOutput)) ??
DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Parse all different resource types using helper methods
@@ -372,10 +370,7 @@ void _parseWindowsCpuData(ServerStatusUpdateReq req, Map<String, String> parsedO
try {
// Windows CPU parsing - JSON format from PowerShell
final cpuRaw = WindowsStatusCmdType.cpu.findInMap(parsedOutput);
if (cpuRaw.isNotEmpty &&
cpuRaw != 'null' &&
!cpuRaw.contains('error') &&
!cpuRaw.contains('Exception')) {
if (cpuRaw.isNotEmpty && cpuRaw != 'null' && !cpuRaw.contains('error') && !cpuRaw.contains('Exception')) {
final cpus = WindowsParser.parseCpu(cpuRaw, req.ss);
if (cpus.isNotEmpty) {
req.ss.cpu.update(cpus);
@@ -397,10 +392,7 @@ void _parseWindowsCpuData(ServerStatusUpdateReq req, Map<String, String> parsedO
void _parseWindowsMemoryData(ServerStatusUpdateReq req, Map<String, String> parsedOutput) {
try {
final memRaw = WindowsStatusCmdType.mem.findInMap(parsedOutput);
if (memRaw.isNotEmpty &&
memRaw != 'null' &&
!memRaw.contains('error') &&
!memRaw.contains('Exception')) {
if (memRaw.isNotEmpty && memRaw != 'null' && !memRaw.contains('error') && !memRaw.contains('Exception')) {
final memory = WindowsParser.parseMemory(memRaw);
if (memory != null) {
req.ss.mem = memory;
@@ -506,7 +498,6 @@ void _parseWindowsGpuData(ServerStatusUpdateReq req, Map<String, String> parsedO
}
}
List<Battery> _parseWindowsBatteries(String raw) {
try {
final dynamic jsonData = json.decode(raw);
@@ -515,24 +506,19 @@ List<Battery> _parseWindowsBatteries(String raw) {
final batteryList = jsonData is List ? jsonData : [jsonData];
for (final batteryData in batteryList) {
final chargeRemaining =
batteryData['EstimatedChargeRemaining'] as int? ?? 0;
final chargeRemaining = batteryData['EstimatedChargeRemaining'] as int? ?? 0;
final batteryStatus = batteryData['BatteryStatus'] as int? ?? 0;
// Windows battery status: 1=Other, 2=Unknown, 3=Full, 4=Low,
// 5=Critical, 6=Charging, 7=ChargingAndLow, 8=ChargingAndCritical,
// Windows battery status: 1=Other, 2=Unknown, 3=Full, 4=Low,
// 5=Critical, 6=Charging, 7=ChargingAndLow, 8=ChargingAndCritical,
// 9=Undefined, 10=PartiallyCharged
final isCharging = batteryStatus == 6 ||
batteryStatus == 7 ||
batteryStatus == 8;
final isCharging = batteryStatus == 6 || batteryStatus == 7 || batteryStatus == 8;
batteries.add(
Battery(
name: 'Battery',
percent: chargeRemaining,
status: isCharging
? BatteryStatus.charging
: BatteryStatus.discharging,
status: isCharging ? BatteryStatus.charging : BatteryStatus.discharging,
),
);
}
@@ -579,12 +565,7 @@ List<NetSpeedPart> _parseWindowsNetwork(String raw, int currentTime) {
final tx = interfaceTx[interfaceName] ?? 0;
netParts.add(
NetSpeedPart(
interfaceName,
BigInt.from(rx.toInt()),
BigInt.from(tx.toInt()),
currentTime,
),
NetSpeedPart(interfaceName, BigInt.from(rx.toInt()), BigInt.from(tx.toInt()), currentTime),
);
}
}
@@ -597,7 +578,7 @@ List<NetSpeedPart> _parseWindowsNetwork(String raw, int currentTime) {
}
String _extractInterfaceName(String path) {
// Extract interface name from path like
// Extract interface name from path like
// "\\Computer\\NetworkInterface(Interface Name)\\..."
final match = RegExp(r'\\NetworkInterface\(([^)]+)\)\\').firstMatch(path);
return match?.group(1) ?? '';
@@ -632,7 +613,7 @@ List<DiskIOPiece> _parseWindowsDiskIO(String raw, int currentTime) {
}
}
// Create DiskIOPiece for each disk - convert bytes to sectors
// Create DiskIOPiece for each disk - convert bytes to sectors
// (assuming 512 bytes per sector)
for (final diskName in diskReads.keys) {
final readBytes = diskReads[diskName] ?? 0;
@@ -659,7 +640,7 @@ List<DiskIOPiece> _parseWindowsDiskIO(String raw, int currentTime) {
}
String _extractDiskName(String path) {
// Extract disk name from path like
// Extract disk name from path like
// "\\Computer\\PhysicalDisk(Disk Name)\\..."
final match = RegExp(r'\\PhysicalDisk\(([^)]+)\)\\').firstMatch(path);
return match?.group(1) ?? '';
@@ -668,9 +649,7 @@ String _extractDiskName(String path) {
void _parseWindowsTemperatures(Temperatures temps, String raw) {
try {
// Handle error output
if (raw.contains('Error') ||
raw.contains('Exception') ||
raw.contains('The term')) {
if (raw.contains('Error') || raw.contains('Exception') || raw.contains('The term')) {
return;
}
@@ -689,7 +668,7 @@ void _parseWindowsTemperatures(Temperatures temps, String raw) {
if (temperature != null) {
// Convert to the format expected by the existing parse method
typeLines.add('/sys/class/thermal/thermal_zone$i/$typeName');
// Convert to millicelsius (multiply by 1000)
// Convert to millicelsius (multiply by 1000)
// as expected by Linux parsing
valueLines.add((temperature * 1000).round().toString());
}

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
@@ -81,6 +80,136 @@ as List<String>?,
}
/// Adds pattern-matching-related methods to [Snippet].
extension SnippetPatterns on Snippet {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Snippet value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Snippet() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Snippet value) $default,){
final _that = this;
switch (_that) {
case _Snippet():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Snippet value)? $default,){
final _that = this;
switch (_that) {
case _Snippet() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String script, List<String>? tags, String? note, List<String>? autoRunOn)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Snippet() when $default != null:
return $default(_that.name,_that.script,_that.tags,_that.note,_that.autoRunOn);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String script, List<String>? tags, String? note, List<String>? autoRunOn) $default,) {final _that = this;
switch (_that) {
case _Snippet():
return $default(_that.name,_that.script,_that.tags,_that.note,_that.autoRunOn);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String script, List<String>? tags, String? note, List<String>? autoRunOn)? $default,) {final _that = this;
switch (_that) {
case _Snippet() when $default != null:
return $default(_that.name,_that.script,_that.tags,_that.note,_that.autoRunOn);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()

View File

@@ -14,16 +14,14 @@ enum SystemType {
static const windowsSign = '__windows';
/// Used for parsing system types from shell output.
///
///
/// This method looks for specific system signatures in the shell output
/// and returns the corresponding SystemType. If no signature is found,
/// it defaults to Linux but logs the detection failure for debugging.
static SystemType parse(String value) {
// Log the raw value for debugging purposes (truncated to avoid spam)
final truncatedValue = value.length > 100
? '${value.substring(0, 100)}...'
: value;
final truncatedValue = value.length > 100 ? '${value.substring(0, 100)}...' : value;
if (value.contains(windowsSign)) {
Loggers.app.info('System detected as Windows from signature in: $truncatedValue');
return SystemType.windows;
@@ -32,24 +30,23 @@ enum SystemType {
Loggers.app.info('System detected as BSD from signature in: $truncatedValue');
return SystemType.bsd;
}
// Log when falling back to Linux detection
if (value.trim().isEmpty) {
Loggers.app.warning(
'System detection received empty input, defaulting to Linux. '
'This may indicate a script execution issue.'
'This may indicate a script execution issue.',
);
} else if (!value.contains(linuxSign)) {
Loggers.app.warning(
'System detection could not find any known signatures (Windows: $windowsSign, '
'BSD: $bsdSign, Linux: $linuxSign) in output: "$truncatedValue". '
'Defaulting to Linux, but this may cause incorrect parsing.'
'Defaulting to Linux, but this may cause incorrect parsing.',
);
} else {
Loggers.app.info('System detected as Linux from signature in: $truncatedValue');
}
return SystemType.linux;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/extension/context/locale.dart';
enum SystemdUnitFunc {
start,
@@ -49,6 +50,18 @@ enum SystemdUnitScope {
}
}
enum SystemdScopeFilter {
all,
system,
user;
String get displayName => switch (this) {
all => libL10n.all,
system => l10n.system,
user => libL10n.user,
};
}
enum SystemdUnitState {
active,
inactive,

View File

@@ -8,7 +8,7 @@ import 'package:server_box/data/model/server/memory.dart';
import 'package:server_box/data/model/server/server.dart';
/// Windows-specific status parsing utilities
///
///
/// This module handles parsing of Windows PowerShell command outputs
/// for server monitoring. It extracts the Windows parsing logic
/// to improve maintainability and readability.
@@ -36,15 +36,13 @@ class WindowsParser {
static String? parseUpTime(String raw) {
try {
// Clean the input - trim whitespace and get the first non-empty line
final cleanedInput = raw.trim().split('\n')
.where((line) => line.trim().isNotEmpty)
.firstOrNull;
final cleanedInput = raw.trim().split('\n').where((line) => line.trim().isNotEmpty).firstOrNull;
if (cleanedInput == null || cleanedInput.isEmpty) {
Loggers.app.warning('Windows uptime parsing: empty or null input');
return null;
}
// Try multiple date formats to handle different Windows locale/version outputs
final formatters = [
DateFormat('EEEE, MMMM d, yyyy h:mm:ss a', 'en_US'), // Original format
@@ -56,24 +54,27 @@ class WindowsParser {
DateFormat('d/M/yyyy h:mm:ss a', 'en_US'), // Short European format
DateFormat('dd/MM/yyyy h:mm:ss a', 'en_US'), // Short European format with zero padding
];
DateTime? dateTime;
for (final formatter in formatters) {
dateTime = formatter.tryParseLoose(cleanedInput);
if (dateTime != null) break;
}
if (dateTime == null) {
Loggers.app.warning('Windows uptime parsing: could not parse date format for: $cleanedInput');
return null;
}
final now = DateTime.now();
final uptime = now.difference(dateTime);
// Validate that the uptime is reasonable (not negative, not too far in the future)
if (uptime.isNegative || uptime.inDays > 3650) { // More than 10 years seems unreasonable
Loggers.app.warning('Windows uptime parsing: unreasonable uptime calculated: ${uptime.inDays} days for date: $cleanedInput');
if (uptime.isNegative || uptime.inDays > 3650) {
// More than 10 years seems unreasonable
Loggers.app.warning(
'Windows uptime parsing: unreasonable uptime calculated: ${uptime.inDays} days for date: $cleanedInput',
);
return null;
}
@@ -168,8 +169,8 @@ class WindowsParser {
}
/// Parse Windows memory information from PowerShell output
///
/// NOTE: Windows Win32_OperatingSystem properties TotalVisibleMemorySize
///
/// NOTE: Windows Win32_OperatingSystem properties TotalVisibleMemorySize
/// and FreePhysicalMemory are returned in KB units.
static Memory? parseMemory(String raw) {
try {
@@ -200,22 +201,19 @@ class WindowsParser {
for (final diskData in diskList) {
final deviceId = diskData['DeviceID']?.toString() ?? '';
final size =
BigInt.tryParse(diskData['Size']?.toString() ?? '0') ?? BigInt.zero;
final freeSpace =
BigInt.tryParse(diskData['FreeSpace']?.toString() ?? '0') ??
BigInt.zero;
final size = BigInt.tryParse(diskData['Size']?.toString() ?? '0') ?? BigInt.zero;
final freeSpace = BigInt.tryParse(diskData['FreeSpace']?.toString() ?? '0') ?? BigInt.zero;
final fileSystem = diskData['FileSystem']?.toString() ?? '';
// Validate all required fields
final hasRequiredFields = deviceId.isNotEmpty &&
size != BigInt.zero &&
freeSpace != BigInt.zero &&
fileSystem.isNotEmpty;
final hasRequiredFields =
deviceId.isNotEmpty && size != BigInt.zero && freeSpace != BigInt.zero && fileSystem.isNotEmpty;
if (!hasRequiredFields) {
Loggers.app.warning('Windows disk parsing: skipping disk with missing required fields. '
'DeviceID: $deviceId, Size: $size, FreeSpace: $freeSpace, FileSystem: $fileSystem');
Loggers.app.warning(
'Windows disk parsing: skipping disk with missing required fields. '
'DeviceID: $deviceId, Size: $size, FreeSpace: $freeSpace, FileSystem: $fileSystem',
);
continue;
}
@@ -223,7 +221,7 @@ class WindowsParser {
final freeKB = freeSpace ~/ BigInt.from(1024);
final usedKB = sizeKB - freeKB;
final usedPercent = sizeKB > BigInt.zero
? ((usedKB * BigInt.from(100)) ~/ sizeKB).toInt()
? ((usedKB * BigInt.from(100)) ~/ sizeKB).toInt().clamp(0, 100)
: 0;
disks.add(
@@ -245,4 +243,4 @@ class WindowsParser {
return [];
}
}
}
}

View File

@@ -16,5 +16,5 @@ Map<String, dynamic> _$WakeOnLanCfgToJson(WakeOnLanCfg instance) =>
<String, dynamic>{
'mac': instance.mac,
'ip': instance.ip,
if (instance.pwd case final value?) 'pwd': value,
'pwd': ?instance.pwd,
};

View File

@@ -7,19 +7,15 @@ part 'app.freezed.dart';
@freezed
abstract class AppState with _$AppState {
const factory AppState({@Default(false) bool desktopMode}) = _AppState;
const factory AppState() = _AppState;
}
@Riverpod(keepAlive: true)
class AppProvider extends _$AppProvider {
class AppStates extends _$AppStates {
static BuildContext? ctx;
@override
AppState build() {
return const AppState();
}
void setDesktop(bool desktopMode) {
state = state.copyWith(desktopMode: desktopMode);
}
}

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
@@ -15,128 +14,193 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AppState {
bool get desktopMode;
/// Create a copy of AppState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AppStateCopyWith<AppState> get copyWith => _$AppStateCopyWithImpl<AppState>(this as AppState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState&&(identical(other.desktopMode, desktopMode) || other.desktopMode == desktopMode));
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState);
}
@override
int get hashCode => Object.hash(runtimeType,desktopMode);
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AppState(desktopMode: $desktopMode)';
return 'AppState()';
}
}
/// @nodoc
abstract mixin class $AppStateCopyWith<$Res> {
factory $AppStateCopyWith(AppState value, $Res Function(AppState) _then) = _$AppStateCopyWithImpl;
@useResult
$Res call({
bool desktopMode
});
class $AppStateCopyWith<$Res> {
$AppStateCopyWith(AppState _, $Res Function(AppState) __);
}
/// Adds pattern-matching-related methods to [AppState].
extension AppStatePatterns on AppState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _AppState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _AppState() when $default != null:
return $default(_that);case _:
return orElse();
}
/// @nodoc
class _$AppStateCopyWithImpl<$Res>
implements $AppStateCopyWith<$Res> {
_$AppStateCopyWithImpl(this._self, this._then);
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
final AppState _self;
final $Res Function(AppState) _then;
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _AppState value) $default,){
final _that = this;
switch (_that) {
case _AppState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
/// Create a copy of AppState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? desktopMode = null,}) {
return _then(_self.copyWith(
desktopMode: null == desktopMode ? _self.desktopMode : desktopMode // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _AppState value)? $default,){
final _that = this;
switch (_that) {
case _AppState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function()? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _AppState() when $default != null:
return $default();case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function() $default,) {final _that = this;
switch (_that) {
case _AppState():
return $default();case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function()? $default,) {final _that = this;
switch (_that) {
case _AppState() when $default != null:
return $default();case _:
return null;
}
}
}
/// @nodoc
class _AppState implements AppState {
const _AppState({this.desktopMode = false});
const _AppState();
@override@JsonKey() final bool desktopMode;
/// Create a copy of AppState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$AppStateCopyWith<_AppState> get copyWith => __$AppStateCopyWithImpl<_AppState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState&&(identical(other.desktopMode, desktopMode) || other.desktopMode == desktopMode));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState);
}
@override
int get hashCode => Object.hash(runtimeType,desktopMode);
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AppState(desktopMode: $desktopMode)';
return 'AppState()';
}
}
/// @nodoc
abstract mixin class _$AppStateCopyWith<$Res> implements $AppStateCopyWith<$Res> {
factory _$AppStateCopyWith(_AppState value, $Res Function(_AppState) _then) = __$AppStateCopyWithImpl;
@override @useResult
$Res call({
bool desktopMode
});
}
/// @nodoc
class __$AppStateCopyWithImpl<$Res>
implements _$AppStateCopyWith<$Res> {
__$AppStateCopyWithImpl(this._self, this._then);
final _AppState _self;
final $Res Function(_AppState) _then;
/// Create a copy of AppState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? desktopMode = null,}) {
return _then(_AppState(
desktopMode: null == desktopMode ? _self.desktopMode : desktopMode // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -6,20 +6,57 @@ part of 'app.dart';
// RiverpodGenerator
// **************************************************************************
String _$appProviderHash() => r'8378ec9d0a9c8d99cc05805047cd2d52ac4dbb56';
@ProviderFor(AppStates)
const appStatesProvider = AppStatesProvider._();
/// See also [AppProvider].
@ProviderFor(AppProvider)
final appProviderProvider = NotifierProvider<AppProvider, AppState>.internal(
AppProvider.new,
name: r'appProviderProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$appProviderHash,
dependencies: null,
allTransitiveDependencies: null,
);
final class AppStatesProvider extends $NotifierProvider<AppStates, AppState> {
const AppStatesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appStatesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appStatesHash();
@$internal
@override
AppStates create() => AppStates();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppState>(value),
);
}
}
String _$appStatesHash() => r'ef96f10f6fff0f3dd6d3128ebf070ad79cbc8bc9';
abstract class _$AppStates extends $Notifier<AppState> {
AppState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AppState, AppState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AppState, AppState>,
AppState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
typedef _$AppProvider = Notifier<AppState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -298,7 +298,7 @@ enum ContainerCmdType {
.map((e) => e.exec(type, sudo: sudo, includeStats: includeStats))
.join('\necho ${ScriptConstants.separator}\n');
}
/// Find out the required segment from [segments]
String find(List<String> segments) {
return segments[index];

View File

@@ -20,6 +20,7 @@ import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/try_limiter.dart';
import 'package:server_box/data/res/status.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
class ServerProvider extends Provider {
const ServerProvider._();
@@ -143,7 +144,7 @@ class ServerProvider extends Provider {
// Start a new update operation
final updateFuture = _updateServer(s.spi);
_serverIdsUpdating[s.spi.id] = updateFuture;
try {
await updateFuture;
} finally {
@@ -183,6 +184,10 @@ class ServerProvider extends Provider {
for (final s in servers.values) {
s.value.conn = ServerConn.disconnected;
s.notify();
// Update SSH session status to disconnected
final sessionId = 'ssh_${s.value.spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
//TryLimiter.clear();
}
@@ -209,6 +214,10 @@ class ServerProvider extends Provider {
item.conn = ServerConn.disconnected;
_manualDisconnectedIds.add(id);
s.notify();
// Remove SSH session when server is manually closed
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
static void addServer(Spi spi) {
@@ -229,10 +238,21 @@ class ServerProvider extends Provider {
Stores.setting.serverOrder.put(serverOrder.value);
Stores.server.delete(id);
_updateTags();
// Remove SSH session when server is deleted
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
bakSync.sync(milliDelay: 1000);
}
static void deleteAll() {
// Remove all SSH sessions before clearing servers
for (final id in servers.keys) {
final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId);
}
servers.clear();
serverOrder.value.clear();
serverOrder.notify();
@@ -253,6 +273,11 @@ class ServerProvider extends Provider {
serverOrder.value.update(old.id, newSpi.id);
Stores.setting.serverOrder.put(serverOrder.value);
serverOrder.notify();
// Update SSH session ID when server ID changes
final oldSessionId = 'ssh_${old.id}';
TermSessionManager.remove(oldSessionId);
// Session will be re-added when reconnecting if necessary
}
// Only reconnect if neccessary
@@ -320,11 +345,26 @@ class ServerProvider extends Provider {
} else {
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
}
// Add SSH session to TermSessionManager
final sessionId = 'ssh_${spi.id}';
TermSessionManager.add(
id: sessionId,
spi: spi,
startTimeMs: time1.millisecondsSinceEpoch,
disconnect: () => _closeOneServer(spi.id),
status: TermSessionStatus.connecting,
);
TermSessionManager.setActive(sessionId, hasTerminal: false);
} catch (e) {
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.connect, message: e.toString());
_setServerState(s, ServerConn.failed);
// Remove SSH session on connection failure
final sessionId = 'ssh_${spi.id}';
TermSessionManager.remove(sessionId);
/// In order to keep privacy, print [spi.name] instead of [spi.id]
Loggers.app.warning('Connect to ${spi.name} failed', e);
return;
@@ -332,13 +372,21 @@ class ServerProvider extends Provider {
_setServerState(s, ServerConn.connected);
// Update SSH session status to connected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected);
try {
// Detect system type using helper
final detectedSystemType = await SystemDetector.detect(sv.client!, spi);
sv.status.system = detectedSystemType;
final (_, writeScriptResult) = await sv.client!.exec((session) async {
final scriptRaw = ShellFuncManager.allScript(spi.custom?.cmds, systemType: detectedSystemType, disabledCmdTypes: spi.disabledCmdTypes).uint8List;
final scriptRaw = ShellFuncManager.allScript(
spi.custom?.cmds,
systemType: detectedSystemType,
disabledCmdTypes: spi.disabledCmdTypes,
).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
}, entry: ShellFuncManager.getInstallShellCmd(spi.id, systemType: detectedSystemType));
@@ -352,6 +400,10 @@ class ServerProvider extends Provider {
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} on SSHAuthFailError catch (e) {
TryLimiter.inc(sid);
@@ -359,6 +411,10 @@ class ServerProvider extends Provider {
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
} catch (e) {
// If max try times < 2 and can't write script, this will stop the status getting and etc.
@@ -367,6 +423,10 @@ class ServerProvider extends Provider {
sv.status.err = err;
Loggers.app.warning(err);
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
}
}
@@ -384,7 +444,7 @@ class ServerProvider extends Provider {
try {
raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string;
dprint('Get status from ${spi.name}:\n$raw');
//dprint('Get status from ${spi.name}:\n$raw');
segments = raw?.split(ScriptConstants.separator).map((e) => e.trim()).toList();
if (raw == null || raw.isEmpty || segments == null || segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) {
@@ -396,6 +456,10 @@ class ServerProvider extends Provider {
TryLimiter.inc(sid);
sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
_setServerState(s, ServerConn.failed);
// Update SSH session status to disconnected on segments error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
} catch (e) {
@@ -403,13 +467,17 @@ class ServerProvider extends Provider {
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
_setServerState(s, ServerConn.failed);
Loggers.app.warning('Get status from ${spi.name} failed', e);
// Update SSH session status to disconnected on status error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}
try {
// Parse script output into command-specific map
final parsedOutput = ScriptConstants.parseScriptOutput(raw);
final req = ServerStatusUpdateReq(
ss: sv.status,
parsedOutput: parsedOutput,
@@ -422,6 +490,10 @@ class ServerProvider extends Provider {
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
_setServerState(s, ServerConn.failed);
Loggers.app.warning('Server status', e, trace);
// Update SSH session status to disconnected on parse error
final sessionId = 'ssh_${spi.id}';
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
return;
}

View File

@@ -8,20 +8,31 @@ import 'package:server_box/data/provider/server.dart';
final class SystemdProvider {
late final VNode<Server> _si;
late final bool _isRoot;
SystemdProvider.init(Spi spi) {
_isRoot = spi.isRoot;
_si = ServerProvider.pick(spi: spi)!;
getUnits();
}
final isBusy = false.vn;
final units = <SystemdUnit>[].vn;
final scopeFilter = SystemdScopeFilter.all.vn;
void dispose() {
isBusy.dispose();
units.dispose();
scopeFilter.dispose();
}
List<SystemdUnit> get filteredUnits {
switch (scopeFilter.value) {
case SystemdScopeFilter.all:
return units.value;
case SystemdScopeFilter.system:
return units.value.where((unit) => unit.scope == SystemdUnitScope.system).toList();
case SystemdScopeFilter.user:
return units.value.where((unit) => unit.scope == SystemdUnitScope.user).toList();
}
}
Future<void> getUnits() async {
@@ -35,12 +46,12 @@ final class SystemdProvider {
final userUnits = <String>[];
final systemUnits = <String>[];
for (final unit in units) {
if (unit.startsWith('/etc/systemd/system')) {
final maybeSystem = unit.contains('/systemd/system');
final maybeUser = unit.contains('/.config/systemd/user');
if (maybeSystem && !maybeUser) {
systemUnits.add(unit);
} else if (unit.startsWith('~/.config/systemd/user')) {
} else {
userUnits.add(unit);
} else if (unit.trim().isNotEmpty) {
Loggers.app.warning('Unknown unit: $unit');
}
}
@@ -48,7 +59,7 @@ final class SystemdProvider {
final parsedSystemUnits = await _parseUnitObj(systemUnits, SystemdUnitScope.system);
this.units.value = [...parsedUserUnits, ...parsedSystemUnits];
} catch (e, s) {
Loggers.app.warning('Parse systemd', e, s);
dprint('Parse systemd', e, s);
}
isBusy.value = false;
@@ -60,7 +71,8 @@ final class SystemdProvider {
'''
for unit in ${unitNames_.join(' ')}; do
state=\$(systemctl show --no-pager \$unit)
echo -n "${ScriptConstants.separator}\n\$state"
echo "\$state"
echo -n "\n${ScriptConstants.separator}\n"
done
''';
final client = _si.value.client!;
@@ -68,21 +80,27 @@ done
final units = result.split(ScriptConstants.separator);
final parsedUnits = <SystemdUnit>[];
for (final unit in units) {
final parts = unit.split('\n');
for (final unit in units.where((e) => e.trim().isNotEmpty)) {
final parts = unit.split('\n').where((e) => e.trim().isNotEmpty).toList();
if (parts.isEmpty) continue;
var name = '';
var type = '';
var state = '';
String? description;
for (final part in parts) {
if (part.startsWith('Id=')) {
final val = _getIniVal(part).split('.');
name = val.first;
type = val.last;
final val = _getIniVal(part);
if (val == null) continue;
// Id=sshd.service
final vals = val.split('.');
name = vals.first;
type = vals.last;
continue;
}
if (part.startsWith('ActiveState=')) {
state = _getIniVal(part);
final val = _getIniVal(part);
if (val == null) continue;
state = val;
continue;
}
if (part.startsWith('Description=')) {
@@ -93,12 +111,12 @@ done
final unitType = SystemdUnitType.fromString(type);
if (unitType == null) {
Loggers.app.warning('Unit type: $type');
dprint('Unit type: $type');
continue;
}
final unitState = SystemdUnitState.fromString(state);
if (unitState == null) {
Loggers.app.warning('Unit state: $state');
dprint('Unit state: $state');
continue;
}
@@ -121,38 +139,28 @@ done
return parsedUnits;
}
late final _getUnitsCmd =
'''
get_files() {
unit_type=\$1
base_dir=\$2
late final _getUnitsCmd = '''
types="service socket mount timer"
# If base_dir is not a directory, return
if [ ! -d "\$base_dir" ]; then
return
fi
get_files() {
unit_type=\$1
base_dir=\$2
[ -d "\$base_dir" ] || return
find "\$base_dir" -type f -name "*.\$unit_type" -print
}
find "\$base_dir" -type f -name "*.\$unit_type" -print | sort
for type in \$types; do
get_files \$type /etc/systemd/system
# Parsing these paths can lead to SSH transport closed errors
# get_files \$type /lib/systemd/system
# get_files \$type /usr/lib/systemd/system
get_files \$type ~/.config/systemd/user
done | sort
''';
}
get_type_files() {
unit_type=\$1
base_dir=""
${_isRoot ? """
get_files \$unit_type /etc/systemd/system
get_files \$unit_type ~/.config/systemd/user""" : """
get_files \$unit_type ~/.config/systemd/user"""}
}
types="service socket mount timer"
for type in \$types; do
get_type_files \$type
done
''';
}
String _getIniVal(String line) {
return line.split('=').last;
String? _getIniVal(String line) {
final idx = line.indexOf('=');
if (idx < 0) return null;
return line.substring(idx + 1).trim();
}

View File

@@ -3,6 +3,6 @@
abstract class BuildData {
static const String name = "ServerBox";
static const int build = 1206;
static const int script = 67;
static const int build = 1220;
static const int script = 68;
}

View 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});
}

View File

@@ -140,6 +140,10 @@ class SettingStore extends HiveStore {
/// Only valid on iOS / Android / Windows
late final useBioAuth = propertyDefault('useBioAuth', false);
/// Delay to lock the App with BioAuth, in seconds.
/// Set to `0` to disable this feature.
late final delayBioAuthLock = propertyDefault('delayBioAuthLock', 0);
/// The performance of highlight is bad
late final editorHighlight = propertyDefault('editorHighlight', true);

View File

@@ -5,7 +5,7 @@ final class _IntroPage extends StatelessWidget {
const _IntroPage(this.pages);
static const _builders = {1: _buildAppSettings};
static const _builders = {1: _buildAppSettings, 2: _buildBackupPasswordMigration};
@override
Widget build(BuildContext context) {
@@ -33,6 +33,43 @@ final class _IntroPage extends StatelessWidget {
SizedBox(height: padTop),
IntroPage.title(text: l10n.init, big: true),
SizedBox(height: padTop),
// Prompt to set backup password after migration or on first launch
ListTile(
leading: const Icon(Icons.lock),
title: Text(l10n.backupPassword),
subtitle: Text(l10n.backupPasswordTip, style: UIs.textGrey),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: () async {
final currentPwd = await SecureStoreProps.bakPwd.read();
final controller = TextEditingController(text: currentPwd ?? '');
final result = await ctx.showRoundDialog<bool>(
title: l10n.backupPassword,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.backupPasswordTip, style: UIs.textGrey),
UIs.height13,
Input(
label: l10n.backupPassword,
controller: controller,
obscureText: true,
onSubmitted: (_) => ctx.pop(true),
),
],
),
actions: Btnx.oks,
);
if (result == true) {
final pwd = controller.text.trim();
if (pwd.isEmpty) {
ctx.showSnackBar(libL10n.empty);
return;
}
await SecureStoreProps.bakPwd.write(pwd);
ctx.showSnackBar(l10n.backupPasswordSet);
}
},
).cardx,
ListTile(
leading: const Icon(IonIcons.language),
title: Text(libL10n.language),
@@ -76,9 +113,86 @@ final class _IntroPage extends StatelessWidget {
);
}
static List<IntroPageBuilder> get builders {
static Widget _buildBackupPasswordMigration(BuildContext ctx, double padTop) {
return ListView(
padding: _introListPad,
children: [
SizedBox(height: padTop),
IntroPage.title(text: l10n.backupPassword, big: true),
SizedBox(height: padTop * 0.5),
Text(
'${l10n.backupTip}\n\n${l10n.backupPasswordTip}',
style: const TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
SizedBox(height: padTop * 0.5),
ListTile(
leading: const Icon(Icons.lock, color: Colors.orange),
title: Text(l10n.backupPassword),
subtitle: Text(l10n.backupPasswordTip, style: UIs.textGrey),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: () async {
final controller = TextEditingController();
final result = await ctx.showRoundDialog<bool>(
title: l10n.backupPassword,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.backupPasswordTip, style: UIs.textGrey),
UIs.height13,
Input(
label: l10n.backupPassword,
controller: controller,
obscureText: true,
onSubmitted: (_) => ctx.pop(true),
),
],
),
actions: [
TextButton(onPressed: () => ctx.pop(false), child: Text(libL10n.cancel)),
TextButton(onPressed: () => ctx.pop(true), child: Text(libL10n.ok)),
],
);
if (result == true) {
final pwd = controller.text.trim();
if (pwd.isNotEmpty) {
await SecureStoreProps.bakPwd.write(pwd);
ctx.showSnackBar(l10n.backupPasswordSet);
}
}
},
).cardx,
SizedBox(height: padTop),
Text(
'This step is recommended for secure backup functionality.',
style: UIs.textGrey,
textAlign: TextAlign.center,
),
UIs.height77,
],
);
}
static Future<List<IntroPageBuilder>> get builders async {
final storedVer = _setting.introVer.fetch();
return _builders.entries.where((e) => e.key > storedVer).map((e) => e.value).toList();
final lastVer = _setting.lastVer.fetch();
// If user is upgrading from older version and doesn't have backup password set,
// show the backup password migration page
final hasBackupPwd = (await SecureStoreProps.bakPwd.read())?.isNotEmpty == true;
final isUpgrading = lastVer > 0 && storedVer < 2; // lastVer > 0 means not first install
final builders = _builders.entries
.where((e) {
if (e.key == 2 && (!isUpgrading || hasBackupPwd)) {
return false; // Skip backup password migration if not upgrading or already has password
}
return e.key > storedVer;
})
.map((e) => e.value)
.toList();
return builders;
}
static final _setting = Stores.setting;

View File

@@ -18,6 +18,7 @@ import 'package:server_box/data/provider/sftp.dart';
import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/ssh/session_manager.dart';
import 'package:server_box/data/store/server.dart';
import 'package:server_box/hive/hive_registrar.g.dart';
@@ -46,6 +47,9 @@ Future<void> _initApp() async {
await _initWindow();
_doPlatformRelated();
// Initialize Android session notification channel/handler
TermSessionManager.init();
}
Future<void> _initData() async {

View File

@@ -28,10 +28,12 @@ class BackupPage extends StatefulWidget {
final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveClientMixin {
final webdavLoading = false.vn;
final gistLoading = false.vn;
@override
void dispose() {
webdavLoading.dispose();
gistLoading.dispose();
super.dispose();
}
@@ -48,8 +50,10 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
[
CenterGreyTitle(libL10n.sync),
_buildTip,
_buildBakPwd,
if (isMacOS || isIOS) _buildIcloud,
_buildWebdav,
_buildGist,
_buildFile,
_buildClipboard,
],
@@ -58,6 +62,82 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
);
}
Widget get _buildBakPwd {
return FutureBuilder<String?>(
future: SecureStoreProps.bakPwd.read(),
builder: (context, snapshot) {
final hasPwd = snapshot.data?.isNotEmpty == true;
return CardX(
child: ListTile(
leading: const Icon(Icons.lock),
title: Text(l10n.backupPassword),
subtitle: Text(hasPwd ? l10n.backupEncrypted : l10n.backupNotEncrypted, style: UIs.textGrey),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(onPressed: () async => _onTapSetBakPwd(context), child: Text(libL10n.setting)),
if (hasPwd) ...[
UIs.width7,
TextButton(
onPressed: () async {
await SecureStoreProps.bakPwd.write(null);
context.showSnackBar(l10n.backupPasswordRemoved);
setState(() {});
},
child: Text(libL10n.delete),
),
],
],
),
onTap: () async => _onTapSetBakPwd(context),
),
);
},
);
}
Future<void> _onTapSetBakPwd(BuildContext context) async {
final currentPwd = await SecureStoreProps.bakPwd.read();
final controller = TextEditingController(text: currentPwd ?? '');
final node = FocusNode();
final result = await context.showRoundDialog<bool>(
title: l10n.backupPassword,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.backupPasswordTip, style: UIs.textGrey),
UIs.height13,
Input(
label: l10n.backupPassword,
controller: controller,
node: node,
obscureText: true,
onSubmitted: (_) => context.pop(true),
),
],
),
actions: Btnx.oks,
);
if (result == true) {
final pwd = controller.text.trim();
if (pwd.isEmpty) {
context.showSnackBar(libL10n.empty);
return;
}
await SecureStoreProps.bakPwd.write(pwd);
context.showSnackBar(l10n.backupPasswordSet);
setState(() {});
}
}
Future<bool> _ensureBakPwd(BuildContext context) async {
final saved = await SecureStoreProps.bakPwd.read();
if (saved != null && saved.isNotEmpty) return true;
await _onTapSetBakPwd(context);
final after = await SecureStoreProps.bakPwd.read();
return after != null && after.isNotEmpty;
}
Widget get _buildTip {
return CardX(
child: ListTile(
@@ -102,6 +182,10 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
context.showSnackBar(l10n.autoBackupConflict);
return false;
}
if (p0) {
final ok = await _ensureBakPwd(context);
if (!ok) return false;
}
if (p0) {
await bakSync.sync(rs: icloud);
}
@@ -133,6 +217,10 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
context.showSnackBar(l10n.autoBackupConflict);
return false;
}
if (p0) {
final ok = await _ensureBakPwd(context);
if (!ok) return false;
}
if (p0) {
final url = PrefProps.webdavUrl.get();
final user = PrefProps.webdavUser.get();
@@ -178,6 +266,67 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
);
}
Widget get _buildGist {
return CardX(
child: ExpandTile(
leading: const Icon(Icons.code),
title: const Text('GitHub Gist'),
initiallyExpanded: false,
children: [
ListTile(
title: Text(libL10n.setting),
trailing: const Icon(Icons.settings),
onTap: () async => _onTapGistSetting(context),
),
ListTile(
title: Text(libL10n.auto),
trailing: StoreSwitch(
prop: PrefProps.gistSync,
validator: (p0) async {
if (p0 && (PrefProps.icloudSync.get() || PrefProps.webdavSync.get())) {
context.showSnackBar(l10n.autoBackupConflict);
return false;
}
if (p0) {
final ok = await _ensureBakPwd(context);
if (!ok) return false;
}
if (p0) {
final token = PrefProps.githubToken.get();
// Allow empty gistId (will create one on first upload)
final hasToken = token != null && token.isNotEmpty;
if (!hasToken) {
context.showSnackBar('Token or Gist ID is empty');
return false;
}
gistLoading.value = true;
await bakSync.sync(rs: GistRs.shared);
gistLoading.value = false;
}
return true;
},
),
),
ListTile(
title: Text(l10n.manual),
trailing: gistLoading.listenVal((loading) {
if (loading) return SizedLoading.small;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(onPressed: () async => _onTapGistDl(context), child: Text(libL10n.restore)),
UIs.width7,
TextButton(onPressed: () async => _onTapGistUp(context), child: Text(libL10n.backup)),
],
);
}),
),
],
),
);
}
Widget get _buildClipboard {
return CardX(
child: ExpandTile(
@@ -289,7 +438,9 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
final date = DateTime.now().ymdhms(ymdSep: '-', hmsSep: '-', sep: '-');
final bakName = '$date-${Miscs.bakFileName}';
try {
final savedPassword = await Stores.setting.backupasswd.read();
final ok = await _ensureBakPwd(context);
if (!ok) return;
final savedPassword = await SecureStoreProps.bakPwd.read();
await BackupV2.backup(bakName, savedPassword);
await Webdav.shared.upload(relativePath: bakName);
Loggers.app.info('Upload webdav backup success');
@@ -301,6 +452,85 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
}
}
Future<void> _onTapGistDl(BuildContext context) async {
gistLoading.value = true;
try {
final files = await GistRs.shared.list();
if (files.isEmpty) return context.showSnackBar(l10n.dirEmpty);
final fileName = await context.showPickSingleDialog(title: libL10n.restore, items: files);
if (fileName == null) return;
await GistRs.shared.download(relativePath: fileName);
final dlFile = await File('${Paths.doc}/$fileName').readAsString();
await BackupService.restoreFromText(context, dlFile);
} catch (e, s) {
context.showErrDialog(e, s, libL10n.restore);
Loggers.app.warning('Download gist backup failed', e, s);
} finally {
gistLoading.value = false;
}
}
Future<void> _onTapGistUp(BuildContext context) async {
gistLoading.value = true;
final date = DateTime.now().ymdhms(ymdSep: '-', hmsSep: '-', sep: '-');
final bakName = '$date-${Miscs.bakFileName}';
try {
final ok = await _ensureBakPwd(context);
if (!ok) return;
final savedPassword = await SecureStoreProps.bakPwd.read();
await BackupV2.backup(bakName, savedPassword);
await GistRs.shared.upload(relativePath: bakName);
Loggers.app.info('Upload gist backup success');
} catch (e, s) {
context.showErrDialog(e, s, l10n.upload);
Loggers.app.warning('Upload gist backup failed', e, s);
} finally {
gistLoading.value = false;
}
}
Future<void> _onTapGistSetting(BuildContext context) async {
final tokenCtrl = TextEditingController(text: PrefProps.githubToken.get());
final gistIdCtrl = TextEditingController(text: PrefProps.gistId.get());
final nodeToken = FocusNode();
final result = await context.showRoundDialog<bool>(
title: 'GitHub Gist',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Input(label: 'Token', controller: tokenCtrl, suggestion: false, node: nodeToken),
Input(
label: 'Gist ID (optional)',
controller: gistIdCtrl,
suggestion: false,
onSubmitted: (_) => context.pop(true),
),
],
),
actions: Btnx.oks,
);
if (result == true) {
try {
final token_ = tokenCtrl.text.trim();
final gistId_ = gistIdCtrl.text.trim();
await GistRs.test(token: token_, gistId: gistId_.isEmpty ? null : gistId_);
context.showSnackBar(libL10n.success);
await PrefProps.githubToken.set(token_);
if (gistId_.isEmpty) {
await PrefProps.gistId.remove();
} else {
await PrefProps.gistId.set(gistId_);
}
} catch (e, s) {
context.showErrDialog(e, s, 'Gist');
}
}
}
Future<void> _onTapWebdavSetting(BuildContext context) async {
final url = TextEditingController(text: PrefProps.webdavUrl.get());
final user = TextEditingController(text: PrefProps.webdavUser.get());

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:server_box/core/chan.dart';
import 'package:server_box/data/model/app/tab.dart';
import 'package:server_box/data/provider/app.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
@@ -28,6 +27,7 @@ class _HomePageState extends State<HomePage>
bool _switchingPage = false;
bool _shouldAuth = false;
DateTime? _pausedTime;
@override
void dispose() {
@@ -62,13 +62,27 @@ class _HomePageState extends State<HomePage>
switch (state) {
case AppLifecycleState.resumed:
if (_shouldAuth) _goAuth();
if (_shouldAuth) {
final delay = Stores.setting.delayBioAuthLock.fetch();
if (delay > 0 && _pausedTime != null) {
final now = DateTime.now();
if (now.difference(_pausedTime ?? now).inSeconds > delay) {
_goAuth();
} else {
_shouldAuth = false;
}
_pausedTime = null;
} else {
_goAuth();
}
}
if (!ServerProvider.isAutoRefreshOn) {
ServerProvider.startAutoRefresh();
}
MethodChans.updateHomeWidget();
break;
case AppLifecycleState.paused:
_pausedTime = DateTime.now();
_shouldAuth = true;
// Keep running in background on Android device
if (isAndroid && Stores.setting.bgRun.fetch()) {
@@ -89,7 +103,6 @@ class _HomePageState extends State<HomePage>
@override
Widget build(BuildContext context) {
super.build(context);
AppProvider.ctx = context;
final isMobile = ResponsiveBreakpoints.of(context).isMobile;
return Scaffold(

View File

@@ -232,11 +232,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
final e = pkis[index];
return ListTile(
contentPadding: const EdgeInsets.only(left: 10, right: 15),
leading: Radio<int>(
value: index,
groupValue: _keyIdx.value,
onChanged: (value) => _keyIdx.value = value,
),
leading: Radio<int>(value: index),
title: Text(e.id, textAlign: TextAlign.start),
subtitle: Text(e.type ?? l10n.unknown, textAlign: TextAlign.start, style: UIs.textGrey),
trailing: Btn.icon(
@@ -254,7 +250,10 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
onTap: () => PrivateKeyEditPage.route.go(context),
),
);
return _keyIdx.listenVal((_) => Column(children: tiles)).cardx;
return RadioGroup<int>(
onChanged: (val) => _keyIdx.value = val,
child: _keyIdx.listenVal((_) => Column(children: tiles)).cardx,
);
});
}
@@ -597,21 +596,16 @@ extension on _ServerEditPageState {
}
void _onTapDisabledCmdTypes() async {
final allCmdTypes = <String>{};
allCmdTypes.addAll(StatusCmdType.values.map((e) => e.name));
allCmdTypes.addAll(BSDStatusCmdType.values.map((e) => e.name));
allCmdTypes.addAll(WindowsStatusCmdType.values.map((e) => e.name));
final allCmdTypes = ShellCmdType.all;
// [TimeSeq] depends on the `time` cmd type, so it should be removed from the list
allCmdTypes.remove(StatusCmdType.time.name);
allCmdTypes.remove(StatusCmdType.time);
final selected = await _showCmdTypesDialog(allCmdTypes);
if (selected == null) return;
_disabledCmdTypes.value = selected;
await _showCmdTypesDialog(allCmdTypes);
}
Future<Set<String>?> _showCmdTypesDialog(Set<String> allCmdTypes) {
return context.showRoundDialog<Set<String>>(
Future<void> _showCmdTypesDialog(Set<ShellCmdType> allCmdTypes) {
return context.showRoundDialog(
title: '${libL10n.disabled} ${l10n.cmd}',
child: SizedBox(
width: 270,
@@ -622,16 +616,30 @@ extension on _ServerEditPageState {
itemBuilder: (context, index) {
final cmdType = allCmdTypes.elementAtOrNull(index);
if (cmdType == null) return UIs.placeholder;
return CheckboxListTile(
title: Text(cmdType),
value: disabled.contains(cmdType),
onChanged: (value) {
if (value == null) return;
if (value) {
_disabledCmdTypes.value.add(cmdType);
final display = cmdType.displayName;
return ListTile(
leading: Icon(cmdType.sysType.icon, size: 20),
title: Text(cmdType.name, style: const TextStyle(fontSize: 16)),
trailing: Checkbox(
value: disabled.contains(display),
onChanged: (value) {
if (value == null) return;
if (value) {
_disabledCmdTypes.value.add(display);
} else {
_disabledCmdTypes.value.remove(display);
}
_disabledCmdTypes.notify();
},
),
onTap: () {
final isDisabled = disabled.contains(display);
if (isDisabled) {
_disabledCmdTypes.value.remove(display);
} else {
_disabledCmdTypes.value.remove(cmdType);
_disabledCmdTypes.value.add(display);
}
_disabledCmdTypes.notify();
},
);
},
@@ -764,6 +772,10 @@ extension on _ServerEditPageState {
_scriptDirCtrl.text = spi.custom?.scriptDir ?? '';
_systemType.value = spi.customSystemType;
_disabledCmdTypes.value = spi.disabledCmdTypes?.toSet() ?? {};
final disabledCmdTypes = spi.disabledCmdTypes?.toSet() ?? {};
final allAvailableCmdTypes = ShellCmdType.all.map((e) => e.displayName);
disabledCmdTypes.removeWhere((e) => !allAvailableCmdTypes.contains(e));
_disabledCmdTypes.value = disabledCmdTypes;
}
}

View File

@@ -20,7 +20,10 @@ extension on _ServerPageState {
Widget _buildTopRightWidget(Server s) {
final (child, onTap) = switch (s.conn) {
ServerConn.connecting || ServerConn.loading || ServerConn.connected => (
SizedLoading(_ServerPageState._kCardHeightMin, strokeWidth: 3, padding: 5),
SizedBox.square(
dimension: _ServerPageState._kCardHeightMin,
child: SizedLoading(_ServerPageState._kCardHeightMin, strokeWidth: 3, padding: 3),
),
null,
),
ServerConn.failed => (

View File

@@ -8,7 +8,7 @@ extension _App on _AppSettingsPageState {
_buildThemeMode(),
_buildAppColor(),
_buildCheckUpdate(),
PlatformPublicSettings.buildBioAuth(),
PlatformPublicSettings.buildBioAuth,
if (specific != null) specific,
_buildAppMore(),
];

View File

@@ -3,23 +3,49 @@ import 'package:flutter/material.dart';
import 'package:server_box/data/res/store.dart';
abstract final class PlatformPublicSettings {
static Widget buildBioAuth() {
static Widget get buildBioAuth {
return ExpandTile(
leading: const Icon(Icons.fingerprint),
title: Text(libL10n.bioAuth),
children: [_buildBioAuth(), _buildBioAuthDelay()],
);
}
static Widget _buildBioAuthDelay() {
return FutureWidget<bool>(
future: LocalAuth.isAvail,
loading: ListTile(
leading: const Icon(Icons.fingerprint),
title: Text(libL10n.bioAuth),
title: Text('${libL10n.delay} (${libL10n.second})'),
subtitle: const Text('...', style: UIs.textGrey),
),
error: (e, _) => ListTile(
title: Text(libL10n.bioAuth),
title: Text('${libL10n.delay} (${libL10n.second})'),
subtitle: Text('${libL10n.fail}: $e', style: UIs.textGrey),
),
success: (can) {
return ListTile(
title: Text('${libL10n.delay} (${libL10n.second})'),
trailing: can == true ? Stores.setting.delayBioAuthLock.fieldWidget() : null,
);
},
);
}
static Widget _buildBioAuth() {
return FutureWidget<bool>(
future: LocalAuth.isAvail,
loading: ListTile(
title: Text(libL10n.switch_),
subtitle: const Text('...', style: UIs.textGrey),
),
error: (e, _) => ListTile(
title: Text(libL10n.switch_),
subtitle: Text('${libL10n.fail}: $e', style: UIs.textGrey),
),
success: (can) {
can ??= false;
return ListTile(
leading: const Icon(Icons.fingerprint),
title: Text(libL10n.bioAuth),
title: Text(libL10n.switch_),
subtitle: can ? null : Text(libL10n.notExistFmt(libL10n.bioAuth), style: UIs.textGrey),
trailing: can
? StoreSwitch(

View File

@@ -61,6 +61,11 @@ extension _Init on SSHPageState {
_listen(session.stdout);
_listen(session.stderr);
// Hold the session for external control (disconnect)
_session = session;
// Mark status connected for notifications / live activities
TermSessionManager.updateStatus(_sessionId, TermSessionStatus.connected);
for (final snippet in SnippetProvider.snippets.value) {
if (snippet.autoRunOn?.contains(widget.args.spi.id) == true) {
snippet.runInTerm(_terminal, widget.args.spi);
@@ -85,6 +90,7 @@ extension _Init on SSHPageState {
context.pop();
}
widget.args.onSessionEnd?.call();
TermSessionManager.remove(_sessionId);
}
void _listen(Stream<Uint8List>? stream) {
@@ -122,6 +128,7 @@ extension _Init on SSHPageState {
_discontinuityTimer?.cancel();
if (!mounted) return;
_writeLn('\n\nConnection lost\r\n');
TermSessionManager.updateStatus(_sessionId, TermSessionStatus.disconnected);
context.showRoundDialog(
title: libL10n.attention,
child: Text('${l10n.disconnected}\n${l10n.goBackQ}'),
@@ -139,3 +146,17 @@ extension _Init on SSHPageState {
_terminal.write('$p0\r\n');
}
}
extension on SSHPageState {
void _disconnectFromNotification() {
// Mark as disconnected in session manager for immediate UI/notification feedback
TermSessionManager.updateStatus(_sessionId, TermSessionStatus.disconnected);
// Try to close the running SSH session, if any
try {
_session?.close();
} catch (e, stackTrace) {
Loggers.app.warning('Error closing SSH session: $e\n$stackTrace');
}
}
}

View File

@@ -21,6 +21,7 @@ import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/provider/virtual_keyboard.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/data/res/terminal.dart';
import 'package:server_box/data/ssh/session_manager.dart';
import 'package:server_box/view/page/storage/sftp.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -81,10 +82,13 @@ class SSHPageState extends State<SSHPage>
bool _isDark = false;
Timer? _virtKeyLongPressTimer;
late SSHClient? _client = widget.args.spi.server?.value.client;
SSHSession? _session;
Timer? _discontinuityTimer;
/// Used for (de)activate the wake lock and forground service
static var _sshConnCount = 0;
late final String _sessionId = ShortId.generate();
late final int _sessionStartMs = DateTime.now().millisecondsSinceEpoch;
@override
void dispose() {
@@ -101,6 +105,9 @@ class SSHPageState extends State<SSHPage>
}
}
// Remove session entry
TermSessionManager.remove(_sessionId);
super.dispose();
}
@@ -117,6 +124,16 @@ class SSHPageState extends State<SSHPage>
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

View File

@@ -41,38 +41,63 @@ final class _SystemdPageState extends State<SystemdPage> {
return CustomScrollView(
slivers: <Widget>[
SliverToBoxAdapter(
child: _pro.isBusy.listenVal(
(isBusy) => AnimatedContainer(
duration: Durations.medium1,
curve: Curves.fastEaseInToSlowEaseOut,
height: isBusy ? SizedLoading.medium.size : 0,
child: isBusy ? SizedLoading.medium : const SizedBox.shrink(),
),
child: Column(
children: [
_buildScopeFilterChips(),
_pro.isBusy.listenVal(
(isBusy) => AnimatedContainer(
duration: Durations.medium1,
curve: Curves.fastEaseInToSlowEaseOut,
height: isBusy ? SizedLoading.medium.size : 0,
width: isBusy ? SizedLoading.medium.size : 0,
child: isBusy ? SizedLoading.medium : const SizedBox.shrink(),
),
),
],
),
),
_buildUnitList(_pro.units),
_buildUnitList(),
],
);
}
Widget _buildUnitList(VNode<List<SystemdUnit>> units) {
return units.listenVal((units) {
if (units.isEmpty) {
return SliverToBoxAdapter(child: CenterGreyTitle(libL10n.empty).paddingSymmetric(horizontal: 13));
}
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final unit = units[index];
return ListTile(
leading: _buildScopeTag(unit.scope),
title: unit.description != null ? TipText(unit.name, unit.description!) : Text(unit.name),
subtitle: Wrap(
children: [_buildStateTag(unit.state), _buildTypeTag(unit.type)],
).paddingOnly(top: 7),
trailing: _buildUnitFuncs(unit),
).cardx.paddingSymmetric(horizontal: 13);
}, childCount: units.length),
);
Widget _buildScopeFilterChips() {
return _pro.scopeFilter.listenVal((currentFilter) {
return Wrap(
spacing: 8,
children: SystemdScopeFilter.values.map((filter) {
final isSelected = filter == currentFilter;
return FilterChip(
selected: isSelected,
label: Text(filter.displayName),
onSelected: (_) => _pro.scopeFilter.value = filter,
);
}).toList(),
).paddingSymmetric(horizontal: 13, vertical: 8);
});
}
Widget _buildUnitList() {
return _pro.units.listenVal((allUnits) {
return _pro.scopeFilter.listenVal((filter) {
final filteredUnits = _pro.filteredUnits;
if (filteredUnits.isEmpty) {
return SliverToBoxAdapter(child: CenterGreyTitle(libL10n.empty).paddingSymmetric(horizontal: 13));
}
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final unit = filteredUnits[index];
return ListTile(
leading: _buildScopeTag(unit.scope),
title: unit.description != null ? TipText(unit.name, unit.description!) : Text(unit.name),
subtitle: Wrap(
children: [_buildStateTag(unit.state), _buildTypeTag(unit.type)],
).paddingOnly(top: 7),
trailing: _buildUnitFuncs(unit),
).cardx.paddingSymmetric(horizontal: 13);
}, childCount: filteredUnits.length),
);
});
});
}

View File

@@ -281,59 +281,24 @@ bool _checkClient(BuildContext context, String id) {
const _runEmulatorShell = '''
#!/bin/sh
# launch_terminal.sh
TERMINAL="\$1"
shift # Remove the first argument (terminal name)
shift
# Auto detect terminal if not provided
if [ -z "\$TERMINAL" ] || [ "\$TERMINAL" = "x-terminal-emulator" ]; then
# Follow the order of preference
for term in kitty alacritty gnome-terminal konsole xfce4-terminal terminator tilix wezterm foot; do
if command -v "\$term" >/dev/null 2>&1; then
TERMINAL="\$term"
break
fi
for term in kitty alacritty gnome-terminal gnome-console konsole xfce4-terminal terminator tilix wezterm foot xterm; do
command -v "\$term" >/dev/null 2>&1 && { TERMINAL="\$term"; break; }
done
[ -z "\$TERMINAL" ] && TERMINAL="x-terminal-emulator"
fi
case "\$TERMINAL" in
gnome-terminal)
exec "\$TERMINAL" -- "\$@"
;;
konsole|terminator|tilix)
exec "\$TERMINAL" -e "\$@"
;;
xfce4-terminal)
exec "\$TERMINAL" -e "\$*"
;;
alacritty)
# Check alacritty version
if "\$TERMINAL" --version 2>&1 | grep -q "alacritty 0\\.1[3-9]"; then
# 0.13.0+
exec "\$TERMINAL" --command "\$@"
else
# Old versions
exec "\$TERMINAL" -e "\$@"
fi
;;
kitty)
exec "\$TERMINAL" "\$@"
;;
wezterm)
exec "\$TERMINAL" start -- "\$@"
;;
foot)
exec "\$TERMINAL" "\$@"
;;
urxvt|rxvt-unicode)
exec "\$TERMINAL" -e "\$@"
;;
x-terminal-emulator|*)
# Default
exec "\$TERMINAL" -e "\$@"
;;
gnome-terminal|gnome-console) exec "\$TERMINAL" -- "\$@" ;;
alacritty)
"\$TERMINAL" --version 2>&1 | grep -q "alacritty 0\\.1[3-9]" &&
exec "\$TERMINAL" --command "\$@" || exec "\$TERMINAL" -e "\$@" ;;
kitty|foot) exec "\$TERMINAL" "\$@" ;;
wezterm) exec "\$TERMINAL" start -- "\$@" ;;
xfce4-terminal) exec "\$TERMINAL" -e "\$*" ;;
*) exec "\$TERMINAL" -e "\$@" ;;
esac
''';

View File

@@ -26,7 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
IcloudStoragePlugin.register(with: registry.registrar(forPlugin: "IcloudStoragePlugin"))
FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))

View File

@@ -1,5 +1,5 @@
PODS:
- app_links (1.0.0):
- app_links (6.4.1):
- FlutterMacOS
- dynamic_color (0.0.2):
- FlutterMacOS
@@ -82,13 +82,13 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d
dynamic_color: b820c000cc68df65e7ba7ff177cb98404ce56651
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
dynamic_color: cb7c2a300ee67ed3bd96c3e852df3af0300bf610
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
icloud_storage: eb5b0f20687cf5a4fabc0b541f3b079cd6df7dcb
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f

Some files were not shown because too many files have changed in this diff Show More