feat: show extension service health

This commit is contained in:
zarzet
2026-05-03 20:20:28 +07:00
parent dcfb22c3f4
commit 1b4a6cd042
26 changed files with 1268 additions and 32 deletions
@@ -3134,6 +3134,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"checkExtensionHealth" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkExtensionHealthJSON(extensionId)
}
result.success(response)
}
"setExtensionSettings" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val settingsJson = call.argument<String>("settings") ?: "{}"
+332
View File
@@ -0,0 +1,332 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
)
type ExtensionHealthResult struct {
ExtensionID string `json:"extension_id"`
Status string `json:"status"`
CheckedAt string `json:"checked_at"`
Checks []ExtensionHealthCheckResult `json:"checks"`
}
type ExtensionHealthCheckResult struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
URL string `json:"url"`
Method string `json:"method"`
ServiceKey string `json:"service_key,omitempty"`
Required bool `json:"required"`
Status string `json:"status"`
HTTPStatus int `json:"http_status,omitempty"`
LatencyMs int64 `json:"latency_ms"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
CheckedAt string `json:"checked_at"`
}
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
result := CheckExtensionHealth(ext)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(bytes), nil
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthResult{
ExtensionID: "",
Status: "unsupported",
CheckedAt: now,
Checks: []ExtensionHealthCheckResult{},
}
if ext == nil || ext.Manifest == nil {
result.Status = "offline"
return result
}
result.ExtensionID = ext.ID
checks := ext.Manifest.ServiceHealth
if len(checks) == 0 {
return result
}
result.Status = "online"
for _, check := range checks {
checkResult := runExtensionHealthCheck(ext.Manifest, check)
result.Checks = append(result.Checks, checkResult)
switch checkResult.Status {
case "offline":
if check.Required {
result.Status = "offline"
} else if result.Status == "online" {
result.Status = "degraded"
}
case "degraded":
if result.Status == "online" {
result.Status = "degraded"
}
case "unknown":
if result.Status == "online" {
result.Status = "unknown"
}
}
}
return result
}
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method == "" {
method = http.MethodGet
}
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthCheckResult{
ID: check.ID,
Label: check.Label,
URL: check.URL,
Method: method,
ServiceKey: strings.TrimSpace(check.ServiceKey),
Required: check.Required,
Status: "unknown",
CheckedAt: now,
}
parsed, err := url.Parse(check.URL)
if err != nil {
result.Status = "offline"
result.Error = fmt.Sprintf("invalid health URL: %v", err)
return result
}
if parsed.Scheme != "https" {
result.Status = "offline"
result.Error = "health check must use https"
return result
}
host := parsed.Hostname()
if host == "" {
result.Status = "offline"
result.Error = "health check URL hostname is required"
return result
}
if isPrivateIP(host) {
result.Status = "offline"
result.Error = "private/local health check host is not allowed"
return result
}
if manifest == nil || !manifest.IsDomainAllowed(host) {
result.Status = "offline"
result.Error = fmt.Sprintf("health check host '%s' is not in extension network permissions", host)
return result
}
if method != http.MethodGet && method != http.MethodHead {
result.Status = "offline"
result.Error = "health check method must be GET or HEAD"
return result
}
timeout := extensionHealthDefaultTimeout
if check.TimeoutMs > 0 {
timeout = time.Duration(check.TimeoutMs) * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, check.URL, nil)
if err != nil {
result.Status = "offline"
result.Error = err.Error()
return result
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgentForURL(parsed))
start := time.Now()
resp, err := NewMetadataHTTPClient(timeout).Do(req)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
result.Status = "offline"
result.Error = err.Error()
return result
}
defer resp.Body.Close()
result.HTTPStatus = resp.StatusCode
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
result.Status = "offline"
result.Message = resp.Status
return result
}
if method == http.MethodHead {
result.Status = "online"
result.Message = resp.Status
return result
}
body, err := io.ReadAll(io.LimitReader(resp.Body, extensionHealthMaxBodyBytes))
if err != nil {
result.Status = "degraded"
result.Error = err.Error()
return result
}
status, message := classifyExtensionHealthBody(body, check.ServiceKey)
result.Status = status
if message == "" {
result.Message = resp.Status
} else {
result.Message = message
}
return result
}
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
if len(strings.TrimSpace(string(body))) == 0 {
return "online", ""
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return "online", ""
}
serviceKey = strings.TrimSpace(serviceKey)
if serviceKey != "" {
if status, message, ok := classifyExtensionHealthService(payload, serviceKey); ok {
return status, message
}
}
rawStatus, _ := payload["status"].(string)
normalized := strings.ToLower(strings.TrimSpace(rawStatus))
switch normalized {
case "", "ok", "up", "online", "healthy", "operational", "pass", "passing":
return "online", rawStatus
case "degraded", "partial", "warning", "warn":
return "degraded", rawStatus
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", rawStatus
default:
return "online", rawStatus
}
}
func classifyExtensionHealthService(payload map[string]interface{}, serviceKey string) (string, string, bool) {
rawServices, ok := payload["services"]
if !ok {
return "", "", false
}
services, ok := rawServices.(map[string]interface{})
if !ok {
return "", "", false
}
rawService, ok := services[serviceKey]
if !ok {
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
}
service, ok := rawService.(map[string]interface{})
if !ok {
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
}
label, _ := service["label"].(string)
detail, _ := service["detail"].(string)
errText, _ := service["error"].(string)
messageParts := []string{}
if strings.TrimSpace(label) != "" {
messageParts = append(messageParts, strings.TrimSpace(label))
}
if strings.TrimSpace(detail) != "" {
messageParts = append(messageParts, strings.TrimSpace(detail))
}
if strings.TrimSpace(errText) != "" {
messageParts = append(messageParts, strings.TrimSpace(errText))
}
rawStatus, hasStatus := service["status"]
okValue, hasOK := service["ok"].(bool)
if statusCode, ok := healthNumber(rawStatus); ok {
if statusCode >= 200 && statusCode < 300 {
return "online", strings.Join(messageParts, ": "), true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", strings.Join(messageParts, ": "), true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", strings.Join(messageParts, ": "), true
}
return "offline", strings.Join(messageParts, ": "), true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", strings.Join(messageParts, ": "), true
}
if hasOK {
if okValue {
return "online", strings.Join(messageParts, ": "), true
}
return "offline", strings.Join(messageParts, ": "), true
}
if !hasStatus {
return "unknown", strings.Join(messageParts, ": "), true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", strings.Join(messageParts, ": "), true
case "degraded", "partial", "warning", "warn":
return "degraded", strings.Join(messageParts, ": "), true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", strings.Join(messageParts, ": "), true
default:
return "unknown", strings.Join(messageParts, ": "), true
}
}
func isExtensionHealthAuthRequired(detail string) bool {
switch strings.ToLower(strings.TrimSpace(detail)) {
case "auth_required", "authorization_required", "login_required", "unauthorized":
return true
default:
return false
}
}
func healthNumber(value interface{}) (int, bool) {
switch v := value.(type) {
case float64:
return int(v), true
case int:
return v, true
case json.Number:
n, err := v.Int64()
return int(n), err == nil
default:
return 0, false
}
}
+2
View File
@@ -1004,6 +1004,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
@@ -1062,6 +1063,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
SearchBehavior: ext.Manifest.SearchBehavior,
TrackMatching: ext.Manifest.TrackMatching,
PostProcessing: ext.Manifest.PostProcessing,
ServiceHealth: ext.Manifest.ServiceHealth,
Capabilities: ext.Manifest.Capabilities,
}
}
+34
View File
@@ -101,6 +101,17 @@ type PostProcessingConfig struct {
Hooks []PostProcessingHook `json:"hooks,omitempty"`
}
type ExtensionHealthCheck struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
URL string `json:"url"`
Method string `json:"method,omitempty"`
ServiceKey string `json:"serviceKey,omitempty"`
TimeoutMs int `json:"timeoutMs,omitempty"`
CacheTTLSeconds int `json:"cacheTtlSeconds,omitempty"`
Required bool `json:"required,omitempty"`
}
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
@@ -121,6 +132,7 @@ type ExtensionManifest struct {
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
@@ -203,6 +215,28 @@ func (m *ExtensionManifest) Validate() error {
}
}
for i, check := range m.ServiceHealth {
if strings.TrimSpace(check.ID) == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].id", i),
Message: "health check id is required",
}
}
if strings.TrimSpace(check.URL) == "" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].url", i),
Message: "health check url is required",
}
}
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method != "" && method != "GET" && method != "HEAD" {
return &ManifestValidationError{
Field: fmt.Sprintf("serviceHealth[%d].method", i),
Message: "health check method must be GET or HEAD",
}
}
}
return nil
}
+7
View File
@@ -671,6 +671,13 @@ import Gobackend // Import Go framework
let response = GobackendGetExtensionSettingsJSON(extensionId, &error)
if let error = error { throw error }
return response
case "checkExtensionHealth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendCheckExtensionHealthJSON(extensionId, &error)
if let error = error { throw error }
return response
case "setExtensionSettings":
let args = call.arguments as! [String: Any]
+12
View File
@@ -2700,6 +2700,18 @@ abstract class AppLocalizations {
/// **'Extension installed successfully'**
String get extensionsInstalledSuccess;
/// Success message after installing multiple extensions
///
/// In en, this message translates to:
/// **'{count} extensions installed successfully'**
String extensionsInstalledCount(int count);
/// Message when installing multiple extensions partially succeeds
///
/// In en, this message translates to:
/// **'Installed {installed} of {attempted} extensions'**
String extensionsInstallPartialSuccess(int installed, int attempted);
/// Setting - download provider order
///
/// In en, this message translates to:
+10
View File
@@ -1467,6 +1467,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get extensionsInstalledSuccess =>
'Erweiterung erfolgreich installiert';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download-Priorität';
+10
View File
@@ -1446,6 +1446,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Extension installed successfully';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download Priority';
+10
View File
@@ -1446,6 +1446,16 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Extension installed successfully';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download Priority';
+10
View File
@@ -1449,6 +1449,16 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Extension installed successfully';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download Priority';
+10
View File
@@ -1446,6 +1446,16 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Extension installed successfully';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download Priority';
+10
View File
@@ -1452,6 +1452,16 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Ekstensi berhasil dipasang';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Prioritas Unduhan';
+10
View File
@@ -1440,6 +1440,16 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get extensionsInstalledSuccess => '拡張のインストールが成功しました';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'ダウンロードの優先度';
+10
View File
@@ -1426,6 +1426,16 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Extension installed successfully';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download Priority';
+10
View File
@@ -1446,6 +1446,16 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Extension installed successfully';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download Priority';
+10
View File
@@ -1446,6 +1446,16 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Extension installed successfully';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download Priority';
+10
View File
@@ -1468,6 +1468,16 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Расширение успешно установлено';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Приоритет скачивания';
+10
View File
@@ -1461,6 +1461,16 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Uzantı başarıyla yüklendi';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'İndirme Önceliği';
+10
View File
@@ -1468,6 +1468,16 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Розширення успішно встановлено';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Пріоритет завантаження';
+10
View File
@@ -1446,6 +1446,16 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get extensionsInstalledSuccess => 'Extension installed successfully';
@override
String extensionsInstalledCount(int count) {
return '$count extensions installed successfully';
}
@override
String extensionsInstallPartialSuccess(int installed, int attempted) {
return 'Installed $installed of $attempted extensions';
}
@override
String get extensionsDownloadPriority => 'Download Priority';
+24
View File
@@ -1893,6 +1893,30 @@
"@extensionsInstalledSuccess": {
"description": "Success message after install"
},
"extensionsInstalledCount": "{count} extensions installed successfully",
"@extensionsInstalledCount": {
"description": "Success message after installing multiple extensions",
"placeholders": {
"count": {
"type": "int",
"description": "Number of installed extensions"
}
}
},
"extensionsInstallPartialSuccess": "Installed {installed} of {attempted} extensions",
"@extensionsInstallPartialSuccess": {
"description": "Message when installing multiple extensions partially succeeds",
"placeholders": {
"installed": {
"type": "int",
"description": "Number of successfully installed extensions"
},
"attempted": {
"type": "int",
"description": "Number of selected extensions"
}
}
},
"extensionsDownloadPriority": "Download Priority",
"@extensionsDownloadPriority": {
"description": "Setting - download provider order"
+280
View File
@@ -85,6 +85,7 @@ class Extension {
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final List<ExtensionServiceHealthCheck> serviceHealth;
final Map<String, dynamic> capabilities;
const Extension({
@@ -110,6 +111,7 @@ class Extension {
this.urlHandler,
this.trackMatching,
this.postProcessing,
this.serviceHealth = const [],
this.capabilities = const {},
});
@@ -162,6 +164,15 @@ class Extension {
json['post_processing'] as Map<String, dynamic>,
)
: null,
serviceHealth:
(json['service_health'] as List<dynamic>?)
?.map(
(h) => ExtensionServiceHealthCheck.fromJson(
h as Map<String, dynamic>,
),
)
.toList() ??
[],
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
);
}
@@ -189,6 +200,7 @@ class Extension {
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
List<ExtensionServiceHealthCheck>? serviceHealth,
Map<String, dynamic>? capabilities,
}) {
return Extension(
@@ -215,6 +227,7 @@ class Extension {
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing,
serviceHealth: serviceHealth ?? this.serviceHealth,
capabilities: capabilities ?? this.capabilities,
);
}
@@ -223,6 +236,7 @@ class Extension {
bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false;
bool get hasServiceHealth => serviceHealth.isNotEmpty;
bool get hasHomeFeed => capabilities['homeFeed'] == true;
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
List<String> get replacesBuiltInProviders {
@@ -597,6 +611,123 @@ class URLHandler {
}
}
class ExtensionServiceHealthCheck {
final String id;
final String? label;
final String url;
final String method;
final String? serviceKey;
final int? timeoutMs;
final int? cacheTtlSeconds;
final bool required;
const ExtensionServiceHealthCheck({
required this.id,
this.label,
required this.url,
this.method = 'GET',
this.serviceKey,
this.timeoutMs,
this.cacheTtlSeconds,
this.required = false,
});
factory ExtensionServiceHealthCheck.fromJson(Map<String, dynamic> json) {
return ExtensionServiceHealthCheck(
id: json['id'] as String? ?? '',
label: json['label'] as String?,
url: json['url'] as String? ?? '',
method: json['method'] as String? ?? 'GET',
serviceKey: json['serviceKey'] as String?,
timeoutMs: json['timeoutMs'] as int?,
cacheTtlSeconds: json['cacheTtlSeconds'] as int?,
required: json['required'] as bool? ?? false,
);
}
}
class ExtensionHealthStatus {
final String extensionId;
final String status;
final DateTime? checkedAt;
final List<ExtensionHealthCheckStatus> checks;
const ExtensionHealthStatus({
required this.extensionId,
required this.status,
this.checkedAt,
this.checks = const [],
});
factory ExtensionHealthStatus.fromJson(Map<String, dynamic> json) {
return ExtensionHealthStatus(
extensionId: json['extension_id'] as String? ?? '',
status: json['status'] as String? ?? 'unknown',
checkedAt: DateTime.tryParse(json['checked_at'] as String? ?? ''),
checks:
(json['checks'] as List<dynamic>?)
?.map(
(c) => ExtensionHealthCheckStatus.fromJson(
c as Map<String, dynamic>,
),
)
.toList() ??
[],
);
}
bool get isSupported => status != 'unsupported';
}
class ExtensionHealthCheckStatus {
final String id;
final String? label;
final String url;
final String method;
final String? serviceKey;
final bool required;
final String status;
final int? httpStatus;
final int latencyMs;
final String? message;
final String? error;
final DateTime? checkedAt;
const ExtensionHealthCheckStatus({
required this.id,
this.label,
required this.url,
required this.method,
this.serviceKey,
this.required = false,
required this.status,
this.httpStatus,
this.latencyMs = 0,
this.message,
this.error,
this.checkedAt,
});
factory ExtensionHealthCheckStatus.fromJson(Map<String, dynamic> json) {
return ExtensionHealthCheckStatus(
id: json['id'] as String? ?? '',
label: json['label'] as String?,
url: json['url'] as String? ?? '',
method: json['method'] as String? ?? 'GET',
serviceKey: json['service_key'] as String?,
required: json['required'] as bool? ?? false,
status: json['status'] as String? ?? 'unknown',
httpStatus: json['http_status'] as int?,
latencyMs: json['latency_ms'] as int? ?? 0,
message: json['message'] as String?,
error: json['error'] as String?,
checkedAt: DateTime.tryParse(json['checked_at'] as String? ?? ''),
);
}
String get displayLabel => label?.trim().isNotEmpty == true ? label! : id;
}
class PostProcessingHook {
final String id;
final String name;
@@ -729,6 +860,7 @@ class ExtensionState {
final List<BuiltInProviderSpec> builtInProviders;
final List<String> providerPriority;
final List<String> metadataProviderPriority;
final Map<String, ExtensionHealthStatus> healthStatuses;
final bool isLoading;
final String? error;
final bool isInitialized;
@@ -738,6 +870,7 @@ class ExtensionState {
this.builtInProviders = const [],
this.providerPriority = const [],
this.metadataProviderPriority = const [],
this.healthStatuses = const {},
this.isLoading = false,
this.error,
this.isInitialized = false,
@@ -748,6 +881,7 @@ class ExtensionState {
List<BuiltInProviderSpec>? builtInProviders,
List<String>? providerPriority,
List<String>? metadataProviderPriority,
Map<String, ExtensionHealthStatus>? healthStatuses,
bool? isLoading,
String? error,
bool? isInitialized,
@@ -758,6 +892,7 @@ class ExtensionState {
providerPriority: providerPriority ?? this.providerPriority,
metadataProviderPriority:
metadataProviderPriority ?? this.metadataProviderPriority,
healthStatuses: healthStatuses ?? this.healthStatuses,
isLoading: isLoading ?? this.isLoading,
error: error,
isInitialized: isInitialized ?? this.isInitialized,
@@ -765,10 +900,28 @@ class ExtensionState {
}
}
class ExtensionInstallBatchResult {
final int attempted;
final int installed;
final Map<String, String> failures;
const ExtensionInstallBatchResult({
required this.attempted,
required this.installed,
this.failures = const {},
});
bool get hasFailures => failures.isNotEmpty;
bool get anyInstalled => installed > 0;
}
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _extensionHealthCacheTtl = Duration(seconds: 60);
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
final Map<String, DateTime> _healthExpiresAt = {};
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
@override
ExtensionState build() {
@@ -778,6 +931,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
ref.onDispose(() {
_appLifecycleListener?.dispose();
_appLifecycleListener = null;
_healthExpiresAt.clear();
_healthInFlight.clear();
});
return const ExtensionState();
}
@@ -897,6 +1052,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
await _reconcileDefaultDownloadService();
await _reconcileMetadataProviderPriority();
_reconcileSearchProvider();
_scheduleExtensionHealthRefresh(extensions);
_log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) {
@@ -912,6 +1068,79 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
void _scheduleExtensionHealthRefresh(List<Extension> extensions) {
for (final ext in extensions) {
if (!ext.enabled || !ext.hasServiceHealth) continue;
unawaited(checkExtensionHealth(ext.id));
}
}
void refreshEnabledExtensionHealth() {
_scheduleExtensionHealthRefresh(state.extensions);
}
Future<ExtensionHealthStatus?> checkExtensionHealth(
String extensionId, {
bool force = false,
}) async {
final ext = state.extensions
.where((extension) => extension.id == extensionId)
.firstOrNull;
if (ext == null || !ext.hasServiceHealth) {
return null;
}
final expiresAt = _healthExpiresAt[extensionId];
final cached = state.healthStatuses[extensionId];
if (!force &&
cached != null &&
expiresAt != null &&
DateTime.now().isBefore(expiresAt)) {
return cached;
}
final inFlight = _healthInFlight[extensionId];
if (!force && inFlight != null) {
return inFlight;
}
final future = () async {
try {
final result = await PlatformBridge.checkExtensionHealth(extensionId);
final status = ExtensionHealthStatus.fromJson(result);
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
return status;
} catch (e) {
_log.w('Failed to check extension health for $extensionId: $e');
final status = ExtensionHealthStatus(
extensionId: extensionId,
status: 'unknown',
checkedAt: DateTime.now(),
checks: const [],
);
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
const Duration(seconds: 20),
);
state = state.copyWith(healthStatuses: updated);
return status;
} finally {
_healthInFlight.remove(extensionId);
}
}();
_healthInFlight[extensionId] = future;
return future;
}
Future<void> refreshBuiltInProviders() async {
final list = await PlatformBridge.getBuiltInProviders();
final providers = list
@@ -942,6 +1171,50 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<ExtensionInstallBatchResult> installExtensions(
List<String> filePaths,
) async {
final uniquePaths = <String>[];
for (final path in filePaths) {
final trimmed = path.trim();
if (trimmed.isEmpty || uniquePaths.contains(trimmed)) continue;
uniquePaths.add(trimmed);
}
if (uniquePaths.isEmpty) {
return const ExtensionInstallBatchResult(attempted: 0, installed: 0);
}
state = state.copyWith(isLoading: true, error: null);
var installed = 0;
final failures = <String, String>{};
for (final path in uniquePaths) {
try {
final result = await PlatformBridge.loadExtensionFromPath(path);
installed++;
_log.i('Installed extension: ${result['name']}');
} catch (e) {
_log.e('Failed to install extension from $path: $e');
failures[path] = e.toString();
}
}
if (installed > 0) {
await refreshExtensions();
}
final firstError = failures.values.firstOrNull;
state = state.copyWith(isLoading: false, error: firstError);
return ExtensionInstallBatchResult(
attempted: uniquePaths.length,
installed: installed,
failures: failures,
);
}
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
try {
return await PlatformBridge.checkExtensionUpgrade(filePath);
@@ -1007,6 +1280,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
await _reconcileMetadataProviderPriority();
_reconcileSearchProvider();
final updatedExt = extensions
.where((extension) => extension.id == extensionId)
.firstOrNull;
if (enabled && updatedExt?.hasServiceHealth == true) {
unawaited(checkExtensionHealth(extensionId, force: true));
}
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
@@ -23,11 +23,15 @@ class ExtensionDetailPage extends ConsumerStatefulWidget {
class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
Map<String, dynamic> _settings = {};
bool _isLoadingSettings = true;
bool _isRefreshingHealth = false;
@override
void initState() {
super.initState();
_loadSettings();
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshHealth();
});
}
Future<void> _loadSettings() async {
@@ -40,6 +44,26 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
});
}
Future<void> _refreshHealth({bool force = false}) async {
final ext = ref
.read(extensionProvider)
.extensions
.where((extension) => extension.id == widget.extensionId)
.firstOrNull;
if (ext == null || !ext.hasServiceHealth) return;
setState(() => _isRefreshingHealth = true);
try {
await ref
.read(extensionProvider.notifier)
.checkExtensionHealth(widget.extensionId, force: force);
} finally {
if (mounted) {
setState(() => _isRefreshingHealth = false);
}
}
}
@override
Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider);
@@ -59,6 +83,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final hasError = extension.status == 'error';
final healthStatus = extState.healthStatuses[extension.id];
return PopScope(
canPop: true,
@@ -227,6 +252,32 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
),
),
if (extension.hasServiceHealth) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Service Status'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HealthSummaryItem(
status: healthStatus,
isRefreshing: _isRefreshingHealth,
onRefresh: () => _refreshHealth(force: true),
),
if (healthStatus != null)
...healthStatus.checks.asMap().entries.map((entry) {
final index = entry.key;
final check = entry.value;
return _HealthCheckItem(
check: check,
showDivider: index < healthStatus.checks.length - 1,
);
}),
],
),
),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionCapabilities,
@@ -285,6 +336,14 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
extension.urlHandler!.patterns.length,
)
: null,
),
_CapabilityItem(
icon: Icons.monitor_heart_outlined,
title: 'Service health',
enabled: extension.hasServiceHealth,
subtitle: extension.hasServiceHealth
? '${extension.serviceHealth.length} check${extension.serviceHealth.length == 1 ? '' : 's'} configured'
: null,
showDivider: false,
),
],
@@ -640,6 +699,203 @@ class _CapabilityItem extends StatelessWidget {
}
}
Color _healthStatusColor(ColorScheme colorScheme, String status) {
switch (status) {
case 'online':
return colorScheme.primary;
case 'degraded':
return colorScheme.tertiary;
case 'offline':
return colorScheme.error;
default:
return colorScheme.onSurfaceVariant;
}
}
IconData _healthStatusIcon(String status) {
switch (status) {
case 'online':
return Icons.check_circle;
case 'degraded':
return Icons.warning_amber_rounded;
case 'offline':
return Icons.error_outline;
default:
return Icons.help_outline;
}
}
String _healthStatusLabel(String status) {
switch (status) {
case 'online':
return 'Online';
case 'degraded':
return 'Degraded';
case 'offline':
return 'Offline';
case 'unsupported':
return 'Not configured';
default:
return 'Unknown';
}
}
class _HealthSummaryItem extends StatelessWidget {
final ExtensionHealthStatus? status;
final bool isRefreshing;
final VoidCallback onRefresh;
const _HealthSummaryItem({
required this.status,
required this.isRefreshing,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final statusValue = status?.status ?? 'unknown';
final color = _healthStatusColor(colorScheme, statusValue);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(_healthStatusIcon(statusValue), color: color),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_healthStatusLabel(statusValue),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
if (status?.checkedAt != null) ...[
const SizedBox(height: 2),
Text(
'Last checked ${TimeOfDay.fromDateTime(status!.checkedAt!.toLocal()).format(context)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
IconButton(
tooltip: 'Refresh status',
onPressed: isRefreshing ? null : onRefresh,
icon: isRefreshing
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
)
: const Icon(Icons.refresh),
),
],
),
),
Divider(
height: 1,
thickness: 1,
indent: 56,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class _HealthCheckItem extends StatelessWidget {
final ExtensionHealthCheckStatus check;
final bool showDivider;
const _HealthCheckItem({required this.check, this.showDivider = true});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final color = _healthStatusColor(colorScheme, check.status);
final detailParts = <String>[
_healthStatusLabel(check.status),
if (check.httpStatus != null) 'HTTP ${check.httpStatus}',
if (check.serviceKey?.isNotEmpty == true) check.serviceKey!,
if (check.latencyMs > 0) '${check.latencyMs} ms',
if (check.required) 'required',
];
final message = check.error?.trim().isNotEmpty == true
? check.error!
: check.message;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_healthStatusIcon(check.status), color: color),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
check.displayLabel,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 2),
Text(
detailParts.join(' · '),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (message != null && message.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: check.error != null
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
indent: 56,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class _PermissionItem extends StatelessWidget {
final String permission;
final bool showDivider;
+92 -31
View File
@@ -53,6 +53,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
await ref
.read(extensionProvider.notifier)
.initialize(extensionsDir, dataDir);
} else {
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
}
}
@@ -212,6 +214,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final ext = entry.value;
return _ExtensionItem(
extension: ext,
healthStatus: extState.healthStatuses[ext.id],
showDivider: index < extState.extensions.length - 1,
onTap: () => Navigator.push(
context,
@@ -285,47 +288,68 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Future<void> _installExtension() async {
final result = await FilePicker.pickFiles(
type: FileType.any,
allowMultiple: false,
allowMultiple: true,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
if (file.path != null) {
if (!file.path!.endsWith('.spotiflac-ext')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarSelectExtFile)),
);
}
return;
}
final success = await ref
.read(extensionProvider.notifier)
.installExtension(file.path!);
final selectedPaths = result.files
.map((file) => file.path)
.whereType<String>()
.toList();
final extensionPaths = selectedPaths
.where((path) => path.toLowerCase().endsWith('.spotiflac-ext'))
.toList();
if (extensionPaths.length != selectedPaths.length) {
if (mounted) {
final extState = ref.read(extensionProvider);
String message;
if (success) {
message = context.l10n.extensionsInstalledSuccess;
} else {
message = _getFriendlyErrorMessage(extState.error);
}
ref.read(extensionProvider.notifier).clearError();
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarSelectExtFile)),
);
}
return;
}
final installResult = await ref
.read(extensionProvider.notifier)
.installExtensions(extensionPaths);
if (mounted) {
final message = _getInstallResultMessage(installResult);
ref.read(extensionProvider.notifier).clearError();
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
}
String _getInstallResultMessage(ExtensionInstallBatchResult result) {
if (result.attempted == 0) {
return context.l10n.snackbarSelectExtFile;
}
if (!result.hasFailures) {
if (result.installed == 1) {
return context.l10n.extensionsInstalledSuccess;
}
return context.l10n.extensionsInstalledCount(result.installed);
}
if (result.anyInstalled) {
return context.l10n.extensionsInstallPartialSuccess(
result.installed,
result.attempted,
);
}
final firstError = result.failures.values.firstOrNull;
return _getFriendlyErrorMessage(firstError);
}
/// Parse error message to be more user-friendly
String _getFriendlyErrorMessage(String? error) {
if (error == null) return 'Failed to install extension';
if (error == null) return context.l10n.snackbarFailedToInstall;
String message = error;
@@ -350,12 +374,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
class _ExtensionItem extends StatelessWidget {
final Extension extension;
final ExtensionHealthStatus? healthStatus;
final bool showDivider;
final VoidCallback onTap;
final ValueChanged<bool> onToggle;
const _ExtensionItem({
required this.extension,
this.healthStatus,
required this.showDivider,
required this.onTap,
required this.onToggle,
@@ -365,6 +391,12 @@ class _ExtensionItem extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasError = extension.status == 'error';
final serviceHealthStatus = extension.hasServiceHealth
? healthStatus?.status
: null;
final serviceHealthColor = serviceHealthStatus == null
? null
: _extensionHealthColor(colorScheme, serviceHealthStatus);
return Column(
mainAxisSize: MainAxisSize.min,
@@ -425,11 +457,14 @@ class _ExtensionItem extends StatelessWidget {
hasError
? extension.errorMessage ??
context.l10n.extensionsErrorLoading
: 'v${extension.version}',
: serviceHealthStatus == null
? 'v${extension.version}'
: 'v${extension.version} · ${_extensionHealthLabel(serviceHealthStatus)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
? colorScheme.error
: colorScheme.onSurfaceVariant,
: serviceHealthColor ??
colorScheme.onSurfaceVariant,
),
),
],
@@ -456,6 +491,32 @@ class _ExtensionItem extends StatelessWidget {
}
}
Color _extensionHealthColor(ColorScheme colorScheme, String status) {
switch (status) {
case 'online':
return colorScheme.primary;
case 'degraded':
return colorScheme.tertiary;
case 'offline':
return colorScheme.error;
default:
return colorScheme.onSurfaceVariant;
}
}
String _extensionHealthLabel(String status) {
switch (status) {
case 'online':
return 'Online';
case 'degraded':
return 'Degraded';
case 'offline':
return 'Offline';
default:
return 'Unknown';
}
}
class _DownloadPriorityItem extends ConsumerWidget {
const _DownloadPriorityItem();
+9
View File
@@ -1159,6 +1159,15 @@ class PlatformBridge {
return _decodeRequiredMapResult(result, 'getExtensionSettings');
}
static Future<Map<String, dynamic>> checkExtensionHealth(
String extensionId,
) async {
final result = await _channel.invokeMethod('checkExtensionHealth', {
'extension_id': extensionId,
});
return _decodeRequiredMapResult(result, 'checkExtensionHealth');
}
static Future<void> setExtensionSettings(
String extensionId,
Map<String, dynamic> settings,
+73 -1
View File
@@ -150,6 +150,10 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth();
});
final builtInServices = _availableBuiltInServices();
final downloadExtensions = _downloadExtensions();
final recommended = widget.recommendedService;
@@ -196,7 +200,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
ref.watch(extensionProvider);
final extensionState = ref.watch(extensionProvider);
final builtInServices = _availableBuiltInServices();
final downloadExtensions = _downloadExtensions();
final hasProviders =
@@ -273,6 +277,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
label: widget.recommendedService == ext.id
? '${ext.displayName} (Recommended)'
: ext.displayName,
healthStatus: ext.hasServiceHealth
? extensionState.healthStatuses[ext.id]?.status
: null,
isSelected: _selectedService == ext.id,
onTap: () =>
setState(() => _selectedService = ext.id),
@@ -421,6 +428,7 @@ class _ServiceChip extends StatelessWidget {
final bool isSelected;
final VoidCallback? onTap;
final String? iconPath;
final String? healthStatus;
final bool isDisabled;
const _ServiceChip({
@@ -428,6 +436,7 @@ class _ServiceChip extends StatelessWidget {
required this.isSelected,
required this.onTap,
this.iconPath,
this.healthStatus,
this.isDisabled = false,
});
@@ -455,6 +464,10 @@ class _ServiceChip extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (healthStatus != null) ...[
_ServiceHealthDot(status: healthStatus!, isDisabled: isDisabled),
const SizedBox(width: 8),
],
if (iconPath != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
@@ -494,6 +507,65 @@ class _ServiceChip extends StatelessWidget {
}
}
class _ServiceHealthDot extends StatelessWidget {
final String status;
final bool isDisabled;
const _ServiceHealthDot({required this.status, required this.isDisabled});
@override
Widget build(BuildContext context) {
final color = isDisabled
? Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3)
: _serviceHealthColor(status);
return Tooltip(
message: _serviceHealthTooltip(status),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.35),
blurRadius: 6,
spreadRadius: 1,
),
],
),
),
);
}
}
Color _serviceHealthColor(String status) {
switch (status) {
case 'online':
return const Color(0xFF35D07F);
case 'degraded':
case 'unknown':
return const Color(0xFFFFC857);
case 'offline':
return const Color(0xFFFF4D5E);
default:
return const Color(0xFFFFC857);
}
}
String _serviceHealthTooltip(String status) {
switch (status) {
case 'online':
return 'Service online';
case 'degraded':
return 'Service degraded';
case 'offline':
return 'Service offline';
default:
return 'Service status unknown';
}
}
class _NoDownloadProviderHint extends StatelessWidget {
final String primaryText;
final String secondaryText;