mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-15 04:34:34 +01:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b0d9a18f | ||
|
|
56e67f4725 | ||
|
|
3b7fdf36fb | ||
|
|
5291d316a2 | ||
|
|
4c369546da | ||
|
|
12a243d139 | ||
|
|
a97b3cf43e | ||
|
|
53a7c0d8ff | ||
|
|
9cb705f8dd | ||
|
|
8270674b7d | ||
|
|
24fd4b782d | ||
|
|
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 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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 = 1231;
|
||||
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.1231;
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
|
||||
import 'package:server_box/data/model/server/dist.dart';
|
||||
import 'package:server_box/data/model/server/server.dart';
|
||||
import 'package:server_box/data/provider/server/single.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
|
||||
extension LogoExt on Server {
|
||||
extension LogoExt on ServerState {
|
||||
String? getLogoUrl(BuildContext context) {
|
||||
var logoUrl = spi.custom?.logoUrl ?? Stores.setting.serverLogoUrl.fetch().selfNotEmptyOrNull;
|
||||
if (logoUrl == null) {
|
||||
@@ -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,24 @@ final class BakSyncer extends SyncIface {
|
||||
const BakSyncer._() : super();
|
||||
|
||||
@override
|
||||
Future<void> saveToFile() => BackupV2.backup();
|
||||
Future<void> saveToFile() async {
|
||||
final pwd = await SecureStoreProps.bakPwd.read();
|
||||
await BackupV2.backup(null, pwd?.isEmpty == true ? 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 +40,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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ Future<SSHClient> genClient(
|
||||
Loggers.app.warning('genClient', e);
|
||||
if (spi.alterUrl == null) rethrow;
|
||||
try {
|
||||
final res = spi.fromStringUrl();
|
||||
final res = spi.parseAlterUrl();
|
||||
alterUser = res.$2;
|
||||
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
|
||||
} catch (e) {
|
||||
|
||||
84
lib/core/utils/server_dedup.dart
Normal file
84
lib/core/utils/server_dedup.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/store/server.dart';
|
||||
|
||||
class ServerDeduplication {
|
||||
/// Remove duplicate servers from the import list based on existing servers
|
||||
/// Returns the deduplicated list
|
||||
static List<Spi> deduplicateServers(List<Spi> importedServers) {
|
||||
final existingServers = ServerStore.instance.fetch();
|
||||
final deduplicated = <Spi>[];
|
||||
|
||||
for (final imported in importedServers) {
|
||||
if (!_isDuplicate(imported, existingServers)) {
|
||||
deduplicated.add(imported);
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated;
|
||||
}
|
||||
|
||||
/// Check if an imported server is a duplicate of an existing server
|
||||
static bool _isDuplicate(Spi imported, List<Spi> existing) {
|
||||
for (final existingSpi in existing) {
|
||||
if (imported.isSameAs(existingSpi)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Resolve name conflicts by appending suffixes
|
||||
static List<Spi> resolveNameConflicts(List<Spi> importedServers) {
|
||||
final existingServers = ServerStore.instance.fetch();
|
||||
final existingNames = existingServers.map((s) => s.name).toSet();
|
||||
final processedNames = <String>{};
|
||||
final result = <Spi>[];
|
||||
|
||||
for (final server in importedServers) {
|
||||
String newName = server.name;
|
||||
int suffix = 1;
|
||||
|
||||
// Check against both existing servers and already processed servers
|
||||
while (existingNames.contains(newName) || processedNames.contains(newName)) {
|
||||
newName = '${server.name} ($suffix)';
|
||||
suffix++;
|
||||
}
|
||||
|
||||
processedNames.add(newName);
|
||||
|
||||
if (newName != server.name) {
|
||||
result.add(server.copyWith(name: newName));
|
||||
} else {
|
||||
result.add(server);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get summary of import operation
|
||||
static ImportSummary getImportSummary(List<Spi> originalList, List<Spi> deduplicatedList) {
|
||||
final duplicateCount = originalList.length - deduplicatedList.length;
|
||||
return ImportSummary(
|
||||
total: originalList.length,
|
||||
duplicates: duplicateCount,
|
||||
toImport: deduplicatedList.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImportSummary {
|
||||
final int total;
|
||||
final int duplicates;
|
||||
final int toImport;
|
||||
|
||||
const ImportSummary({
|
||||
required this.total,
|
||||
required this.duplicates,
|
||||
required this.toImport,
|
||||
});
|
||||
|
||||
bool get hasDuplicates => duplicates > 0;
|
||||
bool get hasItemsToImport => toImport > 0;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
187
lib/core/utils/ssh_config.dart
Normal file
187
lib/core/utils/ssh_config.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'dart:io';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
|
||||
/// Utility class to parse SSH config files under `~/.ssh/config`
|
||||
abstract final class SSHConfig {
|
||||
static const String _defaultPath = '~/.ssh/config';
|
||||
|
||||
static String? get _homePath {
|
||||
final homePath = isWindows ? Platform.environment['USERPROFILE'] : Platform.environment['HOME'];
|
||||
if (homePath == null || homePath.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return homePath;
|
||||
}
|
||||
|
||||
/// Get possible SSH config file paths, with macOS-specific handling
|
||||
static List<String> get _possibleConfigPaths {
|
||||
final paths = <String>[];
|
||||
final homePath = _homePath;
|
||||
|
||||
if (homePath != null) {
|
||||
// Standard path
|
||||
paths.add('$homePath/.ssh/config');
|
||||
|
||||
// On macOS, also try the actual user home directory
|
||||
if (isMacOS) {
|
||||
// Try to get the real user home directory
|
||||
final username = Platform.environment['USER'];
|
||||
if (username != null) {
|
||||
paths.add('/Users/$username/.ssh/config');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// Parse SSH config file and return a list of Spi objects
|
||||
static Future<List<Spi>> parseConfig([String? configPath]) async {
|
||||
final (file, exists) = configExists(configPath);
|
||||
if (!exists || file == null) {
|
||||
Loggers.app.info('SSH config file does not exist at path: ${configPath ?? _defaultPath}');
|
||||
return [];
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
return _parseSSHConfig(content);
|
||||
}
|
||||
|
||||
/// Parse SSH config content
|
||||
static List<Spi> _parseSSHConfig(String content) {
|
||||
final servers = <Spi>[];
|
||||
final lines = content.split('\n');
|
||||
|
||||
String? currentHost;
|
||||
String? hostname;
|
||||
String? user;
|
||||
int port = 22;
|
||||
String? identityFile;
|
||||
String? jumpHost;
|
||||
|
||||
void addServer() {
|
||||
if (currentHost != null && currentHost != '*' && hostname != null) {
|
||||
final spi = Spi(
|
||||
name: currentHost,
|
||||
ip: hostname,
|
||||
port: port,
|
||||
user: user ?? 'root', // Default user is 'root'
|
||||
keyId: identityFile,
|
||||
jumpId: jumpHost,
|
||||
);
|
||||
servers.add(spi);
|
||||
}
|
||||
}
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Handle inline comments
|
||||
final commentIndex = trimmed.indexOf('#');
|
||||
final cleanLine = commentIndex != -1 ? trimmed.substring(0, commentIndex).trim() : trimmed;
|
||||
if (cleanLine.isEmpty) continue;
|
||||
|
||||
final parts = cleanLine.split(RegExp(r'\s+'));
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
final key = parts[0].toLowerCase();
|
||||
var value = parts.sublist(1).join(' ');
|
||||
|
||||
// Remove quotes from values
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'host':
|
||||
// Save previous host config
|
||||
addServer();
|
||||
|
||||
// Reset for new host
|
||||
final originalValue = parts.sublist(1).join(' ');
|
||||
final isQuoted =
|
||||
(originalValue.startsWith('"') && originalValue.endsWith('"')) ||
|
||||
(originalValue.startsWith("'") && originalValue.endsWith("'"));
|
||||
|
||||
currentHost = value;
|
||||
// Skip hosts with multiple patterns (contains spaces but not quoted)
|
||||
if (currentHost.contains(' ') && !isQuoted) {
|
||||
currentHost = null; // Mark as invalid to skip
|
||||
}
|
||||
hostname = null;
|
||||
user = null;
|
||||
port = 22;
|
||||
identityFile = null;
|
||||
jumpHost = null;
|
||||
break;
|
||||
|
||||
case 'hostname':
|
||||
hostname = value;
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
user = value;
|
||||
break;
|
||||
|
||||
case 'port':
|
||||
port = int.tryParse(value) ?? 22;
|
||||
break;
|
||||
|
||||
case 'identityfile':
|
||||
identityFile = value; // Store the path directly
|
||||
break;
|
||||
|
||||
case 'proxyjump':
|
||||
case 'proxycommand':
|
||||
jumpHost = _extractJumpHost(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last server
|
||||
addServer();
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
/// Extract jump host from ProxyJump or ProxyCommand
|
||||
static String? _extractJumpHost(String value) {
|
||||
// For ProxyJump, the format is usually: user@host:port
|
||||
// For ProxyCommand, it's more complex and might need custom parsing
|
||||
if (value.contains('@')) {
|
||||
return value.split(' ').first;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if SSH config file exists, trying multiple possible paths
|
||||
static (File?, bool) configExists([String? configPath]) {
|
||||
if (configPath != null) {
|
||||
// If specific path is provided, use it directly
|
||||
final homePath = _homePath;
|
||||
if (homePath == null) {
|
||||
Loggers.app.warning('Cannot determine home directory for SSH config parsing.');
|
||||
return (null, false);
|
||||
}
|
||||
final expandedPath = configPath.replaceFirst('~', homePath);
|
||||
dprint('Checking SSH config at path: $expandedPath');
|
||||
final file = File(expandedPath);
|
||||
return (file, file.existsSync());
|
||||
}
|
||||
|
||||
// Try multiple possible paths
|
||||
for (final path in _possibleConfigPaths) {
|
||||
dprint('Checking SSH config at path: $path');
|
||||
final file = File(path);
|
||||
if (file.existsSync()) {
|
||||
dprint('Found SSH config at: $path');
|
||||
return (file, true);
|
||||
}
|
||||
}
|
||||
|
||||
dprint('SSH config file not found in any of the expected locations');
|
||||
return (null, false);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'dart:io';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:server_box/data/provider/private_key.dart';
|
||||
import 'package:server_box/data/provider/server/all.dart';
|
||||
import 'package:server_box/data/provider/snippet.dart';
|
||||
import 'package:server_box/data/res/misc.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
|
||||
@@ -44,17 +47,17 @@ abstract class BackupV2 with _$BackupV2 implements Mergeable {
|
||||
Future<void> merge({bool force = false}) async {
|
||||
_loggerV2.info('Merging...');
|
||||
|
||||
// Merge each store
|
||||
await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
|
||||
await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
|
||||
await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
|
||||
// Merge each store and check if changes were made
|
||||
final serverChanged = await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
|
||||
final snippetChanged = await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
|
||||
final keyChanged = await Mergeable.mergeStore(backupData: keys, store: Stores.key, force: force);
|
||||
await Mergeable.mergeStore(backupData: container, store: Stores.container, force: force);
|
||||
await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force);
|
||||
await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force);
|
||||
|
||||
// Reload providers and notify listeners
|
||||
Provider.reload();
|
||||
RNodes.app.notify();
|
||||
if (serverChanged) GlobalRef.gRef?.read(serversNotifierProvider.notifier).reload();
|
||||
if (snippetChanged) GlobalRef.gRef?.read(snippetNotifierProvider.notifier).reload();
|
||||
if (keyChanged) GlobalRef.gRef?.read(privateKeyNotifierProvider.notifier).reload();
|
||||
|
||||
_loggerV2.info('Merge completed');
|
||||
}
|
||||
|
||||
@@ -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,18 @@ 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 {
|
||||
final path = await BackupV2.backup(null, password.isEmpty ? null : password);
|
||||
final saved = await SecureStoreProps.bakPwd.read();
|
||||
final password = saved?.isEmpty == true ? null : saved;
|
||||
|
||||
final path = await BackupV2.backup(null, password?.isEmpty == true ? null : password);
|
||||
await source.saveContent(path);
|
||||
|
||||
// Show success message for clipboard source
|
||||
if (source is ClipboardBackupSource) {
|
||||
context.showSnackBar(libL10n.success);
|
||||
}
|
||||
@@ -41,38 +39,6 @@ class BackupService {
|
||||
await restoreFromText(context, text);
|
||||
}
|
||||
|
||||
/// Handle password dialog for backup operations
|
||||
static Future<String?> _getBackupPassword(BuildContext context) async {
|
||||
final savedPassword = await Stores.setting.backupasswd.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),
|
||||
actions: [
|
||||
Btn.cancel(),
|
||||
TextButton(onPressed: () => context.pop(false), child: Text(l10n.backupPasswordSet)),
|
||||
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;
|
||||
}
|
||||
|
||||
/// Handle restore from text with decryption support
|
||||
static Future<void> restoreFromText(BuildContext context, String text) async {
|
||||
// Check if backup is encrypted
|
||||
@@ -95,7 +61,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(
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:server_box/core/extension/context/locale.dart';
|
||||
enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus }
|
||||
|
||||
class SSHErr extends Err<SSHErrType> {
|
||||
SSHErr({required super.type, super.message});
|
||||
const SSHErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => switch (type) {
|
||||
@@ -29,7 +29,7 @@ enum ContainerErrType {
|
||||
}
|
||||
|
||||
class ContainerErr extends Err<ContainerErrType> {
|
||||
ContainerErr({required super.type, super.message});
|
||||
const ContainerErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => null;
|
||||
@@ -38,7 +38,7 @@ class ContainerErr extends Err<ContainerErrType> {
|
||||
enum ICloudErrType { generic, notFound, multipleFiles }
|
||||
|
||||
class ICloudErr extends Err<ICloudErrType> {
|
||||
ICloudErr({required super.type, super.message});
|
||||
const ICloudErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => null;
|
||||
@@ -47,7 +47,7 @@ class ICloudErr extends Err<ICloudErrType> {
|
||||
enum WebdavErrType { generic, notFound }
|
||||
|
||||
class WebdavErr extends Err<WebdavErrType> {
|
||||
WebdavErr({required super.type, super.message});
|
||||
const WebdavErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => null;
|
||||
@@ -56,7 +56,7 @@ class WebdavErr extends Err<WebdavErrType> {
|
||||
enum PveErrType { unknown, net, loginFailed }
|
||||
|
||||
class PveErr extends Err<PveErrType> {
|
||||
PveErr({required super.type, super.message});
|
||||
const PveErr({required super.type, super.message});
|
||||
|
||||
@override
|
||||
String? get solution => null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:server_box/data/model/app/scripts/script_builders.dart';
|
||||
import 'package:server_box/data/model/app/scripts/script_consts.dart';
|
||||
import 'package:server_box/data/model/server/system.dart';
|
||||
import 'package:server_box/data/provider/server.dart';
|
||||
|
||||
/// Shell functions available in the ServerBox application
|
||||
enum ShellFunc {
|
||||
@@ -26,8 +25,8 @@ enum ShellFunc {
|
||||
};
|
||||
|
||||
/// Execute this shell function on the specified server
|
||||
String exec(String id, {SystemType? systemType}) {
|
||||
final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType);
|
||||
String exec(String id, {SystemType? systemType, required String? customDir}) {
|
||||
final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType, customDir: customDir);
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
final builder = ScriptBuilderFactory.getBuilder(isWindows);
|
||||
|
||||
@@ -51,11 +50,10 @@ class ShellFuncManager {
|
||||
/// Get the script directory for the given [id].
|
||||
///
|
||||
/// Checks for custom script directory first, then falls back to default.
|
||||
static String getScriptDir(String id, {SystemType? systemType}) {
|
||||
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir;
|
||||
static String getScriptDir(String id, {SystemType? systemType, required String? customDir}) {
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
|
||||
if (customScriptDir != null) return _normalizeDir(customScriptDir, isWindows);
|
||||
if (customDir != null) return _normalizeDir(customDir, isWindows);
|
||||
return ScriptPaths.getScriptDir(id, isWindows: isWindows);
|
||||
}
|
||||
|
||||
@@ -66,11 +64,10 @@ class ShellFuncManager {
|
||||
}
|
||||
|
||||
/// Get the full script path for the given [id]
|
||||
static String getScriptPath(String id, {SystemType? systemType}) {
|
||||
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir;
|
||||
if (customScriptDir != null) {
|
||||
static String getScriptPath(String id, {SystemType? systemType, required String? customDir}) {
|
||||
if (customDir != null) {
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
final normalizedDir = _normalizeDir(customScriptDir, isWindows);
|
||||
final normalizedDir = _normalizeDir(customDir, isWindows);
|
||||
final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile;
|
||||
final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator;
|
||||
return '$normalizedDir$separator$fileName';
|
||||
@@ -81,8 +78,8 @@ class ShellFuncManager {
|
||||
}
|
||||
|
||||
/// Get the installation shell command for the script
|
||||
static String getInstallShellCmd(String id, {SystemType? systemType}) {
|
||||
final scriptDir = getScriptDir(id, systemType: systemType);
|
||||
static String getInstallShellCmd(String id, {SystemType? systemType, required String? customDir}) {
|
||||
final scriptDir = getScriptDir(id, systemType: systemType, customDir: customDir);
|
||||
final isWindows = systemType == SystemType.windows;
|
||||
final normalizedDir = _normalizeDir(scriptDir, isWindows);
|
||||
final builder = ScriptBuilderFactory.getBuilder(isWindows);
|
||||
@@ -93,7 +90,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;
|
||||
}
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
|
||||
import 'package:server_box/data/model/server/amd.dart';
|
||||
@@ -11,25 +10,9 @@ import 'package:server_box/data/model/server/memory.dart';
|
||||
import 'package:server_box/data/model/server/net_speed.dart';
|
||||
import 'package:server_box/data/model/server/nvdia.dart';
|
||||
import 'package:server_box/data/model/server/sensors.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/server/system.dart';
|
||||
import 'package:server_box/data/model/server/temp.dart';
|
||||
|
||||
class Server {
|
||||
Spi spi;
|
||||
ServerStatus status;
|
||||
SSHClient? client;
|
||||
ServerConn conn;
|
||||
|
||||
Server(this.spi, this.status, this.conn, {this.client});
|
||||
|
||||
bool get needGenClient => conn < ServerConn.connecting;
|
||||
|
||||
bool get canViewDetails => conn == ServerConn.finished;
|
||||
|
||||
String get id => spi.id;
|
||||
}
|
||||
|
||||
class ServerStatus {
|
||||
Cpus cpu;
|
||||
Memory mem;
|
||||
|
||||
@@ -4,10 +4,8 @@ import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/server/custom.dart';
|
||||
import 'package:server_box/data/model/server/server.dart';
|
||||
import 'package:server_box/data/model/server/system.dart';
|
||||
import 'package:server_box/data/model/server/wol_cfg.dart';
|
||||
import 'package:server_box/data/provider/server.dart';
|
||||
import 'package:server_box/data/store/server.dart';
|
||||
|
||||
part 'server_private_info.freezed.dart';
|
||||
@@ -58,6 +56,7 @@ abstract class Spi with _$Spi {
|
||||
@override
|
||||
String toString() => 'Spi<$oldId>';
|
||||
|
||||
/// Parse the [id], if it's null or empty, generate a new one.
|
||||
static String parseId(Object? id) {
|
||||
if (id == null || id is! String || id.isEmpty) return ShortId.generate();
|
||||
return id;
|
||||
@@ -85,23 +84,26 @@ extension Spix on Spi {
|
||||
return newSpi.id;
|
||||
}
|
||||
|
||||
/// Json encode to string.
|
||||
String toJsonString() => json.encode(toJson());
|
||||
|
||||
VNode<Server>? get server => ServerProvider.pick(spi: this);
|
||||
VNode<Server>? get jumpServer => ServerProvider.pick(id: jumpId);
|
||||
|
||||
bool shouldReconnect(Spi old) {
|
||||
return user != old.user ||
|
||||
ip != old.ip ||
|
||||
port != old.port ||
|
||||
pwd != old.pwd ||
|
||||
keyId != old.keyId ||
|
||||
alterUrl != old.alterUrl ||
|
||||
jumpId != old.jumpId ||
|
||||
custom?.cmds != old.custom?.cmds;
|
||||
/// Returns true if the connection info is the same as [other].
|
||||
bool isSameAs(Spi other) {
|
||||
return user == other.user &&
|
||||
ip == other.ip &&
|
||||
port == other.port &&
|
||||
pwd == other.pwd &&
|
||||
keyId == other.keyId &&
|
||||
jumpId == other.jumpId;
|
||||
}
|
||||
|
||||
(String ip, String usr, int port) fromStringUrl() {
|
||||
/// Returns true if the connection should be re-established.
|
||||
bool shouldReconnect(Spi old) {
|
||||
return !isSameAs(old) || alterUrl != old.alterUrl || custom?.cmds != old.custom?.cmds;
|
||||
}
|
||||
|
||||
/// Parse the [alterUrl] to (ip, user, port).
|
||||
(String ip, String usr, int port) parseAlterUrl() {
|
||||
if (alterUrl == null) {
|
||||
throw SSHErr(type: SSHErrType.connect, message: 'alterUrl is null');
|
||||
}
|
||||
@@ -146,5 +148,6 @@ extension Spix on Spi {
|
||||
id: 'id',
|
||||
);
|
||||
|
||||
/// Returns true if the user is 'root'.
|
||||
bool get isRoot => user == 'root';
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -103,6 +104,11 @@ Future<ServerStatus> _getLinuxStatus(ServerStatusUpdateReq req) async {
|
||||
|
||||
try {
|
||||
req.ss.disk = Disk.parse(StatusCmdType.disk.findInMap(parsedOutput));
|
||||
} catch (e, s) {
|
||||
Loggers.app.warning(e, s);
|
||||
}
|
||||
|
||||
try {
|
||||
req.ss.diskUsage = DiskUsage.parse(req.ss.disk);
|
||||
} catch (e, s) {
|
||||
Loggers.app.warning(e, s);
|
||||
@@ -259,9 +265,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 +273,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;
|
||||
}
|
||||
|
||||
@@ -297,13 +299,16 @@ String? _parseSysVer(String raw) {
|
||||
String? _parseHostName(String raw) {
|
||||
if (raw.isEmpty) return null;
|
||||
if (raw.contains(ScriptConstants.scriptFile)) return null;
|
||||
return raw;
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// 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 +377,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 +399,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 +505,6 @@ void _parseWindowsGpuData(ServerStatusUpdateReq req, Map<String, String> parsedO
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
List<Battery> _parseWindowsBatteries(String raw) {
|
||||
try {
|
||||
final dynamic jsonData = json.decode(raw);
|
||||
@@ -515,24 +513,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 +572,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 +585,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 +620,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 +647,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 +656,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 +675,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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,20 @@ part of 'app.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$appProviderHash() => r'8378ec9d0a9c8d99cc05805047cd2d52ac4dbb56';
|
||||
String _$appStatesHash() => r'ef96f10f6fff0f3dd6d3128ebf070ad79cbc8bc9';
|
||||
|
||||
/// See also [AppProvider].
|
||||
@ProviderFor(AppProvider)
|
||||
final appProviderProvider = NotifierProvider<AppProvider, AppState>.internal(
|
||||
AppProvider.new,
|
||||
name: r'appProviderProvider',
|
||||
/// See also [AppStates].
|
||||
@ProviderFor(AppStates)
|
||||
final appStatesProvider = NotifierProvider<AppStates, AppState>.internal(
|
||||
AppStates.new,
|
||||
name: r'appStatesProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$appProviderHash,
|
||||
: _$appStatesHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AppProvider = Notifier<AppState>;
|
||||
typedef _$AppStates = 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
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'dart:convert';
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:server_box/core/extension/ssh_client.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/app/scripts/script_consts.dart';
|
||||
@@ -12,63 +14,66 @@ import 'package:server_box/data/model/container/ps.dart';
|
||||
import 'package:server_box/data/model/container/type.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
|
||||
part 'container.freezed.dart';
|
||||
part 'container.g.dart';
|
||||
|
||||
final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found");
|
||||
|
||||
class ContainerProvider extends ChangeNotifier {
|
||||
final SSHClient? client;
|
||||
final String userName;
|
||||
final String hostId;
|
||||
final BuildContext context;
|
||||
List<ContainerPs>? items;
|
||||
List<ContainerImg>? images;
|
||||
String? version;
|
||||
ContainerErr? error;
|
||||
String? runLog;
|
||||
ContainerType type;
|
||||
var sudoCompleter = Completer<bool>();
|
||||
bool isBusy = false;
|
||||
@freezed
|
||||
abstract class ContainerState with _$ContainerState {
|
||||
const factory ContainerState({
|
||||
@Default(null) List<ContainerPs>? items,
|
||||
@Default(null) List<ContainerImg>? images,
|
||||
@Default(null) String? version,
|
||||
@Default(null) ContainerErr? error,
|
||||
@Default(null) String? runLog,
|
||||
@Default(ContainerType.docker) ContainerType type,
|
||||
@Default(false) bool isBusy,
|
||||
}) = _ContainerState;
|
||||
}
|
||||
|
||||
ContainerProvider({
|
||||
required this.client,
|
||||
required this.userName,
|
||||
required this.hostId,
|
||||
required this.context,
|
||||
}) : type = Stores.container.getType(hostId) {
|
||||
refresh();
|
||||
@riverpod
|
||||
class ContainerNotifier extends _$ContainerNotifier {
|
||||
var sudoCompleter = Completer<bool>();
|
||||
|
||||
@override
|
||||
ContainerState build(SSHClient? client, String userName, String hostId, BuildContext context) {
|
||||
this.client = client;
|
||||
this.userName = userName;
|
||||
this.hostId = hostId;
|
||||
this.context = context;
|
||||
|
||||
final type = Stores.container.getType(hostId);
|
||||
final initialState = ContainerState(type: type);
|
||||
|
||||
// Async initialization
|
||||
Future.microtask(() => refresh());
|
||||
|
||||
return initialState;
|
||||
}
|
||||
|
||||
Future<void> setType(ContainerType type) async {
|
||||
this.type = type;
|
||||
state = state.copyWith(
|
||||
type: type,
|
||||
error: null,
|
||||
runLog: null,
|
||||
items: null,
|
||||
images: null,
|
||||
version: null,
|
||||
);
|
||||
Stores.container.setType(type, hostId);
|
||||
error = runLog = items = images = version = null;
|
||||
sudoCompleter = Completer<bool>();
|
||||
notifyListeners();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
// Future<bool> _checkDockerInstalled(SSHClient client) async {
|
||||
// final session = await client.execute("docker");
|
||||
// await session.done;
|
||||
// // debugPrint('docker code: ${session.exitCode}');
|
||||
// return session.exitCode == 0;
|
||||
// }
|
||||
|
||||
// String _removeSudoPrompts(String value) {
|
||||
// final regex = RegExp(r"\[sudo\] password for \w+:");
|
||||
// if (value.startsWith(regex)) {
|
||||
// return value.replaceFirstMapped(regex, (match) => "");
|
||||
// }
|
||||
// return value;
|
||||
// }
|
||||
|
||||
void _requiresSudo() async {
|
||||
/// Podman is rootless
|
||||
if (type == ContainerType.podman) return sudoCompleter.complete(false);
|
||||
if (state.type == ContainerType.podman) return sudoCompleter.complete(false);
|
||||
if (!Stores.setting.containerTrySudo.fetch()) {
|
||||
return sudoCompleter.complete(false);
|
||||
}
|
||||
|
||||
final res = await client?.run(_wrap(ContainerCmdType.images.exec(type)));
|
||||
final res = await client?.run(_wrap(ContainerCmdType.images.exec(state.type)));
|
||||
if (res?.string.toLowerCase().contains('permission denied') ?? false) {
|
||||
return sudoCompleter.complete(true);
|
||||
}
|
||||
@@ -76,8 +81,8 @@ class ContainerProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> refresh({bool isAuto = false}) async {
|
||||
if (isBusy) return;
|
||||
isBusy = true;
|
||||
if (state.isBusy) return;
|
||||
state = state.copyWith(isBusy: true);
|
||||
|
||||
if (!sudoCompleter.isCompleted) _requiresSudo();
|
||||
|
||||
@@ -85,11 +90,14 @@ class ContainerProvider extends ChangeNotifier {
|
||||
|
||||
/// If sudo is required and auto refresh is enabled, skip the refresh.
|
||||
/// Or this will ask for pwd again and again.
|
||||
if (sudo && isAuto) return;
|
||||
if (sudo && isAuto) {
|
||||
state = state.copyWith(isBusy: false);
|
||||
return;
|
||||
}
|
||||
final includeStats = Stores.setting.containerParseStat.fetch();
|
||||
|
||||
var raw = '';
|
||||
final cmd = _wrap(ContainerCmdType.execAll(type, sudo: sudo, includeStats: includeStats));
|
||||
final cmd = _wrap(ContainerCmdType.execAll(state.type, sudo: sudo, includeStats: includeStats));
|
||||
final code = await client?.execWithPwd(
|
||||
cmd,
|
||||
context: context,
|
||||
@@ -97,75 +105,79 @@ class ContainerProvider extends ChangeNotifier {
|
||||
id: hostId,
|
||||
);
|
||||
|
||||
isBusy = false;
|
||||
state = state.copyWith(isBusy: false);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
/// Code 127 means command not found
|
||||
if (code == 127 || raw.contains(_dockerNotFound)) {
|
||||
error = ContainerErr(type: ContainerErrType.notInstalled);
|
||||
notifyListeners();
|
||||
state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check result segments count
|
||||
final segments = raw.split(ScriptConstants.separator);
|
||||
if (segments.length != ContainerCmdType.values.length) {
|
||||
error = ContainerErr(
|
||||
type: ContainerErrType.segmentsNotMatch,
|
||||
message: 'Container segments: ${segments.length}',
|
||||
state = state.copyWith(
|
||||
error: ContainerErr(
|
||||
type: ContainerErrType.segmentsNotMatch,
|
||||
message: 'Container segments: ${segments.length}',
|
||||
),
|
||||
);
|
||||
Loggers.app.warning('Container segments: ${segments.length}\n$raw');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse version
|
||||
final verRaw = ContainerCmdType.version.find(segments);
|
||||
try {
|
||||
version = json.decode(verRaw)['Client']['Version'];
|
||||
final version = json.decode(verRaw)['Client']['Version'];
|
||||
state = state.copyWith(version: version, error: null);
|
||||
} catch (e, trace) {
|
||||
error = ContainerErr(type: ContainerErrType.invalidVersion, message: '$e');
|
||||
state = state.copyWith(
|
||||
error: ContainerErr(type: ContainerErrType.invalidVersion, message: '$e'),
|
||||
);
|
||||
Loggers.app.warning('Container version failed', e, trace);
|
||||
} finally {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Parse ps
|
||||
final psRaw = ContainerCmdType.ps.find(segments);
|
||||
try {
|
||||
final lines = psRaw.split('\n');
|
||||
if (type == ContainerType.docker) {
|
||||
if (state.type == ContainerType.docker) {
|
||||
/// Due to the fetched data is not in json format, skip table header
|
||||
lines.removeWhere((element) => element.contains('CONTAINER ID'));
|
||||
}
|
||||
lines.removeWhere((element) => element.isEmpty);
|
||||
items = lines.map((e) => ContainerPs.fromRaw(e, type)).toList();
|
||||
final items = lines.map((e) => ContainerPs.fromRaw(e, state.type)).toList();
|
||||
state = state.copyWith(items: items);
|
||||
} catch (e, trace) {
|
||||
error = ContainerErr(type: ContainerErrType.parsePs, message: '$e');
|
||||
state = state.copyWith(
|
||||
error: ContainerErr(type: ContainerErrType.parsePs, message: '$e'),
|
||||
);
|
||||
Loggers.app.warning('Container ps failed', e, trace);
|
||||
} finally {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Parse images
|
||||
final imageRaw = ContainerCmdType.images.find(segments).trim();
|
||||
final isEntireJson = imageRaw.startsWith('[') && imageRaw.endsWith(']');
|
||||
try {
|
||||
List<ContainerImg> images;
|
||||
if (isEntireJson) {
|
||||
images = (json.decode(imageRaw) as List)
|
||||
.map((e) => ContainerImg.fromRawJson(json.encode(e), type))
|
||||
.map((e) => ContainerImg.fromRawJson(json.encode(e), state.type))
|
||||
.toList();
|
||||
} else {
|
||||
final lines = imageRaw.split('\n');
|
||||
lines.removeWhere((element) => element.isEmpty);
|
||||
images = lines.map((e) => ContainerImg.fromRawJson(e, type)).toList();
|
||||
images = lines.map((e) => ContainerImg.fromRawJson(e, state.type)).toList();
|
||||
}
|
||||
state = state.copyWith(images: images);
|
||||
} catch (e, trace) {
|
||||
error = ContainerErr(type: ContainerErrType.parseImages, message: '$e');
|
||||
state = state.copyWith(
|
||||
error: ContainerErr(type: ContainerErrType.parseImages, message: '$e'),
|
||||
);
|
||||
Loggers.app.warning('Container images failed', e, trace);
|
||||
} finally {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Parse stats
|
||||
@@ -173,7 +185,7 @@ class ContainerProvider extends ChangeNotifier {
|
||||
try {
|
||||
final statsLines = statsRaw.split('\n');
|
||||
statsLines.removeWhere((element) => element.isEmpty);
|
||||
for (var item in items!) {
|
||||
for (var item in state.items!) {
|
||||
final id = item.id;
|
||||
if (id == null) continue;
|
||||
final statsLine = statsLines.firstWhereOrNull(
|
||||
@@ -185,10 +197,10 @@ class ContainerProvider extends ChangeNotifier {
|
||||
item.parseStats(statsLine);
|
||||
}
|
||||
} catch (e, trace) {
|
||||
error = ContainerErr(type: ContainerErrType.parseStats, message: '$e');
|
||||
state = state.copyWith(
|
||||
error: ContainerErr(type: ContainerErrType.parseStats, message: '$e'),
|
||||
);
|
||||
Loggers.app.warning('Parse docker stats: $statsRaw', e, trace);
|
||||
} finally {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,25 +235,23 @@ class ContainerProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<ContainerErr?> run(String cmd, {bool autoRefresh = true}) async {
|
||||
cmd = switch (type) {
|
||||
cmd = switch (state.type) {
|
||||
ContainerType.docker => 'docker $cmd',
|
||||
ContainerType.podman => 'podman $cmd',
|
||||
};
|
||||
|
||||
runLog = '';
|
||||
state = state.copyWith(runLog: '');
|
||||
final errs = <String>[];
|
||||
final code = await client?.execWithPwd(
|
||||
_wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd),
|
||||
context: context,
|
||||
onStdout: (data, _) {
|
||||
runLog = '$runLog$data';
|
||||
notifyListeners();
|
||||
state = state.copyWith(runLog: '${state.runLog}$data');
|
||||
},
|
||||
onStderr: (data, _) => errs.add(data),
|
||||
id: hostId,
|
||||
);
|
||||
runLog = null;
|
||||
notifyListeners();
|
||||
state = state.copyWith(runLog: null);
|
||||
|
||||
if (code != 0) {
|
||||
return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim());
|
||||
@@ -262,6 +272,7 @@ class ContainerProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const _jsonFmt = '--format "{{json .}}"';
|
||||
|
||||
enum ContainerCmdType {
|
||||
@@ -298,7 +309,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];
|
||||
|
||||
305
lib/data/provider/container.freezed.dart
Normal file
305
lib/data/provider/container.freezed.dart
Normal file
@@ -0,0 +1,305 @@
|
||||
// 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
|
||||
|
||||
part of 'container.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ContainerState {
|
||||
|
||||
List<ContainerPs>? get items; List<ContainerImg>? get images; String? get version; ContainerErr? get error; String? get runLog; ContainerType get type; bool get isBusy;
|
||||
/// Create a copy of ContainerState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ContainerStateCopyWith<ContainerState> get copyWith => _$ContainerStateCopyWithImpl<ContainerState>(this as ContainerState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ContainerState&&const DeepCollectionEquality().equals(other.items, items)&&const DeepCollectionEquality().equals(other.images, images)&&(identical(other.version, version) || other.version == version)&&(identical(other.error, error) || other.error == error)&&(identical(other.runLog, runLog) || other.runLog == runLog)&&(identical(other.type, type) || other.type == type)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(items),const DeepCollectionEquality().hash(images),version,error,runLog,type,isBusy);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ContainerState(items: $items, images: $images, version: $version, error: $error, runLog: $runLog, type: $type, isBusy: $isBusy)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ContainerStateCopyWith<$Res> {
|
||||
factory $ContainerStateCopyWith(ContainerState value, $Res Function(ContainerState) _then) = _$ContainerStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ContainerStateCopyWithImpl<$Res>
|
||||
implements $ContainerStateCopyWith<$Res> {
|
||||
_$ContainerStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ContainerState _self;
|
||||
final $Res Function(ContainerState) _then;
|
||||
|
||||
/// Create a copy of ContainerState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? items = freezed,Object? images = freezed,Object? version = freezed,Object? error = freezed,Object? runLog = freezed,Object? type = null,Object? isBusy = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
items: freezed == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<ContainerPs>?,images: freezed == images ? _self.images : images // ignore: cast_nullable_to_non_nullable
|
||||
as List<ContainerImg>?,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
|
||||
as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
|
||||
as ContainerErr?,runLog: freezed == runLog ? _self.runLog : runLog // ignore: cast_nullable_to_non_nullable
|
||||
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as ContainerType,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ContainerState].
|
||||
extension ContainerStatePatterns on ContainerState {
|
||||
/// 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( _ContainerState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ContainerState() 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( _ContainerState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ContainerState():
|
||||
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( _ContainerState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ContainerState() 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( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ContainerState() when $default != null:
|
||||
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);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( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ContainerState():
|
||||
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);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( List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ContainerState() when $default != null:
|
||||
return $default(_that.items,_that.images,_that.version,_that.error,_that.runLog,_that.type,_that.isBusy);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ContainerState implements ContainerState {
|
||||
const _ContainerState({final List<ContainerPs>? items = null, final List<ContainerImg>? images = null, this.version = null, this.error = null, this.runLog = null, this.type = ContainerType.docker, this.isBusy = false}): _items = items,_images = images;
|
||||
|
||||
|
||||
final List<ContainerPs>? _items;
|
||||
@override@JsonKey() List<ContainerPs>? get items {
|
||||
final value = _items;
|
||||
if (value == null) return null;
|
||||
if (_items is EqualUnmodifiableListView) return _items;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
final List<ContainerImg>? _images;
|
||||
@override@JsonKey() List<ContainerImg>? get images {
|
||||
final value = _images;
|
||||
if (value == null) return null;
|
||||
if (_images is EqualUnmodifiableListView) return _images;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
@override@JsonKey() final String? version;
|
||||
@override@JsonKey() final ContainerErr? error;
|
||||
@override@JsonKey() final String? runLog;
|
||||
@override@JsonKey() final ContainerType type;
|
||||
@override@JsonKey() final bool isBusy;
|
||||
|
||||
/// Create a copy of ContainerState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ContainerStateCopyWith<_ContainerState> get copyWith => __$ContainerStateCopyWithImpl<_ContainerState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ContainerState&&const DeepCollectionEquality().equals(other._items, _items)&&const DeepCollectionEquality().equals(other._images, _images)&&(identical(other.version, version) || other.version == version)&&(identical(other.error, error) || other.error == error)&&(identical(other.runLog, runLog) || other.runLog == runLog)&&(identical(other.type, type) || other.type == type)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_items),const DeepCollectionEquality().hash(_images),version,error,runLog,type,isBusy);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ContainerState(items: $items, images: $images, version: $version, error: $error, runLog: $runLog, type: $type, isBusy: $isBusy)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ContainerStateCopyWith<$Res> implements $ContainerStateCopyWith<$Res> {
|
||||
factory _$ContainerStateCopyWith(_ContainerState value, $Res Function(_ContainerState) _then) = __$ContainerStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
List<ContainerPs>? items, List<ContainerImg>? images, String? version, ContainerErr? error, String? runLog, ContainerType type, bool isBusy
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ContainerStateCopyWithImpl<$Res>
|
||||
implements _$ContainerStateCopyWith<$Res> {
|
||||
__$ContainerStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ContainerState _self;
|
||||
final $Res Function(_ContainerState) _then;
|
||||
|
||||
/// Create a copy of ContainerState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? items = freezed,Object? images = freezed,Object? version = freezed,Object? error = freezed,Object? runLog = freezed,Object? type = null,Object? isBusy = null,}) {
|
||||
return _then(_ContainerState(
|
||||
items: freezed == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<ContainerPs>?,images: freezed == images ? _self._images : images // ignore: cast_nullable_to_non_nullable
|
||||
as List<ContainerImg>?,version: freezed == version ? _self.version : version // ignore: cast_nullable_to_non_nullable
|
||||
as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
|
||||
as ContainerErr?,runLog: freezed == runLog ? _self.runLog : runLog // ignore: cast_nullable_to_non_nullable
|
||||
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as ContainerType,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
228
lib/data/provider/container.g.dart
Normal file
228
lib/data/provider/container.g.dart
Normal file
@@ -0,0 +1,228 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'container.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$containerNotifierHash() => r'db8f8a6b6071b7b33fbf79128dfed408a5b9fdad';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$ContainerNotifier
|
||||
extends BuildlessAutoDisposeNotifier<ContainerState> {
|
||||
late final SSHClient? client;
|
||||
late final String userName;
|
||||
late final String hostId;
|
||||
late final BuildContext context;
|
||||
|
||||
ContainerState build(
|
||||
SSHClient? client,
|
||||
String userName,
|
||||
String hostId,
|
||||
BuildContext context,
|
||||
);
|
||||
}
|
||||
|
||||
/// See also [ContainerNotifier].
|
||||
@ProviderFor(ContainerNotifier)
|
||||
const containerNotifierProvider = ContainerNotifierFamily();
|
||||
|
||||
/// See also [ContainerNotifier].
|
||||
class ContainerNotifierFamily extends Family<ContainerState> {
|
||||
/// See also [ContainerNotifier].
|
||||
const ContainerNotifierFamily();
|
||||
|
||||
/// See also [ContainerNotifier].
|
||||
ContainerNotifierProvider call(
|
||||
SSHClient? client,
|
||||
String userName,
|
||||
String hostId,
|
||||
BuildContext context,
|
||||
) {
|
||||
return ContainerNotifierProvider(client, userName, hostId, context);
|
||||
}
|
||||
|
||||
@override
|
||||
ContainerNotifierProvider getProviderOverride(
|
||||
covariant ContainerNotifierProvider provider,
|
||||
) {
|
||||
return call(
|
||||
provider.client,
|
||||
provider.userName,
|
||||
provider.hostId,
|
||||
provider.context,
|
||||
);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'containerNotifierProvider';
|
||||
}
|
||||
|
||||
/// See also [ContainerNotifier].
|
||||
class ContainerNotifierProvider
|
||||
extends AutoDisposeNotifierProviderImpl<ContainerNotifier, ContainerState> {
|
||||
/// See also [ContainerNotifier].
|
||||
ContainerNotifierProvider(
|
||||
SSHClient? client,
|
||||
String userName,
|
||||
String hostId,
|
||||
BuildContext context,
|
||||
) : this._internal(
|
||||
() => ContainerNotifier()
|
||||
..client = client
|
||||
..userName = userName
|
||||
..hostId = hostId
|
||||
..context = context,
|
||||
from: containerNotifierProvider,
|
||||
name: r'containerNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$containerNotifierHash,
|
||||
dependencies: ContainerNotifierFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
ContainerNotifierFamily._allTransitiveDependencies,
|
||||
client: client,
|
||||
userName: userName,
|
||||
hostId: hostId,
|
||||
context: context,
|
||||
);
|
||||
|
||||
ContainerNotifierProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.client,
|
||||
required this.userName,
|
||||
required this.hostId,
|
||||
required this.context,
|
||||
}) : super.internal();
|
||||
|
||||
final SSHClient? client;
|
||||
final String userName;
|
||||
final String hostId;
|
||||
final BuildContext context;
|
||||
|
||||
@override
|
||||
ContainerState runNotifierBuild(covariant ContainerNotifier notifier) {
|
||||
return notifier.build(client, userName, hostId, context);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(ContainerNotifier Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: ContainerNotifierProvider._internal(
|
||||
() => create()
|
||||
..client = client
|
||||
..userName = userName
|
||||
..hostId = hostId
|
||||
..context = context,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
client: client,
|
||||
userName: userName,
|
||||
hostId: hostId,
|
||||
context: context,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeNotifierProviderElement<ContainerNotifier, ContainerState>
|
||||
createElement() {
|
||||
return _ContainerNotifierProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ContainerNotifierProvider &&
|
||||
other.client == client &&
|
||||
other.userName == userName &&
|
||||
other.hostId == hostId &&
|
||||
other.context == context;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, client.hashCode);
|
||||
hash = _SystemHash.combine(hash, userName.hashCode);
|
||||
hash = _SystemHash.combine(hash, hostId.hashCode);
|
||||
hash = _SystemHash.combine(hash, context.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin ContainerNotifierRef on AutoDisposeNotifierProviderRef<ContainerState> {
|
||||
/// The parameter `client` of this provider.
|
||||
SSHClient? get client;
|
||||
|
||||
/// The parameter `userName` of this provider.
|
||||
String get userName;
|
||||
|
||||
/// The parameter `hostId` of this provider.
|
||||
String get hostId;
|
||||
|
||||
/// The parameter `context` of this provider.
|
||||
BuildContext get context;
|
||||
}
|
||||
|
||||
class _ContainerNotifierProviderElement
|
||||
extends
|
||||
AutoDisposeNotifierProviderElement<ContainerNotifier, ContainerState>
|
||||
with ContainerNotifierRef {
|
||||
_ContainerNotifierProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
SSHClient? get client => (origin as ContainerNotifierProvider).client;
|
||||
@override
|
||||
String get userName => (origin as ContainerNotifierProvider).userName;
|
||||
@override
|
||||
String get hostId => (origin as ContainerNotifierProvider).hostId;
|
||||
@override
|
||||
BuildContext get context => (origin as ContainerNotifierProvider).context;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1,45 +1,61 @@
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:server_box/core/sync.dart';
|
||||
import 'package:server_box/data/model/server/private_key_info.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
|
||||
class PrivateKeyProvider extends Provider {
|
||||
const PrivateKeyProvider._();
|
||||
static const instance = PrivateKeyProvider._();
|
||||
part 'private_key.freezed.dart';
|
||||
part 'private_key.g.dart';
|
||||
|
||||
static final pkis = <PrivateKeyInfo>[].vn;
|
||||
@freezed
|
||||
abstract class PrivateKeyState with _$PrivateKeyState {
|
||||
const factory PrivateKeyState({@Default(<PrivateKeyInfo>[]) List<PrivateKeyInfo> keys}) = _PrivateKeyState;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class PrivateKeyNotifier extends _$PrivateKeyNotifier {
|
||||
@override
|
||||
void load() {
|
||||
super.load();
|
||||
pkis.value = Stores.key.fetch();
|
||||
PrivateKeyState build() {
|
||||
return _load();
|
||||
}
|
||||
|
||||
static void add(PrivateKeyInfo info) {
|
||||
pkis.value.add(info);
|
||||
pkis.notify();
|
||||
void reload() {
|
||||
final newState = _load();
|
||||
if (newState == state) return;
|
||||
state = newState;
|
||||
}
|
||||
|
||||
PrivateKeyState _load() {
|
||||
final keys = Stores.key.fetch();
|
||||
return stateOrNull?.copyWith(keys: keys) ?? PrivateKeyState(keys: keys);
|
||||
}
|
||||
|
||||
void add(PrivateKeyInfo info) {
|
||||
final newKeys = [...state.keys, info];
|
||||
state = state.copyWith(keys: newKeys);
|
||||
Stores.key.put(info);
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
static void delete(PrivateKeyInfo info) {
|
||||
pkis.value.removeWhere((e) => e.id == info.id);
|
||||
pkis.notify();
|
||||
void delete(PrivateKeyInfo info) {
|
||||
final newKeys = state.keys.where((e) => e.id != info.id).toList();
|
||||
state = state.copyWith(keys: newKeys);
|
||||
Stores.key.delete(info);
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
static void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) {
|
||||
final idx = pkis.value.indexWhere((e) => e.id == old.id);
|
||||
void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) {
|
||||
final keys = [...state.keys];
|
||||
final idx = keys.indexWhere((e) => e.id == old.id);
|
||||
if (idx == -1) {
|
||||
pkis.value.add(newInfo);
|
||||
keys.add(newInfo);
|
||||
Stores.key.put(newInfo);
|
||||
Stores.key.delete(old);
|
||||
} else {
|
||||
pkis.value[idx] = newInfo;
|
||||
keys[idx] = newInfo;
|
||||
Stores.key.put(newInfo);
|
||||
}
|
||||
pkis.notify();
|
||||
state = state.copyWith(keys: keys);
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
}
|
||||
|
||||
277
lib/data/provider/private_key.freezed.dart
Normal file
277
lib/data/provider/private_key.freezed.dart
Normal file
@@ -0,0 +1,277 @@
|
||||
// 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
|
||||
|
||||
part of 'private_key.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$PrivateKeyState {
|
||||
|
||||
List<PrivateKeyInfo> get keys;
|
||||
/// Create a copy of PrivateKeyState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$PrivateKeyStateCopyWith<PrivateKeyState> get copyWith => _$PrivateKeyStateCopyWithImpl<PrivateKeyState>(this as PrivateKeyState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PrivateKeyState&&const DeepCollectionEquality().equals(other.keys, keys));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(keys));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PrivateKeyState(keys: $keys)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $PrivateKeyStateCopyWith<$Res> {
|
||||
factory $PrivateKeyStateCopyWith(PrivateKeyState value, $Res Function(PrivateKeyState) _then) = _$PrivateKeyStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<PrivateKeyInfo> keys
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$PrivateKeyStateCopyWithImpl<$Res>
|
||||
implements $PrivateKeyStateCopyWith<$Res> {
|
||||
_$PrivateKeyStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final PrivateKeyState _self;
|
||||
final $Res Function(PrivateKeyState) _then;
|
||||
|
||||
/// Create a copy of PrivateKeyState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? keys = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
keys: null == keys ? _self.keys : keys // ignore: cast_nullable_to_non_nullable
|
||||
as List<PrivateKeyInfo>,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [PrivateKeyState].
|
||||
extension PrivateKeyStatePatterns on PrivateKeyState {
|
||||
/// 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( _PrivateKeyState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PrivateKeyState() 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( _PrivateKeyState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PrivateKeyState():
|
||||
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( _PrivateKeyState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PrivateKeyState() 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( List<PrivateKeyInfo> keys)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PrivateKeyState() when $default != null:
|
||||
return $default(_that.keys);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( List<PrivateKeyInfo> keys) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PrivateKeyState():
|
||||
return $default(_that.keys);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( List<PrivateKeyInfo> keys)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PrivateKeyState() when $default != null:
|
||||
return $default(_that.keys);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _PrivateKeyState implements PrivateKeyState {
|
||||
const _PrivateKeyState({final List<PrivateKeyInfo> keys = const <PrivateKeyInfo>[]}): _keys = keys;
|
||||
|
||||
|
||||
final List<PrivateKeyInfo> _keys;
|
||||
@override@JsonKey() List<PrivateKeyInfo> get keys {
|
||||
if (_keys is EqualUnmodifiableListView) return _keys;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_keys);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of PrivateKeyState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$PrivateKeyStateCopyWith<_PrivateKeyState> get copyWith => __$PrivateKeyStateCopyWithImpl<_PrivateKeyState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PrivateKeyState&&const DeepCollectionEquality().equals(other._keys, _keys));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_keys));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PrivateKeyState(keys: $keys)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$PrivateKeyStateCopyWith<$Res> implements $PrivateKeyStateCopyWith<$Res> {
|
||||
factory _$PrivateKeyStateCopyWith(_PrivateKeyState value, $Res Function(_PrivateKeyState) _then) = __$PrivateKeyStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
List<PrivateKeyInfo> keys
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$PrivateKeyStateCopyWithImpl<$Res>
|
||||
implements _$PrivateKeyStateCopyWith<$Res> {
|
||||
__$PrivateKeyStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _PrivateKeyState _self;
|
||||
final $Res Function(_PrivateKeyState) _then;
|
||||
|
||||
/// Create a copy of PrivateKeyState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? keys = null,}) {
|
||||
return _then(_PrivateKeyState(
|
||||
keys: null == keys ? _self._keys : keys // ignore: cast_nullable_to_non_nullable
|
||||
as List<PrivateKeyInfo>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
27
lib/data/provider/private_key.g.dart
Normal file
27
lib/data/provider/private_key.g.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'private_key.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$privateKeyNotifierHash() =>
|
||||
r'404836a4409f64d305c1e22f4a57b52985a57b68';
|
||||
|
||||
/// See also [PrivateKeyNotifier].
|
||||
@ProviderFor(PrivateKeyNotifier)
|
||||
final privateKeyNotifierProvider =
|
||||
NotifierProvider<PrivateKeyNotifier, PrivateKeyState>.internal(
|
||||
PrivateKeyNotifier.new,
|
||||
name: r'privateKeyNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$privateKeyNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$PrivateKeyNotifier = Notifier<PrivateKeyState>;
|
||||
// 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
|
||||
82
lib/data/provider/providers.dart
Normal file
82
lib/data/provider/providers.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:server_box/data/provider/app.dart';
|
||||
import 'package:server_box/data/provider/private_key.dart';
|
||||
import 'package:server_box/data/provider/server/all.dart';
|
||||
import 'package:server_box/data/provider/sftp.dart';
|
||||
import 'package:server_box/data/provider/snippet.dart';
|
||||
|
||||
/// !library;
|
||||
/// ref.useNotifier, ref.readProvider, ref.watchProvider
|
||||
///
|
||||
/// Usage:
|
||||
/// - `providers.read.server` -> `ref.read(serversNotifierProvider)`
|
||||
/// - `providers.use.snippet` -> `ref.read(snippetsNotifierProvider.notifier)`
|
||||
|
||||
extension RiverpodNotifiers on ConsumerState {
|
||||
T useNotifier<T extends Notifier<Object?>>(NotifierProvider<T, Object?> provider) {
|
||||
return ref.read(provider.notifier);
|
||||
}
|
||||
|
||||
T readProvider<T>(ProviderBase<T> provider) {
|
||||
return ref.read(provider);
|
||||
}
|
||||
|
||||
T watchProvider<T>(ProviderBase<T> provider) {
|
||||
return ref.watch(provider);
|
||||
}
|
||||
|
||||
MyProviders get providers => MyProviders(ref);
|
||||
}
|
||||
|
||||
final class MyProviders {
|
||||
final WidgetRef ref;
|
||||
const MyProviders(this.ref);
|
||||
|
||||
ReadMyProvider get read => ReadMyProvider(ref);
|
||||
WatchMyProvider get watch => WatchMyProvider(ref);
|
||||
UseNotifierMyProvider get use => UseNotifierMyProvider(ref);
|
||||
}
|
||||
|
||||
final class ReadMyProvider {
|
||||
final WidgetRef ref;
|
||||
const ReadMyProvider(this.ref);
|
||||
|
||||
T call<T>(ProviderBase<T> provider) => ref.read(provider);
|
||||
|
||||
// Specific provider getters
|
||||
ServersState get server => ref.read(serversNotifierProvider);
|
||||
SnippetState get snippet => ref.read(snippetNotifierProvider);
|
||||
AppState get app => ref.read(appStatesProvider);
|
||||
PrivateKeyState get privateKey => ref.read(privateKeyNotifierProvider);
|
||||
SftpState get sftp => ref.read(sftpNotifierProvider);
|
||||
}
|
||||
|
||||
final class WatchMyProvider {
|
||||
final WidgetRef ref;
|
||||
const WatchMyProvider(this.ref);
|
||||
|
||||
T call<T>(ProviderBase<T> provider) => ref.watch(provider);
|
||||
|
||||
// Specific provider getters
|
||||
ServersState get server => ref.watch(serversNotifierProvider);
|
||||
SnippetState get snippet => ref.watch(snippetNotifierProvider);
|
||||
AppState get app => ref.watch(appStatesProvider);
|
||||
PrivateKeyState get privateKey => ref.watch(privateKeyNotifierProvider);
|
||||
SftpState get sftp => ref.watch(sftpNotifierProvider);
|
||||
}
|
||||
|
||||
final class UseNotifierMyProvider {
|
||||
final WidgetRef ref;
|
||||
const UseNotifierMyProvider(this.ref);
|
||||
|
||||
T call<T extends Notifier<Object?>>(NotifierProvider<T, Object?> provider) =>
|
||||
ref.read(provider.notifier);
|
||||
|
||||
// Specific provider notifier getters
|
||||
ServersNotifier get server => ref.read(serversNotifierProvider.notifier);
|
||||
SnippetNotifier get snippet => ref.read(snippetNotifierProvider.notifier);
|
||||
AppStates get app => ref.read(appStatesProvider.notifier);
|
||||
PrivateKeyNotifier get privateKey => ref.read(privateKeyNotifierProvider.notifier);
|
||||
SftpNotifier get sftp => ref.read(sftpNotifierProvider.notifier);
|
||||
}
|
||||
@@ -7,71 +7,90 @@ import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/server/pve.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/provider/server/single.dart';
|
||||
|
||||
part 'pve.freezed.dart';
|
||||
part 'pve.g.dart';
|
||||
|
||||
typedef PveCtrlFunc = Future<bool> Function(String node, String id);
|
||||
|
||||
final class PveProvider extends ChangeNotifier {
|
||||
final Spi spi;
|
||||
@freezed
|
||||
abstract class PveState with _$PveState {
|
||||
const factory PveState({
|
||||
@Default(null) PveErr? error,
|
||||
@Default(null) PveRes? data,
|
||||
@Default(null) String? release,
|
||||
@Default(false) bool isBusy,
|
||||
@Default(false) bool isConnected,
|
||||
}) = _PveState;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class PveNotifier extends _$PveNotifier {
|
||||
late final Spi spi;
|
||||
late String addr;
|
||||
late final SSHClient _client;
|
||||
late final ServerSocket _serverSocket;
|
||||
final List<SSHForwardChannel> _forwards = [];
|
||||
int _localPort = 0;
|
||||
late final Dio session;
|
||||
late final bool _ignoreCert;
|
||||
|
||||
PveProvider({required this.spi}) {
|
||||
final client = spi.server?.value.client;
|
||||
@override
|
||||
PveState build(Spi spiParam) {
|
||||
spi = spiParam;
|
||||
final serverState = ref.watch(serverNotifierProvider(spi.id));
|
||||
final client = serverState.client;
|
||||
if (client == null) {
|
||||
throw Exception('Server client is null');
|
||||
return const PveState(error: PveErr(type: PveErrType.net, message: 'Server client is null'));
|
||||
}
|
||||
_client = client;
|
||||
final addr = spi.custom?.pveAddr;
|
||||
if (addr == null) {
|
||||
err.value = 'PVE address is null';
|
||||
return;
|
||||
return PveState(error: PveErr(type: PveErrType.net, message: 'PVE address is null'));
|
||||
}
|
||||
this.addr = addr;
|
||||
_init();
|
||||
_ignoreCert = spi.custom?.pveIgnoreCert ?? false;
|
||||
_initSession();
|
||||
// Async initialization
|
||||
Future.microtask(() => _init());
|
||||
return const PveState();
|
||||
}
|
||||
|
||||
final err = ValueNotifier<String?>(null);
|
||||
final connected = Completer<void>();
|
||||
void _initSession() {
|
||||
session = Dio()
|
||||
..httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
final client = HttpClient();
|
||||
client.connectionFactory = cf;
|
||||
if (_ignoreCert) {
|
||||
client.badCertificateCallback = (_, _, _) => true;
|
||||
}
|
||||
return client;
|
||||
},
|
||||
validateCertificate: _ignoreCert ? (_, _, _) => true : null,
|
||||
);
|
||||
}
|
||||
|
||||
late final _ignoreCert = spi.custom?.pveIgnoreCert ?? false;
|
||||
late final session = Dio()
|
||||
..httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
final client = HttpClient();
|
||||
client.connectionFactory = cf;
|
||||
if (_ignoreCert) {
|
||||
client.badCertificateCallback = (_, _, _) => true;
|
||||
}
|
||||
return client;
|
||||
},
|
||||
validateCertificate: _ignoreCert ? (_, _, _) => true : null,
|
||||
);
|
||||
|
||||
final data = ValueNotifier<PveRes?>(null);
|
||||
|
||||
bool get onlyOneNode => data.value?.nodes.length == 1;
|
||||
String? release;
|
||||
bool isBusy = false;
|
||||
bool get onlyOneNode => state.data?.nodes.length == 1;
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
await _forward();
|
||||
await _login();
|
||||
await _getRelease();
|
||||
state = state.copyWith(isConnected: true);
|
||||
} on PveErr {
|
||||
err.value = l10n.pveLoginFailed;
|
||||
state = state.copyWith(error: PveErr(type: PveErrType.loginFailed, message: l10n.pveLoginFailed));
|
||||
} catch (e, s) {
|
||||
Loggers.app.warning('PVE init failed', e, s);
|
||||
err.value = e.toString();
|
||||
} finally {
|
||||
connected.complete();
|
||||
state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,72 +165,81 @@ final class PveProvider extends ChangeNotifier {
|
||||
final resp = await session.get('$addr/api2/extjs/version');
|
||||
final version = resp.data['data']['release'] as String?;
|
||||
if (version != null) {
|
||||
release = version;
|
||||
state = state.copyWith(release: version);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> list() async {
|
||||
await connected.future;
|
||||
if (isBusy) return;
|
||||
isBusy = true;
|
||||
if (!state.isConnected || state.isBusy) return;
|
||||
state = state.copyWith(isBusy: true);
|
||||
try {
|
||||
final resp = await session.get('$addr/api2/json/cluster/resources');
|
||||
final res = resp.data['data'] as List;
|
||||
final result = await Computer.shared.start(PveRes.parse, (
|
||||
res,
|
||||
data.value,
|
||||
state.data,
|
||||
));
|
||||
data.value = result;
|
||||
state = state.copyWith(data: result, error: null);
|
||||
} catch (e) {
|
||||
Loggers.app.warning('PVE list failed', e);
|
||||
err.value = e.toString();
|
||||
state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
|
||||
} finally {
|
||||
isBusy = false;
|
||||
state = state.copyWith(isBusy: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> reboot(String node, String id) async {
|
||||
await connected.future;
|
||||
if (!state.isConnected) return false;
|
||||
final resp = await session.post(
|
||||
'$addr/api2/json/nodes/$node/$id/status/reboot',
|
||||
);
|
||||
return _isCtrlSuc(resp);
|
||||
final success = _isCtrlSuc(resp);
|
||||
if (success) await list(); // Refresh data
|
||||
return success;
|
||||
}
|
||||
|
||||
Future<bool> start(String node, String id) async {
|
||||
await connected.future;
|
||||
if (!state.isConnected) return false;
|
||||
final resp = await session.post(
|
||||
'$addr/api2/json/nodes/$node/$id/status/start',
|
||||
);
|
||||
return _isCtrlSuc(resp);
|
||||
final success = _isCtrlSuc(resp);
|
||||
if (success) await list(); // Refresh data
|
||||
return success;
|
||||
}
|
||||
|
||||
Future<bool> stop(String node, String id) async {
|
||||
await connected.future;
|
||||
if (!state.isConnected) return false;
|
||||
final resp = await session.post(
|
||||
'$addr/api2/json/nodes/$node/$id/status/stop',
|
||||
);
|
||||
return _isCtrlSuc(resp);
|
||||
final success = _isCtrlSuc(resp);
|
||||
if (success) await list(); // Refresh data
|
||||
return success;
|
||||
}
|
||||
|
||||
Future<bool> shutdown(String node, String id) async {
|
||||
await connected.future;
|
||||
if (!state.isConnected) return false;
|
||||
final resp = await session.post(
|
||||
'$addr/api2/json/nodes/$node/$id/status/shutdown',
|
||||
);
|
||||
return _isCtrlSuc(resp);
|
||||
final success = _isCtrlSuc(resp);
|
||||
if (success) await list(); // Refresh data
|
||||
return success;
|
||||
}
|
||||
|
||||
bool _isCtrlSuc(Response resp) {
|
||||
return resp.statusCode == 200;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
super.dispose();
|
||||
await _serverSocket.close();
|
||||
try {
|
||||
await _serverSocket.close();
|
||||
} catch (_) {}
|
||||
for (final forward in _forwards) {
|
||||
forward.close();
|
||||
try {
|
||||
forward.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
283
lib/data/provider/pve.freezed.dart
Normal file
283
lib/data/provider/pve.freezed.dart
Normal file
@@ -0,0 +1,283 @@
|
||||
// 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
|
||||
|
||||
part of 'pve.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$PveState {
|
||||
|
||||
PveErr? get error; PveRes? get data; String? get release; bool get isBusy; bool get isConnected;
|
||||
/// Create a copy of PveState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$PveStateCopyWith<PveState> get copyWith => _$PveStateCopyWithImpl<PveState>(this as PveState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PveState&&(identical(other.error, error) || other.error == error)&&(identical(other.data, data) || other.data == data)&&(identical(other.release, release) || other.release == release)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,error,data,release,isBusy,isConnected);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PveState(error: $error, data: $data, release: $release, isBusy: $isBusy, isConnected: $isConnected)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $PveStateCopyWith<$Res> {
|
||||
factory $PveStateCopyWith(PveState value, $Res Function(PveState) _then) = _$PveStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$PveStateCopyWithImpl<$Res>
|
||||
implements $PveStateCopyWith<$Res> {
|
||||
_$PveStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final PveState _self;
|
||||
final $Res Function(PveState) _then;
|
||||
|
||||
/// Create a copy of PveState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? error = freezed,Object? data = freezed,Object? release = freezed,Object? isBusy = null,Object? isConnected = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
|
||||
as PveErr?,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
||||
as PveRes?,release: freezed == release ? _self.release : release // ignore: cast_nullable_to_non_nullable
|
||||
as String?,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [PveState].
|
||||
extension PveStatePatterns on PveState {
|
||||
/// 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( _PveState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PveState() 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( _PveState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PveState():
|
||||
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( _PveState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PveState() 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( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PveState() when $default != null:
|
||||
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);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( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PveState():
|
||||
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);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( PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PveState() when $default != null:
|
||||
return $default(_that.error,_that.data,_that.release,_that.isBusy,_that.isConnected);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _PveState implements PveState {
|
||||
const _PveState({this.error = null, this.data = null, this.release = null, this.isBusy = false, this.isConnected = false});
|
||||
|
||||
|
||||
@override@JsonKey() final PveErr? error;
|
||||
@override@JsonKey() final PveRes? data;
|
||||
@override@JsonKey() final String? release;
|
||||
@override@JsonKey() final bool isBusy;
|
||||
@override@JsonKey() final bool isConnected;
|
||||
|
||||
/// Create a copy of PveState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$PveStateCopyWith<_PveState> get copyWith => __$PveStateCopyWithImpl<_PveState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PveState&&(identical(other.error, error) || other.error == error)&&(identical(other.data, data) || other.data == data)&&(identical(other.release, release) || other.release == release)&&(identical(other.isBusy, isBusy) || other.isBusy == isBusy)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,error,data,release,isBusy,isConnected);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PveState(error: $error, data: $data, release: $release, isBusy: $isBusy, isConnected: $isConnected)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$PveStateCopyWith<$Res> implements $PveStateCopyWith<$Res> {
|
||||
factory _$PveStateCopyWith(_PveState value, $Res Function(_PveState) _then) = __$PveStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
PveErr? error, PveRes? data, String? release, bool isBusy, bool isConnected
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$PveStateCopyWithImpl<$Res>
|
||||
implements _$PveStateCopyWith<$Res> {
|
||||
__$PveStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _PveState _self;
|
||||
final $Res Function(_PveState) _then;
|
||||
|
||||
/// Create a copy of PveState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? error = freezed,Object? data = freezed,Object? release = freezed,Object? isBusy = null,Object? isConnected = null,}) {
|
||||
return _then(_PveState(
|
||||
error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
|
||||
as PveErr?,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
||||
as PveRes?,release: freezed == release ? _self.release : release // ignore: cast_nullable_to_non_nullable
|
||||
as String?,isBusy: null == isBusy ? _self.isBusy : isBusy // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
160
lib/data/provider/pve.g.dart
Normal file
160
lib/data/provider/pve.g.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'pve.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pveNotifierHash() => r'b5da7240db1b9ee7d61f238cebca45821b7a3445';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$PveNotifier extends BuildlessAutoDisposeNotifier<PveState> {
|
||||
late final Spi spiParam;
|
||||
|
||||
PveState build(Spi spiParam);
|
||||
}
|
||||
|
||||
/// See also [PveNotifier].
|
||||
@ProviderFor(PveNotifier)
|
||||
const pveNotifierProvider = PveNotifierFamily();
|
||||
|
||||
/// See also [PveNotifier].
|
||||
class PveNotifierFamily extends Family<PveState> {
|
||||
/// See also [PveNotifier].
|
||||
const PveNotifierFamily();
|
||||
|
||||
/// See also [PveNotifier].
|
||||
PveNotifierProvider call(Spi spiParam) {
|
||||
return PveNotifierProvider(spiParam);
|
||||
}
|
||||
|
||||
@override
|
||||
PveNotifierProvider getProviderOverride(
|
||||
covariant PveNotifierProvider provider,
|
||||
) {
|
||||
return call(provider.spiParam);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'pveNotifierProvider';
|
||||
}
|
||||
|
||||
/// See also [PveNotifier].
|
||||
class PveNotifierProvider
|
||||
extends AutoDisposeNotifierProviderImpl<PveNotifier, PveState> {
|
||||
/// See also [PveNotifier].
|
||||
PveNotifierProvider(Spi spiParam)
|
||||
: this._internal(
|
||||
() => PveNotifier()..spiParam = spiParam,
|
||||
from: pveNotifierProvider,
|
||||
name: r'pveNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$pveNotifierHash,
|
||||
dependencies: PveNotifierFamily._dependencies,
|
||||
allTransitiveDependencies: PveNotifierFamily._allTransitiveDependencies,
|
||||
spiParam: spiParam,
|
||||
);
|
||||
|
||||
PveNotifierProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.spiParam,
|
||||
}) : super.internal();
|
||||
|
||||
final Spi spiParam;
|
||||
|
||||
@override
|
||||
PveState runNotifierBuild(covariant PveNotifier notifier) {
|
||||
return notifier.build(spiParam);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(PveNotifier Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: PveNotifierProvider._internal(
|
||||
() => create()..spiParam = spiParam,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
spiParam: spiParam,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeNotifierProviderElement<PveNotifier, PveState> createElement() {
|
||||
return _PveNotifierProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is PveNotifierProvider && other.spiParam == spiParam;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, spiParam.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin PveNotifierRef on AutoDisposeNotifierProviderRef<PveState> {
|
||||
/// The parameter `spiParam` of this provider.
|
||||
Spi get spiParam;
|
||||
}
|
||||
|
||||
class _PveNotifierProviderElement
|
||||
extends AutoDisposeNotifierProviderElement<PveNotifier, PveState>
|
||||
with PveNotifierRef {
|
||||
_PveNotifierProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
Spi get spiParam => (origin as PveNotifierProvider).spiParam;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1,433 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
// import 'dart:io';
|
||||
|
||||
import 'package:computer/computer.dart';
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:server_box/core/extension/ssh_client.dart';
|
||||
import 'package:server_box/core/sync.dart';
|
||||
import 'package:server_box/core/utils/server.dart';
|
||||
import 'package:server_box/core/utils/ssh_auth.dart';
|
||||
import 'package:server_box/data/helper/system_detector.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/app/scripts/script_consts.dart';
|
||||
import 'package:server_box/data/model/app/scripts/shell_func.dart';
|
||||
import 'package:server_box/data/model/server/server.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/server/server_status_update_req.dart';
|
||||
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';
|
||||
|
||||
class ServerProvider extends Provider {
|
||||
const ServerProvider._();
|
||||
static const instance = ServerProvider._();
|
||||
|
||||
static final Map<String, VNode<Server>> servers = {};
|
||||
static final serverOrder = <String>[].vn;
|
||||
static final _tags = <String>{}.vn;
|
||||
static VNode<Set<String>> get tags => _tags;
|
||||
|
||||
static Timer? _timer;
|
||||
|
||||
static final _manualDisconnectedIds = <String>{};
|
||||
|
||||
static final _serverIdsUpdating = <String, Future<void>?>{};
|
||||
|
||||
@override
|
||||
Future<void> load() async {
|
||||
super.load();
|
||||
// #147
|
||||
// Clear all servers because of restarting app will cause duplicate servers
|
||||
final oldServers = Map<String, VNode<Server>>.from(servers);
|
||||
servers.clear();
|
||||
serverOrder.value.clear();
|
||||
|
||||
final spis = Stores.server.fetch();
|
||||
for (int idx = 0; idx < spis.length; idx++) {
|
||||
final spi = spis[idx];
|
||||
final originServer = oldServers[spi.id];
|
||||
|
||||
/// #258
|
||||
/// If not [shouldReconnect], then keep the old state.
|
||||
if (originServer != null && !originServer.value.spi.shouldReconnect(spi)) {
|
||||
originServer.value.spi = spi;
|
||||
servers[spi.id] = originServer;
|
||||
} else {
|
||||
final newServer = genServer(spi);
|
||||
servers[spi.id] = newServer.vn;
|
||||
}
|
||||
}
|
||||
final serverOrder_ = Stores.setting.serverOrder.fetch();
|
||||
if (serverOrder_.isNotEmpty) {
|
||||
spis.reorder(order: serverOrder_, finder: (n, id) => n.id == id);
|
||||
serverOrder.value.addAll(spis.map((e) => e.id));
|
||||
} else {
|
||||
serverOrder.value.addAll(servers.keys);
|
||||
}
|
||||
// Must use [equals] to compare [Order] here.
|
||||
if (!serverOrder.value.equals(serverOrder_)) {
|
||||
Stores.setting.serverOrder.put(serverOrder.value);
|
||||
}
|
||||
_updateTags();
|
||||
// Must notify here, or the UI will not be updated.
|
||||
serverOrder.notify();
|
||||
}
|
||||
|
||||
/// Get a [Server] by [spi] or [id].
|
||||
///
|
||||
/// Priority: [spi] > [id]
|
||||
static VNode<Server>? pick({Spi? spi, String? id}) {
|
||||
if (spi != null) {
|
||||
return servers[spi.id];
|
||||
}
|
||||
if (id != null) {
|
||||
return servers[id];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void _updateTags() {
|
||||
final tags = <String>{};
|
||||
for (final s in servers.values) {
|
||||
final spiTags = s.value.spi.tags;
|
||||
if (spiTags == null) continue;
|
||||
for (final t in spiTags) {
|
||||
tags.add(t);
|
||||
}
|
||||
}
|
||||
_tags.value = tags;
|
||||
}
|
||||
|
||||
static Server genServer(Spi spi) {
|
||||
return Server(spi, InitStatus.status, ServerConn.disconnected);
|
||||
}
|
||||
|
||||
/// if [spi] is specificed then only refresh this server
|
||||
/// [onlyFailed] only refresh failed servers
|
||||
static Future<void> refresh({Spi? spi, bool onlyFailed = false}) async {
|
||||
if (spi != null) {
|
||||
_manualDisconnectedIds.remove(spi.id);
|
||||
await _getData(spi);
|
||||
return;
|
||||
}
|
||||
|
||||
await Future.wait(
|
||||
servers.values.map((val) async {
|
||||
final s = val.value;
|
||||
if (onlyFailed) {
|
||||
if (s.conn != ServerConn.failed) return;
|
||||
TryLimiter.reset(s.spi.id);
|
||||
}
|
||||
|
||||
if (_manualDisconnectedIds.contains(s.spi.id)) return;
|
||||
|
||||
if (s.conn == ServerConn.disconnected && !s.spi.autoConnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already updating, and if so, wait for it to complete
|
||||
final existingUpdate = _serverIdsUpdating[s.spi.id];
|
||||
if (existingUpdate != null) {
|
||||
// Already updating, wait for the existing update to complete
|
||||
try {
|
||||
await existingUpdate;
|
||||
} catch (e) {
|
||||
// Ignore errors from the existing update, we'll try our own
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a new update operation
|
||||
final updateFuture = _updateServer(s.spi);
|
||||
_serverIdsUpdating[s.spi.id] = updateFuture;
|
||||
|
||||
try {
|
||||
await updateFuture;
|
||||
} finally {
|
||||
_serverIdsUpdating.remove(s.spi.id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _updateServer(Spi spi) async {
|
||||
await _getData(spi);
|
||||
}
|
||||
|
||||
static Future<void> startAutoRefresh() async {
|
||||
var duration = Stores.setting.serverStatusUpdateInterval.fetch();
|
||||
stopAutoRefresh();
|
||||
if (duration == 0) return;
|
||||
if (duration < 0 || duration > 10 || duration == 1) {
|
||||
duration = 3;
|
||||
Loggers.app.warning('Invalid duration: $duration, use default 3');
|
||||
}
|
||||
_timer = Timer.periodic(Duration(seconds: duration), (_) async {
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
|
||||
static void stopAutoRefresh() {
|
||||
if (_timer != null) {
|
||||
_timer!.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
static bool get isAutoRefreshOn => _timer != null;
|
||||
|
||||
static void setDisconnected() {
|
||||
for (final s in servers.values) {
|
||||
s.value.conn = ServerConn.disconnected;
|
||||
s.notify();
|
||||
}
|
||||
//TryLimiter.clear();
|
||||
}
|
||||
|
||||
static void closeServer({String? id}) {
|
||||
if (id == null) {
|
||||
for (final s in servers.values) {
|
||||
_closeOneServer(s.value.spi.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_closeOneServer(id);
|
||||
}
|
||||
|
||||
static void _closeOneServer(String id) {
|
||||
final s = servers[id];
|
||||
if (s == null) {
|
||||
Loggers.app.warning('Server with id $id not found');
|
||||
return;
|
||||
}
|
||||
final item = s.value;
|
||||
item.client?.close();
|
||||
item.client = null;
|
||||
item.conn = ServerConn.disconnected;
|
||||
_manualDisconnectedIds.add(id);
|
||||
s.notify();
|
||||
}
|
||||
|
||||
static void addServer(Spi spi) {
|
||||
servers[spi.id] = genServer(spi).vn;
|
||||
Stores.server.put(spi);
|
||||
serverOrder.value.add(spi.id);
|
||||
serverOrder.notify();
|
||||
Stores.setting.serverOrder.put(serverOrder.value);
|
||||
_updateTags();
|
||||
refresh(spi: spi);
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
static void delServer(String id) {
|
||||
servers.remove(id);
|
||||
serverOrder.value.remove(id);
|
||||
serverOrder.notify();
|
||||
Stores.setting.serverOrder.put(serverOrder.value);
|
||||
Stores.server.delete(id);
|
||||
_updateTags();
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
static void deleteAll() {
|
||||
servers.clear();
|
||||
serverOrder.value.clear();
|
||||
serverOrder.notify();
|
||||
Stores.setting.serverOrder.put(serverOrder.value);
|
||||
Stores.server.clear();
|
||||
_updateTags();
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
static Future<void> updateServer(Spi old, Spi newSpi) async {
|
||||
if (old != newSpi) {
|
||||
Stores.server.update(old, newSpi);
|
||||
servers[old.id]?.value.spi = newSpi;
|
||||
|
||||
if (newSpi.id != old.id) {
|
||||
servers[newSpi.id] = servers[old.id]!;
|
||||
servers.remove(old.id);
|
||||
serverOrder.value.update(old.id, newSpi.id);
|
||||
Stores.setting.serverOrder.put(serverOrder.value);
|
||||
serverOrder.notify();
|
||||
}
|
||||
|
||||
// Only reconnect if neccessary
|
||||
if (newSpi.shouldReconnect(old)) {
|
||||
// Use [newSpi.id] instead of [old.id] because [old.id] may be changed
|
||||
TryLimiter.reset(newSpi.id);
|
||||
refresh(spi: newSpi);
|
||||
}
|
||||
}
|
||||
_updateTags();
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
static void _setServerState(VNode<Server> s, ServerConn ss) {
|
||||
s.value.conn = ss;
|
||||
s.notify();
|
||||
}
|
||||
|
||||
static Future<void> _getData(Spi spi) async {
|
||||
final sid = spi.id;
|
||||
final s = servers[sid];
|
||||
|
||||
if (s == null) return;
|
||||
|
||||
final sv = s.value;
|
||||
if (!TryLimiter.canTry(sid)) {
|
||||
if (sv.conn != ServerConn.failed) {
|
||||
_setServerState(s, ServerConn.failed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sv.status.err = null;
|
||||
|
||||
if (sv.needGenClient || (sv.client?.isClosed ?? true)) {
|
||||
_setServerState(s, ServerConn.connecting);
|
||||
|
||||
final wol = spi.wolCfg;
|
||||
if (wol != null) {
|
||||
try {
|
||||
await wol.wake();
|
||||
} catch (e) {
|
||||
// TryLimiter.inc(sid);
|
||||
// s.status.err = SSHErr(
|
||||
// type: SSHErrType.connect,
|
||||
// message: 'Wake on lan failed: $e',
|
||||
// );
|
||||
// _setServerState(s, ServerConn.failed);
|
||||
Loggers.app.warning('Wake on lan failed', e);
|
||||
// return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final time1 = DateTime.now();
|
||||
sv.client = await genClient(
|
||||
spi,
|
||||
timeout: Duration(seconds: Stores.setting.timeout.fetch()),
|
||||
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi),
|
||||
);
|
||||
final time2 = DateTime.now();
|
||||
final spentTime = time2.difference(time1).inMilliseconds;
|
||||
if (spi.jumpId == null) {
|
||||
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
|
||||
} else {
|
||||
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
|
||||
}
|
||||
} catch (e) {
|
||||
TryLimiter.inc(sid);
|
||||
sv.status.err = SSHErr(type: SSHErrType.connect, message: e.toString());
|
||||
_setServerState(s, ServerConn.failed);
|
||||
|
||||
/// In order to keep privacy, print [spi.name] instead of [spi.id]
|
||||
Loggers.app.warning('Connect to ${spi.name} failed', e);
|
||||
return;
|
||||
}
|
||||
|
||||
_setServerState(s, ServerConn.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;
|
||||
session.stdin.add(scriptRaw);
|
||||
session.stdin.close();
|
||||
}, entry: ShellFuncManager.getInstallShellCmd(spi.id, systemType: detectedSystemType));
|
||||
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
|
||||
ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
|
||||
throw writeScriptResult;
|
||||
}
|
||||
} on SSHAuthAbortError catch (e) {
|
||||
TryLimiter.inc(sid);
|
||||
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
|
||||
sv.status.err = err;
|
||||
Loggers.app.warning(err);
|
||||
_setServerState(s, ServerConn.failed);
|
||||
return;
|
||||
} on SSHAuthFailError catch (e) {
|
||||
TryLimiter.inc(sid);
|
||||
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
|
||||
sv.status.err = err;
|
||||
Loggers.app.warning(err);
|
||||
_setServerState(s, ServerConn.failed);
|
||||
return;
|
||||
} catch (e) {
|
||||
// If max try times < 2 and can't write script, this will stop the status getting and etc.
|
||||
// TryLimiter.inc(sid);
|
||||
final err = SSHErr(type: SSHErrType.writeScript, message: e.toString());
|
||||
sv.status.err = err;
|
||||
Loggers.app.warning(err);
|
||||
_setServerState(s, ServerConn.failed);
|
||||
}
|
||||
}
|
||||
|
||||
if (sv.conn == ServerConn.connecting) return;
|
||||
|
||||
/// Keep [finished] state, or the UI will be refreshed to [loading] state
|
||||
/// instead of the '$Temp | $Uptime'.
|
||||
/// eg: '32C | 7 days'
|
||||
if (sv.conn != ServerConn.finished) {
|
||||
_setServerState(s, ServerConn.loading);
|
||||
}
|
||||
|
||||
List<String>? segments;
|
||||
String? raw;
|
||||
|
||||
try {
|
||||
raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string;
|
||||
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()) {
|
||||
// Keep previous server status when err occurs
|
||||
if (sv.conn != ServerConn.failed && sv.status.more.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
TryLimiter.inc(sid);
|
||||
sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
|
||||
_setServerState(s, ServerConn.failed);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
TryLimiter.inc(sid);
|
||||
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
|
||||
_setServerState(s, ServerConn.failed);
|
||||
Loggers.app.warning('Get status from ${spi.name} failed', e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse script output into command-specific map
|
||||
final parsedOutput = ScriptConstants.parseScriptOutput(raw);
|
||||
|
||||
final req = ServerStatusUpdateReq(
|
||||
ss: sv.status,
|
||||
parsedOutput: parsedOutput,
|
||||
system: sv.status.system,
|
||||
customCmds: spi.custom?.cmds ?? {},
|
||||
);
|
||||
sv.status = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${sv.id}>');
|
||||
} catch (e, trace) {
|
||||
TryLimiter.inc(sid);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Call this every time for setting [Server.isBusy] to false
|
||||
_setServerState(s, ServerConn.finished);
|
||||
// reset try times only after prepared successfully
|
||||
TryLimiter.reset(sid);
|
||||
}
|
||||
}
|
||||
278
lib/data/provider/server/all.dart
Normal file
278
lib/data/provider/server/all.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:server_box/core/sync.dart';
|
||||
import 'package:server_box/data/model/server/server.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/server/try_limiter.dart';
|
||||
import 'package:server_box/data/provider/server/single.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
import 'package:server_box/data/ssh/session_manager.dart';
|
||||
|
||||
part 'all.freezed.dart';
|
||||
part 'all.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ServersState with _$ServersState {
|
||||
const factory ServersState({
|
||||
@Default({}) Map<String, Spi> servers,
|
||||
@Default([]) List<String> serverOrder,
|
||||
@Default(<String>{}) Set<String> tags,
|
||||
@Default(<String>{}) Set<String> manualDisconnectedIds,
|
||||
Timer? autoRefreshTimer,
|
||||
}) = _ServersState;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ServersNotifier extends _$ServersNotifier {
|
||||
@override
|
||||
ServersState build() {
|
||||
return _load();
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
final newState = _load();
|
||||
if (newState == state) return;
|
||||
state = newState;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
ServersState _load() {
|
||||
final spis = Stores.server.fetch();
|
||||
final newServers = <String, Spi>{};
|
||||
final newServerOrder = <String>[];
|
||||
|
||||
for (final spi in spis) {
|
||||
newServers[spi.id] = spi;
|
||||
}
|
||||
|
||||
final serverOrder_ = Stores.setting.serverOrder.fetch();
|
||||
if (serverOrder_.isNotEmpty) {
|
||||
spis.reorder(order: serverOrder_, finder: (n, id) => n.id == id);
|
||||
newServerOrder.addAll(spis.map((e) => e.id));
|
||||
} else {
|
||||
newServerOrder.addAll(newServers.keys);
|
||||
}
|
||||
|
||||
// Must use [equals] to compare [Order] here.
|
||||
if (!newServerOrder.equals(serverOrder_)) {
|
||||
Stores.setting.serverOrder.put(newServerOrder);
|
||||
}
|
||||
|
||||
final newTags = _calculateTags(newServers);
|
||||
|
||||
return stateOrNull?.copyWith(servers: newServers, serverOrder: newServerOrder, tags: newTags) ??
|
||||
ServersState(servers: newServers, serverOrder: newServerOrder, tags: newTags);
|
||||
}
|
||||
|
||||
Set<String> _calculateTags(Map<String, Spi> servers) {
|
||||
final tags = <String>{};
|
||||
for (final spi in servers.values) {
|
||||
final spiTags = spi.tags;
|
||||
if (spiTags == null) continue;
|
||||
for (final t in spiTags) {
|
||||
tags.add(t);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// Get a [Spi] by [spi] or [id].
|
||||
///
|
||||
/// Priority: [spi] > [id]
|
||||
Spi? pick({Spi? spi, String? id}) {
|
||||
if (spi != null) {
|
||||
return state.servers[spi.id];
|
||||
}
|
||||
if (id != null) {
|
||||
return state.servers[id];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// if [spi] is specificed then only refresh this server
|
||||
/// [onlyFailed] only refresh failed servers
|
||||
Future<void> refresh({Spi? spi, bool onlyFailed = false}) async {
|
||||
if (spi != null) {
|
||||
final newManualDisconnected = Set<String>.from(state.manualDisconnectedIds)..remove(spi.id);
|
||||
state = state.copyWith(manualDisconnectedIds: newManualDisconnected);
|
||||
final serverNotifier = ref.read(serverNotifierProvider(spi.id).notifier);
|
||||
await serverNotifier.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
await Future.wait(
|
||||
state.servers.entries.map((entry) async {
|
||||
final serverId = entry.key;
|
||||
final spi = entry.value;
|
||||
|
||||
if (onlyFailed) {
|
||||
final serverState = ref.read(serverNotifierProvider(serverId));
|
||||
if (serverState.conn != ServerConn.failed) return;
|
||||
TryLimiter.reset(serverId);
|
||||
}
|
||||
|
||||
if (state.manualDisconnectedIds.contains(serverId)) return;
|
||||
|
||||
final serverState = ref.read(serverNotifierProvider(serverId));
|
||||
if (serverState.conn == ServerConn.disconnected && !spi.autoConnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
final serverNotifier = ref.read(serverNotifierProvider(serverId).notifier);
|
||||
await serverNotifier.refresh();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> startAutoRefresh() async {
|
||||
var duration = Stores.setting.serverStatusUpdateInterval.fetch();
|
||||
stopAutoRefresh();
|
||||
if (duration == 0) return;
|
||||
if (duration < 0 || duration > 10 || duration == 1) {
|
||||
duration = 3;
|
||||
Loggers.app.warning('Invalid duration: $duration, use default 3');
|
||||
}
|
||||
final timer = Timer.periodic(Duration(seconds: duration), (_) async {
|
||||
await refresh();
|
||||
});
|
||||
state = state.copyWith(autoRefreshTimer: timer);
|
||||
}
|
||||
|
||||
void stopAutoRefresh() {
|
||||
final timer = state.autoRefreshTimer;
|
||||
if (timer != null) {
|
||||
timer.cancel();
|
||||
state = state.copyWith(autoRefreshTimer: null);
|
||||
}
|
||||
}
|
||||
|
||||
bool get isAutoRefreshOn => state.autoRefreshTimer != null;
|
||||
|
||||
void setDisconnected() {
|
||||
for (final serverId in state.servers.keys) {
|
||||
final serverNotifier = ref.read(serverNotifierProvider(serverId).notifier);
|
||||
serverNotifier.updateConnection(ServerConn.disconnected);
|
||||
|
||||
// Update SSH session status to disconnected
|
||||
final sessionId = 'ssh_$serverId';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||
}
|
||||
//TryLimiter.clear();
|
||||
}
|
||||
|
||||
void closeServer({String? id}) {
|
||||
if (id == null) {
|
||||
for (final serverId in state.servers.keys) {
|
||||
closeOneServer(serverId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
closeOneServer(id);
|
||||
}
|
||||
|
||||
void closeOneServer(String id) {
|
||||
final spi = state.servers[id];
|
||||
if (spi == null) {
|
||||
Loggers.app.warning('Server with id $id not found');
|
||||
return;
|
||||
}
|
||||
|
||||
final serverNotifier = ref.read(serverNotifierProvider(id).notifier);
|
||||
serverNotifier.closeConnection();
|
||||
|
||||
final newManualDisconnected = Set<String>.from(state.manualDisconnectedIds)..add(id);
|
||||
state = state.copyWith(manualDisconnectedIds: newManualDisconnected);
|
||||
|
||||
// Remove SSH session when server is manually closed
|
||||
final sessionId = 'ssh_$id';
|
||||
TermSessionManager.remove(sessionId);
|
||||
}
|
||||
|
||||
void addServer(Spi spi) {
|
||||
final newServers = Map<String, Spi>.from(state.servers);
|
||||
newServers[spi.id] = spi;
|
||||
|
||||
final newOrder = List<String>.from(state.serverOrder)..add(spi.id);
|
||||
final newTags = _calculateTags(newServers);
|
||||
|
||||
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
|
||||
|
||||
Stores.server.put(spi);
|
||||
Stores.setting.serverOrder.put(newOrder);
|
||||
refresh(spi: spi);
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
void delServer(String id) {
|
||||
final newServers = Map<String, Spi>.from(state.servers);
|
||||
newServers.remove(id);
|
||||
|
||||
final newOrder = List<String>.from(state.serverOrder)..remove(id);
|
||||
final newTags = _calculateTags(newServers);
|
||||
|
||||
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
|
||||
|
||||
Stores.setting.serverOrder.put(newOrder);
|
||||
Stores.server.delete(id);
|
||||
|
||||
// Remove SSH session when server is deleted
|
||||
final sessionId = 'ssh_$id';
|
||||
TermSessionManager.remove(sessionId);
|
||||
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
void deleteAll() {
|
||||
// Remove all SSH sessions before clearing servers
|
||||
for (final id in state.servers.keys) {
|
||||
final sessionId = 'ssh_$id';
|
||||
TermSessionManager.remove(sessionId);
|
||||
}
|
||||
|
||||
state = const ServersState();
|
||||
|
||||
Stores.setting.serverOrder.put([]);
|
||||
Stores.server.clear();
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
|
||||
Future<void> updateServer(Spi old, Spi newSpi) async {
|
||||
if (old != newSpi) {
|
||||
Stores.server.update(old, newSpi);
|
||||
|
||||
final newServers = Map<String, Spi>.from(state.servers);
|
||||
final newOrder = List<String>.from(state.serverOrder);
|
||||
|
||||
if (newSpi.id != old.id) {
|
||||
newServers[newSpi.id] = newSpi;
|
||||
newServers.remove(old.id);
|
||||
newOrder.update(old.id, newSpi.id);
|
||||
Stores.setting.serverOrder.put(newOrder);
|
||||
|
||||
// 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
|
||||
} else {
|
||||
newServers[old.id] = newSpi;
|
||||
// Update SPI in the corresponding IndividualServerNotifier
|
||||
final serverNotifier = ref.read(serverNotifierProvider(old.id).notifier);
|
||||
serverNotifier.updateSpi(newSpi);
|
||||
}
|
||||
|
||||
final newTags = _calculateTags(newServers);
|
||||
state = state.copyWith(servers: newServers, serverOrder: newOrder, tags: newTags);
|
||||
|
||||
// Only reconnect if neccessary
|
||||
if (newSpi.shouldReconnect(old)) {
|
||||
// Use [newSpi.id] instead of [old.id] because [old.id] may be changed
|
||||
TryLimiter.reset(newSpi.id);
|
||||
refresh(spi: newSpi);
|
||||
}
|
||||
}
|
||||
bakSync.sync(milliDelay: 1000);
|
||||
}
|
||||
}
|
||||
307
lib/data/provider/server/all.freezed.dart
Normal file
307
lib/data/provider/server/all.freezed.dart
Normal file
@@ -0,0 +1,307 @@
|
||||
// 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
|
||||
|
||||
part of 'all.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ServersState {
|
||||
|
||||
Map<String, Spi> get servers; List<String> get serverOrder; Set<String> get tags; Set<String> get manualDisconnectedIds; Timer? get autoRefreshTimer;
|
||||
/// Create a copy of ServersState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ServersStateCopyWith<ServersState> get copyWith => _$ServersStateCopyWithImpl<ServersState>(this as ServersState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServersState&&const DeepCollectionEquality().equals(other.servers, servers)&&const DeepCollectionEquality().equals(other.serverOrder, serverOrder)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.manualDisconnectedIds, manualDisconnectedIds)&&(identical(other.autoRefreshTimer, autoRefreshTimer) || other.autoRefreshTimer == autoRefreshTimer));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(servers),const DeepCollectionEquality().hash(serverOrder),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(manualDisconnectedIds),autoRefreshTimer);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServersState(servers: $servers, serverOrder: $serverOrder, tags: $tags, manualDisconnectedIds: $manualDisconnectedIds, autoRefreshTimer: $autoRefreshTimer)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ServersStateCopyWith<$Res> {
|
||||
factory $ServersStateCopyWith(ServersState value, $Res Function(ServersState) _then) = _$ServersStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ServersStateCopyWithImpl<$Res>
|
||||
implements $ServersStateCopyWith<$Res> {
|
||||
_$ServersStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ServersState _self;
|
||||
final $Res Function(ServersState) _then;
|
||||
|
||||
/// Create a copy of ServersState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? servers = null,Object? serverOrder = null,Object? tags = null,Object? manualDisconnectedIds = null,Object? autoRefreshTimer = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
servers: null == servers ? _self.servers : servers // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Spi>,serverOrder: null == serverOrder ? _self.serverOrder : serverOrder // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
|
||||
as Set<String>,manualDisconnectedIds: null == manualDisconnectedIds ? _self.manualDisconnectedIds : manualDisconnectedIds // ignore: cast_nullable_to_non_nullable
|
||||
as Set<String>,autoRefreshTimer: freezed == autoRefreshTimer ? _self.autoRefreshTimer : autoRefreshTimer // ignore: cast_nullable_to_non_nullable
|
||||
as Timer?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ServersState].
|
||||
extension ServersStatePatterns on ServersState {
|
||||
/// 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( _ServersState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ServersState() 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( _ServersState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ServersState():
|
||||
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( _ServersState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ServersState() 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( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ServersState() when $default != null:
|
||||
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);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( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ServersState():
|
||||
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);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( Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ServersState() when $default != null:
|
||||
return $default(_that.servers,_that.serverOrder,_that.tags,_that.manualDisconnectedIds,_that.autoRefreshTimer);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ServersState implements ServersState {
|
||||
const _ServersState({final Map<String, Spi> servers = const {}, final List<String> serverOrder = const [], final Set<String> tags = const <String>{}, final Set<String> manualDisconnectedIds = const <String>{}, this.autoRefreshTimer}): _servers = servers,_serverOrder = serverOrder,_tags = tags,_manualDisconnectedIds = manualDisconnectedIds;
|
||||
|
||||
|
||||
final Map<String, Spi> _servers;
|
||||
@override@JsonKey() Map<String, Spi> get servers {
|
||||
if (_servers is EqualUnmodifiableMapView) return _servers;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_servers);
|
||||
}
|
||||
|
||||
final List<String> _serverOrder;
|
||||
@override@JsonKey() List<String> get serverOrder {
|
||||
if (_serverOrder is EqualUnmodifiableListView) return _serverOrder;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_serverOrder);
|
||||
}
|
||||
|
||||
final Set<String> _tags;
|
||||
@override@JsonKey() Set<String> get tags {
|
||||
if (_tags is EqualUnmodifiableSetView) return _tags;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableSetView(_tags);
|
||||
}
|
||||
|
||||
final Set<String> _manualDisconnectedIds;
|
||||
@override@JsonKey() Set<String> get manualDisconnectedIds {
|
||||
if (_manualDisconnectedIds is EqualUnmodifiableSetView) return _manualDisconnectedIds;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableSetView(_manualDisconnectedIds);
|
||||
}
|
||||
|
||||
@override final Timer? autoRefreshTimer;
|
||||
|
||||
/// Create a copy of ServersState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ServersStateCopyWith<_ServersState> get copyWith => __$ServersStateCopyWithImpl<_ServersState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServersState&&const DeepCollectionEquality().equals(other._servers, _servers)&&const DeepCollectionEquality().equals(other._serverOrder, _serverOrder)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._manualDisconnectedIds, _manualDisconnectedIds)&&(identical(other.autoRefreshTimer, autoRefreshTimer) || other.autoRefreshTimer == autoRefreshTimer));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_servers),const DeepCollectionEquality().hash(_serverOrder),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_manualDisconnectedIds),autoRefreshTimer);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServersState(servers: $servers, serverOrder: $serverOrder, tags: $tags, manualDisconnectedIds: $manualDisconnectedIds, autoRefreshTimer: $autoRefreshTimer)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ServersStateCopyWith<$Res> implements $ServersStateCopyWith<$Res> {
|
||||
factory _$ServersStateCopyWith(_ServersState value, $Res Function(_ServersState) _then) = __$ServersStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
Map<String, Spi> servers, List<String> serverOrder, Set<String> tags, Set<String> manualDisconnectedIds, Timer? autoRefreshTimer
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ServersStateCopyWithImpl<$Res>
|
||||
implements _$ServersStateCopyWith<$Res> {
|
||||
__$ServersStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ServersState _self;
|
||||
final $Res Function(_ServersState) _then;
|
||||
|
||||
/// Create a copy of ServersState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? servers = null,Object? serverOrder = null,Object? tags = null,Object? manualDisconnectedIds = null,Object? autoRefreshTimer = freezed,}) {
|
||||
return _then(_ServersState(
|
||||
servers: null == servers ? _self._servers : servers // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, Spi>,serverOrder: null == serverOrder ? _self._serverOrder : serverOrder // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
|
||||
as Set<String>,manualDisconnectedIds: null == manualDisconnectedIds ? _self._manualDisconnectedIds : manualDisconnectedIds // ignore: cast_nullable_to_non_nullable
|
||||
as Set<String>,autoRefreshTimer: freezed == autoRefreshTimer ? _self.autoRefreshTimer : autoRefreshTimer // ignore: cast_nullable_to_non_nullable
|
||||
as Timer?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
26
lib/data/provider/server/all.g.dart
Normal file
26
lib/data/provider/server/all.g.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'all.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$serversNotifierHash() => r'2ae641188f772794a32e8700c008f51ba0cc1ec9';
|
||||
|
||||
/// See also [ServersNotifier].
|
||||
@ProviderFor(ServersNotifier)
|
||||
final serversNotifierProvider =
|
||||
NotifierProvider<ServersNotifier, ServersState>.internal(
|
||||
ServersNotifier.new,
|
||||
name: r'serversNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$serversNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ServersNotifier = Notifier<ServersState>;
|
||||
// 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
|
||||
361
lib/data/provider/server/single.dart
Normal file
361
lib/data/provider/server/single.dart
Normal file
@@ -0,0 +1,361 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:computer/computer.dart';
|
||||
import 'package:dartssh2/dartssh2.dart';
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter_gbk2utf8/flutter_gbk2utf8.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:server_box/core/extension/ssh_client.dart';
|
||||
import 'package:server_box/core/utils/server.dart';
|
||||
import 'package:server_box/core/utils/ssh_auth.dart';
|
||||
import 'package:server_box/data/helper/system_detector.dart';
|
||||
import 'package:server_box/data/model/app/error.dart';
|
||||
import 'package:server_box/data/model/app/scripts/script_consts.dart';
|
||||
import 'package:server_box/data/model/app/scripts/shell_func.dart';
|
||||
import 'package:server_box/data/model/server/server.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/server/server_status_update_req.dart';
|
||||
import 'package:server_box/data/model/server/system.dart';
|
||||
import 'package:server_box/data/model/server/try_limiter.dart';
|
||||
import 'package:server_box/data/provider/server/all.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';
|
||||
|
||||
part 'single.g.dart';
|
||||
part 'single.freezed.dart';
|
||||
|
||||
// Individual server state, including connection and status information
|
||||
@freezed
|
||||
abstract class ServerState with _$ServerState {
|
||||
const factory ServerState({
|
||||
required Spi spi,
|
||||
required ServerStatus status,
|
||||
@Default(ServerConn.disconnected) ServerConn conn,
|
||||
SSHClient? client,
|
||||
Future<void>? updateFuture,
|
||||
}) = _ServerState;
|
||||
}
|
||||
|
||||
// Individual server state management
|
||||
@riverpod
|
||||
class ServerNotifier extends _$ServerNotifier {
|
||||
@override
|
||||
ServerState build(String serverId) {
|
||||
final serverNotifier = ref.read(serversNotifierProvider);
|
||||
final spi = serverNotifier.servers[serverId];
|
||||
if (spi == null) {
|
||||
throw StateError('Server $serverId not found');
|
||||
}
|
||||
|
||||
return ServerState(spi: spi, status: InitStatus.status);
|
||||
}
|
||||
|
||||
// Update connection status
|
||||
void updateConnection(ServerConn conn) {
|
||||
state = state.copyWith(conn: conn);
|
||||
}
|
||||
|
||||
// Update server status
|
||||
void updateStatus(ServerStatus status) {
|
||||
state = state.copyWith(status: status);
|
||||
}
|
||||
|
||||
// Update SSH client
|
||||
void updateClient(SSHClient? client) {
|
||||
state = state.copyWith(client: client);
|
||||
}
|
||||
|
||||
// Update SPI configuration
|
||||
void updateSpi(Spi spi) {
|
||||
state = state.copyWith(spi: spi);
|
||||
}
|
||||
|
||||
// Close connection
|
||||
void closeConnection() {
|
||||
final client = state.client;
|
||||
client?.close();
|
||||
state = state.copyWith(client: null, conn: ServerConn.disconnected);
|
||||
}
|
||||
|
||||
// Refresh server status
|
||||
Future<void> refresh() async {
|
||||
if (state.updateFuture != null) {
|
||||
await state.updateFuture;
|
||||
return;
|
||||
}
|
||||
|
||||
final updateFuture = _updateServer();
|
||||
state = state.copyWith(updateFuture: updateFuture);
|
||||
|
||||
try {
|
||||
await updateFuture;
|
||||
} finally {
|
||||
state = state.copyWith(updateFuture: null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateServer() async {
|
||||
await _getData();
|
||||
}
|
||||
|
||||
Future<void> _getData() async {
|
||||
final spi = state.spi;
|
||||
final sid = spi.id;
|
||||
|
||||
if (!TryLimiter.canTry(sid)) {
|
||||
if (state.conn != ServerConn.failed) {
|
||||
updateConnection(ServerConn.failed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final newStatus = state.status..err = null; // Clear previous error
|
||||
updateStatus(newStatus);
|
||||
|
||||
if (state.conn < ServerConn.connecting || (state.client?.isClosed ?? true)) {
|
||||
updateConnection(ServerConn.connecting);
|
||||
|
||||
// Wake on LAN
|
||||
final wol = spi.wolCfg;
|
||||
if (wol != null) {
|
||||
try {
|
||||
await wol.wake();
|
||||
} catch (e) {
|
||||
Loggers.app.warning('Wake on lan failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final time1 = DateTime.now();
|
||||
final client = await genClient(
|
||||
spi,
|
||||
timeout: Duration(seconds: Stores.setting.timeout.fetch()),
|
||||
onKeyboardInteractive: (_) => KeybordInteractive.defaultHandle(spi),
|
||||
);
|
||||
updateClient(client);
|
||||
|
||||
final time2 = DateTime.now();
|
||||
final spentTime = time2.difference(time1).inMilliseconds;
|
||||
if (spi.jumpId == null) {
|
||||
Loggers.app.info('Connected to ${spi.name} in $spentTime ms.');
|
||||
} else {
|
||||
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
|
||||
}
|
||||
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.add(
|
||||
id: sessionId,
|
||||
spi: spi,
|
||||
startTimeMs: time1.millisecondsSinceEpoch,
|
||||
disconnect: () => ref.read(serversNotifierProvider.notifier).closeOneServer(spi.id),
|
||||
status: TermSessionStatus.connecting,
|
||||
);
|
||||
TermSessionManager.setActive(sessionId, hasTerminal: false);
|
||||
} catch (e) {
|
||||
TryLimiter.inc(sid);
|
||||
final newStatus = state.status..err = SSHErr(type: SSHErrType.connect, message: e.toString());
|
||||
updateStatus(newStatus);
|
||||
updateConnection(ServerConn.failed);
|
||||
|
||||
// Remove SSH session when connection fails
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.remove(sessionId);
|
||||
|
||||
Loggers.app.warning('Connect to ${spi.name} failed', e);
|
||||
return;
|
||||
}
|
||||
|
||||
updateConnection(ServerConn.connected);
|
||||
|
||||
// Update SSH session status to connected
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.connected);
|
||||
|
||||
try {
|
||||
// Detect system type
|
||||
final detectedSystemType = await SystemDetector.detect(state.client!, spi);
|
||||
final newStatus = state.status..system = detectedSystemType;
|
||||
updateStatus(newStatus);
|
||||
|
||||
final (_, writeScriptResult) = await state.client!.exec(
|
||||
(session) async {
|
||||
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,
|
||||
customDir: spi.custom?.scriptDir,
|
||||
),
|
||||
);
|
||||
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
|
||||
ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType);
|
||||
throw writeScriptResult;
|
||||
}
|
||||
} on SSHAuthAbortError catch (e) {
|
||||
TryLimiter.inc(sid);
|
||||
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
|
||||
final newStatus = state.status..err = err;
|
||||
updateStatus(newStatus);
|
||||
Loggers.app.warning(err);
|
||||
updateConnection(ServerConn.failed);
|
||||
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||
return;
|
||||
} on SSHAuthFailError catch (e) {
|
||||
TryLimiter.inc(sid);
|
||||
final err = SSHErr(type: SSHErrType.auth, message: e.toString());
|
||||
final newStatus = state.status..err = err;
|
||||
updateStatus(newStatus);
|
||||
Loggers.app.warning(err);
|
||||
updateConnection(ServerConn.failed);
|
||||
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||
return;
|
||||
} catch (e) {
|
||||
final err = SSHErr(type: SSHErrType.writeScript, message: e.toString());
|
||||
final newStatus = state.status..err = err;
|
||||
updateStatus(newStatus);
|
||||
Loggers.app.warning(err);
|
||||
updateConnection(ServerConn.failed);
|
||||
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.conn == ServerConn.connecting) return;
|
||||
|
||||
// Keep finished status to prevent UI from refreshing to loading state
|
||||
if (state.conn != ServerConn.finished) {
|
||||
updateConnection(ServerConn.loading);
|
||||
}
|
||||
|
||||
List<String>? segments;
|
||||
String? raw;
|
||||
|
||||
try {
|
||||
final execResult = await state.client?.run(
|
||||
ShellFunc.status.exec(spi.id, systemType: state.status.system, customDir: spi.custom?.scriptDir),
|
||||
);
|
||||
if (execResult != null) {
|
||||
String? rawStr;
|
||||
bool needGbk = false;
|
||||
try {
|
||||
rawStr = utf8.decode(execResult, allowMalformed: true);
|
||||
// If there are unparseable characters, try fallback to GBK decoding
|
||||
if (rawStr.contains('<EFBFBD>')) {
|
||||
Loggers.app.warning('UTF8 decoding failed, use GBK decoding');
|
||||
needGbk = true;
|
||||
}
|
||||
} catch (e) {
|
||||
Loggers.app.warning('UTF8 decoding failed, use GBK decoding', e);
|
||||
needGbk = true;
|
||||
}
|
||||
if (needGbk) {
|
||||
try {
|
||||
rawStr = gbk.decode(execResult);
|
||||
} catch (e2) {
|
||||
Loggers.app.warning('GBK decoding failed', e2);
|
||||
rawStr = null;
|
||||
}
|
||||
}
|
||||
if (rawStr == null) {
|
||||
Loggers.app.warning('Decoding failed, execResult: $execResult');
|
||||
}
|
||||
raw = rawStr;
|
||||
} else {
|
||||
raw = execResult.toString();
|
||||
}
|
||||
|
||||
if (raw == null || raw.isEmpty) {
|
||||
TryLimiter.inc(sid);
|
||||
final newStatus = state.status
|
||||
..err = SSHErr(type: SSHErrType.segements, message: 'decode or split failed, raw:\n$raw');
|
||||
updateStatus(newStatus);
|
||||
updateConnection(ServerConn.failed);
|
||||
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
segments = raw.split(ScriptConstants.separator).map((e) => e.trim()).toList();
|
||||
if (raw.isEmpty || segments.isEmpty) {
|
||||
if (Stores.setting.keepStatusWhenErr.fetch()) {
|
||||
// Keep previous server status when error occurs
|
||||
if (state.conn != ServerConn.failed && state.status.more.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
TryLimiter.inc(sid);
|
||||
final newStatus = state.status
|
||||
..err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
|
||||
updateStatus(newStatus);
|
||||
updateConnection(ServerConn.failed);
|
||||
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
TryLimiter.inc(sid);
|
||||
final newStatus = state.status..err = SSHErr(type: SSHErrType.getStatus, message: e.toString());
|
||||
updateStatus(newStatus);
|
||||
updateConnection(ServerConn.failed);
|
||||
Loggers.app.warning('Get status from ${spi.name} failed', e);
|
||||
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse script output into command-specific mappings
|
||||
final parsedOutput = ScriptConstants.parseScriptOutput(raw);
|
||||
|
||||
final req = ServerStatusUpdateReq(
|
||||
ss: state.status,
|
||||
parsedOutput: parsedOutput,
|
||||
system: state.status.system,
|
||||
customCmds: spi.custom?.cmds ?? {},
|
||||
);
|
||||
final newStatus = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${spi.id}>');
|
||||
updateStatus(newStatus);
|
||||
} catch (e, trace) {
|
||||
TryLimiter.inc(sid);
|
||||
final newStatus = state.status
|
||||
..err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
|
||||
updateStatus(newStatus);
|
||||
updateConnection(ServerConn.failed);
|
||||
Loggers.app.warning('Server status', e, trace);
|
||||
|
||||
final sessionId = 'ssh_${spi.id}';
|
||||
TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set Server.isBusy to false each time this method is called
|
||||
updateConnection(ServerConn.finished);
|
||||
// Reset retry count only after successful preparation
|
||||
TryLimiter.reset(sid);
|
||||
}
|
||||
}
|
||||
|
||||
extension IndividualServerStateExtension on ServerState {
|
||||
bool get needGenClient => conn < ServerConn.connecting;
|
||||
|
||||
bool get canViewDetails => conn == ServerConn.finished;
|
||||
|
||||
String get id => spi.id;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user