mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-16 21:24:56 +01:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcb3d7e2b3 | ||
|
|
f5634d6e88 | ||
|
|
5497ad83e0 | ||
|
|
4a7827f41a | ||
|
|
60671fe461 | ||
|
|
bc1b6e5a4a | ||
|
|
1d553eccd5 | ||
|
|
68734a9e52 | ||
|
|
ed8a1d18b9 | ||
|
|
e4a9875620 | ||
|
|
6f9aa2ece9 | ||
|
|
13e28675af | ||
|
|
8c0e0f89d5 | ||
|
|
9b01da5a23 |
66
.github/workflows/claude.yml
vendored
Normal file
66
.github/workflows/claude.yml
vendored
Normal 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
|
||||
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
38
android/app/src/main/res/layout/widget_configure.xml
Normal file
38
android/app/src/main/res/layout/widget_configure.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1 +0,0 @@
|
||||
extensions:
|
||||
@@ -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
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
<string>en</string>
|
||||
<string>zh</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>CFBundleName</key>
|
||||
<string>ServerBox</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
|
||||
@@ -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>
|
||||
|
||||
95
ios/Runner/LiveActivityManager.swift
Normal file
95
ios/Runner/LiveActivityManager.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// LiveActivityManager.swift
|
||||
// Runner
|
||||
//
|
||||
// Handles starting/updating/stopping Terminal Live Activities from Flutter via MethodChannel.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
@available(iOS 16.2, *)
|
||||
class LiveActivityManager {
|
||||
static var current: Activity<TerminalAttributes>?
|
||||
|
||||
struct Payload: Decodable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let startTimeMs: Int
|
||||
let status: String
|
||||
let hasTerminal: Bool?
|
||||
let connectionCount: Int?
|
||||
}
|
||||
|
||||
private static func parse(_ json: String) -> Payload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(Payload.self, from: data)
|
||||
}
|
||||
|
||||
static func start(json: String) {
|
||||
guard #available(iOS 16.2, *) else { return }
|
||||
guard let p = parse(json) else { return }
|
||||
let attributes = TerminalAttributes(id: p.id)
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
|
||||
// Localize multi-connection title/subtitle on iOS side
|
||||
let isMulti = (p.id == "multi_connections")
|
||||
let title = isMulti
|
||||
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
|
||||
: p.title
|
||||
let subtitle = isMulti
|
||||
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
|
||||
: p.subtitle
|
||||
let state = TerminalAttributes.ContentState(
|
||||
id: p.id,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
status: p.status,
|
||||
startTime: date,
|
||||
hasTerminal: p.hasTerminal ?? true,
|
||||
connectionCount: p.connectionCount ?? 1
|
||||
)
|
||||
let content = ActivityContent(state: state, staleDate: nil)
|
||||
do {
|
||||
current = try Activity<TerminalAttributes>.request(attributes: attributes, content: content, pushType: nil)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
static func update(json: String) {
|
||||
guard #available(iOS 16.2, *) else { return }
|
||||
guard let p = parse(json) else { return }
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
|
||||
// Localize multi-connection title/subtitle on iOS side
|
||||
let isMulti = (p.id == "multi_connections")
|
||||
let title = isMulti
|
||||
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
|
||||
: p.title
|
||||
let subtitle = isMulti
|
||||
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
|
||||
: p.subtitle
|
||||
let state = TerminalAttributes.ContentState(
|
||||
id: p.id,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
status: p.status,
|
||||
startTime: date,
|
||||
hasTerminal: p.hasTerminal ?? true,
|
||||
connectionCount: p.connectionCount ?? 1
|
||||
)
|
||||
if let activity = current {
|
||||
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
|
||||
} else {
|
||||
start(json: json)
|
||||
}
|
||||
}
|
||||
|
||||
static func stop() {
|
||||
guard #available(iOS 16.2, *) else { return }
|
||||
if let activity = current {
|
||||
Task { await activity.end(dismissalPolicy: .immediate) }
|
||||
current = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
39
ios/Runner/TerminalLiveActivityAttributes.swift
Normal file
39
ios/Runner/TerminalLiveActivityAttributes.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// TerminalLiveActivityAttributes.swift
|
||||
// Runner
|
||||
//
|
||||
// Mirror of the ActivityKit attributes used in the extension.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
public struct TerminalAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
public var id: String
|
||||
public var title: String
|
||||
public var subtitle: String
|
||||
public var status: String
|
||||
public var startTime: Date
|
||||
public var hasTerminal: Bool
|
||||
public var connectionCount: Int
|
||||
|
||||
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.status = status
|
||||
self.startTime = startTime
|
||||
self.hasTerminal = hasTerminal
|
||||
self.connectionCount = connectionCount
|
||||
}
|
||||
}
|
||||
|
||||
public var id: String
|
||||
|
||||
public init(id: String) {
|
||||
self.id = id
|
||||
}
|
||||
}
|
||||
|
||||
8
ios/Runner/de.lproj/Localizable.strings
Normal file
8
ios/Runner/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Verbunden";
|
||||
"Connecting" = "Verbindung wird hergestellt";
|
||||
"Disconnected" = "Getrennt";
|
||||
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
|
||||
"1 connection" = "1 Verbindung";
|
||||
"%d connections" = "%d Verbindungen";
|
||||
|
||||
8
ios/Runner/en.lproj/Localizable.strings
Normal file
8
ios/Runner/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Connected";
|
||||
"Connecting" = "Connecting";
|
||||
"Disconnected" = "Disconnected";
|
||||
"Multiple SSH sessions active" = "Multiple SSH sessions active";
|
||||
"1 connection" = "1 connection";
|
||||
"%d connections" = "%d connections";
|
||||
|
||||
8
ios/Runner/es.lproj/Localizable.strings
Normal file
8
ios/Runner/es.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Conectado";
|
||||
"Connecting" = "Conectando";
|
||||
"Disconnected" = "Desconectado";
|
||||
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
|
||||
"1 connection" = "1 conexión";
|
||||
"%d connections" = "%d conexiones";
|
||||
|
||||
8
ios/Runner/fr.lproj/Localizable.strings
Normal file
8
ios/Runner/fr.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Connecté";
|
||||
"Connecting" = "Connexion en cours";
|
||||
"Disconnected" = "Déconnecté";
|
||||
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
|
||||
"1 connection" = "1 connexion";
|
||||
"%d connections" = "%d connexions";
|
||||
|
||||
8
ios/Runner/id.lproj/Localizable.strings
Normal file
8
ios/Runner/id.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Terhubung";
|
||||
"Connecting" = "Menghubungkan";
|
||||
"Disconnected" = "Terputus";
|
||||
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
|
||||
"1 connection" = "1 koneksi";
|
||||
"%d connections" = "%d koneksi";
|
||||
|
||||
8
ios/Runner/ja.lproj/Localizable.strings
Normal file
8
ios/Runner/ja.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "ターミナル";
|
||||
"Connected" = "接続済み";
|
||||
"Connecting" = "接続中";
|
||||
"Disconnected" = "切断";
|
||||
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
||||
"1 connection" = "1 件の接続";
|
||||
"%d connections" = "%d 件の接続";
|
||||
|
||||
8
ios/Runner/pt-BR.lproj/Localizable.strings
Normal file
8
ios/Runner/pt-BR.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Conectado";
|
||||
"Connecting" = "Conectando";
|
||||
"Disconnected" = "Desconectado";
|
||||
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
|
||||
"1 connection" = "1 conexão";
|
||||
"%d connections" = "%d conexões";
|
||||
|
||||
8
ios/Runner/ru.lproj/Localizable.strings
Normal file
8
ios/Runner/ru.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Терминал";
|
||||
"Connected" = "Подключено";
|
||||
"Connecting" = "Подключение";
|
||||
"Disconnected" = "Отключено";
|
||||
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
||||
"1 connection" = "1 подключение";
|
||||
"%d connections" = "%d подключений";
|
||||
|
||||
8
ios/Runner/zh-Hans.lproj/Localizable.strings
Normal file
8
ios/Runner/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "终端";
|
||||
"Connected" = "已连接";
|
||||
"Connecting" = "连接中";
|
||||
"Disconnected" = "已断开连接";
|
||||
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
||||
"1 connection" = "1 个连接";
|
||||
"%d connections" = "%d 个连接";
|
||||
|
||||
8
ios/Runner/zh-Hant.lproj/Localizable.strings
Normal file
8
ios/Runner/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "終端機";
|
||||
"Connected" = "已連線";
|
||||
"Connecting" = "連線中";
|
||||
"Disconnected" = "已中斷連線";
|
||||
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
||||
"1 connection" = "1 個連線";
|
||||
"%d connections" = "%d 個連線";
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
<dict>
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,5 +12,8 @@ import SwiftUI
|
||||
struct StatusWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
StatusWidget()
|
||||
if #available(iOSApplicationExtension 16.1, *) {
|
||||
TerminalLiveActivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
185
ios/StatusWidget/TerminalLiveActivity.swift
Normal file
185
ios/StatusWidget/TerminalLiveActivity.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
//
|
||||
// TerminalLiveActivity.swift
|
||||
// StatusWidget
|
||||
//
|
||||
// Renders the Live Activity UI for SSH/Terminal sessions.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import ActivityKit
|
||||
|
||||
// Helper to map status strings to a color dot (case-insensitive).
|
||||
@inline(__always)
|
||||
private func getStatusDotColor(_ status: String) -> Color {
|
||||
switch status.lowercased() {
|
||||
case "connected":
|
||||
return .green
|
||||
case "connecting":
|
||||
return .yellow
|
||||
case "disconnected":
|
||||
return .red
|
||||
default:
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize status for display: capitalize first letter only.
|
||||
@inline(__always)
|
||||
private func formatStatus(_ status: String) -> String {
|
||||
let trimmed = status.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let first = trimmed.first else { return status }
|
||||
let head = String(first).uppercased()
|
||||
let tail = String(trimmed.dropFirst()).lowercased()
|
||||
return head + tail
|
||||
}
|
||||
|
||||
// Localize known statuses; fall back to formatted original.
|
||||
@inline(__always)
|
||||
private func localizedStatus(_ status: String) -> String {
|
||||
switch status.lowercased() {
|
||||
case "connected":
|
||||
return NSLocalizedString("Connected", comment: "Session connected status")
|
||||
case "connecting":
|
||||
return NSLocalizedString("Connecting", comment: "Session connecting status")
|
||||
case "disconnected":
|
||||
return NSLocalizedString("Disconnected", comment: "Session disconnected status")
|
||||
default:
|
||||
return formatStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
struct TerminalLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TerminalAttributes.self) { context in
|
||||
let state = context.state
|
||||
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Text(state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if state.connectionCount > 1 {
|
||||
Text("(\(state.connectionCount))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(state.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(state.subtitle)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(getStatusDotColor(state.status))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(localizedStatus(state.status))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Text(context.state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
if context.state.connectionCount > 1 {
|
||||
Text("(\(context.state.connectionCount))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(context.state.title)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(getStatusDotColor(context.state.status))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(localizedStatus(context.state.status))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
Text(context.state.subtitle)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||
} compactTrailing: {
|
||||
EmptyView()
|
||||
} minimal: {
|
||||
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@available(iOS 16.2, *)
|
||||
struct TerminalLiveActivity_Previews: PreviewProvider {
|
||||
static let attributes = TerminalAttributes(id: "preview")
|
||||
static let contentState = TerminalAttributes.ContentState(
|
||||
id: "preview",
|
||||
title: "root@server-01",
|
||||
subtitle: "CPU 37% • Mem 1.3G/2.0G",
|
||||
status: "Connected",
|
||||
startTime: Date().addingTimeInterval(-1234),
|
||||
hasTerminal: true,
|
||||
connectionCount: 2
|
||||
)
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
// 锁屏 / 通知样式预览
|
||||
attributes
|
||||
.previewContext(contentState, viewKind: .content)
|
||||
.previewDisplayName("Lock Screen")
|
||||
|
||||
// 岛屿展开态预览
|
||||
attributes
|
||||
.previewContext(contentState, viewKind: .dynamicIsland(.expanded))
|
||||
.previewDisplayName("Dynamic Island • Expanded")
|
||||
|
||||
// 岛屿紧凑态预览
|
||||
attributes
|
||||
.previewContext(contentState, viewKind: .dynamicIsland(.compact))
|
||||
.previewDisplayName("Dynamic Island • Compact")
|
||||
|
||||
// 岛屿最小态预览
|
||||
attributes
|
||||
.previewContext(contentState, viewKind: .dynamicIsland(.minimal))
|
||||
.previewDisplayName("Dynamic Island • Minimal")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
39
ios/StatusWidget/TerminalLiveActivityAttributes.swift
Normal file
39
ios/StatusWidget/TerminalLiveActivityAttributes.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// TerminalLiveActivityAttributes.swift
|
||||
// StatusWidget
|
||||
//
|
||||
// Defines ActivityKit attributes and content state for SSH/Terminal Live Activities.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
public struct TerminalAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
public var id: String
|
||||
public var title: String
|
||||
public var subtitle: String
|
||||
public var status: String
|
||||
public var startTime: Date
|
||||
public var hasTerminal: Bool
|
||||
public var connectionCount: Int
|
||||
|
||||
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.status = status
|
||||
self.startTime = startTime
|
||||
self.hasTerminal = hasTerminal
|
||||
self.connectionCount = connectionCount
|
||||
}
|
||||
}
|
||||
|
||||
public var id: String
|
||||
|
||||
public init(id: String) {
|
||||
self.id = id
|
||||
}
|
||||
}
|
||||
|
||||
8
ios/StatusWidget/de.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Verbunden";
|
||||
"Connecting" = "Verbindung wird hergestellt";
|
||||
"Disconnected" = "Getrennt";
|
||||
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
|
||||
"1 connection" = "1 Verbindung";
|
||||
"%d connections" = "%d Verbindungen";
|
||||
|
||||
8
ios/StatusWidget/en.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Connected";
|
||||
"Connecting" = "Connecting";
|
||||
"Disconnected" = "Disconnected";
|
||||
"Multiple SSH sessions active" = "Multiple SSH sessions active";
|
||||
"1 connection" = "1 connection";
|
||||
"%d connections" = "%d connections";
|
||||
|
||||
8
ios/StatusWidget/es.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/es.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Conectado";
|
||||
"Connecting" = "Conectando";
|
||||
"Disconnected" = "Desconectado";
|
||||
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
|
||||
"1 connection" = "1 conexión";
|
||||
"%d connections" = "%d conexiones";
|
||||
|
||||
8
ios/StatusWidget/fr.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/fr.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Connecté";
|
||||
"Connecting" = "Connexion en cours";
|
||||
"Disconnected" = "Déconnecté";
|
||||
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
|
||||
"1 connection" = "1 connexion";
|
||||
"%d connections" = "%d connexions";
|
||||
|
||||
8
ios/StatusWidget/id.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/id.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Terhubung";
|
||||
"Connecting" = "Menghubungkan";
|
||||
"Disconnected" = "Terputus";
|
||||
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
|
||||
"1 connection" = "1 koneksi";
|
||||
"%d connections" = "%d koneksi";
|
||||
|
||||
8
ios/StatusWidget/ja.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/ja.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "ターミナル";
|
||||
"Connected" = "接続済み";
|
||||
"Connecting" = "接続中";
|
||||
"Disconnected" = "切断";
|
||||
"Multiple SSH sessions active" = "複数の SSH セッションがアクティブ";
|
||||
"1 connection" = "1 件の接続";
|
||||
"%d connections" = "%d 件の接続";
|
||||
|
||||
8
ios/StatusWidget/pt-BR.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/pt-BR.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Terminal";
|
||||
"Connected" = "Conectado";
|
||||
"Connecting" = "Conectando";
|
||||
"Disconnected" = "Desconectado";
|
||||
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
|
||||
"1 connection" = "1 conexão";
|
||||
"%d connections" = "%d conexões";
|
||||
|
||||
8
ios/StatusWidget/ru.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/ru.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "Терминал";
|
||||
"Connected" = "Подключено";
|
||||
"Connecting" = "Подключение";
|
||||
"Disconnected" = "Отключено";
|
||||
"Multiple SSH sessions active" = "Несколько активных сеансов SSH";
|
||||
"1 connection" = "1 подключение";
|
||||
"%d connections" = "%d подключений";
|
||||
|
||||
8
ios/StatusWidget/zh-Hans.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "终端";
|
||||
"Connected" = "已连接";
|
||||
"Connecting" = "连接中";
|
||||
"Disconnected" = "已断开连接";
|
||||
"Multiple SSH sessions active" = "多个 SSH 会话正在活动";
|
||||
"1 connection" = "1 个连接";
|
||||
"%d connections" = "%d 个连接";
|
||||
|
||||
8
ios/StatusWidget/zh-Hant.lproj/Localizable.strings
Normal file
8
ios/StatusWidget/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,8 @@
|
||||
"Terminal" = "終端機";
|
||||
"Connected" = "已連線";
|
||||
"Connecting" = "連線中";
|
||||
"Disconnected" = "已中斷連線";
|
||||
"Multiple SSH sessions active" = "多個 SSH 連線運行中";
|
||||
"1 connection" = "1 個連線";
|
||||
"%d connections" = "%d 個連線";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
141
ios/WatchWidget/WatchStatusWidget.swift
Normal file
141
ios/WatchWidget/WatchStatusWidget.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
||||
17
ios/WatchWidget/WatchStatusWidgetBundle.swift
Normal file
17
ios/WatchWidget/WatchStatusWidgetBundle.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
29
lib/app.dart
29
lib/app.dart
@@ -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);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] ?? '';
|
||||
|
||||
@@ -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 '''
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
200
lib/data/ssh/session_manager.dart
Normal file
200
lib/data/ssh/session_manager.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:server_box/core/chan.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
|
||||
enum TermSessionStatus {
|
||||
connecting,
|
||||
connected,
|
||||
disconnected;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name.capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a running SSH terminal session for Android notifications and iOS Live Activities.
|
||||
class TermSessionInfo {
|
||||
final String id;
|
||||
final String title; // e.g. server name
|
||||
final String subtitle; // e.g. user@ip:port
|
||||
final int startTimeMs;
|
||||
final TermSessionStatus status;
|
||||
|
||||
TermSessionInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.startTimeMs,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
Map<String, Object> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'subtitle': subtitle,
|
||||
'startTimeMs': startTimeMs,
|
||||
'status': status.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Singleton to track active SSH sessions and sync to Android notifications.
|
||||
abstract final class TermSessionManager {
|
||||
static final Map<String, _Entry> _entries = {};
|
||||
static String? _activeId; // For iOS Live Activity
|
||||
static Timer? _updateTimer; // Timer for iOS Live Activity updates
|
||||
static const _updateInterval = Duration(seconds: 5); // 5-second update interval
|
||||
|
||||
static void init() {
|
||||
if (isAndroid) {
|
||||
MethodChans.registerHandler((id) async {
|
||||
_entries[id]?.disconnect?.call();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a session record and push update to Android.
|
||||
static void add({
|
||||
required String id,
|
||||
required Spi spi,
|
||||
required int startTimeMs,
|
||||
required VoidCallback disconnect,
|
||||
TermSessionStatus status = TermSessionStatus.connecting,
|
||||
}) {
|
||||
final info = TermSessionInfo(
|
||||
id: id,
|
||||
title: spi.name,
|
||||
subtitle: spi.oldId,
|
||||
startTimeMs: startTimeMs,
|
||||
status: status,
|
||||
);
|
||||
_entries[id] = _Entry(info, disconnect, hasTerminalUI: true);
|
||||
_activeId = id; // most recent as active
|
||||
_sync();
|
||||
}
|
||||
|
||||
static void updateStatus(String id, TermSessionStatus status) {
|
||||
final old = _entries[id];
|
||||
if (old == null) return;
|
||||
_entries[id] = _Entry(
|
||||
TermSessionInfo(
|
||||
id: old.info.id,
|
||||
title: old.info.title,
|
||||
subtitle: old.info.subtitle,
|
||||
startTimeMs: old.info.startTimeMs,
|
||||
status: status,
|
||||
),
|
||||
old.disconnect,
|
||||
hasTerminalUI: old.hasTerminalUI,
|
||||
);
|
||||
_sync();
|
||||
}
|
||||
|
||||
static void remove(String id) {
|
||||
_entries.remove(id);
|
||||
if (_activeId == id) {
|
||||
_activeId = _entries.keys.firstOrNull;
|
||||
}
|
||||
_sync();
|
||||
}
|
||||
|
||||
static Future<void> _sync() async {
|
||||
// Android: update foreground service notifications
|
||||
if (isAndroid) {
|
||||
final isRunning = await MethodChans.isServiceRunning();
|
||||
if (_entries.isEmpty) {
|
||||
if (isRunning) {
|
||||
MethodChans.stopService();
|
||||
}
|
||||
await MethodChans.updateSessions(jsonEncode({'sessions': []}));
|
||||
} else {
|
||||
if (!isRunning) {
|
||||
MethodChans.startService();
|
||||
}
|
||||
final payload = jsonEncode({'sessions': _entries.values.map((e) => e.info.toJson()).toList()});
|
||||
await MethodChans.updateSessions(payload);
|
||||
}
|
||||
}
|
||||
|
||||
// iOS: manage Live Activity timer
|
||||
if (isIOS) {
|
||||
if (_entries.isEmpty) {
|
||||
_updateTimer?.cancel();
|
||||
_updateTimer = null;
|
||||
await MethodChans.stopLiveActivity();
|
||||
} else {
|
||||
// Start timer if not already running
|
||||
_updateTimer ??= Timer.periodic(_updateInterval, (_) => _updateLiveActivity());
|
||||
// Immediately update for immediate feedback
|
||||
await _updateLiveActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _updateLiveActivity() async {
|
||||
if (!isIOS || _entries.isEmpty) return;
|
||||
|
||||
final connectionCount = _entries.length;
|
||||
|
||||
if (connectionCount == 1) {
|
||||
// Single connection: show hostname
|
||||
final id = _activeId ?? _entries.keys.first;
|
||||
final entry = _entries[id];
|
||||
if (entry == null) return;
|
||||
final payload = jsonEncode({
|
||||
...entry.info.toJson(),
|
||||
'hasTerminal': entry.hasTerminalUI,
|
||||
'connectionCount': connectionCount,
|
||||
});
|
||||
await MethodChans.updateLiveActivity(payload);
|
||||
} else {
|
||||
// Multiple connections: show connection count
|
||||
final id = _activeId ?? _entries.keys.first;
|
||||
final entry = _entries[id];
|
||||
if (entry == null) return;
|
||||
final payload = jsonEncode({
|
||||
'id': 'multi_connections',
|
||||
'title': '$connectionCount connections',
|
||||
'subtitle': 'Multiple SSH sessions active',
|
||||
'startTimeMs': entry.info.startTimeMs,
|
||||
'status': TermSessionStatus.connected.toString(),
|
||||
'hasTerminal': entry.hasTerminalUI,
|
||||
'connectionCount': connectionCount,
|
||||
});
|
||||
await MethodChans.updateLiveActivity(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark which session is actively displayed in UI (for iOS Live Activity).
|
||||
static void setActive(String id, {bool hasTerminal = true}) {
|
||||
_activeId = id;
|
||||
final old = _entries[id];
|
||||
if (old != null) {
|
||||
_entries[id] = _Entry(old.info, old.disconnect, hasTerminalUI: hasTerminal);
|
||||
_sync();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop Live Activity when app is closed/terminated (iOS only).
|
||||
static Future<void> stopLiveActivityOnAppClose() async {
|
||||
if (!isIOS) return;
|
||||
|
||||
// Cancel any running timers
|
||||
_updateTimer?.cancel();
|
||||
_updateTimer = null;
|
||||
|
||||
// Stop the Live Activity
|
||||
await MethodChans.stopLiveActivity();
|
||||
}
|
||||
}
|
||||
|
||||
class _Entry {
|
||||
final TermSessionInfo info;
|
||||
final VoidCallback? disconnect;
|
||||
final bool hasTerminalUI;
|
||||
_Entry(this.info, this.disconnect, {this.hasTerminalUI = true});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
120
lib/intro.dart
120
lib/intro.dart
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -8,7 +8,7 @@ extension _App on _AppSettingsPageState {
|
||||
_buildThemeMode(),
|
||||
_buildAppColor(),
|
||||
_buildCheckUpdate(),
|
||||
PlatformPublicSettings.buildBioAuth(),
|
||||
PlatformPublicSettings.buildBioAuth,
|
||||
if (specific != null) specific,
|
||||
_buildAppMore(),
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
''';
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user