mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 12:48:03 +02:00
feat: show extension service health
This commit is contained in:
@@ -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") ?: "{}"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'ダウンロードの優先度';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Приоритет скачивания';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Пріоритет завантаження';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user