Compare commits

...

25 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
b1b0d9a18f bump: v1231 2025-09-01 01:19:23 +08:00
lollipopkit🏳️‍⚧️
56e67f4725 fix: sync will refresh the entire app (#877) 2025-09-01 01:18:06 +08:00
lollipopkit🏳️‍⚧️
3b7fdf36fb opt. 2025-08-31 23:59:53 +08:00
lollipopkit🏳️‍⚧️
5291d316a2 fix: ensure unique IDs for bulk server import to prevent overwriting (#875) 2025-08-31 21:20:27 +08:00
lollipopkit🏳️‍⚧️
4c369546da fix: replace String.fromCharCodes with utf8.decode for proper Chinese character handling in JSON import (#874) 2025-08-31 20:06:47 +08:00
lollipopkit🏳️‍⚧️
12a243d139 feat: import servers from ~/.ssh/config (#873) 2025-08-31 19:33:29 +08:00
lollipopkit🏳️‍⚧️
a97b3cf43e opt.: bak pwd is optional (#872) 2025-08-31 11:11:47 +08:00
lollipopkit🏳️‍⚧️
53a7c0d8ff migrate: riverpod + freezed (#870) 2025-08-31 00:55:54 +08:00
lollipopkit🏳️‍⚧️
9cb705f8dd fix: parsing hostname (#865) 2025-08-22 09:18:21 +08:00
lollipopkit🏳️‍⚧️
8270674b7d chore: tests 2025-08-22 00:25:26 +08:00
lxdklp
24fd4b782d fix: GBK decoding fallback (#863)
* fix #757

* fix #757

* apply the code recommendations from sourcery ai

* Make sure raw is non-empty data

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

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

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

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

View File

@@ -20,7 +20,7 @@ jobs:
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.32.2" flutter-version: "3.35.1"
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: "zulu" distribution: "zulu"
@@ -58,24 +58,17 @@ jobs:
run: | run: |
sudo apt update sudo apt update
# Basic # 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 # App Specific
sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev libsecret-1-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
- name: Build - name: Build
run: | run: |
dart run fl_build -p linux 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 - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: | files: |
${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.appimage ${{ env.APP_NAME }}_${{ env.BUILD_NUMBER }}_amd64.AppImage
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -46,6 +46,15 @@
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> 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 <receiver
android:name=".widget.HomeWidget" android:name=".widget.HomeWidget"
android:exported="false" android:exported="false"

View File

@@ -5,11 +5,28 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File import java.io.File
import java.util.* import java.util.*
class ForegroundService : Service() { class ForegroundService : Service() {
companion object {
@Volatile
var isRunning: Boolean = false
}
private val chanId = "ForegroundServiceChannel" private val chanId = "ForegroundServiceChannel"
private val GROUP_KEY = "ssh_sessions_group"
private val SUMMARY_ID = 1000
private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND"
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
private var isFgStarted = false
private val postedIds = mutableSetOf<Int>()
// Stable mapping from session-id -> notification-id to avoid hash collisions
private val notificationIdMap = mutableMapOf<String, Int>()
private val nextNotificationId = java.util.concurrent.atomic.AtomicInteger(2001)
private fun logError(message: String, error: Throwable? = null) { private fun logError(message: String, error: Throwable? = null) {
Log.e("ForegroundService", message, error) Log.e("ForegroundService", message, error)
@@ -26,6 +43,7 @@ class ForegroundService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d("ForegroundService", "Service onCreate") Log.d("ForegroundService", "Service onCreate")
isRunning = true
createNotificationChannel() createNotificationChannel()
} }
@@ -50,24 +68,20 @@ class ForegroundService : Service() {
val action = intent.action val action = intent.action
Log.d("ForegroundService", "onStartCommand action=$action") Log.d("ForegroundService", "onStartCommand action=$action")
// Create notification before starting foreground
val notification = createNotification()
// Use try-catch for startForeground
try {
startForeground(1, notification)
} catch (e: Exception) {
logError("Failed to start foreground", e)
stopSelf()
return START_NOT_STICKY
}
return when (action) { return when (action) {
"ACTION_STOP_FOREGROUND" -> { ACTION_STOP_FOREGROUND -> {
clearAll()
stopForegroundService() stopForegroundService()
START_NOT_STICKY START_NOT_STICKY
} }
ACTION_UPDATE_SESSIONS -> {
val payload = intent.getStringExtra("payload") ?: "{}"
handleUpdateSessions(payload)
START_STICKY
}
else -> { else -> {
// Default bring up foreground with placeholder
ensureForeground(createSummaryNotification(0, emptyList()))
START_STICKY START_STICKY
} }
} }
@@ -101,24 +115,99 @@ class ForegroundService : Service() {
} }
} }
private fun createNotification(): Notification { private fun ensureForeground(notification: Notification) {
try { try {
val notificationIntent = Intent(this, MainActivity::class.java) if (!isFgStarted) {
val pendingIntent = PendingIntent.getActivity( startForeground(SUMMARY_ID, notification)
this, isFgStarted = true
0, } else {
notificationIntent, val nm = getSystemService(NotificationManager::class.java)
PendingIntent.FLAG_IMMUTABLE nm?.notify(SUMMARY_ID, notification)
)
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
action = "ACTION_STOP_FOREGROUND"
} }
val deletePendingIntent = PendingIntent.getService( } catch (e: Exception) {
this, logError("Failed to start/update foreground", e)
0, }
deleteIntent, }
PendingIntent.FLAG_IMMUTABLE
private fun createSummaryNotification(count: Int, lines: List<String>): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
)
val stopIntent = Intent(this, ForegroundService::class.java).apply { action = ACTION_STOP_FOREGROUND }
val stopPending = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, chanId)
} else {
Notification.Builder(this)
}
val inbox = Notification.InboxStyle()
lines.forEach { inbox.addLine(it) }
return builder
.setContentTitle("SSH sessions: $count active")
.setContentText(if (lines.isNotEmpty()) lines.first() else "Running")
.setSmallIcon(R.mipmap.ic_launcher)
.setStyle(inbox)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", stopPending)
.build()
}
private fun handleUpdateSessions(payload: String) {
val nm = getSystemService(NotificationManager::class.java)
if (nm == null) {
logError("NotificationManager null")
return
}
val sessions = mutableListOf<SessionItem>()
try {
val obj = JSONObject(payload)
val arr: JSONArray = obj.optJSONArray("sessions") ?: JSONArray()
for (i in 0 until arr.length()) {
val s = arr.optJSONObject(i) ?: continue
val id = s.optString("id")
val title = s.optString("title")
val sub = s.optString("subtitle")
val whenMs = s.optLong("startTimeMs", System.currentTimeMillis())
val status = s.optString("status", "connected")
if (id.isNotEmpty()) {
sessions.add(SessionItem(id, title, sub, whenMs, status))
}
}
} catch (e: Exception) {
logError("Failed to parse payload", e)
}
// Clear if empty
if (sessions.isEmpty()) {
clearAll()
return
}
// Build per-session notifications
val currentIds = mutableSetOf<Int>()
val summaryLines = mutableListOf<String>()
sessions.forEach { s ->
// Assign a stable, collision-resistant id per session for this service lifecycle
val nid = notificationIdMap.getOrPut(s.id) { nextNotificationId.getAndIncrement() }
currentIds.add(nid)
summaryLines.add("${s.title}: ${s.status}")
val disconnectIntent = Intent(this, MainActivity::class.java).apply {
action = ACTION_DISCONNECT_SESSION
putExtra("session_id", s.id)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val disconnectPending = PendingIntent.getActivity(
this, nid, disconnectIntent, PendingIntent.FLAG_IMMUTABLE
) )
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -127,23 +216,60 @@ class ForegroundService : Service() {
Notification.Builder(this) Notification.Builder(this)
} }
return builder val noti = builder
.setContentTitle("Server Box") .setContentTitle(s.title)
.setContentText("Running in background") .setContentText("${s.subtitle} · ${s.status}")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
.build()
} catch (e: Exception) {
logError("Error creating notification", e)
// Return a basic notification as fallback
return Notification.Builder(this)
.setContentTitle("Server Box")
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setWhen(s.startWhen)
.setUsesChronometer(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.addAction(android.R.drawable.ic_media_pause, "Disconnect", disconnectPending)
.build() .build()
nm.notify(nid, noti)
} }
// Cancel stale ones
val toCancel = postedIds - currentIds
toCancel.forEach { nm.cancel(it) }
// Clean up id mappings for canceled notifications to prevent growth
if (toCancel.isNotEmpty()) {
val keysToRemove = notificationIdMap.filterValues { it in toCancel }.keys
keysToRemove.forEach { notificationIdMap.remove(it) }
}
postedIds.clear()
postedIds.addAll(currentIds)
// Post/update summary and ensure foreground
val maxSummaryLines = 5
val truncated = summaryLines.size > maxSummaryLines
val displaySummaryLines = if (truncated) {
summaryLines.take(maxSummaryLines) + "...and ${summaryLines.size - maxSummaryLines} more"
} else {
summaryLines
}
val summary = createSummaryNotification(sessions.size, displaySummaryLines)
ensureForeground(summary)
} }
private fun clearAll() {
val nm = getSystemService(NotificationManager::class.java)
nm?.cancel(SUMMARY_ID)
postedIds.forEach { id -> nm?.cancel(id) }
postedIds.clear()
isFgStarted = false
}
data class SessionItem(
val id: String,
val title: String,
val subtitle: String,
val startWhen: Long,
val status: String,
)
private fun stopForegroundService() { private fun stopForegroundService() {
try { try {
stopForeground(true) stopForeground(true)
@@ -157,5 +283,6 @@ class ForegroundService : Service() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
Log.d("ForegroundService", "Service onDestroy") Log.d("ForegroundService", "Service onDestroy")
isRunning = false
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: extensions:

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # 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. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

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

View File

@@ -9,6 +9,10 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; };
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */; };
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; }; 7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */; };
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; }; 7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */; };
@@ -36,6 +40,8 @@
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; }; E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; }; E3AE8AEC2AB601DB000A6459 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AE8AE92AB601DB000A6459 /* Utils.swift */; };
E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; }; E3DB67ED2A31FE200027B8CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E3DB67EB2A31FE200027B8CB /* LaunchScreen.storyboard */; };
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */; };
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */ = {isa = PBXBuildFile; fileRef = F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -95,6 +101,10 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 278C1EB3935F9285537B0516 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivity.swift; sourceTree = "<group>"; };
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLiveActivityAttributes.swift; sourceTree = "<group>"; };
5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 5A4B3EB10512B2EB8E10213B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -156,6 +166,26 @@
E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; }; E3D26BD22B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = "<group>"; };
E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; }; E3D26BD32B9966EC00D83425 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = "<group>"; };
E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; E3DB67EC2A31FE200027B8CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F0009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F000C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1002 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1006 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1007 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1008 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F1009 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
F0A1B2C31A2B3C4D5E6F100C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -233,6 +263,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */,
7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */, 7538AEC22BB83FAB002AB82A /* PrivacyInfo.xcprivacy */,
E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */, E398BF6A29BDB34500FE4FD5 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -242,6 +273,8 @@
E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */, E39A76AD2AB9A2F70067C641 /* Info-Profile.plist */,
E39A76AC2AB9A2F70067C641 /* Info-Release.plist */, E39A76AC2AB9A2F70067C641 /* Info-Release.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */,
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
E3AE8AE92AB601DB000A6459 /* Utils.swift */, E3AE8AE92AB601DB000A6459 /* Utils.swift */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -263,8 +296,11 @@
E33A3E3A2A626DCE009744AB /* StatusWidget */ = { E33A3E3A2A626DCE009744AB /* StatusWidget */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */,
7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */, 7538AEC42BB83FC8002AB82A /* PrivacyInfo.xcprivacy */,
E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */, E33A3E3B2A626DCE009744AB /* StatusWidgetBundle.swift */,
4A2DCD6D2E4B128100CF68B7 /* TerminalLiveActivity.swift */,
4A2DCD6E2E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift */,
E33A3E3F2A626DCE009744AB /* StatusWidget.swift */, E33A3E3F2A626DCE009744AB /* StatusWidget.swift */,
E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */, E37C48ED2B9C30EE00E542D2 /* StatusWidget.intentdefinition */,
E33A3E442A626DD0009744AB /* Info.plist */, E33A3E442A626DD0009744AB /* Info.plist */,
@@ -412,6 +448,7 @@
E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */, E39A76B02AB9A2F70067C641 /* Info-Profile.plist in Resources */,
7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */, 7538AEC32BB83FAB002AB82A /* PrivacyInfo.xcprivacy in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
F0A1B2C31A2B3C4D5E6F0005 /* Localizable.strings in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -420,6 +457,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */, 7538AEC52BB83FC8002AB82A /* PrivacyInfo.xcprivacy in Resources */,
F0A1B2C31A2B3C4D5E6F1005 /* Localizable.strings (StatusWidget) in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -516,6 +554,8 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */, E37C48EA2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */, E3AE8AEA2AB601DB000A6459 /* Utils.swift in Sources */,
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */,
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -525,6 +565,8 @@
files = ( files = (
E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */, E33A3E402A626DCE009744AB /* StatusWidget.swift in Sources */,
E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */, E37C48EB2B9C30EE00E542D2 /* StatusWidget.intentdefinition in Sources */,
4A2DCD6F2E4B128100CF68B7 /* TerminalLiveActivity.swift in Sources */,
4A2DCD702E4B128100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */, E33A3E3C2A626DCE009744AB /* StatusWidgetBundle.swift in Sources */,
E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */, E3AE8AEB2AB601DB000A6459 /* Utils.swift in Sources */,
); );
@@ -610,6 +652,40 @@
name = LaunchScreen.storyboard; name = LaunchScreen.storyboard;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
F0A1B2C31A2B3C4D5E6F0001 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
F0A1B2C31A2B3C4D5E6F0002 /* en */,
F0A1B2C31A2B3C4D5E6F0003 /* zh-Hans */,
F0A1B2C31A2B3C4D5E6F0004 /* zh-Hant */,
F0A1B2C31A2B3C4D5E6F0006 /* fr */,
F0A1B2C31A2B3C4D5E6F0007 /* ru */,
F0A1B2C31A2B3C4D5E6F0008 /* es */,
F0A1B2C31A2B3C4D5E6F0009 /* de */,
F0A1B2C31A2B3C4D5E6F000A /* pt-BR */,
F0A1B2C31A2B3C4D5E6F000B /* id */,
F0A1B2C31A2B3C4D5E6F000C /* ja */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
F0A1B2C31A2B3C4D5E6F1001 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
F0A1B2C31A2B3C4D5E6F1002 /* en */,
F0A1B2C31A2B3C4D5E6F1003 /* zh-Hans */,
F0A1B2C31A2B3C4D5E6F1004 /* zh-Hant */,
F0A1B2C31A2B3C4D5E6F1006 /* fr */,
F0A1B2C31A2B3C4D5E6F1007 /* ru */,
F0A1B2C31A2B3C4D5E6F1008 /* es */,
F0A1B2C31A2B3C4D5E6F1009 /* de */,
F0A1B2C31A2B3C4D5E6F100A /* pt-BR */,
F0A1B2C31A2B3C4D5E6F100B /* id */,
F0A1B2C31A2B3C4D5E6F100C /* ja */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */ /* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -655,7 +731,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@@ -672,17 +748,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -739,7 +815,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -789,7 +865,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@@ -808,17 +884,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -836,17 +912,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -867,7 +943,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -880,7 +956,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
@@ -906,7 +982,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -919,7 +995,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -942,7 +1018,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -955,7 +1031,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -978,7 +1054,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -990,7 +1066,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
@@ -1019,7 +1095,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1031,7 +1107,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox; PRODUCT_NAME = ServerBox;
@@ -1057,7 +1133,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1206; CURRENT_PROJECT_VERSION = 1231;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6; DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1069,7 +1145,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.1206; MARKETING_VERSION = 1.0.1231;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox; PRODUCT_NAME = ServerBox;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
//
// LiveActivityManager.swift
// Runner
//
// Handles starting/updating/stopping Terminal Live Activities from Flutter via MethodChannel.
//
import Foundation
import ActivityKit
@available(iOS 16.2, *)
class LiveActivityManager {
static var current: Activity<TerminalAttributes>?
struct Payload: Decodable {
let id: String
let title: String
let subtitle: String
let startTimeMs: Int
let status: String
let hasTerminal: Bool?
let connectionCount: Int?
}
private static func parse(_ json: String) -> Payload? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(Payload.self, from: data)
}
static func start(json: String) {
guard #available(iOS 16.2, *) else { return }
guard let p = parse(json) else { return }
let attributes = TerminalAttributes(id: p.id)
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
// Localize multi-connection title/subtitle on iOS side
let isMulti = (p.id == "multi_connections")
let title = isMulti
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
: p.title
let subtitle = isMulti
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
: p.subtitle
let state = TerminalAttributes.ContentState(
id: p.id,
title: title,
subtitle: subtitle,
status: p.status,
startTime: date,
hasTerminal: p.hasTerminal ?? true,
connectionCount: p.connectionCount ?? 1
)
let content = ActivityContent(state: state, staleDate: nil)
do {
current = try Activity<TerminalAttributes>.request(attributes: attributes, content: content, pushType: nil)
} catch {
// ignore
}
}
static func update(json: String) {
guard #available(iOS 16.2, *) else { return }
guard let p = parse(json) else { return }
let date = Date(timeIntervalSince1970: TimeInterval(p.startTimeMs) / 1000.0)
// Localize multi-connection title/subtitle on iOS side
let isMulti = (p.id == "multi_connections")
let title = isMulti
? String(format: NSLocalizedString("%d connections", comment: "Title for multiple connections"), p.connectionCount ?? 1)
: p.title
let subtitle = isMulti
? NSLocalizedString("Multiple SSH sessions active", comment: "Subtitle for multiple connections")
: p.subtitle
let state = TerminalAttributes.ContentState(
id: p.id,
title: title,
subtitle: subtitle,
status: p.status,
startTime: date,
hasTerminal: p.hasTerminal ?? true,
connectionCount: p.connectionCount ?? 1
)
if let activity = current {
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
} else {
start(json: json)
}
}
static func stop() {
guard #available(iOS 16.2, *) else { return }
if let activity = current {
Task { await activity.end(dismissalPolicy: .immediate) }
current = nil
}
}
}

View File

@@ -0,0 +1,39 @@
//
// TerminalLiveActivityAttributes.swift
// Runner
//
// Mirror of the ActivityKit attributes used in the extension.
//
import Foundation
import ActivityKit
@available(iOS 16.1, *)
public struct TerminalAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
public var id: String
public var title: String
public var subtitle: String
public var status: String
public var startTime: Date
public var hasTerminal: Bool
public var connectionCount: Int
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
self.id = id
self.title = title
self.subtitle = subtitle
self.status = status
self.startTime = startTime
self.hasTerminal = hasTerminal
self.connectionCount = connectionCount
}
}
public var id: String
public init(id: String) {
self.id = id
}
}

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Verbunden";
"Connecting" = "Verbindung wird hergestellt";
"Disconnected" = "Getrennt";
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
"1 connection" = "1 Verbindung";
"%d connections" = "%d Verbindungen";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Connected";
"Connecting" = "Connecting";
"Disconnected" = "Disconnected";
"Multiple SSH sessions active" = "Multiple SSH sessions active";
"1 connection" = "1 connection";
"%d connections" = "%d connections";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Conectado";
"Connecting" = "Conectando";
"Disconnected" = "Desconectado";
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
"1 connection" = "1 conexión";
"%d connections" = "%d conexiones";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Connecté";
"Connecting" = "Connexion en cours";
"Disconnected" = "Déconnecté";
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
"1 connection" = "1 connexion";
"%d connections" = "%d connexions";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Terhubung";
"Connecting" = "Menghubungkan";
"Disconnected" = "Terputus";
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
"1 connection" = "1 koneksi";
"%d connections" = "%d koneksi";

View File

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

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Conectado";
"Connecting" = "Conectando";
"Disconnected" = "Desconectado";
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
"1 connection" = "1 conexão";
"%d connections" = "%d conexões";

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,142 @@ let demoStatus = Status(name: "Server", cpu: "31.7%", mem: "1.3g / 1.9g", disk:
let domain = "com.lollipopkit.toolbox" let domain = "com.lollipopkit.toolbox"
let bgColor = DynamicColor(dark: UIColor.black, light: UIColor.white) 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 { struct Provider: IntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry { func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus)) SimpleEntry(date: Date(), configuration: ConfigurationIntent(), state: .normal(demoStatus))
@@ -29,11 +165,13 @@ struct Provider: IntentTimelineProvider {
var url = configuration.url var url = configuration.url
let family = context.family let family = context.family
#if os(iOS)
if #available(iOSApplicationExtension 16.0, *) { if #available(iOSApplicationExtension 16.0, *) {
if family == .accessoryInline || family == .accessoryRectangular { 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 currentDate = Date()
let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)! let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
@@ -111,7 +249,7 @@ struct StatusWidgetEntryView : View {
Button(intent: RefreshIntent()) { Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
.resizable() .resizable()
.frame(width: 10, height: 12.7) .frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
}.tint(.gray) }.tint(.gray)
} }
} }
@@ -123,6 +261,37 @@ struct StatusWidgetEntryView : View {
case .normal(let data): case .normal(let data):
let sumColor: Color = .primary.opacity(0.7) let sumColor: Color = .primary.opacity(0.7)
switch family { 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: case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack { HStack {
@@ -142,6 +311,7 @@ struct StatusWidgetEntryView : View {
.widgetBackground() .widgetBackground()
case .accessoryInline: case .accessoryInline:
Text("\(data.name) \(data.cpu)").widgetBackground() Text("\(data.name) \(data.cpu)").widgetBackground()
#endif
default: default:
VStack(alignment: .leading, spacing: 3.7) { VStack(alignment: .leading, spacing: 3.7) {
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
@@ -151,7 +321,7 @@ struct StatusWidgetEntryView : View {
Button(intent: RefreshIntent()) { Button(intent: RefreshIntent()) {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
.resizable() .resizable()
.frame(width: 10, height: 12.7) .frame(width: WidgetConstants.Dimensions.refreshIconSmall, height: WidgetConstants.Dimensions.refreshIconSmall * 1.27)
}.tint(.gray) }.tint(.gray)
} }
} else { } else {
@@ -162,9 +332,6 @@ struct StatusWidgetEntryView : View {
DetailItem(icon: "memorychip", text: data.mem, color: sumColor) DetailItem(icon: "memorychip", text: data.mem, color: sumColor)
DetailItem(icon: "externaldrive", text: data.disk, color: sumColor) DetailItem(icon: "externaldrive", text: data.disk, color: sumColor)
DetailItem(icon: "network", text: data.net, 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) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.autoPadding() .autoPadding()
@@ -177,8 +344,16 @@ struct StatusWidgetEntryView : View {
extension View { extension View {
@ViewBuilder @ViewBuilder
func widgetBackground() -> some View { func widgetBackground() -> some View {
// Set bg to black in Night, white in Day // Modern card-style background with subtle effects
let backgroundView = Color(bgColor.resolve()) let backgroundView = LinearGradient(
gradient: Gradient(colors: [
Color(bgColor.resolve()),
Color(bgColor.resolve()).opacity(0.95)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
containerBackground(for: .widget) { containerBackground(for: .widget) {
backgroundView 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 { func autoPadding() -> some View {
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
return self return self.padding(.all, WidgetConstants.Spacing.tight)
} else { } 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 { struct StatusWidget: Widget {
@@ -207,11 +397,15 @@ struct StatusWidget: Widget {
} }
.configurationDisplayName("Status") .configurationDisplayName("Status")
.description("Status of your servers.") .description("Status of your servers.")
if #available(iOSApplicationExtension 16.0, *) { #if os(iOS)
return cfg.supportedFamilies([.systemSmall, .accessoryRectangular, .accessoryInline]) if #available(iOSApplicationExtension 16.0, *) {
return cfg.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
} else { } 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 let color: Color
var body: some View { var body: some View {
HStack(spacing: 6.7) { HStack(spacing: WidgetConstants.Spacing.normal) {
Image(systemName: icon).resizable().foregroundColor(color).frame(width: 11, height: 11, alignment: .center) 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) Text(text)
.font(.system(size: 11, design: .monospaced)) .font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundColor(color) .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
}
} }
} }
} }
// // Modern gauge tile with enhanced visual design
struct CirclePercent: View { struct GaugeTile: View {
// eg: 31.7% let label: String
let percent: 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 { var body: some View {
// 31.7% -> 0.317 VStack(spacing: WidgetConstants.Spacing.normal) {
let percentD = Double(percent.trimmingCharacters(in: .init(charactersIn: "%"))) ZStack {
let double = (percentD ?? 0) / 100 // Background circle with subtle shadow effect
Circle() Circle()
.trim(from: 0, to: CGFloat(double)) .stroke(Color.primary.opacity(0.1), lineWidth: 4)
.stroke(Color.primary, lineWidth: 3) .background(
.animation(.easeInOut(duration: 0.5)) 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 { struct DynamicColor {
let dark: UIColor let dark: UIColor
let light: UIColor let light: UIColor

View File

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

View File

@@ -0,0 +1,185 @@
//
// TerminalLiveActivity.swift
// StatusWidget
//
// Renders the Live Activity UI for SSH/Terminal sessions.
//
import SwiftUI
import WidgetKit
import ActivityKit
// Helper to map status strings to a color dot (case-insensitive).
@inline(__always)
private func getStatusDotColor(_ status: String) -> Color {
switch status.lowercased() {
case "connected":
return .green
case "connecting":
return .yellow
case "disconnected":
return .red
default:
return .secondary
}
}
// Normalize status for display: capitalize first letter only.
@inline(__always)
private func formatStatus(_ status: String) -> String {
let trimmed = status.trimmingCharacters(in: .whitespacesAndNewlines)
guard let first = trimmed.first else { return status }
let head = String(first).uppercased()
let tail = String(trimmed.dropFirst()).lowercased()
return head + tail
}
// Localize known statuses; fall back to formatted original.
@inline(__always)
private func localizedStatus(_ status: String) -> String {
switch status.lowercased() {
case "connected":
return NSLocalizedString("Connected", comment: "Session connected status")
case "connecting":
return NSLocalizedString("Connecting", comment: "Session connecting status")
case "disconnected":
return NSLocalizedString("Disconnected", comment: "Session disconnected status")
default:
return formatStatus(status)
}
}
@available(iOS 16.1, *)
struct TerminalLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TerminalAttributes.self) { context in
let state = context.state
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
.font(.caption)
.foregroundStyle(.secondary)
if state.connectionCount > 1 {
Text("(\(state.connectionCount))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text(state.title)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
Text(state.subtitle)
.font(.subheadline)
.lineLimit(1)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Circle()
.fill(getStatusDotColor(state.status))
.frame(width: 6, height: 6)
Text(localizedStatus(state.status))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 8)
Image(systemName: state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
.font(.title3)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 10)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text(context.state.hasTerminal ? NSLocalizedString("Terminal", comment: "Terminal label") : "SSH")
.font(.caption2)
.foregroundStyle(.secondary)
if context.state.connectionCount > 1 {
Text("(\(context.state.connectionCount))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Text(context.state.title)
.font(.subheadline)
.lineLimit(1)
.truncationMode(.tail)
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 6) {
HStack(spacing: 6) {
Circle()
.fill(getStatusDotColor(context.state.status))
.frame(width: 6, height: 6)
Text(localizedStatus(context.state.status))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.subtitle)
.font(.caption)
.lineLimit(1)
.foregroundStyle(.secondary)
}
} compactLeading: {
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
} compactTrailing: {
EmptyView()
} minimal: {
Image(systemName: context.state.hasTerminal ? "terminal" : "bolt.horizontal.circle")
}
}
}
}
#if DEBUG
@available(iOS 16.2, *)
struct TerminalLiveActivity_Previews: PreviewProvider {
static let attributes = TerminalAttributes(id: "preview")
static let contentState = TerminalAttributes.ContentState(
id: "preview",
title: "root@server-01",
subtitle: "CPU 37% • Mem 1.3G/2.0G",
status: "Connected",
startTime: Date().addingTimeInterval(-1234),
hasTerminal: true,
connectionCount: 2
)
static var previews: some View {
Group {
// /
attributes
.previewContext(contentState, viewKind: .content)
.previewDisplayName("Lock Screen")
// 屿
attributes
.previewContext(contentState, viewKind: .dynamicIsland(.expanded))
.previewDisplayName("Dynamic Island • Expanded")
// 屿
attributes
.previewContext(contentState, viewKind: .dynamicIsland(.compact))
.previewDisplayName("Dynamic Island • Compact")
// 屿
attributes
.previewContext(contentState, viewKind: .dynamicIsland(.minimal))
.previewDisplayName("Dynamic Island • Minimal")
}
}
}
#endif

View File

@@ -0,0 +1,39 @@
//
// TerminalLiveActivityAttributes.swift
// StatusWidget
//
// Defines ActivityKit attributes and content state for SSH/Terminal Live Activities.
//
import Foundation
import ActivityKit
@available(iOS 16.1, *)
public struct TerminalAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
public var id: String
public var title: String
public var subtitle: String
public var status: String
public var startTime: Date
public var hasTerminal: Bool
public var connectionCount: Int
public init(id: String, title: String, subtitle: String, status: String, startTime: Date, hasTerminal: Bool, connectionCount: Int = 1) {
self.id = id
self.title = title
self.subtitle = subtitle
self.status = status
self.startTime = startTime
self.hasTerminal = hasTerminal
self.connectionCount = connectionCount
}
}
public var id: String
public init(id: String) {
self.id = id
}
}

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Verbunden";
"Connecting" = "Verbindung wird hergestellt";
"Disconnected" = "Getrennt";
"Multiple SSH sessions active" = "Mehrere aktive SSH-Sitzungen";
"1 connection" = "1 Verbindung";
"%d connections" = "%d Verbindungen";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Connected";
"Connecting" = "Connecting";
"Disconnected" = "Disconnected";
"Multiple SSH sessions active" = "Multiple SSH sessions active";
"1 connection" = "1 connection";
"%d connections" = "%d connections";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Conectado";
"Connecting" = "Conectando";
"Disconnected" = "Desconectado";
"Multiple SSH sessions active" = "Varias sesiones SSH activas";
"1 connection" = "1 conexión";
"%d connections" = "%d conexiones";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Connecté";
"Connecting" = "Connexion en cours";
"Disconnected" = "Déconnecté";
"Multiple SSH sessions active" = "Plusieurs sessions SSH actives";
"1 connection" = "1 connexion";
"%d connections" = "%d connexions";

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Terhubung";
"Connecting" = "Menghubungkan";
"Disconnected" = "Terputus";
"Multiple SSH sessions active" = "Beberapa sesi SSH aktif";
"1 connection" = "1 koneksi";
"%d connections" = "%d koneksi";

View File

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

View File

@@ -0,0 +1,8 @@
"Terminal" = "Terminal";
"Connected" = "Conectado";
"Connecting" = "Conectando";
"Disconnected" = "Desconectado";
"Multiple SSH sessions active" = "Várias sessões SSH ativas";
"1 connection" = "1 conexão";
"%d connections" = "%d conexões";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,19 +12,85 @@ abstract final class MethodChans {
/// Issue #662 /// Issue #662
static void startService() { static void startService() {
// if (Stores.setting.fgService.fetch() != true) return; if (Stores.setting.fgService.fetch() != true) return;
// _channel.invokeMethod('startService'); _channel.invokeMethod('startService');
} }
/// Issue #662 /// Issue #662
static void stopService() { static void stopService() {
// if (Stores.setting.fgService.fetch() != true) return; if (Stores.setting.fgService.fetch() != true) return;
// _channel.invokeMethod('stopService'); _channel.invokeMethod('stopService');
} }
static void updateHomeWidget() async { static void updateHomeWidget() async {
if (!isIOS || !isAndroid) return; if (!isIOS && !isAndroid) return;
if (!Stores.setting.autoUpdateHomeWidget.fetch()) return; if (!Stores.setting.autoUpdateHomeWidget.fetch()) return;
await _channel.invokeMethod('updateHomeWidget'); await _channel.invokeMethod('updateHomeWidget');
} }
/// Update Android foreground service notifications for SSH sessions
/// The [payload] is a JSON string describing sessions list.
static Future<void> updateSessions(String payload) async {
if (!isAndroid) return;
try {
Loggers.app.info('Updating Android sessions: $payload');
await _channel.invokeMethod('updateSessions', payload);
} catch (_) {
// ignore
}
}
/// Query whether the Android foreground service is currently running.
static Future<bool> isServiceRunning() async {
if (!isAndroid) return false;
try {
final res = await _channel.invokeMethod('isServiceRunning');
return res == true;
} catch (_) {
return false;
}
}
// iOS Live Activities controls
static Future<void> startLiveActivity(String payload) async {
if (!isIOS) return;
try {
Loggers.app.info('Starting iOS Live Activity: $payload');
await _channel.invokeMethod('startLiveActivity', payload);
} catch (_) {}
}
static Future<void> updateLiveActivity(String payload) async {
if (!isIOS) return;
try {
Loggers.app.info('Updating iOS Live Activity: $payload');
await _channel.invokeMethod('updateLiveActivity', payload);
} catch (_) {}
}
static Future<void> stopLiveActivity() async {
if (!isIOS) return;
try {
Loggers.app.info('Stopping iOS Live Activity');
await _channel.invokeMethod('stopLiveActivity');
} catch (_) {}
}
/// Register a handler for native -> Flutter callbacks.
/// Currently handles: `disconnectSession` with argument map {id: string}
static void registerHandler(Future<void> Function(String id) onDisconnect) {
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'disconnectSession':
final args = call.arguments;
final id = args is Map ? args['id'] as String? : args as String?;
if (id != null && id.isNotEmpty) {
await onDisconnect(id);
}
return;
default:
return;
}
});
}
} }

View File

@@ -2,10 +2,10 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.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/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'; import 'package:server_box/data/res/store.dart';
extension LogoExt on Server { extension LogoExt on ServerState {
String? getLogoUrl(BuildContext context) { String? getLogoUrl(BuildContext context) {
var logoUrl = spi.custom?.logoUrl ?? Stores.setting.serverLogoUrl.fetch().selfNotEmptyOrNull; var logoUrl = spi.custom?.logoUrl ?? Stores.setting.serverLogoUrl.fetch().selfNotEmptyOrNull;
if (logoUrl == null) { if (logoUrl == null) {

View File

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

View File

@@ -12,12 +12,24 @@ final class BakSyncer extends SyncIface {
const BakSyncer._() : super(); const BakSyncer._() : super();
@override @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 @override
Future<Mergeable> fromFile(String path) async { Future<Mergeable> fromFile(String path) async {
final content = await File(path).readAsString(); 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 @override
@@ -28,6 +40,9 @@ final class BakSyncer extends SyncIface {
final webdavEnabled = PrefProps.webdavSync.get(); final webdavEnabled = PrefProps.webdavSync.get();
if (webdavEnabled) return Webdav.shared; if (webdavEnabled) return Webdav.shared;
final gistEnabled = PrefProps.gistSync.get();
if (gistEnabled) return GistRs.shared;
return null; return null;
} }
} }

View File

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

View File

@@ -78,7 +78,7 @@ Future<SSHClient> genClient(
Loggers.app.warning('genClient', e); Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow; if (spi.alterUrl == null) rethrow;
try { try {
final res = spi.fromStringUrl(); final res = spi.parseAlterUrl();
alterUser = res.$2; alterUser = res.$2;
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout); return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
} catch (e) { } catch (e) {

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

View File

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

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

View File

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

View File

@@ -4,6 +4,9 @@ import 'dart:io';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:logging/logging.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/misc.dart';
import 'package:server_box/data/res/store.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 { Future<void> merge({bool force = false}) async {
_loggerV2.info('Merging...'); _loggerV2.info('Merging...');
// Merge each store // Merge each store and check if changes were made
await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force); final serverChanged = await Mergeable.mergeStore(backupData: spis, store: Stores.server, force: force);
await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force); final snippetChanged = await Mergeable.mergeStore(backupData: snippets, store: Stores.snippet, force: force);
await Mergeable.mergeStore(backupData: keys, store: Stores.key, 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: container, store: Stores.container, force: force);
await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force); await Mergeable.mergeStore(backupData: history, store: Stores.history, force: force);
await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force); await Mergeable.mergeStore(backupData: settings, store: Stores.setting, force: force);
// Reload providers and notify listeners if (serverChanged) GlobalRef.gRef?.read(serversNotifierProvider.notifier).reload();
Provider.reload(); if (snippetChanged) GlobalRef.gRef?.read(snippetNotifierProvider.notifier).reload();
RNodes.app.notify(); if (keyChanged) GlobalRef.gRef?.read(privateKeyNotifierProvider.notifier).reload();
_loggerV2.info('Merge completed'); _loggerV2.info('Merge completed');
} }

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint // 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 // 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 /// @nodoc
@JsonSerializable() @JsonSerializable()

View File

@@ -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/backup2.dart';
import 'package:server_box/data/model/app/bak/backup_source.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/model/app/bak/utils.dart';
import 'package:server_box/data/res/store.dart';
/// Service class for handling backup operations /// Service class for handling backup operations
class BackupService { class BackupService {
/// Perform backup operation with the given source /// Perform backup operation with the given source
static Future<void> backup(BuildContext context, BackupSource source) async { static Future<void> backup(BuildContext context, BackupSource source) async {
final password = await _getBackupPassword(context);
if (password == null) return;
try { 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); await source.saveContent(path);
// Show success message for clipboard source
if (source is ClipboardBackupSource) { if (source is ClipboardBackupSource) {
context.showSnackBar(libL10n.success); context.showSnackBar(libL10n.success);
} }
@@ -41,38 +39,6 @@ class BackupService {
await restoreFromText(context, text); 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 /// Handle restore from text with decryption support
static Future<void> restoreFromText(BuildContext context, String text) async { static Future<void> restoreFromText(BuildContext context, String text) async {
// Check if backup is encrypted // Check if backup is encrypted
@@ -95,7 +61,7 @@ class BackupService {
} }
// Try with saved password first // Try with saved password first
final savedPassword = await Stores.setting.backupasswd.read(); final savedPassword = await SecureStoreProps.bakPwd.read();
if (savedPassword != null && savedPassword.isNotEmpty) { if (savedPassword != null && savedPassword.isNotEmpty) {
try { try {
final (backup, err) = await context.showLoadingDialog( final (backup, err) = await context.showLoadingDialog(

View File

@@ -4,7 +4,7 @@ import 'package:server_box/core/extension/context/locale.dart';
enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus } enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus }
class SSHErr extends Err<SSHErrType> { class SSHErr extends Err<SSHErrType> {
SSHErr({required super.type, super.message}); const SSHErr({required super.type, super.message});
@override @override
String? get solution => switch (type) { String? get solution => switch (type) {
@@ -29,7 +29,7 @@ enum ContainerErrType {
} }
class ContainerErr extends Err<ContainerErrType> { class ContainerErr extends Err<ContainerErrType> {
ContainerErr({required super.type, super.message}); const ContainerErr({required super.type, super.message});
@override @override
String? get solution => null; String? get solution => null;
@@ -38,7 +38,7 @@ class ContainerErr extends Err<ContainerErrType> {
enum ICloudErrType { generic, notFound, multipleFiles } enum ICloudErrType { generic, notFound, multipleFiles }
class ICloudErr extends Err<ICloudErrType> { class ICloudErr extends Err<ICloudErrType> {
ICloudErr({required super.type, super.message}); const ICloudErr({required super.type, super.message});
@override @override
String? get solution => null; String? get solution => null;
@@ -47,7 +47,7 @@ class ICloudErr extends Err<ICloudErrType> {
enum WebdavErrType { generic, notFound } enum WebdavErrType { generic, notFound }
class WebdavErr extends Err<WebdavErrType> { class WebdavErr extends Err<WebdavErrType> {
WebdavErr({required super.type, super.message}); const WebdavErr({required super.type, super.message});
@override @override
String? get solution => null; String? get solution => null;
@@ -56,7 +56,7 @@ class WebdavErr extends Err<WebdavErrType> {
enum PveErrType { unknown, net, loginFailed } enum PveErrType { unknown, net, loginFailed }
class PveErr extends Err<PveErrType> { class PveErr extends Err<PveErrType> {
PveErr({required super.type, super.message}); const PveErr({required super.type, super.message});
@override @override
String? get solution => null; String? get solution => null;

View File

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

View File

@@ -106,7 +106,7 @@ switch (\$args[0]) {
/// Get Windows status command with command-specific separators /// Get Windows status command with command-specific separators
String _getWindowsStatusCommand({required List<String> disabledCmdTypes}) { 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 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 /// Get Unix status command with OS detection
String _getUnixStatusCommand({required List<String> disabledCmdTypes}) { String _getUnixStatusCommand({required List<String> disabledCmdTypes}) {
// Generate command lists with command-specific separators, filtering disabled commands // 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 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(); final bsdCommands = filteredBsdCmdTypes.map((e) => '${e.divider}${e.cmd}').join('').trimRight();
return ''' return '''

View File

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

View File

@@ -1,7 +1,6 @@
import 'package:server_box/data/model/app/scripts/script_builders.dart'; 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/app/scripts/script_consts.dart';
import 'package:server_box/data/model/server/system.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 /// Shell functions available in the ServerBox application
enum ShellFunc { enum ShellFunc {
@@ -26,8 +25,8 @@ enum ShellFunc {
}; };
/// Execute this shell function on the specified server /// Execute this shell function on the specified server
String exec(String id, {SystemType? systemType}) { String exec(String id, {SystemType? systemType, required String? customDir}) {
final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType); final scriptPath = ShellFuncManager.getScriptPath(id, systemType: systemType, customDir: customDir);
final isWindows = systemType == SystemType.windows; final isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows); final builder = ScriptBuilderFactory.getBuilder(isWindows);
@@ -51,11 +50,10 @@ class ShellFuncManager {
/// Get the script directory for the given [id]. /// Get the script directory for the given [id].
/// ///
/// Checks for custom script directory first, then falls back to default. /// Checks for custom script directory first, then falls back to default.
static String getScriptDir(String id, {SystemType? systemType}) { static String getScriptDir(String id, {SystemType? systemType, required String? customDir}) {
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir;
final isWindows = systemType == SystemType.windows; 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); return ScriptPaths.getScriptDir(id, isWindows: isWindows);
} }
@@ -66,11 +64,10 @@ class ShellFuncManager {
} }
/// Get the full script path for the given [id] /// Get the full script path for the given [id]
static String getScriptPath(String id, {SystemType? systemType}) { static String getScriptPath(String id, {SystemType? systemType, required String? customDir}) {
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir; if (customDir != null) {
if (customScriptDir != null) {
final isWindows = systemType == SystemType.windows; final isWindows = systemType == SystemType.windows;
final normalizedDir = _normalizeDir(customScriptDir, isWindows); final normalizedDir = _normalizeDir(customDir, isWindows);
final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile; final fileName = isWindows ? ScriptConstants.scriptFileWindows : ScriptConstants.scriptFile;
final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator; final separator = isWindows ? ScriptConstants.windowsPathSeparator : ScriptConstants.unixPathSeparator;
return '$normalizedDir$separator$fileName'; return '$normalizedDir$separator$fileName';
@@ -81,8 +78,8 @@ class ShellFuncManager {
} }
/// Get the installation shell command for the script /// Get the installation shell command for the script
static String getInstallShellCmd(String id, {SystemType? systemType}) { static String getInstallShellCmd(String id, {SystemType? systemType, required String? customDir}) {
final scriptDir = getScriptDir(id, systemType: systemType); final scriptDir = getScriptDir(id, systemType: systemType, customDir: customDir);
final isWindows = systemType == SystemType.windows; final isWindows = systemType == SystemType.windows;
final normalizedDir = _normalizeDir(scriptDir, isWindows); final normalizedDir = _normalizeDir(scriptDir, isWindows);
final builder = ScriptBuilderFactory.getBuilder(isWindows); final builder = ScriptBuilderFactory.getBuilder(isWindows);
@@ -93,7 +90,11 @@ class ShellFuncManager {
} }
/// Generate complete script based on system type /// 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 isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows); final builder = ScriptBuilderFactory.getBuilder(isWindows);

View File

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

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint // 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 // 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 /// @nodoc
@JsonSerializable() @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 /// @nodoc
@JsonSerializable() @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 /// @nodoc
@JsonSerializable() @JsonSerializable()

View File

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

View File

@@ -1,4 +1,3 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.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/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/server/amd.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/net_speed.dart';
import 'package:server_box/data/model/server/nvdia.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/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/system.dart';
import 'package:server_box/data/model/server/temp.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 { class ServerStatus {
Cpus cpu; Cpus cpu;
Memory mem; Memory mem;

View File

@@ -4,10 +4,8 @@ import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:server_box/data/model/app/error.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/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/system.dart';
import 'package:server_box/data/model/server/wol_cfg.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'; import 'package:server_box/data/store/server.dart';
part 'server_private_info.freezed.dart'; part 'server_private_info.freezed.dart';
@@ -58,6 +56,7 @@ abstract class Spi with _$Spi {
@override @override
String toString() => 'Spi<$oldId>'; String toString() => 'Spi<$oldId>';
/// Parse the [id], if it's null or empty, generate a new one.
static String parseId(Object? id) { static String parseId(Object? id) {
if (id == null || id is! String || id.isEmpty) return ShortId.generate(); if (id == null || id is! String || id.isEmpty) return ShortId.generate();
return id; return id;
@@ -85,23 +84,26 @@ extension Spix on Spi {
return newSpi.id; return newSpi.id;
} }
/// Json encode to string.
String toJsonString() => json.encode(toJson()); String toJsonString() => json.encode(toJson());
VNode<Server>? get server => ServerProvider.pick(spi: this); /// Returns true if the connection info is the same as [other].
VNode<Server>? get jumpServer => ServerProvider.pick(id: jumpId); bool isSameAs(Spi other) {
return user == other.user &&
bool shouldReconnect(Spi old) { ip == other.ip &&
return user != old.user || port == other.port &&
ip != old.ip || pwd == other.pwd &&
port != old.port || keyId == other.keyId &&
pwd != old.pwd || jumpId == other.jumpId;
keyId != old.keyId ||
alterUrl != old.alterUrl ||
jumpId != old.jumpId ||
custom?.cmds != old.custom?.cmds;
} }
(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) { if (alterUrl == null) {
throw SSHErr(type: SSHErrType.connect, message: 'alterUrl is null'); throw SSHErr(type: SSHErrType.connect, message: 'alterUrl is null');
} }
@@ -146,5 +148,6 @@ extension Spix on Spi {
id: 'id', id: 'id',
); );
/// Returns true if the user is 'root'.
bool get isRoot => user == 'root'; bool get isRoot => user == 'root';
} }

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint // 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 // 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 /// @nodoc
@JsonSerializable(includeIfNull: false) @JsonSerializable(includeIfNull: false)

View File

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

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint // 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 // 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 /// @nodoc
@JsonSerializable() @JsonSerializable()

View File

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

View File

@@ -1,5 +1,6 @@
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:server_box/core/extension/context/locale.dart';
enum SystemdUnitFunc { enum SystemdUnitFunc {
start, 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 { enum SystemdUnitState {
active, active,
inactive, inactive,

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint // 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 // 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 /// @nodoc
mixin _$AppState { 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 @override
bool operator ==(Object other) { 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 @override
int get hashCode => Object.hash(runtimeType,desktopMode); int get hashCode => runtimeType.hashCode;
@override @override
String toString() { String toString() {
return 'AppState(desktopMode: $desktopMode)'; return 'AppState()';
} }
} }
/// @nodoc /// @nodoc
abstract mixin class $AppStateCopyWith<$Res> { class $AppStateCopyWith<$Res> {
factory $AppStateCopyWith(AppState value, $Res Function(AppState) _then) = _$AppStateCopyWithImpl; $AppStateCopyWith(AppState _, $Res Function(AppState) __);
@useResult }
$Res call({
bool desktopMode
});
/// 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> /// A `switch`-like method, using callbacks.
implements $AppStateCopyWith<$Res> { ///
_$AppStateCopyWithImpl(this._self, this._then); /// 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; @optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _AppState value) $default,){
final $Res Function(AppState) _then; 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,}) { /// A variant of `map` that fallback to returning `null`.
return _then(_self.copyWith( ///
desktopMode: null == desktopMode ? _self.desktopMode : desktopMode // ignore: cast_nullable_to_non_nullable /// It is equivalent to doing:
as bool, /// ```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 /// @nodoc
class _AppState implements AppState { 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 @override
bool operator ==(Object other) { 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 @override
int get hashCode => Object.hash(runtimeType,desktopMode); int get hashCode => runtimeType.hashCode;
@override @override
String toString() { 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 // dart format on

View File

@@ -6,20 +6,20 @@ part of 'app.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$appProviderHash() => r'8378ec9d0a9c8d99cc05805047cd2d52ac4dbb56'; String _$appStatesHash() => r'ef96f10f6fff0f3dd6d3128ebf070ad79cbc8bc9';
/// See also [AppProvider]. /// See also [AppStates].
@ProviderFor(AppProvider) @ProviderFor(AppStates)
final appProviderProvider = NotifierProvider<AppProvider, AppState>.internal( final appStatesProvider = NotifierProvider<AppStates, AppState>.internal(
AppProvider.new, AppStates.new,
name: r'appProviderProvider', name: r'appStatesProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null ? null
: _$appProviderHash, : _$appStatesHash,
dependencies: null, dependencies: null,
allTransitiveDependencies: null, allTransitiveDependencies: null,
); );
typedef _$AppProvider = Notifier<AppState>; typedef _$AppStates = Notifier<AppState>;
// ignore_for_file: type=lint // 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 // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -4,6 +4,8 @@ import 'dart:convert';
import 'package:dartssh2/dartssh2.dart'; import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.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/core/extension/ssh_client.dart';
import 'package:server_box/data/model/app/error.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/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/model/container/type.dart';
import 'package:server_box/data/res/store.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"); final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found");
class ContainerProvider extends ChangeNotifier { @freezed
final SSHClient? client; abstract class ContainerState with _$ContainerState {
final String userName; const factory ContainerState({
final String hostId; @Default(null) List<ContainerPs>? items,
final BuildContext context; @Default(null) List<ContainerImg>? images,
List<ContainerPs>? items; @Default(null) String? version,
List<ContainerImg>? images; @Default(null) ContainerErr? error,
String? version; @Default(null) String? runLog,
ContainerErr? error; @Default(ContainerType.docker) ContainerType type,
String? runLog; @Default(false) bool isBusy,
ContainerType type; }) = _ContainerState;
var sudoCompleter = Completer<bool>(); }
bool isBusy = false;
ContainerProvider({ @riverpod
required this.client, class ContainerNotifier extends _$ContainerNotifier {
required this.userName, var sudoCompleter = Completer<bool>();
required this.hostId,
required this.context, @override
}) : type = Stores.container.getType(hostId) { ContainerState build(SSHClient? client, String userName, String hostId, BuildContext context) {
refresh(); 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 { 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); Stores.container.setType(type, hostId);
error = runLog = items = images = version = null;
sudoCompleter = Completer<bool>(); sudoCompleter = Completer<bool>();
notifyListeners();
await refresh(); 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 { void _requiresSudo() async {
/// Podman is rootless /// 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()) { if (!Stores.setting.containerTrySudo.fetch()) {
return sudoCompleter.complete(false); 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) { if (res?.string.toLowerCase().contains('permission denied') ?? false) {
return sudoCompleter.complete(true); return sudoCompleter.complete(true);
} }
@@ -76,8 +81,8 @@ class ContainerProvider extends ChangeNotifier {
} }
Future<void> refresh({bool isAuto = false}) async { Future<void> refresh({bool isAuto = false}) async {
if (isBusy) return; if (state.isBusy) return;
isBusy = true; state = state.copyWith(isBusy: true);
if (!sudoCompleter.isCompleted) _requiresSudo(); if (!sudoCompleter.isCompleted) _requiresSudo();
@@ -85,11 +90,14 @@ class ContainerProvider extends ChangeNotifier {
/// If sudo is required and auto refresh is enabled, skip the refresh. /// If sudo is required and auto refresh is enabled, skip the refresh.
/// Or this will ask for pwd again and again. /// 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(); final includeStats = Stores.setting.containerParseStat.fetch();
var raw = ''; 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( final code = await client?.execWithPwd(
cmd, cmd,
context: context, context: context,
@@ -97,75 +105,79 @@ class ContainerProvider extends ChangeNotifier {
id: hostId, id: hostId,
); );
isBusy = false; state = state.copyWith(isBusy: false);
if (!context.mounted) return; if (!context.mounted) return;
/// Code 127 means command not found /// Code 127 means command not found
if (code == 127 || raw.contains(_dockerNotFound)) { if (code == 127 || raw.contains(_dockerNotFound)) {
error = ContainerErr(type: ContainerErrType.notInstalled); state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled));
notifyListeners();
return; return;
} }
// Check result segments count // Check result segments count
final segments = raw.split(ScriptConstants.separator); final segments = raw.split(ScriptConstants.separator);
if (segments.length != ContainerCmdType.values.length) { if (segments.length != ContainerCmdType.values.length) {
error = ContainerErr( state = state.copyWith(
type: ContainerErrType.segmentsNotMatch, error: ContainerErr(
message: 'Container segments: ${segments.length}', type: ContainerErrType.segmentsNotMatch,
message: 'Container segments: ${segments.length}',
),
); );
Loggers.app.warning('Container segments: ${segments.length}\n$raw'); Loggers.app.warning('Container segments: ${segments.length}\n$raw');
notifyListeners();
return; return;
} }
// Parse version // Parse version
final verRaw = ContainerCmdType.version.find(segments); final verRaw = ContainerCmdType.version.find(segments);
try { try {
version = json.decode(verRaw)['Client']['Version']; final version = json.decode(verRaw)['Client']['Version'];
state = state.copyWith(version: version, error: null);
} catch (e, trace) { } 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); Loggers.app.warning('Container version failed', e, trace);
} finally {
notifyListeners();
} }
// Parse ps // Parse ps
final psRaw = ContainerCmdType.ps.find(segments); final psRaw = ContainerCmdType.ps.find(segments);
try { try {
final lines = psRaw.split('\n'); 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 /// Due to the fetched data is not in json format, skip table header
lines.removeWhere((element) => element.contains('CONTAINER ID')); lines.removeWhere((element) => element.contains('CONTAINER ID'));
} }
lines.removeWhere((element) => element.isEmpty); 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) { } 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); Loggers.app.warning('Container ps failed', e, trace);
} finally {
notifyListeners();
} }
// Parse images // Parse images
final imageRaw = ContainerCmdType.images.find(segments).trim(); final imageRaw = ContainerCmdType.images.find(segments).trim();
final isEntireJson = imageRaw.startsWith('[') && imageRaw.endsWith(']'); final isEntireJson = imageRaw.startsWith('[') && imageRaw.endsWith(']');
try { try {
List<ContainerImg> images;
if (isEntireJson) { if (isEntireJson) {
images = (json.decode(imageRaw) as List) images = (json.decode(imageRaw) as List)
.map((e) => ContainerImg.fromRawJson(json.encode(e), type)) .map((e) => ContainerImg.fromRawJson(json.encode(e), state.type))
.toList(); .toList();
} else { } else {
final lines = imageRaw.split('\n'); final lines = imageRaw.split('\n');
lines.removeWhere((element) => element.isEmpty); 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) { } 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); Loggers.app.warning('Container images failed', e, trace);
} finally {
notifyListeners();
} }
// Parse stats // Parse stats
@@ -173,7 +185,7 @@ class ContainerProvider extends ChangeNotifier {
try { try {
final statsLines = statsRaw.split('\n'); final statsLines = statsRaw.split('\n');
statsLines.removeWhere((element) => element.isEmpty); statsLines.removeWhere((element) => element.isEmpty);
for (var item in items!) { for (var item in state.items!) {
final id = item.id; final id = item.id;
if (id == null) continue; if (id == null) continue;
final statsLine = statsLines.firstWhereOrNull( final statsLine = statsLines.firstWhereOrNull(
@@ -185,10 +197,10 @@ class ContainerProvider extends ChangeNotifier {
item.parseStats(statsLine); item.parseStats(statsLine);
} }
} catch (e, trace) { } 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); 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 { Future<ContainerErr?> run(String cmd, {bool autoRefresh = true}) async {
cmd = switch (type) { cmd = switch (state.type) {
ContainerType.docker => 'docker $cmd', ContainerType.docker => 'docker $cmd',
ContainerType.podman => 'podman $cmd', ContainerType.podman => 'podman $cmd',
}; };
runLog = ''; state = state.copyWith(runLog: '');
final errs = <String>[]; final errs = <String>[];
final code = await client?.execWithPwd( final code = await client?.execWithPwd(
_wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd), _wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd),
context: context, context: context,
onStdout: (data, _) { onStdout: (data, _) {
runLog = '$runLog$data'; state = state.copyWith(runLog: '${state.runLog}$data');
notifyListeners();
}, },
onStderr: (data, _) => errs.add(data), onStderr: (data, _) => errs.add(data),
id: hostId, id: hostId,
); );
runLog = null; state = state.copyWith(runLog: null);
notifyListeners();
if (code != 0) { if (code != 0) {
return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim()); return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim());
@@ -262,6 +272,7 @@ class ContainerProvider extends ChangeNotifier {
} }
} }
const _jsonFmt = '--format "{{json .}}"'; const _jsonFmt = '--format "{{json .}}"';
enum ContainerCmdType { enum ContainerCmdType {
@@ -298,7 +309,7 @@ enum ContainerCmdType {
.map((e) => e.exec(type, sudo: sudo, includeStats: includeStats)) .map((e) => e.exec(type, sudo: sudo, includeStats: includeStats))
.join('\necho ${ScriptConstants.separator}\n'); .join('\necho ${ScriptConstants.separator}\n');
} }
/// Find out the required segment from [segments] /// Find out the required segment from [segments]
String find(List<String> segments) { String find(List<String> segments) {
return segments[index]; return segments[index];

View 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

View 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

View File

@@ -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/core/sync.dart';
import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/model/server/private_key_info.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
class PrivateKeyProvider extends Provider { part 'private_key.freezed.dart';
const PrivateKeyProvider._(); part 'private_key.g.dart';
static const instance = PrivateKeyProvider._();
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 @override
void load() { PrivateKeyState build() {
super.load(); return _load();
pkis.value = Stores.key.fetch();
} }
static void add(PrivateKeyInfo info) { void reload() {
pkis.value.add(info); final newState = _load();
pkis.notify(); 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); Stores.key.put(info);
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
static void delete(PrivateKeyInfo info) { void delete(PrivateKeyInfo info) {
pkis.value.removeWhere((e) => e.id == info.id); final newKeys = state.keys.where((e) => e.id != info.id).toList();
pkis.notify(); state = state.copyWith(keys: newKeys);
Stores.key.delete(info); Stores.key.delete(info);
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
static void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) { void update(PrivateKeyInfo old, PrivateKeyInfo newInfo) {
final idx = pkis.value.indexWhere((e) => e.id == old.id); final keys = [...state.keys];
final idx = keys.indexWhere((e) => e.id == old.id);
if (idx == -1) { if (idx == -1) {
pkis.value.add(newInfo); keys.add(newInfo);
Stores.key.put(newInfo); Stores.key.put(newInfo);
Stores.key.delete(old); Stores.key.delete(old);
} else { } else {
pkis.value[idx] = newInfo; keys[idx] = newInfo;
Stores.key.put(newInfo); Stores.key.put(newInfo);
} }
pkis.notify(); state = state.copyWith(keys: keys);
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
} }

View 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

View 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

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

View File

@@ -7,71 +7,90 @@ import 'package:dio/dio.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.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/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/error.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/pve.dart';
import 'package:server_box/data/model/server/server_private_info.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); typedef PveCtrlFunc = Future<bool> Function(String node, String id);
final class PveProvider extends ChangeNotifier { @freezed
final Spi spi; 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 String addr;
late final SSHClient _client; late final SSHClient _client;
late final ServerSocket _serverSocket; late final ServerSocket _serverSocket;
final List<SSHForwardChannel> _forwards = []; final List<SSHForwardChannel> _forwards = [];
int _localPort = 0; int _localPort = 0;
late final Dio session;
late final bool _ignoreCert;
PveProvider({required this.spi}) { @override
final client = spi.server?.value.client; PveState build(Spi spiParam) {
spi = spiParam;
final serverState = ref.watch(serverNotifierProvider(spi.id));
final client = serverState.client;
if (client == null) { if (client == null) {
throw Exception('Server client is null'); return const PveState(error: PveErr(type: PveErrType.net, message: 'Server client is null'));
} }
_client = client; _client = client;
final addr = spi.custom?.pveAddr; final addr = spi.custom?.pveAddr;
if (addr == null) { if (addr == null) {
err.value = 'PVE address is null'; return PveState(error: PveErr(type: PveErrType.net, message: 'PVE address is null'));
return;
} }
this.addr = addr; this.addr = addr;
_init(); _ignoreCert = spi.custom?.pveIgnoreCert ?? false;
_initSession();
// Async initialization
Future.microtask(() => _init());
return const PveState();
} }
final err = ValueNotifier<String?>(null); void _initSession() {
final connected = Completer<void>(); 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; bool get onlyOneNode => state.data?.nodes.length == 1;
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;
Future<void> _init() async { Future<void> _init() async {
try { try {
await _forward(); await _forward();
await _login(); await _login();
await _getRelease(); await _getRelease();
state = state.copyWith(isConnected: true);
} on PveErr { } on PveErr {
err.value = l10n.pveLoginFailed; state = state.copyWith(error: PveErr(type: PveErrType.loginFailed, message: l10n.pveLoginFailed));
} catch (e, s) { } catch (e, s) {
Loggers.app.warning('PVE init failed', e, s); Loggers.app.warning('PVE init failed', e, s);
err.value = e.toString(); state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
} finally {
connected.complete();
} }
} }
@@ -146,72 +165,81 @@ final class PveProvider extends ChangeNotifier {
final resp = await session.get('$addr/api2/extjs/version'); final resp = await session.get('$addr/api2/extjs/version');
final version = resp.data['data']['release'] as String?; final version = resp.data['data']['release'] as String?;
if (version != null) { if (version != null) {
release = version; state = state.copyWith(release: version);
} }
} }
Future<void> list() async { Future<void> list() async {
await connected.future; if (!state.isConnected || state.isBusy) return;
if (isBusy) return; state = state.copyWith(isBusy: true);
isBusy = true;
try { try {
final resp = await session.get('$addr/api2/json/cluster/resources'); final resp = await session.get('$addr/api2/json/cluster/resources');
final res = resp.data['data'] as List; final res = resp.data['data'] as List;
final result = await Computer.shared.start(PveRes.parse, ( final result = await Computer.shared.start(PveRes.parse, (
res, res,
data.value, state.data,
)); ));
data.value = result; state = state.copyWith(data: result, error: null);
} catch (e) { } catch (e) {
Loggers.app.warning('PVE list failed', e); Loggers.app.warning('PVE list failed', e);
err.value = e.toString(); state = state.copyWith(error: PveErr(type: PveErrType.unknown, message: e.toString()));
} finally { } finally {
isBusy = false; state = state.copyWith(isBusy: false);
} }
} }
Future<bool> reboot(String node, String id) async { Future<bool> reboot(String node, String id) async {
await connected.future; if (!state.isConnected) return false;
final resp = await session.post( final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/reboot', '$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 { Future<bool> start(String node, String id) async {
await connected.future; if (!state.isConnected) return false;
final resp = await session.post( final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/start', '$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 { Future<bool> stop(String node, String id) async {
await connected.future; if (!state.isConnected) return false;
final resp = await session.post( final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/stop', '$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 { Future<bool> shutdown(String node, String id) async {
await connected.future; if (!state.isConnected) return false;
final resp = await session.post( final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/shutdown', '$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) { bool _isCtrlSuc(Response resp) {
return resp.statusCode == 200; return resp.statusCode == 200;
} }
@override
Future<void> dispose() async { Future<void> dispose() async {
super.dispose(); try {
await _serverSocket.close(); await _serverSocket.close();
} catch (_) {}
for (final forward in _forwards) { for (final forward in _forwards) {
forward.close(); try {
forward.close();
} catch (_) {}
} }
} }
} }

View 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

View 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

View File

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

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

View 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

View 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

View 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