From 1b4a6cd04209dec18b7486630b50bbc832de8d2a Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 3 May 2026 20:20:28 +0700 Subject: [PATCH] feat: show extension service health --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 7 + go_backend/extension_health.go | 332 ++++++++++++++++++ go_backend/extension_manager.go | 2 + go_backend/extension_manifest.go | 34 ++ ios/Runner/AppDelegate.swift | 7 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_de.dart | 10 + lib/l10n/app_localizations_en.dart | 10 + lib/l10n/app_localizations_es.dart | 10 + lib/l10n/app_localizations_fr.dart | 10 + lib/l10n/app_localizations_hi.dart | 10 + lib/l10n/app_localizations_id.dart | 10 + lib/l10n/app_localizations_ja.dart | 10 + lib/l10n/app_localizations_ko.dart | 10 + lib/l10n/app_localizations_nl.dart | 10 + lib/l10n/app_localizations_pt.dart | 10 + lib/l10n/app_localizations_ru.dart | 10 + lib/l10n/app_localizations_tr.dart | 10 + lib/l10n/app_localizations_uk.dart | 10 + lib/l10n/app_localizations_zh.dart | 10 + lib/l10n/arb/app_en.arb | 24 ++ lib/providers/extension_provider.dart | 280 +++++++++++++++ .../settings/extension_detail_page.dart | 256 ++++++++++++++ lib/screens/settings/extensions_page.dart | 123 +++++-- lib/services/platform_bridge.dart | 9 + lib/widgets/download_service_picker.dart | 74 +++- 26 files changed, 1268 insertions(+), 32 deletions(-) create mode 100644 go_backend/extension_health.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index cfe22a39..c20aabfe 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -3134,6 +3134,13 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "checkExtensionHealth" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.checkExtensionHealthJSON(extensionId) + } + result.success(response) + } "setExtensionSettings" -> { val extensionId = call.argument("extension_id") ?: "" val settingsJson = call.argument("settings") ?: "{}" diff --git a/go_backend/extension_health.go b/go_backend/extension_health.go new file mode 100644 index 00000000..102fe80a --- /dev/null +++ b/go_backend/extension_health.go @@ -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 + } +} diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 3dda18c3..07b70dba 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -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, } } diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index e1b92141..5a81cbec 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -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 } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 468eeff5..d7ff7dc8 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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] diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0f01c664..e5544b13 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9fdf3786..1768330d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6635cbd3..fa8f0ad6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 24ce5af0..7d5e362f 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f15e7061..d4b8eb0c 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 285e0879..0884b6f0 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index cd6207f2..513fc229 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 5d62f020..09ae9e94 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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 => 'ダウンロードの優先度'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 77ae26ef..d0da5cf1 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 11f5e11c..6f9abd68 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 89516a04..7e3747ae 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 5212ee2c..88100e9d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Приоритет скачивания'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 1d3144c2..711bb933 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 573912f4..ab57bde4 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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 => 'Пріоритет завантаження'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index b8754108..3a9f0211 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index e3a4763f..1494cb2a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 04627e41..05d418a3 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -85,6 +85,7 @@ class Extension { final URLHandler? urlHandler; final TrackMatching? trackMatching; final PostProcessing? postProcessing; + final List serviceHealth; final Map 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, ) : null, + serviceHealth: + (json['service_health'] as List?) + ?.map( + (h) => ExtensionServiceHealthCheck.fromJson( + h as Map, + ), + ) + .toList() ?? + [], capabilities: (json['capabilities'] as Map?) ?? const {}, ); } @@ -189,6 +200,7 @@ class Extension { URLHandler? urlHandler, TrackMatching? trackMatching, PostProcessing? postProcessing, + List? serviceHealth, Map? 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 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 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 checks; + + const ExtensionHealthStatus({ + required this.extensionId, + required this.status, + this.checkedAt, + this.checks = const [], + }); + + factory ExtensionHealthStatus.fromJson(Map 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?) + ?.map( + (c) => ExtensionHealthCheckStatus.fromJson( + c as Map, + ), + ) + .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 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 builtInProviders; final List providerPriority; final List metadataProviderPriority; + final Map 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? builtInProviders, List? providerPriority, List? metadataProviderPriority, + Map? 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 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 { + static const _extensionHealthCacheTtl = Duration(seconds: 60); AppLifecycleListener? _appLifecycleListener; bool _cleanupInFlight = false; Completer? _initializationCompleter; + final Map _healthExpiresAt = {}; + final Map> _healthInFlight = {}; @override ExtensionState build() { @@ -778,6 +931,8 @@ class ExtensionNotifier extends Notifier { ref.onDispose(() { _appLifecycleListener?.dispose(); _appLifecycleListener = null; + _healthExpiresAt.clear(); + _healthInFlight.clear(); }); return const ExtensionState(); } @@ -897,6 +1052,7 @@ class ExtensionNotifier extends Notifier { 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 { } } + void _scheduleExtensionHealthRefresh(List extensions) { + for (final ext in extensions) { + if (!ext.enabled || !ext.hasServiceHealth) continue; + unawaited(checkExtensionHealth(ext.id)); + } + } + + void refreshEnabledExtensionHealth() { + _scheduleExtensionHealthRefresh(state.extensions); + } + + Future 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.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.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 refreshBuiltInProviders() async { final list = await PlatformBridge.getBuiltInProviders(); final providers = list @@ -942,6 +1171,50 @@ class ExtensionNotifier extends Notifier { } } + Future installExtensions( + List filePaths, + ) async { + final uniquePaths = []; + 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 = {}; + + 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> checkExtensionUpgrade(String filePath) async { try { return await PlatformBridge.checkExtensionUpgrade(filePath); @@ -1007,6 +1280,13 @@ class ExtensionNotifier extends Notifier { 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); diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 09a3570e..27117357 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -23,11 +23,15 @@ class ExtensionDetailPage extends ConsumerStatefulWidget { class _ExtensionDetailPageState extends ConsumerState { Map _settings = {}; bool _isLoadingSettings = true; + bool _isRefreshingHealth = false; @override void initState() { super.initState(); _loadSettings(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _refreshHealth(); + }); } Future _loadSettings() async { @@ -40,6 +44,26 @@ class _ExtensionDetailPageState extends ConsumerState { }); } + Future _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 { 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 { ), ), + 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 { 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 = [ + _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; diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index c1c0a2d6..1a9819de 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -53,6 +53,8 @@ class _ExtensionsPageState extends ConsumerState { await ref .read(extensionProvider.notifier) .initialize(extensionsDir, dataDir); + } else { + ref.read(extensionProvider.notifier).refreshEnabledExtensionHealth(); } } @@ -212,6 +214,7 @@ class _ExtensionsPageState extends ConsumerState { 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 { Future _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() + .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 { class _ExtensionItem extends StatelessWidget { final Extension extension; + final ExtensionHealthStatus? healthStatus; final bool showDivider; final VoidCallback onTap; final ValueChanged 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(); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 878f4515..df4480d3 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1159,6 +1159,15 @@ class PlatformBridge { return _decodeRequiredMapResult(result, 'getExtensionSettings'); } + static Future> checkExtensionHealth( + String extensionId, + ) async { + final result = await _channel.invokeMethod('checkExtensionHealth', { + 'extension_id': extensionId, + }); + return _decodeRequiredMapResult(result, 'checkExtensionHealth'); + } + static Future setExtensionSettings( String extensionId, Map settings, diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 498d081f..c1e63644 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -150,6 +150,10 @@ class _DownloadServicePickerState extends ConsumerState { @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 { @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 { 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;