Compare commits

...

47 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
fd2bf08f78 bump: v1253 2025-09-09 13:32:17 +08:00
lollipopkit🏳️‍⚧️
98e13c39cf fix: android channel invoke 2025-09-09 13:30:37 +08:00
lollipopkit🏳️‍⚧️
e70abeef04 bump: v1251 2025-09-09 13:14:01 +08:00
lollipopkit🏳️‍⚧️
194774d6fb opt.: system detect logic to avoid creating useless file (#905) 2025-09-09 13:10:40 +08:00
lollipopkit🏳️‍⚧️
640d61bab9 fix: holding Backspace doesnt work on desktop (#903) 2025-09-08 14:06:35 +08:00
lollipopkit🏳️‍⚧️
7f4cf22cc9 fix: rm camera perm on mac 2025-09-08 12:37:30 +08:00
lollipopkit🏳️‍⚧️
05a927753f feat: stop all servers in noti center (#901) 2025-09-06 14:04:53 +08:00
lollipopkit🏳️‍⚧️
0c7b72fb2c bump: v1246 2025-09-05 12:31:33 +08:00
lollipopkit🏳️‍⚧️
a869b97502 fix: server stat l10n 2025-09-05 00:24:18 +08:00
lollipopkit🏳️‍⚧️
eadd343205 readd: home drawer
Fixes #900
2025-09-05 00:12:41 +08:00
lollipopkit🏳️‍⚧️
1bac986fe0 bug: single server providers should be keepalived (#899) 2025-09-04 23:50:00 +08:00
lollipopkit🏳️‍⚧️
a94be6c2c3 fix: macOS appstore rejection (#893) 2025-09-03 22:19:04 +08:00
lollipopkit🏳️‍⚧️
fc8e9b4bb1 bump: v1241 2025-09-03 09:24:33 +08:00
lollipopkit🏳️‍⚧️
ec4b633889 fix: watchOS app cfg (#890) 2025-09-03 01:41:08 +08:00
lollipopkit🏳️‍⚧️
e51804fa70 new: custom tabs (#889) 2025-09-03 01:05:03 +08:00
lollipopkit🏳️‍⚧️
2466341999 feat: server conn statistics (#888) 2025-09-02 19:41:56 +08:00
lollipopkit🏳️‍⚧️
929061213f refactor: docker status parsing (#886) 2025-09-02 13:22:54 +08:00
lollipopkit🏳️‍⚧️
6b52679942 fix: resolve Docker interface blank issue caused by LateInitializationError (#884) 2025-09-02 12:44:05 +08:00
lollipopkit🏳️‍⚧️
efc0315c93 new: CLAUDE.md 2025-09-01 23:32:44 +08:00
lollipopkit🏳️‍⚧️
8e4c2a7cde fix: fallback to df on incompatible system (#880) 2025-09-01 23:32:20 +08:00
lollipopkit🏳️‍⚧️
4ec7f5895e fix: imported servers from ssh config are the same (#882) 2025-09-01 23:06:58 +08:00
lollipopkit🏳️‍⚧️
ee22cdb55f fix: private key can't be selected in edit page (#879) 2025-09-01 13:05:54 +08:00
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
217 changed files with 17700 additions and 3389 deletions

View File

@@ -17,17 +17,28 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
channel: 'stable' # or: 'beta', 'dev' or 'master' channel: 'stable'
cache: true
cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:'
- name: Cache pub dependencies
uses: actions/cache@v4
with:
path: |
${{ env.PUB_CACHE }}
~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
# Uncomment this step to verify the use of 'dart format' on each commit.
- name: Verify formatting
run: dart format --output=none .
# Consider passing '--fatal-infos' for slightly stricter analysis. # Consider passing '--fatal-infos' for slightly stricter analysis.
- name: Analyze project source - name: Analyze project source
run: dart analyze run: dart analyze

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

@@ -9,6 +9,11 @@ on:
permissions: permissions:
contents: write contents: write
# Set by fl_build
# env:
# APP_NAME: ServerBox
# BUILD_NUMBER: ${{ github.ref_name }}
jobs: jobs:
releaseAndroid: releaseAndroid:
name: Release android name: Release android
@@ -20,7 +25,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.3"
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: "zulu" distribution: "zulu"
@@ -58,24 +63,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 }}
@@ -105,16 +103,12 @@ jobs:
# uses: actions/checkout@v4 # uses: actions/checkout@v4
# - name: Install Flutter # - name: Install Flutter
# uses: subosito/flutter-action@v2 # uses: subosito/flutter-action@v2
# with:
# channel: 'stable'
# flutter-version: '3.32.1'
# - name: Build # - name: Build
# run: dart run fl_build -p ios,mac # run: dart run fl_build -p ios
# - 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 }}_universal_macos.zip
# ${{ env.APP_NAME }}_universal.ipa # ${{ env.APP_NAME }}_universal.ipa
# env: # env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

95
CLAUDE.md Normal file
View File

@@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Development
- `flutter run` - Run the app in development mode
- `dart run fl_build -p PLATFORM` - Build the app for specific platform (see fl_build package)
- `dart run build_runner build --delete-conflicting-outputs` - Generate code for models with annotations (json_serializable, freezed, hive, riverpod)
- Every time you change model files, run this command to regenerate code (Hive adapters, Riverpod providers, etc.)
- Generated files include: `*.g.dart`, `*.freezed.dart` files
### Testing
- `flutter test` - Run unit tests
- `flutter test test/battery_test.dart` - Run specific test file
## Architecture
This is a Flutter application for managing Linux servers with the following key architectural components:
### Project Structure
- `lib/core/` - Core utilities, extensions, and routing
- `lib/data/` - Data layer with models, providers, and storage
- `model/` - Data models organized by feature (server, container, ssh, etc.)
- `provider/` - Riverpod providers for state management
- `store/` - Local storage implementations using Hive
- `lib/view/` - UI layer with pages and widgets
- `lib/generated/` - Generated localization files
- `lib/hive/` - Hive adapters for local storage
### Key Technologies
- **State Management**: Riverpod with code generation (riverpod_annotation)
- **Local Storage**: Hive for persistent data with generated adapters
- **SSH/SFTP**: Custom dartssh2 fork for server connections
- **Terminal**: Custom xterm.dart fork for SSH terminal interface
- **Networking**: dio for HTTP requests
- **Charts**: fl_chart for server status visualization
- **Localization**: Flutter's built-in i18n with ARB files
- **Code Generation**: Uses build_runner with json_serializable, freezed, hive_generator, riverpod_generator
### Data Models
- Server management models in `lib/data/model/server/`
- Container/Docker models in `lib/data/model/container/`
- SSH and SFTP models in respective directories
- Most models use freezed for immutability and json_annotation for serialization
### Features
- Server status monitoring (CPU, memory, disk, network)
- SSH terminal with virtual keyboard
- SFTP file browser
- Docker container management
- Process and systemd service management
- Server snippets and custom commands
- Multi-language support (12+ languages)
- Cross-platform support (iOS, Android, macOS, Linux, Windows)
### State Management Pattern
- Uses Riverpod providers for dependency injection and state management
- Uses Freezed for immutable state models
- Providers are organized by feature in `lib/data/provider/`
- State is often persisted using Hive stores in `lib/data/store/`
### Build System
- Uses custom `fl_build` package for cross-platform building
- `make.dart` script handles pre/post build tasks (metadata generation)
- Supports building for multiple platforms with platform-specific configurations
- Many dependencies are custom forks hosted on GitHub (dartssh2, xterm, fl_lib, etc.)
### Important Notes
- **Never run code formatting commands** - The codebase has specific formatting that should not be changed
- **Always run code generation** after modifying models with annotations (freezed, json_serializable, hive, riverpod)
- Generated files (`*.g.dart`, `*.freezed.dart`) should not be manually edited
- AGAIN, NEVER run code formatting commands.
- USE dependency injection via GetIt for services like Stores, Services and etc.
- Generate all l10n files using `flutter gen-l10n` command after modifying ARB files.
- USE `hive_ce` not `hive` package for Hive integration.
- Which no need to config `HiveField` and `HiveType` manually.
- USE widgets and utilities from `fl_lib` package for common functionalities.
- Such as `CustomAppBar`, `context.showRoundDialog`, `Input`, `Btnx.cancelOk`, etc.
- You can use context7 MCP to search `lppcg fl_lib KEYWORD` to find relevant widgets and utilities.
- USE `libL10n` and `l10n` for localization strings.
- `libL10n` is from `fl_lib` package, and `l10n` is from this project.
- Before adding new strings, check if it already exists in `libL10n`.
- Prioritize using strings from `libL10n` to avoid duplication, even if the meaning is not 100% exact, just use the substitution of `libL10n`.
- Split UI into Widget build, Actions, Utils. use `extension on` to achieve this

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,27 @@ 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 NOTIFICATION_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 +42,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 +67,23 @@ 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 -> {
// Notify Flutter to stop all connections before stopping service
val stopAllIntent = Intent("tech.lolli.toolbox.STOP_ALL_CONNECTIONS")
sendBroadcast(stopAllIntent)
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(createMergedNotification(0, emptyList(), emptyList()))
START_STICKY START_STICKY
} }
} }
@@ -101,49 +117,147 @@ 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(NOTIFICATION_ID, notification)
this, isFgStarted = true
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE
)
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
action = "ACTION_STOP_FOREGROUND"
}
val deletePendingIntent = PendingIntent.getService(
this,
0,
deleteIntent,
PendingIntent.FLAG_IMMUTABLE
)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, chanId)
} else { } else {
Notification.Builder(this) val nm = getSystemService(NotificationManager::class.java)
nm?.notify(NOTIFICATION_ID, notification)
} }
return builder
.setContentTitle("Server Box")
.setContentText("Running in background")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
.build()
} catch (e: Exception) { } catch (e: Exception) {
logError("Error creating notification", e) logError("Failed to start/update foreground", e)
// Return a basic notification as fallback
return Notification.Builder(this)
.setContentTitle("Server Box")
.setSmallIcon(R.mipmap.ic_launcher)
.build()
} }
} }
private fun createMergedNotification(count: Int, lines: List<String>, sessions: List<SessionItem>): 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)
}
// Use the earliest session's start time for chronometer
val earliestStartTime = sessions.minOfOrNull { it.startWhen } ?: System.currentTimeMillis()
val title = when {
count == 0 -> "Server Box"
count == 1 -> sessions.first().title
else -> "SSH sessions: $count active"
}
val contentText = when {
count == 0 -> "Ready for connections"
count == 1 -> {
val session = sessions.first()
"${session.subtitle} · ${session.status}"
}
else -> "Multiple SSH connections active"
}
// For multiple sessions, show details in expanded view
val style = if (count > 1) {
val inbox = Notification.InboxStyle()
val maxLines = 5
val displayLines = if (lines.size > maxLines) {
lines.take(maxLines) + "...and ${lines.size - maxLines} more"
} else {
lines
}
displayLines.forEach { inbox.addLine(it) }
inbox.setBigContentTitle(title)
inbox
} else {
null
}
val notification = builder
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(R.mipmap.ic_launcher)
.setWhen(earliestStartTime)
.setUsesChronometer(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop All", stopPending)
if (style != null) {
notification.setStyle(style)
}
return notification.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
}
// Cancel any existing individual notifications (we only show merged notification now)
val toCancel = postedIds.toSet()
toCancel.forEach { nm.cancel(it) }
postedIds.clear()
notificationIdMap.clear()
// Create merged notification content
val summaryLines = sessions.map { "${it.title}: ${it.status}" }
val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions)
ensureForeground(mergedNotification)
}
private fun clearAll() {
val nm = getSystemService(NotificationManager::class.java)
nm?.cancel(NOTIFICATION_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 +271,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

@@ -4,6 +4,9 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.Manifest import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
@@ -13,20 +16,34 @@ 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"
private val ACTION_STOP_ALL_CONNECTIONS = "tech.lolli.toolbox.STOP_ALL_CONNECTIONS"
private var stopAllReceiver: BroadcastReceiver? = null
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 +68,38 @@ 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)
// Register broadcast receiver for stop all connections
setupStopAllReceiver()
} }
private fun reqPerm() { private fun reqPerm() {
@@ -77,5 +120,57 @@ 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}")
}
}
}
}
}
private fun setupStopAllReceiver() {
stopAllReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_STOP_ALL_CONNECTIONS && ::channel.isInitialized) {
try {
channel.invokeMethod("stopAllConnections", null)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to invoke stopAllConnections: ${e.message}")
}
}
}
}
val filter = IntentFilter(ACTION_STOP_ALL_CONNECTIONS)
registerReceiver(stopAllReceiver, filter)
}
override fun onDestroy() {
super.onDestroy()
stopAllReceiver?.let {
unregisterReceiver(it)
stopAllReceiver = null
}
}
}

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

Submodule flutter_server_box.wiki added at f440010313

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 = 1253;
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.1253;
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 = 1253;
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.1253;
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 = 1253;
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.1253;
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 = 1253;
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.1253;
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 = 1253;
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.1253;
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 = 1253;
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.1253;
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 = 1253;
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.1253;
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 = 1253;
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.1253;
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 = 1253;
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.1253;
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,90 @@ 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}
/// - `stopAllConnections` with no arguments
static void registerHandler(Future<void> Function(String id) onDisconnect, [VoidCallback? onStopAll]) {
_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;
case 'stopAllConnections':
onStopAll?.call();
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,188 @@
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(
id: ShortId.generate(),
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. 'uname -a' command to detect Linux/BSD/Darwin
/// 2. 'uname -a' command to detect Linux/BSD/Darwin /// 2. 'ver' command to detect Windows (if uname fails)
/// ///
/// 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) {
@@ -25,17 +22,8 @@ class SystemDetector {
} }
try { try {
// Try to detect Windows systems first (more reliable detection) // Try to detect Unix/Linux/BSD systems first (more reliable and doesn't create files)
final powershellResult = await client.run('ver 2>nul').string; final unixResult = await client.run('uname -a 2>/dev/null').string;
if (powershellResult.isNotEmpty &&
(powershellResult.contains('Windows') || powershellResult.contains('NT'))) {
detectedSystemType = SystemType.windows;
dprint('Detected Windows system type for ${spi.oldId}');
return detectedSystemType;
}
// Try to detect Unix/Linux/BSD systems
final unixResult = await client.run('uname -a').string;
if (unixResult.contains('Linux')) { if (unixResult.contains('Linux')) {
detectedSystemType = SystemType.linux; detectedSystemType = SystemType.linux;
dprint('Detected Linux system type for ${spi.oldId}'); dprint('Detected Linux system type for ${spi.oldId}');
@@ -45,6 +33,15 @@ class SystemDetector {
dprint('Detected BSD system type for ${spi.oldId}'); dprint('Detected BSD system type for ${spi.oldId}');
return detectedSystemType; return detectedSystemType;
} }
// If uname fails, try to detect Windows systems
final powershellResult = await client.run('ver 2>nul').string;
if (powershellResult.isNotEmpty &&
(powershellResult.contains('Windows') || powershellResult.contains('NT'))) {
detectedSystemType = SystemType.windows;
dprint('Detected Windows system type for ${spi.oldId}');
return detectedSystemType;
}
} catch (e) { } catch (e) {
Loggers.app.warning('System detection failed for ${spi.oldId}: $e'); Loggers.app.warning('System detection failed for ${spi.oldId}: $e');
} }
@@ -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'),
@@ -23,8 +55,8 @@ enum StatusCmdType implements CommandType {
uptime('uptime'), uptime('uptime'),
conn('cat /proc/net/snmp'), conn('cat /proc/net/snmp'),
disk( disk(
'lsblk --bytes --json --output ' '(lsblk --bytes --json --output '
'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID', 'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID 2>/dev/null && echo "LSBLK_SUCCESS") || df -k'
), ),
mem("cat /proc/meminfo | grep -E 'Mem|Swap'"), mem("cat /proc/meminfo | grep -E 'Mem|Swap'"),
tempType('cat /sys/class/thermal/thermal_zone*/type'), tempType('cat /sys/class/thermal/thermal_zone*/type'),
@@ -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

@@ -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:hive_ce_flutter/adapters.dart';
import 'package:icons_plus/icons_plus.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/view/page/server/tab/tab.dart'; import 'package:server_box/view/page/server/tab/tab.dart';
@@ -8,10 +9,17 @@ import 'package:server_box/view/page/snippet/list.dart';
import 'package:server_box/view/page/ssh/tab.dart'; import 'package:server_box/view/page/ssh/tab.dart';
import 'package:server_box/view/page/storage/local.dart'; import 'package:server_box/view/page/storage/local.dart';
part 'tab.g.dart';
@HiveType(typeId: 103)
enum AppTab { enum AppTab {
@HiveField(0)
server, server,
@HiveField(1)
ssh, ssh,
@HiveField(2)
file, file,
@HiveField(3)
snippet snippet
//settings, //settings,
; ;
@@ -93,4 +101,35 @@ enum AppTab {
static List<NavigationRailDestination> get navRailDestinations { static List<NavigationRailDestination> get navRailDestinations {
return AppTab.values.map((e) => e.navRailDestination).toList(); return AppTab.values.map((e) => e.navRailDestination).toList();
} }
/// Helper function to parse AppTab list from stored object
static List<AppTab> parseAppTabsFromObj(dynamic val) {
if (val is List) {
final tabs = <AppTab>[];
for (final e in val) {
final tab = _parseAppTabFromElement(e);
if (tab != null) {
tabs.add(tab);
}
}
if (tabs.isNotEmpty) return tabs;
}
return AppTab.values;
}
/// Helper function to parse a single AppTab from various element types
static AppTab? _parseAppTabFromElement(dynamic e) {
if (e is AppTab) {
return e;
} else if (e is String) {
return AppTab.values.firstWhereOrNull((t) => t.name == e);
} else if (e is int) {
if (e >= 0 && e < AppTab.values.length) {
return AppTab.values[e];
}
}
return null;
}
} }

View File

@@ -0,0 +1,52 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AppTabAdapter extends TypeAdapter<AppTab> {
@override
final typeId = 103;
@override
AppTab read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return AppTab.server;
case 1:
return AppTab.ssh;
case 2:
return AppTab.file;
case 3:
return AppTab.snippet;
default:
return AppTab.server;
}
}
@override
void write(BinaryWriter writer, AppTab obj) {
switch (obj) {
case AppTab.server:
writer.writeByte(0);
case AppTab.ssh:
writer.writeByte(1);
case AppTab.file:
writer.writeByte(2);
case AppTab.snippet:
writer.writeByte(3);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AppTabAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.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/container/status.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/misc.dart'; import 'package:server_box/data/res/misc.dart';
@@ -10,7 +11,7 @@ sealed class ContainerPs {
final String? image = null; final String? image = null;
String? get name; String? get name;
String? get cmd; String? get cmd;
bool get running; ContainerStatus get status;
String? cpu; String? cpu;
String? mem; String? mem;
@@ -51,7 +52,7 @@ final class PodmanPs implements ContainerPs {
String? get cmd => command?.firstOrNull; String? get cmd => command?.firstOrNull;
@override @override
bool get running => exited != true; ContainerStatus get status => ContainerStatus.fromPodmanExited(exited);
@override @override
void parseStats(String s) { void parseStats(String s) {
@@ -121,10 +122,7 @@ final class DockerPs implements ContainerPs {
String? get cmd => null; String? get cmd => null;
@override @override
bool get running { ContainerStatus get status => ContainerStatus.fromDockerState(state);
if (state?.contains('Exited') == true) return false;
return true;
}
@override @override
void parseStats(String s) { void parseStats(String s) {

View File

@@ -0,0 +1,70 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/extension/context/locale.dart';
/// Represents the various states a container can be in.
/// Supports both Docker and Podman container status parsing.
enum ContainerStatus {
running,
exited,
created,
paused,
restarting,
removing,
dead,
unknown;
/// Check if the container is actively running
bool get isRunning => this == ContainerStatus.running;
/// Check if the container can be started
bool get canStart =>
this == ContainerStatus.exited ||
this == ContainerStatus.created ||
this == ContainerStatus.dead;
/// Check if the container can be stopped
bool get canStop =>
this == ContainerStatus.running || this == ContainerStatus.paused;
/// Check if the container can be restarted
bool get canRestart =>
this != ContainerStatus.removing && this != ContainerStatus.unknown;
/// Parse Docker container status string to ContainerStatus
static ContainerStatus fromDockerState(String? state) {
if (state == null || state.isEmpty) return ContainerStatus.unknown;
final lowerState = state.toLowerCase();
if (lowerState.startsWith('up')) return ContainerStatus.running;
if (lowerState.contains('exited')) return ContainerStatus.exited;
if (lowerState.contains('created')) return ContainerStatus.created;
if (lowerState.contains('paused')) return ContainerStatus.paused;
if (lowerState.contains('restarting')) return ContainerStatus.restarting;
if (lowerState.contains('removing')) return ContainerStatus.removing;
if (lowerState.contains('dead')) return ContainerStatus.dead;
return ContainerStatus.unknown;
}
/// Parse Podman container status from exited boolean
static ContainerStatus fromPodmanExited(bool? exited) {
if (exited == true) return ContainerStatus.exited;
if (exited == false) return ContainerStatus.running;
return ContainerStatus.unknown;
}
/// Get display string for the status
String get displayName {
return switch (this) {
ContainerStatus.running => l10n.running,
ContainerStatus.exited => libL10n.exit,
ContainerStatus.created => 'Created',
ContainerStatus.paused => 'Paused',
ContainerStatus.restarting => 'Restarting',
ContainerStatus.removing => 'Removing',
ContainerStatus.dead => 'Dead',
ContainerStatus.unknown => libL10n.unknown,
};
}
}

View File

@@ -0,0 +1,79 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hive_ce/hive.dart';
part 'connection_stat.freezed.dart';
part 'connection_stat.g.dart';
@freezed
@HiveType(typeId: 100)
abstract class ConnectionStat with _$ConnectionStat {
const factory ConnectionStat({
@HiveField(0) required String serverId,
@HiveField(1) required String serverName,
@HiveField(2) required DateTime timestamp,
@HiveField(3) required ConnectionResult result,
@HiveField(4) @Default('') String errorMessage,
@HiveField(5) required int durationMs,
}) = _ConnectionStat;
factory ConnectionStat.fromJson(Map<String, dynamic> json) =>
_$ConnectionStatFromJson(json);
}
@freezed
@HiveType(typeId: 101)
abstract class ServerConnectionStats with _$ServerConnectionStats {
const factory ServerConnectionStats({
@HiveField(0) required String serverId,
@HiveField(1) required String serverName,
@HiveField(2) required int totalAttempts,
@HiveField(3) required int successCount,
@HiveField(4) required int failureCount,
@HiveField(5) @Default(null) DateTime? lastSuccessTime,
@HiveField(6) @Default(null) DateTime? lastFailureTime,
@HiveField(7) @Default([]) List<ConnectionStat> recentConnections,
@HiveField(8) required double successRate,
}) = _ServerConnectionStats;
factory ServerConnectionStats.fromJson(Map<String, dynamic> json) =>
_$ServerConnectionStatsFromJson(json);
}
@HiveType(typeId: 102)
enum ConnectionResult {
@HiveField(0)
@JsonValue('success')
success,
@HiveField(1)
@JsonValue('timeout')
timeout,
@HiveField(2)
@JsonValue('auth_failed')
authFailed,
@HiveField(3)
@JsonValue('network_error')
networkError,
@HiveField(4)
@JsonValue('unknown_error')
unknownError,
}
extension ConnectionResultExtension on ConnectionResult {
String get displayName {
switch (this) {
case ConnectionResult.success:
return libL10n.success;
case ConnectionResult.timeout:
return '${libL10n.error}(timeout)';
case ConnectionResult.authFailed:
return '${libL10n.error}(auth)';
case ConnectionResult.networkError:
return '${libL10n.error}(${libL10n.network})';
case ConnectionResult.unknownError:
return '${libL10n.error}(${libL10n.unknown})';
}
}
bool get isSuccess => this == ConnectionResult.success;
}

View File

@@ -0,0 +1,585 @@
// 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 'connection_stat.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ConnectionStat {
@HiveField(0) String get serverId;@HiveField(1) String get serverName;@HiveField(2) DateTime get timestamp;@HiveField(3) ConnectionResult get result;@HiveField(4) String get errorMessage;@HiveField(5) int get durationMs;
/// Create a copy of ConnectionStat
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ConnectionStatCopyWith<ConnectionStat> get copyWith => _$ConnectionStatCopyWithImpl<ConnectionStat>(this as ConnectionStat, _$identity);
/// Serializes this ConnectionStat to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ConnectionStat&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.result, result) || other.result == result)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serverId,serverName,timestamp,result,errorMessage,durationMs);
@override
String toString() {
return 'ConnectionStat(serverId: $serverId, serverName: $serverName, timestamp: $timestamp, result: $result, errorMessage: $errorMessage, durationMs: $durationMs)';
}
}
/// @nodoc
abstract mixin class $ConnectionStatCopyWith<$Res> {
factory $ConnectionStatCopyWith(ConnectionStat value, $Res Function(ConnectionStat) _then) = _$ConnectionStatCopyWithImpl;
@useResult
$Res call({
@HiveField(0) String serverId,@HiveField(1) String serverName,@HiveField(2) DateTime timestamp,@HiveField(3) ConnectionResult result,@HiveField(4) String errorMessage,@HiveField(5) int durationMs
});
}
/// @nodoc
class _$ConnectionStatCopyWithImpl<$Res>
implements $ConnectionStatCopyWith<$Res> {
_$ConnectionStatCopyWithImpl(this._self, this._then);
final ConnectionStat _self;
final $Res Function(ConnectionStat) _then;
/// Create a copy of ConnectionStat
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? serverName = null,Object? timestamp = null,Object? result = null,Object? errorMessage = null,Object? durationMs = null,}) {
return _then(_self.copyWith(
serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable
as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
as DateTime,result: null == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
as ConnectionResult,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [ConnectionStat].
extension ConnectionStatPatterns on ConnectionStat {
/// 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( _ConnectionStat value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ConnectionStat() 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( _ConnectionStat value) $default,){
final _that = this;
switch (_that) {
case _ConnectionStat():
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( _ConnectionStat value)? $default,){
final _that = this;
switch (_that) {
case _ConnectionStat() 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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) DateTime timestamp, @HiveField(3) ConnectionResult result, @HiveField(4) String errorMessage, @HiveField(5) int durationMs)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ConnectionStat() when $default != null:
return $default(_that.serverId,_that.serverName,_that.timestamp,_that.result,_that.errorMessage,_that.durationMs);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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) DateTime timestamp, @HiveField(3) ConnectionResult result, @HiveField(4) String errorMessage, @HiveField(5) int durationMs) $default,) {final _that = this;
switch (_that) {
case _ConnectionStat():
return $default(_that.serverId,_that.serverName,_that.timestamp,_that.result,_that.errorMessage,_that.durationMs);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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) DateTime timestamp, @HiveField(3) ConnectionResult result, @HiveField(4) String errorMessage, @HiveField(5) int durationMs)? $default,) {final _that = this;
switch (_that) {
case _ConnectionStat() when $default != null:
return $default(_that.serverId,_that.serverName,_that.timestamp,_that.result,_that.errorMessage,_that.durationMs);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ConnectionStat implements ConnectionStat {
const _ConnectionStat({@HiveField(0) required this.serverId, @HiveField(1) required this.serverName, @HiveField(2) required this.timestamp, @HiveField(3) required this.result, @HiveField(4) this.errorMessage = '', @HiveField(5) required this.durationMs});
factory _ConnectionStat.fromJson(Map<String, dynamic> json) => _$ConnectionStatFromJson(json);
@override@HiveField(0) final String serverId;
@override@HiveField(1) final String serverName;
@override@HiveField(2) final DateTime timestamp;
@override@HiveField(3) final ConnectionResult result;
@override@JsonKey()@HiveField(4) final String errorMessage;
@override@HiveField(5) final int durationMs;
/// Create a copy of ConnectionStat
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ConnectionStatCopyWith<_ConnectionStat> get copyWith => __$ConnectionStatCopyWithImpl<_ConnectionStat>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ConnectionStatToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ConnectionStat&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.result, result) || other.result == result)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.durationMs, durationMs) || other.durationMs == durationMs));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serverId,serverName,timestamp,result,errorMessage,durationMs);
@override
String toString() {
return 'ConnectionStat(serverId: $serverId, serverName: $serverName, timestamp: $timestamp, result: $result, errorMessage: $errorMessage, durationMs: $durationMs)';
}
}
/// @nodoc
abstract mixin class _$ConnectionStatCopyWith<$Res> implements $ConnectionStatCopyWith<$Res> {
factory _$ConnectionStatCopyWith(_ConnectionStat value, $Res Function(_ConnectionStat) _then) = __$ConnectionStatCopyWithImpl;
@override @useResult
$Res call({
@HiveField(0) String serverId,@HiveField(1) String serverName,@HiveField(2) DateTime timestamp,@HiveField(3) ConnectionResult result,@HiveField(4) String errorMessage,@HiveField(5) int durationMs
});
}
/// @nodoc
class __$ConnectionStatCopyWithImpl<$Res>
implements _$ConnectionStatCopyWith<$Res> {
__$ConnectionStatCopyWithImpl(this._self, this._then);
final _ConnectionStat _self;
final $Res Function(_ConnectionStat) _then;
/// Create a copy of ConnectionStat
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? serverName = null,Object? timestamp = null,Object? result = null,Object? errorMessage = null,Object? durationMs = null,}) {
return _then(_ConnectionStat(
serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable
as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
as String,timestamp: null == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
as DateTime,result: null == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
as ConnectionResult,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,durationMs: null == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
mixin _$ServerConnectionStats {
@HiveField(0) String get serverId;@HiveField(1) String get serverName;@HiveField(2) int get totalAttempts;@HiveField(3) int get successCount;@HiveField(4) int get failureCount;@HiveField(5) DateTime? get lastSuccessTime;@HiveField(6) DateTime? get lastFailureTime;@HiveField(7) List<ConnectionStat> get recentConnections;@HiveField(8) double get successRate;
/// Create a copy of ServerConnectionStats
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ServerConnectionStatsCopyWith<ServerConnectionStats> get copyWith => _$ServerConnectionStatsCopyWithImpl<ServerConnectionStats>(this as ServerConnectionStats, _$identity);
/// Serializes this ServerConnectionStats to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ServerConnectionStats&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.totalAttempts, totalAttempts) || other.totalAttempts == totalAttempts)&&(identical(other.successCount, successCount) || other.successCount == successCount)&&(identical(other.failureCount, failureCount) || other.failureCount == failureCount)&&(identical(other.lastSuccessTime, lastSuccessTime) || other.lastSuccessTime == lastSuccessTime)&&(identical(other.lastFailureTime, lastFailureTime) || other.lastFailureTime == lastFailureTime)&&const DeepCollectionEquality().equals(other.recentConnections, recentConnections)&&(identical(other.successRate, successRate) || other.successRate == successRate));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serverId,serverName,totalAttempts,successCount,failureCount,lastSuccessTime,lastFailureTime,const DeepCollectionEquality().hash(recentConnections),successRate);
@override
String toString() {
return 'ServerConnectionStats(serverId: $serverId, serverName: $serverName, totalAttempts: $totalAttempts, successCount: $successCount, failureCount: $failureCount, lastSuccessTime: $lastSuccessTime, lastFailureTime: $lastFailureTime, recentConnections: $recentConnections, successRate: $successRate)';
}
}
/// @nodoc
abstract mixin class $ServerConnectionStatsCopyWith<$Res> {
factory $ServerConnectionStatsCopyWith(ServerConnectionStats value, $Res Function(ServerConnectionStats) _then) = _$ServerConnectionStatsCopyWithImpl;
@useResult
$Res call({
@HiveField(0) String serverId,@HiveField(1) String serverName,@HiveField(2) int totalAttempts,@HiveField(3) int successCount,@HiveField(4) int failureCount,@HiveField(5) DateTime? lastSuccessTime,@HiveField(6) DateTime? lastFailureTime,@HiveField(7) List<ConnectionStat> recentConnections,@HiveField(8) double successRate
});
}
/// @nodoc
class _$ServerConnectionStatsCopyWithImpl<$Res>
implements $ServerConnectionStatsCopyWith<$Res> {
_$ServerConnectionStatsCopyWithImpl(this._self, this._then);
final ServerConnectionStats _self;
final $Res Function(ServerConnectionStats) _then;
/// Create a copy of ServerConnectionStats
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? serverId = null,Object? serverName = null,Object? totalAttempts = null,Object? successCount = null,Object? failureCount = null,Object? lastSuccessTime = freezed,Object? lastFailureTime = freezed,Object? recentConnections = null,Object? successRate = null,}) {
return _then(_self.copyWith(
serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable
as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
as String,totalAttempts: null == totalAttempts ? _self.totalAttempts : totalAttempts // ignore: cast_nullable_to_non_nullable
as int,successCount: null == successCount ? _self.successCount : successCount // ignore: cast_nullable_to_non_nullable
as int,failureCount: null == failureCount ? _self.failureCount : failureCount // ignore: cast_nullable_to_non_nullable
as int,lastSuccessTime: freezed == lastSuccessTime ? _self.lastSuccessTime : lastSuccessTime // ignore: cast_nullable_to_non_nullable
as DateTime?,lastFailureTime: freezed == lastFailureTime ? _self.lastFailureTime : lastFailureTime // ignore: cast_nullable_to_non_nullable
as DateTime?,recentConnections: null == recentConnections ? _self.recentConnections : recentConnections // ignore: cast_nullable_to_non_nullable
as List<ConnectionStat>,successRate: null == successRate ? _self.successRate : successRate // ignore: cast_nullable_to_non_nullable
as double,
));
}
}
/// Adds pattern-matching-related methods to [ServerConnectionStats].
extension ServerConnectionStatsPatterns on ServerConnectionStats {
/// 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( _ServerConnectionStats value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ServerConnectionStats() 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( _ServerConnectionStats value) $default,){
final _that = this;
switch (_that) {
case _ServerConnectionStats():
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( _ServerConnectionStats value)? $default,){
final _that = this;
switch (_that) {
case _ServerConnectionStats() 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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) int totalAttempts, @HiveField(3) int successCount, @HiveField(4) int failureCount, @HiveField(5) DateTime? lastSuccessTime, @HiveField(6) DateTime? lastFailureTime, @HiveField(7) List<ConnectionStat> recentConnections, @HiveField(8) double successRate)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ServerConnectionStats() when $default != null:
return $default(_that.serverId,_that.serverName,_that.totalAttempts,_that.successCount,_that.failureCount,_that.lastSuccessTime,_that.lastFailureTime,_that.recentConnections,_that.successRate);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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) int totalAttempts, @HiveField(3) int successCount, @HiveField(4) int failureCount, @HiveField(5) DateTime? lastSuccessTime, @HiveField(6) DateTime? lastFailureTime, @HiveField(7) List<ConnectionStat> recentConnections, @HiveField(8) double successRate) $default,) {final _that = this;
switch (_that) {
case _ServerConnectionStats():
return $default(_that.serverId,_that.serverName,_that.totalAttempts,_that.successCount,_that.failureCount,_that.lastSuccessTime,_that.lastFailureTime,_that.recentConnections,_that.successRate);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(@HiveField(0) String serverId, @HiveField(1) String serverName, @HiveField(2) int totalAttempts, @HiveField(3) int successCount, @HiveField(4) int failureCount, @HiveField(5) DateTime? lastSuccessTime, @HiveField(6) DateTime? lastFailureTime, @HiveField(7) List<ConnectionStat> recentConnections, @HiveField(8) double successRate)? $default,) {final _that = this;
switch (_that) {
case _ServerConnectionStats() when $default != null:
return $default(_that.serverId,_that.serverName,_that.totalAttempts,_that.successCount,_that.failureCount,_that.lastSuccessTime,_that.lastFailureTime,_that.recentConnections,_that.successRate);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ServerConnectionStats implements ServerConnectionStats {
const _ServerConnectionStats({@HiveField(0) required this.serverId, @HiveField(1) required this.serverName, @HiveField(2) required this.totalAttempts, @HiveField(3) required this.successCount, @HiveField(4) required this.failureCount, @HiveField(5) this.lastSuccessTime = null, @HiveField(6) this.lastFailureTime = null, @HiveField(7) final List<ConnectionStat> recentConnections = const [], @HiveField(8) required this.successRate}): _recentConnections = recentConnections;
factory _ServerConnectionStats.fromJson(Map<String, dynamic> json) => _$ServerConnectionStatsFromJson(json);
@override@HiveField(0) final String serverId;
@override@HiveField(1) final String serverName;
@override@HiveField(2) final int totalAttempts;
@override@HiveField(3) final int successCount;
@override@HiveField(4) final int failureCount;
@override@JsonKey()@HiveField(5) final DateTime? lastSuccessTime;
@override@JsonKey()@HiveField(6) final DateTime? lastFailureTime;
final List<ConnectionStat> _recentConnections;
@override@JsonKey()@HiveField(7) List<ConnectionStat> get recentConnections {
if (_recentConnections is EqualUnmodifiableListView) return _recentConnections;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_recentConnections);
}
@override@HiveField(8) final double successRate;
/// Create a copy of ServerConnectionStats
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ServerConnectionStatsCopyWith<_ServerConnectionStats> get copyWith => __$ServerConnectionStatsCopyWithImpl<_ServerConnectionStats>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ServerConnectionStatsToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ServerConnectionStats&&(identical(other.serverId, serverId) || other.serverId == serverId)&&(identical(other.serverName, serverName) || other.serverName == serverName)&&(identical(other.totalAttempts, totalAttempts) || other.totalAttempts == totalAttempts)&&(identical(other.successCount, successCount) || other.successCount == successCount)&&(identical(other.failureCount, failureCount) || other.failureCount == failureCount)&&(identical(other.lastSuccessTime, lastSuccessTime) || other.lastSuccessTime == lastSuccessTime)&&(identical(other.lastFailureTime, lastFailureTime) || other.lastFailureTime == lastFailureTime)&&const DeepCollectionEquality().equals(other._recentConnections, _recentConnections)&&(identical(other.successRate, successRate) || other.successRate == successRate));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,serverId,serverName,totalAttempts,successCount,failureCount,lastSuccessTime,lastFailureTime,const DeepCollectionEquality().hash(_recentConnections),successRate);
@override
String toString() {
return 'ServerConnectionStats(serverId: $serverId, serverName: $serverName, totalAttempts: $totalAttempts, successCount: $successCount, failureCount: $failureCount, lastSuccessTime: $lastSuccessTime, lastFailureTime: $lastFailureTime, recentConnections: $recentConnections, successRate: $successRate)';
}
}
/// @nodoc
abstract mixin class _$ServerConnectionStatsCopyWith<$Res> implements $ServerConnectionStatsCopyWith<$Res> {
factory _$ServerConnectionStatsCopyWith(_ServerConnectionStats value, $Res Function(_ServerConnectionStats) _then) = __$ServerConnectionStatsCopyWithImpl;
@override @useResult
$Res call({
@HiveField(0) String serverId,@HiveField(1) String serverName,@HiveField(2) int totalAttempts,@HiveField(3) int successCount,@HiveField(4) int failureCount,@HiveField(5) DateTime? lastSuccessTime,@HiveField(6) DateTime? lastFailureTime,@HiveField(7) List<ConnectionStat> recentConnections,@HiveField(8) double successRate
});
}
/// @nodoc
class __$ServerConnectionStatsCopyWithImpl<$Res>
implements _$ServerConnectionStatsCopyWith<$Res> {
__$ServerConnectionStatsCopyWithImpl(this._self, this._then);
final _ServerConnectionStats _self;
final $Res Function(_ServerConnectionStats) _then;
/// Create a copy of ServerConnectionStats
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? serverId = null,Object? serverName = null,Object? totalAttempts = null,Object? successCount = null,Object? failureCount = null,Object? lastSuccessTime = freezed,Object? lastFailureTime = freezed,Object? recentConnections = null,Object? successRate = null,}) {
return _then(_ServerConnectionStats(
serverId: null == serverId ? _self.serverId : serverId // ignore: cast_nullable_to_non_nullable
as String,serverName: null == serverName ? _self.serverName : serverName // ignore: cast_nullable_to_non_nullable
as String,totalAttempts: null == totalAttempts ? _self.totalAttempts : totalAttempts // ignore: cast_nullable_to_non_nullable
as int,successCount: null == successCount ? _self.successCount : successCount // ignore: cast_nullable_to_non_nullable
as int,failureCount: null == failureCount ? _self.failureCount : failureCount // ignore: cast_nullable_to_non_nullable
as int,lastSuccessTime: freezed == lastSuccessTime ? _self.lastSuccessTime : lastSuccessTime // ignore: cast_nullable_to_non_nullable
as DateTime?,lastFailureTime: freezed == lastFailureTime ? _self.lastFailureTime : lastFailureTime // ignore: cast_nullable_to_non_nullable
as DateTime?,recentConnections: null == recentConnections ? _self._recentConnections : recentConnections // ignore: cast_nullable_to_non_nullable
as List<ConnectionStat>,successRate: null == successRate ? _self.successRate : successRate // ignore: cast_nullable_to_non_nullable
as double,
));
}
}
// dart format on

View File

@@ -0,0 +1,233 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connection_stat.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ConnectionStatAdapter extends TypeAdapter<ConnectionStat> {
@override
final typeId = 100;
@override
ConnectionStat read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ConnectionStat(
serverId: fields[0] as String,
serverName: fields[1] as String,
timestamp: fields[2] as DateTime,
result: fields[3] as ConnectionResult,
errorMessage: fields[4] == null ? '' : fields[4] as String,
durationMs: (fields[5] as num).toInt(),
);
}
@override
void write(BinaryWriter writer, ConnectionStat obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.serverId)
..writeByte(1)
..write(obj.serverName)
..writeByte(2)
..write(obj.timestamp)
..writeByte(3)
..write(obj.result)
..writeByte(4)
..write(obj.errorMessage)
..writeByte(5)
..write(obj.durationMs);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ConnectionStatAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ServerConnectionStatsAdapter extends TypeAdapter<ServerConnectionStats> {
@override
final typeId = 101;
@override
ServerConnectionStats read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ServerConnectionStats(
serverId: fields[0] as String,
serverName: fields[1] as String,
totalAttempts: (fields[2] as num).toInt(),
successCount: (fields[3] as num).toInt(),
failureCount: (fields[4] as num).toInt(),
lastSuccessTime: fields[5] == null ? null : fields[5] as DateTime?,
lastFailureTime: fields[6] == null ? null : fields[6] as DateTime?,
recentConnections: fields[7] == null
? []
: (fields[7] as List).cast<ConnectionStat>(),
successRate: (fields[8] as num).toDouble(),
);
}
@override
void write(BinaryWriter writer, ServerConnectionStats obj) {
writer
..writeByte(9)
..writeByte(0)
..write(obj.serverId)
..writeByte(1)
..write(obj.serverName)
..writeByte(2)
..write(obj.totalAttempts)
..writeByte(3)
..write(obj.successCount)
..writeByte(4)
..write(obj.failureCount)
..writeByte(5)
..write(obj.lastSuccessTime)
..writeByte(6)
..write(obj.lastFailureTime)
..writeByte(7)
..write(obj.recentConnections)
..writeByte(8)
..write(obj.successRate);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ServerConnectionStatsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ConnectionResultAdapter extends TypeAdapter<ConnectionResult> {
@override
final typeId = 102;
@override
ConnectionResult read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return ConnectionResult.success;
case 1:
return ConnectionResult.timeout;
case 2:
return ConnectionResult.authFailed;
case 3:
return ConnectionResult.networkError;
case 4:
return ConnectionResult.unknownError;
default:
return ConnectionResult.success;
}
}
@override
void write(BinaryWriter writer, ConnectionResult obj) {
switch (obj) {
case ConnectionResult.success:
writer.writeByte(0);
case ConnectionResult.timeout:
writer.writeByte(1);
case ConnectionResult.authFailed:
writer.writeByte(2);
case ConnectionResult.networkError:
writer.writeByte(3);
case ConnectionResult.unknownError:
writer.writeByte(4);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ConnectionResultAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ConnectionStat _$ConnectionStatFromJson(Map<String, dynamic> json) =>
_ConnectionStat(
serverId: json['serverId'] as String,
serverName: json['serverName'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
result: $enumDecode(_$ConnectionResultEnumMap, json['result']),
errorMessage: json['errorMessage'] as String? ?? '',
durationMs: (json['durationMs'] as num).toInt(),
);
Map<String, dynamic> _$ConnectionStatToJson(_ConnectionStat instance) =>
<String, dynamic>{
'serverId': instance.serverId,
'serverName': instance.serverName,
'timestamp': instance.timestamp.toIso8601String(),
'result': _$ConnectionResultEnumMap[instance.result]!,
'errorMessage': instance.errorMessage,
'durationMs': instance.durationMs,
};
const _$ConnectionResultEnumMap = {
ConnectionResult.success: 'success',
ConnectionResult.timeout: 'timeout',
ConnectionResult.authFailed: 'auth_failed',
ConnectionResult.networkError: 'network_error',
ConnectionResult.unknownError: 'unknown_error',
};
_ServerConnectionStats _$ServerConnectionStatsFromJson(
Map<String, dynamic> json,
) => _ServerConnectionStats(
serverId: json['serverId'] as String,
serverName: json['serverName'] as String,
totalAttempts: (json['totalAttempts'] as num).toInt(),
successCount: (json['successCount'] as num).toInt(),
failureCount: (json['failureCount'] as num).toInt(),
lastSuccessTime: json['lastSuccessTime'] == null
? null
: DateTime.parse(json['lastSuccessTime'] as String),
lastFailureTime: json['lastFailureTime'] == null
? null
: DateTime.parse(json['lastFailureTime'] as String),
recentConnections:
(json['recentConnections'] as List<dynamic>?)
?.map((e) => ConnectionStat.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
successRate: (json['successRate'] as num).toDouble(),
);
Map<String, dynamic> _$ServerConnectionStatsToJson(
_ServerConnectionStats instance,
) => <String, dynamic>{
'serverId': instance.serverId,
'serverName': instance.serverName,
'totalAttempts': instance.totalAttempts,
'successCount': instance.successCount,
'failureCount': instance.failureCount,
'lastSuccessTime': instance.lastSuccessTime?.toIso8601String(),
'lastFailureTime': instance.lastFailureTime?.toIso8601String(),
'recentConnections': instance.recentConnections,
'successRate': instance.successRate,
};

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

@@ -44,22 +44,49 @@ class Disk with EquatableMixin {
static List<Disk> parse(String raw) { static List<Disk> parse(String raw) {
final list = <Disk>[]; final list = <Disk>[];
raw = raw.trim(); raw = raw.trim();
try {
if (raw.startsWith('{')) { if (raw.isEmpty) {
// Parse JSON output from lsblk command dprint('Empty disk info data received');
final Map<String, dynamic> jsonData = json.decode(raw); return list;
final List<dynamic> blockdevices = jsonData['blockdevices'] ?? []; }
for (final device in blockdevices) { try {
// Process each device // Check if we have lsblk JSON output with success marker
_processTopLevelDevice(device, list); if (raw.startsWith('{')) {
// Extract JSON part (excluding the success marker if present)
final jsonEnd = raw.indexOf('\nLSBLK_SUCCESS');
final jsonPart = jsonEnd > 0 ? raw.substring(0, jsonEnd) : raw;
try {
final Map<String, dynamic> jsonData = json.decode(jsonPart);
final List<dynamic> blockdevices = jsonData['blockdevices'] ?? [];
for (final device in blockdevices) {
// Process each device
_processTopLevelDevice(device, list);
}
// If we successfully parsed JSON and have valid disks, return them
if (list.isNotEmpty) {
return list;
}
} on FormatException catch (e) {
Loggers.app.warning('JSON parsing failed, falling back to df -k output: $e');
} catch (e) {
Loggers.app.warning('Error processing JSON disk data, falling back to df -k output: $e', e);
} }
} else { }
// Fallback to the old parsing method in case of non-JSON output
// Check if we have df -k output (fallback case)
if (raw.contains('Filesystem') && raw.contains('Mounted on')) {
return _parseWithOldMethod(raw); return _parseWithOldMethod(raw);
} }
// If we reach here, both parsing methods failed
Loggers.app.warning('Unable to parse disk info with any method');
} catch (e) { } catch (e) {
Loggers.app.warning('Failed to parse disk info: $e', e); Loggers.app.warning('Failed to parse disk info with both methods: $e', e);
} }
return list; return list;
} }
@@ -88,6 +115,32 @@ class Disk with EquatableMixin {
} }
} }
/// Parse filesystem fields from device data
static ({BigInt size, BigInt used, BigInt avail, int usedPercent}) _parseFilesystemFields(Map<String, dynamic> device) {
// Helper function to parse size strings safely
BigInt parseSize(String? sizeStr) {
if (sizeStr == null || sizeStr.isEmpty || sizeStr == 'null' || sizeStr == '0') {
return BigInt.zero;
}
return (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024);
}
// Helper function to parse percentage strings
int parsePercent(String? percentStr) {
if (percentStr == null || percentStr.isEmpty || percentStr == 'null') {
return 0;
}
return int.tryParse(percentStr.replaceAll('%', '')) ?? 0;
}
return (
size: parseSize(device['fssize']?.toString()),
used: parseSize(device['fsused']?.toString()),
avail: parseSize(device['fsavail']?.toString()),
usedPercent: parsePercent(device['fsuse%']?.toString()),
);
}
/// Process a single device without recursively processing its children /// Process a single device without recursively processing its children
static Disk? _processSingleDevice(Map<String, dynamic> device) { static Disk? _processSingleDevice(Map<String, dynamic> device) {
final fstype = device['fstype']?.toString(); final fstype = device['fstype']?.toString();
@@ -102,20 +155,7 @@ class Disk with EquatableMixin {
return null; return null;
} }
final sizeStr = device['fssize']?.toString() ?? '0'; final fsFields = _parseFilesystemFields(device);
final size = (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024);
final usedStr = device['fsused']?.toString() ?? '0';
final used = (BigInt.tryParse(usedStr) ?? BigInt.zero) ~/ BigInt.from(1024);
final availStr = device['fsavail']?.toString() ?? '0';
final avail = (BigInt.tryParse(availStr) ?? BigInt.zero) ~/ BigInt.from(1024);
// Parse fsuse% which is usually in the format "45%"
String usePercentStr = device['fsuse%']?.toString() ?? '0';
usePercentStr = usePercentStr.replaceAll('%', '');
final usedPercent = int.tryParse(usePercentStr) ?? 0;
final name = device['name']?.toString(); final name = device['name']?.toString();
final kname = device['kname']?.toString(); final kname = device['kname']?.toString();
final uuid = device['uuid']?.toString(); final uuid = device['uuid']?.toString();
@@ -124,10 +164,10 @@ class Disk with EquatableMixin {
path: path, path: path,
fsTyp: fstype, fsTyp: fstype,
mount: mountpoint, mount: mountpoint,
usedPercent: usedPercent, usedPercent: fsFields.usedPercent,
used: used, used: fsFields.used,
size: size, size: fsFields.size,
avail: avail, avail: fsFields.avail,
name: name, name: name,
kname: kname, kname: kname,
uuid: uuid, uuid: uuid,
@@ -155,20 +195,7 @@ class Disk with EquatableMixin {
// Handle common filesystem cases or parent devices with children // Handle common filesystem cases or parent devices with children
if ((fstype != null && _shouldCalc(fstype, mount)) || (childDisks.isNotEmpty && path.isNotEmpty)) { if ((fstype != null && _shouldCalc(fstype, mount)) || (childDisks.isNotEmpty && path.isNotEmpty)) {
final sizeStr = device['fssize']?.toString() ?? '0'; final fsFields = _parseFilesystemFields(device);
final size = (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024);
final usedStr = device['fsused']?.toString() ?? '0';
final used = (BigInt.tryParse(usedStr) ?? BigInt.zero) ~/ BigInt.from(1024);
final availStr = device['fsavail']?.toString() ?? '0';
final avail = (BigInt.tryParse(availStr) ?? BigInt.zero) ~/ BigInt.from(1024);
// Parse fsuse% which is usually in the format "45%"
String usePercentStr = device['fsuse%']?.toString() ?? '0';
usePercentStr = usePercentStr.replaceAll('%', '');
final usedPercent = int.tryParse(usePercentStr) ?? 0;
final name = device['name']?.toString(); final name = device['name']?.toString();
final kname = device['kname']?.toString(); final kname = device['kname']?.toString();
final uuid = device['uuid']?.toString(); final uuid = device['uuid']?.toString();
@@ -177,10 +204,10 @@ class Disk with EquatableMixin {
path: path, path: path,
fsTyp: fstype, fsTyp: fstype,
mount: mount, mount: mount,
usedPercent: usedPercent, usedPercent: fsFields.usedPercent,
used: used, used: fsFields.used,
size: size, size: fsFields.size,
avail: avail, avail: fsFields.avail,
name: name, name: name,
kname: kname, kname: kname,
uuid: uuid, uuid: uuid,

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,61 @@ 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(); 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 +76,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 +85,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 +100,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 +180,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 +192,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 +230,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 +267,7 @@ class ContainerProvider extends ChangeNotifier {
} }
} }
const _jsonFmt = '--format "{{json .}}"'; const _jsonFmt = '--format "{{json .}}"';
enum ContainerCmdType { enum ContainerCmdType {
@@ -298,7 +304,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'fea65e66499234b0a59bffff8d69c4ab8c93b2fd';
/// 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);
} }
} }

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