feat(cli): replace media command with doctor and add runtime dependency checks
- commit
- 5bd0e1745051e440a1684020e2f5b54d2dfd1730
- author
- brice · · 2025-12-18 20:25 UTC
- committer
- brice · · 2025-12-18 21:39 UTC
- parent
- 0639fb7
- signature
- unsigned
15 files · +924 -143
M README.md +4 -0
| @@ -118,8 +118,10 @@ Prerequisites: | |||
| 118 | 118 | - Go 1.25+ | |
| 119 | 119 | - Node.js 20+ and npm (for building the embedded TypeScript frontend) | |
| 120 | 120 | - For GUI build: WebKitGTK 6 and GTK4 dev packages (examples) | |
| 121 | + | - Minimum runtime versions: WebKitGTK 6.0 >= `2.50`, GTK4 >= `4.20`, GLib >= `2.84` | |
| 121 | 122 | - Debian/Ubuntu: `libwebkitgtk-6.0-dev libgtk-4-dev build-essential` | |
| 122 | 123 | - Arch: `webkitgtk-6.0 gtk4 base-devel` | |
| 124 | + | - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs) | |
| 123 | 125 | ||
| 124 | 126 | Build options: | |
| 125 | 127 | - GUI (default, recommended): | |
| @@ -191,8 +193,10 @@ sudo apt install va-driver-all | |||
| 191 | 193 | - Go 1.25+ | |
| 192 | 194 | - Node.js 20+ and npm (for building the embedded TypeScript frontend) | |
| 193 | 195 | - WebKitGTK 6 and GTK4 dev packages: | |
| 196 | + | - Minimum runtime versions: WebKitGTK 6.0 >= `2.50`, GTK4 >= `4.20`, GLib >= `2.84` | |
| 194 | 197 | - Debian/Ubuntu: `libwebkitgtk-6.0-dev libgtk-4-dev build-essential` | |
| 195 | 198 | - Arch: `webkitgtk-6.0 gtk4 base-devel` | |
| 199 | + | - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs) | |
| 196 | 200 | ||
| 197 | 201 | **Build options:** | |
| 198 | 202 | - GUI build (default, recommended): | |
| @@ -118,8 +118,10 @@ Prerequisites: | |||||
| 118 | - Go 1.25+ | 118 | - Go 1.25+ | ||
| 119 | - Node.js 20+ and npm (for building the embedded TypeScript frontend) | 119 | - Node.js 20+ and npm (for building the embedded TypeScript frontend) | ||
| 120 | - For GUI build: WebKitGTK 6 and GTK4 dev packages (examples) | 120 | - For GUI build: WebKitGTK 6 and GTK4 dev packages (examples) | ||
| 121 | + | - Minimum runtime versions: WebKitGTK 6.0 >= `2.50`, GTK4 >= `4.20`, GLib >= `2.84` | |||
| 121 | - Debian/Ubuntu: `libwebkitgtk-6.0-dev libgtk-4-dev build-essential` | 122 | - Debian/Ubuntu: `libwebkitgtk-6.0-dev libgtk-4-dev build-essential` | ||
| 122 | - Arch: `webkitgtk-6.0 gtk4 base-devel` | 123 | - Arch: `webkitgtk-6.0 gtk4 base-devel` | ||
| 124 | + | - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs) | |||
| 123 | 125 | ||||
| 124 | Build options: | 126 | Build options: | ||
| 125 | - GUI (default, recommended): | 127 | - GUI (default, recommended): | ||
| @@ -191,8 +193,10 @@ sudo apt install va-driver-all | |||||
| 191 | - Go 1.25+ | 193 | - Go 1.25+ | ||
| 192 | - Node.js 20+ and npm (for building the embedded TypeScript frontend) | 194 | - Node.js 20+ and npm (for building the embedded TypeScript frontend) | ||
| 193 | - WebKitGTK 6 and GTK4 dev packages: | 195 | - WebKitGTK 6 and GTK4 dev packages: | ||
| 196 | + | - Minimum runtime versions: WebKitGTK 6.0 >= `2.50`, GTK4 >= `4.20`, GLib >= `2.84` | |||
| 194 | - Debian/Ubuntu: `libwebkitgtk-6.0-dev libgtk-4-dev build-essential` | 197 | - Debian/Ubuntu: `libwebkitgtk-6.0-dev libgtk-4-dev build-essential` | ||
| 195 | - Arch: `webkitgtk-6.0 gtk4 base-devel` | 198 | - Arch: `webkitgtk-6.0 gtk4 base-devel` | ||
| 199 | + | - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs) | |||
| 196 | 200 | ||||
| 197 | **Build options:** | 201 | **Build options:** | ||
| 198 | - GUI build (default, recommended): | 202 | - GUI build (default, recommended): | ||
M cmd/dumber/main.go +34 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/domain/build" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 18 | 19 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| @@ -70,6 +71,9 @@ func runGUI() { | |||
| 70 | 71 | ||
| 71 | 72 | cfg := config.Get() | |
| 72 | 73 | ||
| 74 | + | // Apply optional /opt-style runtime prefix overrides (if configured) | |
| 75 | + | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | |
| 76 | + | ||
| 73 | 77 | // Generate session ID for this browser run | |
| 74 | 78 | sessionID := logging.GenerateSessionID() | |
| 75 | 79 | ||
| @@ -104,6 +108,36 @@ func runGUI() { | |||
| 104 | 108 | // Create root context with logger | |
| 105 | 109 | ctx := logging.WithContext(context.Background(), logger) | |
| 106 | 110 | ||
| 111 | + | // Verify runtime requirements (GTK4/WebKitGTK) before creating any WebKit objects | |
| 112 | + | probe := deps.NewPkgConfigProbe() | |
| 113 | + | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |
| 114 | + | runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{ | |
| 115 | + | Prefix: cfg.Runtime.Prefix, | |
| 116 | + | }) | |
| 117 | + | if err != nil { | |
| 118 | + | logger.Fatal().Err(err).Msg("runtime requirements check failed") | |
| 119 | + | } | |
| 120 | + | if !runtimeOut.OK { | |
| 121 | + | for _, c := range runtimeOut.Checks { | |
| 122 | + | if c.Installed { | |
| 123 | + | logger.Error(). | |
| 124 | + | Str("dependency", c.PkgConfigName). | |
| 125 | + | Str("have", c.Version). | |
| 126 | + | Str("need", c.RequiredVersion). | |
| 127 | + | Bool("ok", c.MeetsRequirement). | |
| 128 | + | Msg("runtime dependency") | |
| 129 | + | } else { | |
| 130 | + | logger.Error(). | |
| 131 | + | Str("dependency", c.PkgConfigName). | |
| 132 | + | Str("need", c.RequiredVersion). | |
| 133 | + | Msg("runtime dependency missing") | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | logger.Fatal(). | |
| 137 | + | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |
| 138 | + | Msg("runtime requirements not met") | |
| 139 | + | } | |
| 140 | + | ||
| 107 | 141 | // Check media playback requirements (GStreamer/VA-API) | |
| 108 | 142 | mediaDiagAdapter := media.New() | |
| 109 | 143 | checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter) | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/domain/build" | 13 | "github.com/bnema/dumber/internal/domain/build" | ||
| 14 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | 14 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/config" | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/deps" | |||
| 16 | "github.com/bnema/dumber/internal/infrastructure/favicon" | 17 | "github.com/bnema/dumber/internal/infrastructure/favicon" | ||
| 17 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 18 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 18 | "github.com/bnema/dumber/internal/infrastructure/media" | 19 | "github.com/bnema/dumber/internal/infrastructure/media" | ||
| @@ -70,6 +71,9 @@ func runGUI() { | |||||
| 70 | 71 | ||||
| 71 | cfg := config.Get() | 72 | cfg := config.Get() | ||
| 72 | 73 | ||||
| 74 | + | // Apply optional /opt-style runtime prefix overrides (if configured) | |||
| 75 | + | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | |||
| 76 | + | ||||
| 73 | // Generate session ID for this browser run | 77 | // Generate session ID for this browser run | ||
| 74 | sessionID := logging.GenerateSessionID() | 78 | sessionID := logging.GenerateSessionID() | ||
| 75 | 79 | ||||
| @@ -104,6 +108,36 @@ func runGUI() { | |||||
| 104 | // Create root context with logger | 108 | // Create root context with logger | ||
| 105 | ctx := logging.WithContext(context.Background(), logger) | 109 | ctx := logging.WithContext(context.Background(), logger) | ||
| 106 | 110 | ||||
| 111 | + | // Verify runtime requirements (GTK4/WebKitGTK) before creating any WebKit objects | |||
| 112 | + | probe := deps.NewPkgConfigProbe() | |||
| 113 | + | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |||
| 114 | + | runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{ | |||
| 115 | + | Prefix: cfg.Runtime.Prefix, | |||
| 116 | + | }) | |||
| 117 | + | if err != nil { | |||
| 118 | + | logger.Fatal().Err(err).Msg("runtime requirements check failed") | |||
| 119 | + | } | |||
| 120 | + | if !runtimeOut.OK { | |||
| 121 | + | for _, c := range runtimeOut.Checks { | |||
| 122 | + | if c.Installed { | |||
| 123 | + | logger.Error(). | |||
| 124 | + | Str("dependency", c.PkgConfigName). | |||
| 125 | + | Str("have", c.Version). | |||
| 126 | + | Str("need", c.RequiredVersion). | |||
| 127 | + | Bool("ok", c.MeetsRequirement). | |||
| 128 | + | Msg("runtime dependency") | |||
| 129 | + | } else { | |||
| 130 | + | logger.Error(). | |||
| 131 | + | Str("dependency", c.PkgConfigName). | |||
| 132 | + | Str("need", c.RequiredVersion). | |||
| 133 | + | Msg("runtime dependency missing") | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | logger.Fatal(). | |||
| 137 | + | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |||
| 138 | + | Msg("runtime requirements not met") | |||
| 139 | + | } | |||
| 140 | + | ||||
| 107 | // Check media playback requirements (GStreamer/VA-API) | 141 | // Check media playback requirements (GStreamer/VA-API) | ||
| 108 | mediaDiagAdapter := media.New() | 142 | mediaDiagAdapter := media.New() | ||
| 109 | checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter) | 143 | checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter) | ||
M docs/CONFIG.md +19 -1
| @@ -292,7 +292,25 @@ show_diagnostics = true # Log warnings if HW accel unavailable | |||
| 292 | 292 | **Diagnostics CLI:** | |
| 293 | 293 | ```bash | |
| 294 | 294 | # Check GStreamer plugins and VA-API status | |
| 295 | - | dumber media | |
| 295 | + | dumber doctor --media | |
| 296 | + | ``` | |
| 297 | + | ||
| 298 | + | ## Runtime | |
| 299 | + | ||
| 300 | + | | Key | Type | Default | Description | | |
| 301 | + | |-----|------|---------|-------------| | |
| 302 | + | | `runtime.prefix` | string | `""` | Optional runtime prefix (e.g., `/opt/webkitgtk`) used to locate newer GTK/WebKitGTK installs | | |
| 303 | + | ||
| 304 | + | If set, dumber prepends prefix-derived paths to: | |
| 305 | + | - `PKG_CONFIG_PATH` (for version checks) | |
| 306 | + | - `LD_LIBRARY_PATH` (for runtime library loading) | |
| 307 | + | - `GI_TYPELIB_PATH` (GObject introspection) | |
| 308 | + | - `XDG_DATA_DIRS` (schemas/resources) | |
| 309 | + | ||
| 310 | + | **Example:** | |
| 311 | + | ```toml | |
| 312 | + | [runtime] | |
| 313 | + | prefix = "/opt/webkitgtk" | |
| 296 | 314 | ``` | |
| 297 | 315 | ||
| 298 | 316 | ## Content Filtering | |
| @@ -292,7 +292,25 @@ show_diagnostics = true # Log warnings if HW accel unavailable | |||||
| 292 | **Diagnostics CLI:** | 292 | **Diagnostics CLI:** | ||
| 293 | ```bash | 293 | ```bash | ||
| 294 | # Check GStreamer plugins and VA-API status | 294 | # Check GStreamer plugins and VA-API status | ||
| 295 | - | dumber media | 295 | + | dumber doctor --media |
| 296 | + | ``` | |||
| 297 | + | ||||
| 298 | + | ## Runtime | |||
| 299 | + | ||||
| 300 | + | | Key | Type | Default | Description | | |||
| 301 | + | |-----|------|---------|-------------| | |||
| 302 | + | | `runtime.prefix` | string | `""` | Optional runtime prefix (e.g., `/opt/webkitgtk`) used to locate newer GTK/WebKitGTK installs | | |||
| 303 | + | ||||
| 304 | + | If set, dumber prepends prefix-derived paths to: | |||
| 305 | + | - `PKG_CONFIG_PATH` (for version checks) | |||
| 306 | + | - `LD_LIBRARY_PATH` (for runtime library loading) | |||
| 307 | + | - `GI_TYPELIB_PATH` (GObject introspection) | |||
| 308 | + | - `XDG_DATA_DIRS` (schemas/resources) | |||
| 309 | + | ||||
| 310 | + | **Example:** | |||
| 311 | + | ```toml | |||
| 312 | + | [runtime] | |||
| 313 | + | prefix = "/opt/webkitgtk" | |||
| 296 | ``` | 314 | ``` | ||
| 297 | 315 | ||||
| 298 | ## Content Filtering | 316 | ## Content Filtering | ||
A internal/application/port/runtime.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | // Package port defines interfaces for external dependencies. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // PkgConfigErrorKind describes the category of a pkg-config failure. | |
| 11 | + | type PkgConfigErrorKind string | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | PkgConfigErrorKindCommandMissing PkgConfigErrorKind = "command_missing" | |
| 15 | + | PkgConfigErrorKindPackageMissing PkgConfigErrorKind = "package_missing" | |
| 16 | + | PkgConfigErrorKindUnknown PkgConfigErrorKind = "unknown" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | var ( | |
| 20 | + | // ErrPkgConfigMissing indicates pkg-config is not available on the host. | |
| 21 | + | ErrPkgConfigMissing = errors.New("pkg-config missing") | |
| 22 | + | // ErrPkgConfigPackageMissing indicates the requested .pc package was not found. | |
| 23 | + | ErrPkgConfigPackageMissing = errors.New("pkg-config package missing") | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // PkgConfigError wraps an error returned by pkg-config probing. | |
| 27 | + | type PkgConfigError struct { | |
| 28 | + | Kind PkgConfigErrorKind | |
| 29 | + | Package string | |
| 30 | + | Output string | |
| 31 | + | Err error | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (e *PkgConfigError) Error() string { | |
| 35 | + | if e == nil { | |
| 36 | + | return "pkg-config error" | |
| 37 | + | } | |
| 38 | + | msg := fmt.Sprintf("pkg-config (%s): %s", e.Kind, e.Package) | |
| 39 | + | if e.Output != "" { | |
| 40 | + | msg += ": " + e.Output | |
| 41 | + | } | |
| 42 | + | if e.Err != nil { | |
| 43 | + | msg += ": " + e.Err.Error() | |
| 44 | + | } | |
| 45 | + | return msg | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (e *PkgConfigError) Unwrap() error { | |
| 49 | + | if e == nil { | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | return e.Err | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // RuntimeVersionProbe provides runtime dependency discovery. | |
| 56 | + | // | |
| 57 | + | // Implementations may use pkg-config and may apply a prefix override to influence | |
| 58 | + | // pkg-config search paths. | |
| 59 | + | type RuntimeVersionProbe interface { | |
| 60 | + | PkgConfigModVersion(ctx context.Context, pkgName string, prefix string) (string, error) | |
| 61 | + | } | |
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | // Package port defines interfaces for external dependencies. | |||
| 2 | + | package port | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // PkgConfigErrorKind describes the category of a pkg-config failure. | |||
| 11 | + | type PkgConfigErrorKind string | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | PkgConfigErrorKindCommandMissing PkgConfigErrorKind = "command_missing" | |||
| 15 | + | PkgConfigErrorKindPackageMissing PkgConfigErrorKind = "package_missing" | |||
| 16 | + | PkgConfigErrorKindUnknown PkgConfigErrorKind = "unknown" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | var ( | |||
| 20 | + | // ErrPkgConfigMissing indicates pkg-config is not available on the host. | |||
| 21 | + | ErrPkgConfigMissing = errors.New("pkg-config missing") | |||
| 22 | + | // ErrPkgConfigPackageMissing indicates the requested .pc package was not found. | |||
| 23 | + | ErrPkgConfigPackageMissing = errors.New("pkg-config package missing") | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // PkgConfigError wraps an error returned by pkg-config probing. | |||
| 27 | + | type PkgConfigError struct { | |||
| 28 | + | Kind PkgConfigErrorKind | |||
| 29 | + | Package string | |||
| 30 | + | Output string | |||
| 31 | + | Err error | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (e *PkgConfigError) Error() string { | |||
| 35 | + | if e == nil { | |||
| 36 | + | return "pkg-config error" | |||
| 37 | + | } | |||
| 38 | + | msg := fmt.Sprintf("pkg-config (%s): %s", e.Kind, e.Package) | |||
| 39 | + | if e.Output != "" { | |||
| 40 | + | msg += ": " + e.Output | |||
| 41 | + | } | |||
| 42 | + | if e.Err != nil { | |||
| 43 | + | msg += ": " + e.Err.Error() | |||
| 44 | + | } | |||
| 45 | + | return msg | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (e *PkgConfigError) Unwrap() error { | |||
| 49 | + | if e == nil { | |||
| 50 | + | return nil | |||
| 51 | + | } | |||
| 52 | + | return e.Err | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // RuntimeVersionProbe provides runtime dependency discovery. | |||
| 56 | + | // | |||
| 57 | + | // Implementations may use pkg-config and may apply a prefix override to influence | |||
| 58 | + | // pkg-config search paths. | |||
| 59 | + | type RuntimeVersionProbe interface { | |||
| 60 | + | PkgConfigModVersion(ctx context.Context, pkgName string, prefix string) (string, error) | |||
| 61 | + | } | |||
A internal/application/usecase/check_runtime_dependencies.go +216 -0
| @@ -0,0 +1,216 @@ | |||
| 1 | + | // Package usecase contains application business logic. | |
| 2 | + | package usecase | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | defaultMinGTK4Version = "4.20" | |
| 14 | + | defaultMinWebKitGTKVersion = "2.50" | |
| 15 | + | defaultMinGLibVersion = "2.84" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type RuntimeDependencyID string | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | RuntimeDependencyGTK4 RuntimeDependencyID = "gtk4" | |
| 22 | + | RuntimeDependencyWebKitGTK RuntimeDependencyID = "webkitgtk-6.0" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // RuntimeDependencyStatus contains the result of checking a runtime dependency. | |
| 26 | + | type RuntimeDependencyStatus struct { | |
| 27 | + | ID RuntimeDependencyID | |
| 28 | + | PkgConfigName string | |
| 29 | + | DisplayName string | |
| 30 | + | ||
| 31 | + | Installed bool | |
| 32 | + | Version string | |
| 33 | + | ||
| 34 | + | RequiredVersion string | |
| 35 | + | MeetsRequirement bool | |
| 36 | + | ||
| 37 | + | Error string | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // CheckRuntimeDependenciesUseCase validates runtime requirements for the GUI browser. | |
| 41 | + | type CheckRuntimeDependenciesUseCase struct { | |
| 42 | + | probe port.RuntimeVersionProbe | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // NewCheckRuntimeDependenciesUseCase creates a new use case. | |
| 46 | + | func NewCheckRuntimeDependenciesUseCase(probe port.RuntimeVersionProbe) *CheckRuntimeDependenciesUseCase { | |
| 47 | + | return &CheckRuntimeDependenciesUseCase{probe: probe} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // CheckRuntimeDependenciesInput contains options for runtime dependency checks. | |
| 51 | + | type CheckRuntimeDependenciesInput struct { | |
| 52 | + | // Prefix optionally points to a custom runtime prefix (e.g. /opt/webkitgtk). | |
| 53 | + | // Implementations may use this to influence pkg-config search paths. | |
| 54 | + | Prefix string | |
| 55 | + | ||
| 56 | + | // Min versions. If empty, defaults are used. | |
| 57 | + | MinGTK4Version string | |
| 58 | + | MinWebKitGTKVersion string | |
| 59 | + | MinGLibVersion string | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // CheckRuntimeDependenciesOutput contains the result of the runtime dependency checks. | |
| 63 | + | type CheckRuntimeDependenciesOutput struct { | |
| 64 | + | Prefix string | |
| 65 | + | OK bool | |
| 66 | + | Checks []RuntimeDependencyStatus | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Execute checks WebKitGTK and GTK runtime versions. | |
| 70 | + | func (uc *CheckRuntimeDependenciesUseCase) Execute(ctx context.Context, input CheckRuntimeDependenciesInput) (*CheckRuntimeDependenciesOutput, error) { | |
| 71 | + | log := logging.FromContext(ctx).With().Str("component", "runtime-check").Logger() | |
| 72 | + | ||
| 73 | + | minGTK := input.MinGTK4Version | |
| 74 | + | if minGTK == "" { | |
| 75 | + | minGTK = defaultMinGTK4Version | |
| 76 | + | } | |
| 77 | + | minWebKit := input.MinWebKitGTKVersion | |
| 78 | + | if minWebKit == "" { | |
| 79 | + | minWebKit = defaultMinWebKitGTKVersion | |
| 80 | + | } | |
| 81 | + | minGLib := input.MinGLibVersion | |
| 82 | + | if minGLib == "" { | |
| 83 | + | minGLib = defaultMinGLibVersion | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | checks := []RuntimeDependencyStatus{ | |
| 87 | + | { | |
| 88 | + | ID: RuntimeDependencyGTK4, | |
| 89 | + | PkgConfigName: "gtk4", | |
| 90 | + | DisplayName: "GTK4", | |
| 91 | + | RequiredVersion: minGTK, | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | ID: RuntimeDependencyWebKitGTK, | |
| 95 | + | PkgConfigName: "webkitgtk-6.0", | |
| 96 | + | DisplayName: "WebKitGTK 6.0", | |
| 97 | + | RequiredVersion: minWebKit, | |
| 98 | + | }, | |
| 99 | + | { | |
| 100 | + | ID: "glib-2.0", | |
| 101 | + | PkgConfigName: "glib-2.0", | |
| 102 | + | DisplayName: "GLib", | |
| 103 | + | RequiredVersion: minGLib, | |
| 104 | + | }, | |
| 105 | + | { | |
| 106 | + | ID: "gio-2.0", | |
| 107 | + | PkgConfigName: "gio-2.0", | |
| 108 | + | DisplayName: "GIO", | |
| 109 | + | RequiredVersion: minGLib, | |
| 110 | + | }, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | allOK := true | |
| 114 | + | for i := range checks { | |
| 115 | + | status := &checks[i] | |
| 116 | + | ||
| 117 | + | version, err := uc.probe.PkgConfigModVersion(ctx, status.PkgConfigName, input.Prefix) | |
| 118 | + | if err != nil { | |
| 119 | + | status.Installed = false | |
| 120 | + | status.MeetsRequirement = false | |
| 121 | + | status.Error = err.Error() | |
| 122 | + | allOK = false | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | status.Installed = true | |
| 127 | + | status.Version = strings.TrimSpace(version) | |
| 128 | + | ||
| 129 | + | cmp, ok := compareVersion(status.Version, status.RequiredVersion) | |
| 130 | + | if !ok { | |
| 131 | + | status.MeetsRequirement = false | |
| 132 | + | status.Error = "could not parse version" | |
| 133 | + | allOK = false | |
| 134 | + | continue | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | status.MeetsRequirement = cmp >= 0 | |
| 138 | + | if !status.MeetsRequirement { | |
| 139 | + | allOK = false | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | log.Debug().Bool("ok", allOK).Str("prefix", input.Prefix).Msg("runtime dependency check complete") | |
| 144 | + | return &CheckRuntimeDependenciesOutput{Prefix: input.Prefix, OK: allOK, Checks: checks}, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // compareVersion compares two version strings. | |
| 148 | + | // Returns 1 if a > b, 0 if a == b, -1 if a < b. ok is false if either cannot be parsed. | |
| 149 | + | func compareVersion(a, b string) (cmp int, ok bool) { | |
| 150 | + | av, ok := parseVersionPrefix(a) | |
| 151 | + | if !ok { | |
| 152 | + | return 0, false | |
| 153 | + | } | |
| 154 | + | bv, ok := parseVersionPrefix(b) | |
| 155 | + | if !ok { | |
| 156 | + | return 0, false | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | max := len(av) | |
| 160 | + | if len(bv) > max { | |
| 161 | + | max = len(bv) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | for i := 0; i < max; i++ { | |
| 165 | + | x := 0 | |
| 166 | + | if i < len(av) { | |
| 167 | + | x = av[i] | |
| 168 | + | } | |
| 169 | + | y := 0 | |
| 170 | + | if i < len(bv) { | |
| 171 | + | y = bv[i] | |
| 172 | + | } | |
| 173 | + | switch { | |
| 174 | + | case x > y: | |
| 175 | + | return 1, true | |
| 176 | + | case x < y: | |
| 177 | + | return -1, true | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | return 0, true | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // parseVersionPrefix parses a dotted numeric version prefix (e.g. 4.20.3). | |
| 184 | + | // It stops at the first non-digit/dot after a numeric segment. | |
| 185 | + | func parseVersionPrefix(s string) ([]int, bool) { | |
| 186 | + | var parts []int | |
| 187 | + | cur := 0 | |
| 188 | + | inNum := false | |
| 189 | + | ||
| 190 | + | loop: | |
| 191 | + | for i := 0; i < len(s); i++ { | |
| 192 | + | c := s[i] | |
| 193 | + | switch { | |
| 194 | + | case c >= '0' && c <= '9': | |
| 195 | + | inNum = true | |
| 196 | + | cur = cur*10 + int(c-'0') | |
| 197 | + | case c == '.': | |
| 198 | + | if !inNum { | |
| 199 | + | return nil, false | |
| 200 | + | } | |
| 201 | + | parts = append(parts, cur) | |
| 202 | + | cur = 0 | |
| 203 | + | inNum = false | |
| 204 | + | default: | |
| 205 | + | break loop | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | if inNum { | |
| 210 | + | parts = append(parts, cur) | |
| 211 | + | } | |
| 212 | + | if len(parts) == 0 { | |
| 213 | + | return nil, false | |
| 214 | + | } | |
| 215 | + | return parts, true | |
| 216 | + | } | |
| @@ -0,0 +1,216 @@ | |||||
| 1 | + | // Package usecase contains application business logic. | |||
| 2 | + | package usecase | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | defaultMinGTK4Version = "4.20" | |||
| 14 | + | defaultMinWebKitGTKVersion = "2.50" | |||
| 15 | + | defaultMinGLibVersion = "2.84" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | type RuntimeDependencyID string | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | RuntimeDependencyGTK4 RuntimeDependencyID = "gtk4" | |||
| 22 | + | RuntimeDependencyWebKitGTK RuntimeDependencyID = "webkitgtk-6.0" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // RuntimeDependencyStatus contains the result of checking a runtime dependency. | |||
| 26 | + | type RuntimeDependencyStatus struct { | |||
| 27 | + | ID RuntimeDependencyID | |||
| 28 | + | PkgConfigName string | |||
| 29 | + | DisplayName string | |||
| 30 | + | ||||
| 31 | + | Installed bool | |||
| 32 | + | Version string | |||
| 33 | + | ||||
| 34 | + | RequiredVersion string | |||
| 35 | + | MeetsRequirement bool | |||
| 36 | + | ||||
| 37 | + | Error string | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // CheckRuntimeDependenciesUseCase validates runtime requirements for the GUI browser. | |||
| 41 | + | type CheckRuntimeDependenciesUseCase struct { | |||
| 42 | + | probe port.RuntimeVersionProbe | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // NewCheckRuntimeDependenciesUseCase creates a new use case. | |||
| 46 | + | func NewCheckRuntimeDependenciesUseCase(probe port.RuntimeVersionProbe) *CheckRuntimeDependenciesUseCase { | |||
| 47 | + | return &CheckRuntimeDependenciesUseCase{probe: probe} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // CheckRuntimeDependenciesInput contains options for runtime dependency checks. | |||
| 51 | + | type CheckRuntimeDependenciesInput struct { | |||
| 52 | + | // Prefix optionally points to a custom runtime prefix (e.g. /opt/webkitgtk). | |||
| 53 | + | // Implementations may use this to influence pkg-config search paths. | |||
| 54 | + | Prefix string | |||
| 55 | + | ||||
| 56 | + | // Min versions. If empty, defaults are used. | |||
| 57 | + | MinGTK4Version string | |||
| 58 | + | MinWebKitGTKVersion string | |||
| 59 | + | MinGLibVersion string | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // CheckRuntimeDependenciesOutput contains the result of the runtime dependency checks. | |||
| 63 | + | type CheckRuntimeDependenciesOutput struct { | |||
| 64 | + | Prefix string | |||
| 65 | + | OK bool | |||
| 66 | + | Checks []RuntimeDependencyStatus | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Execute checks WebKitGTK and GTK runtime versions. | |||
| 70 | + | func (uc *CheckRuntimeDependenciesUseCase) Execute(ctx context.Context, input CheckRuntimeDependenciesInput) (*CheckRuntimeDependenciesOutput, error) { | |||
| 71 | + | log := logging.FromContext(ctx).With().Str("component", "runtime-check").Logger() | |||
| 72 | + | ||||
| 73 | + | minGTK := input.MinGTK4Version | |||
| 74 | + | if minGTK == "" { | |||
| 75 | + | minGTK = defaultMinGTK4Version | |||
| 76 | + | } | |||
| 77 | + | minWebKit := input.MinWebKitGTKVersion | |||
| 78 | + | if minWebKit == "" { | |||
| 79 | + | minWebKit = defaultMinWebKitGTKVersion | |||
| 80 | + | } | |||
| 81 | + | minGLib := input.MinGLibVersion | |||
| 82 | + | if minGLib == "" { | |||
| 83 | + | minGLib = defaultMinGLibVersion | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | checks := []RuntimeDependencyStatus{ | |||
| 87 | + | { | |||
| 88 | + | ID: RuntimeDependencyGTK4, | |||
| 89 | + | PkgConfigName: "gtk4", | |||
| 90 | + | DisplayName: "GTK4", | |||
| 91 | + | RequiredVersion: minGTK, | |||
| 92 | + | }, | |||
| 93 | + | { | |||
| 94 | + | ID: RuntimeDependencyWebKitGTK, | |||
| 95 | + | PkgConfigName: "webkitgtk-6.0", | |||
| 96 | + | DisplayName: "WebKitGTK 6.0", | |||
| 97 | + | RequiredVersion: minWebKit, | |||
| 98 | + | }, | |||
| 99 | + | { | |||
| 100 | + | ID: "glib-2.0", | |||
| 101 | + | PkgConfigName: "glib-2.0", | |||
| 102 | + | DisplayName: "GLib", | |||
| 103 | + | RequiredVersion: minGLib, | |||
| 104 | + | }, | |||
| 105 | + | { | |||
| 106 | + | ID: "gio-2.0", | |||
| 107 | + | PkgConfigName: "gio-2.0", | |||
| 108 | + | DisplayName: "GIO", | |||
| 109 | + | RequiredVersion: minGLib, | |||
| 110 | + | }, | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | allOK := true | |||
| 114 | + | for i := range checks { | |||
| 115 | + | status := &checks[i] | |||
| 116 | + | ||||
| 117 | + | version, err := uc.probe.PkgConfigModVersion(ctx, status.PkgConfigName, input.Prefix) | |||
| 118 | + | if err != nil { | |||
| 119 | + | status.Installed = false | |||
| 120 | + | status.MeetsRequirement = false | |||
| 121 | + | status.Error = err.Error() | |||
| 122 | + | allOK = false | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | status.Installed = true | |||
| 127 | + | status.Version = strings.TrimSpace(version) | |||
| 128 | + | ||||
| 129 | + | cmp, ok := compareVersion(status.Version, status.RequiredVersion) | |||
| 130 | + | if !ok { | |||
| 131 | + | status.MeetsRequirement = false | |||
| 132 | + | status.Error = "could not parse version" | |||
| 133 | + | allOK = false | |||
| 134 | + | continue | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | status.MeetsRequirement = cmp >= 0 | |||
| 138 | + | if !status.MeetsRequirement { | |||
| 139 | + | allOK = false | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | log.Debug().Bool("ok", allOK).Str("prefix", input.Prefix).Msg("runtime dependency check complete") | |||
| 144 | + | return &CheckRuntimeDependenciesOutput{Prefix: input.Prefix, OK: allOK, Checks: checks}, nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // compareVersion compares two version strings. | |||
| 148 | + | // Returns 1 if a > b, 0 if a == b, -1 if a < b. ok is false if either cannot be parsed. | |||
| 149 | + | func compareVersion(a, b string) (cmp int, ok bool) { | |||
| 150 | + | av, ok := parseVersionPrefix(a) | |||
| 151 | + | if !ok { | |||
| 152 | + | return 0, false | |||
| 153 | + | } | |||
| 154 | + | bv, ok := parseVersionPrefix(b) | |||
| 155 | + | if !ok { | |||
| 156 | + | return 0, false | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | max := len(av) | |||
| 160 | + | if len(bv) > max { | |||
| 161 | + | max = len(bv) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | for i := 0; i < max; i++ { | |||
| 165 | + | x := 0 | |||
| 166 | + | if i < len(av) { | |||
| 167 | + | x = av[i] | |||
| 168 | + | } | |||
| 169 | + | y := 0 | |||
| 170 | + | if i < len(bv) { | |||
| 171 | + | y = bv[i] | |||
| 172 | + | } | |||
| 173 | + | switch { | |||
| 174 | + | case x > y: | |||
| 175 | + | return 1, true | |||
| 176 | + | case x < y: | |||
| 177 | + | return -1, true | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | return 0, true | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // parseVersionPrefix parses a dotted numeric version prefix (e.g. 4.20.3). | |||
| 184 | + | // It stops at the first non-digit/dot after a numeric segment. | |||
| 185 | + | func parseVersionPrefix(s string) ([]int, bool) { | |||
| 186 | + | var parts []int | |||
| 187 | + | cur := 0 | |||
| 188 | + | inNum := false | |||
| 189 | + | ||||
| 190 | + | loop: | |||
| 191 | + | for i := 0; i < len(s); i++ { | |||
| 192 | + | c := s[i] | |||
| 193 | + | switch { | |||
| 194 | + | case c >= '0' && c <= '9': | |||
| 195 | + | inNum = true | |||
| 196 | + | cur = cur*10 + int(c-'0') | |||
| 197 | + | case c == '.': | |||
| 198 | + | if !inNum { | |||
| 199 | + | return nil, false | |||
| 200 | + | } | |||
| 201 | + | parts = append(parts, cur) | |||
| 202 | + | cur = 0 | |||
| 203 | + | inNum = false | |||
| 204 | + | default: | |||
| 205 | + | break loop | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | if inNum { | |||
| 210 | + | parts = append(parts, cur) | |||
| 211 | + | } | |||
| 212 | + | if len(parts) == 0 { | |||
| 213 | + | return nil, false | |||
| 214 | + | } | |||
| 215 | + | return parts, true | |||
| 216 | + | } | |||
A internal/application/usecase/run_media_diagnostics.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | // Package usecase contains application business logic. | |
| 2 | + | package usecase | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // RunMediaDiagnosticsUseCase retrieves detailed media playback diagnostics. | |
| 11 | + | type RunMediaDiagnosticsUseCase struct { | |
| 12 | + | diagnostics port.MediaDiagnostics | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func NewRunMediaDiagnosticsUseCase(diagnostics port.MediaDiagnostics) *RunMediaDiagnosticsUseCase { | |
| 16 | + | return &RunMediaDiagnosticsUseCase{diagnostics: diagnostics} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type RunMediaDiagnosticsInput struct{} | |
| 20 | + | ||
| 21 | + | type RunMediaDiagnosticsOutput struct { | |
| 22 | + | GStreamerAvailable bool | |
| 23 | + | HasVAPlugin bool | |
| 24 | + | HasVAAPIPlugin bool | |
| 25 | + | HasNVCodecPlugin bool | |
| 26 | + | ||
| 27 | + | AV1Decoders []string | |
| 28 | + | H264Decoders []string | |
| 29 | + | H265Decoders []string | |
| 30 | + | VP9Decoders []string | |
| 31 | + | ||
| 32 | + | VAAPIAvailable bool | |
| 33 | + | VAAPIDriver string | |
| 34 | + | VAAPIVersion string | |
| 35 | + | ||
| 36 | + | HWAccelAvailable bool | |
| 37 | + | AV1HWAvailable bool | |
| 38 | + | Warnings []string | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (uc *RunMediaDiagnosticsUseCase) Execute(ctx context.Context, _ RunMediaDiagnosticsInput) (*RunMediaDiagnosticsOutput, error) { | |
| 42 | + | result := uc.diagnostics.RunDiagnostics(ctx) | |
| 43 | + | if result == nil { | |
| 44 | + | return &RunMediaDiagnosticsOutput{}, nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | return &RunMediaDiagnosticsOutput{ | |
| 48 | + | GStreamerAvailable: result.GStreamerAvailable, | |
| 49 | + | HasVAPlugin: result.HasVAPlugin, | |
| 50 | + | HasVAAPIPlugin: result.HasVAAPIPlugin, | |
| 51 | + | HasNVCodecPlugin: result.HasNVCodecPlugin, | |
| 52 | + | ||
| 53 | + | AV1Decoders: result.AV1Decoders, | |
| 54 | + | H264Decoders: result.H264Decoders, | |
| 55 | + | H265Decoders: result.H265Decoders, | |
| 56 | + | VP9Decoders: result.VP9Decoders, | |
| 57 | + | ||
| 58 | + | VAAPIAvailable: result.VAAPIAvailable, | |
| 59 | + | VAAPIDriver: result.VAAPIDriver, | |
| 60 | + | VAAPIVersion: result.VAAPIVersion, | |
| 61 | + | ||
| 62 | + | HWAccelAvailable: result.HWAccelAvailable, | |
| 63 | + | AV1HWAvailable: result.AV1HWAvailable, | |
| 64 | + | Warnings: result.Warnings, | |
| 65 | + | }, nil | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | // Package usecase contains application business logic. | |||
| 2 | + | package usecase | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // RunMediaDiagnosticsUseCase retrieves detailed media playback diagnostics. | |||
| 11 | + | type RunMediaDiagnosticsUseCase struct { | |||
| 12 | + | diagnostics port.MediaDiagnostics | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func NewRunMediaDiagnosticsUseCase(diagnostics port.MediaDiagnostics) *RunMediaDiagnosticsUseCase { | |||
| 16 | + | return &RunMediaDiagnosticsUseCase{diagnostics: diagnostics} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type RunMediaDiagnosticsInput struct{} | |||
| 20 | + | ||||
| 21 | + | type RunMediaDiagnosticsOutput struct { | |||
| 22 | + | GStreamerAvailable bool | |||
| 23 | + | HasVAPlugin bool | |||
| 24 | + | HasVAAPIPlugin bool | |||
| 25 | + | HasNVCodecPlugin bool | |||
| 26 | + | ||||
| 27 | + | AV1Decoders []string | |||
| 28 | + | H264Decoders []string | |||
| 29 | + | H265Decoders []string | |||
| 30 | + | VP9Decoders []string | |||
| 31 | + | ||||
| 32 | + | VAAPIAvailable bool | |||
| 33 | + | VAAPIDriver string | |||
| 34 | + | VAAPIVersion string | |||
| 35 | + | ||||
| 36 | + | HWAccelAvailable bool | |||
| 37 | + | AV1HWAvailable bool | |||
| 38 | + | Warnings []string | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (uc *RunMediaDiagnosticsUseCase) Execute(ctx context.Context, _ RunMediaDiagnosticsInput) (*RunMediaDiagnosticsOutput, error) { | |||
| 42 | + | result := uc.diagnostics.RunDiagnostics(ctx) | |||
| 43 | + | if result == nil { | |||
| 44 | + | return &RunMediaDiagnosticsOutput{}, nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | return &RunMediaDiagnosticsOutput{ | |||
| 48 | + | GStreamerAvailable: result.GStreamerAvailable, | |||
| 49 | + | HasVAPlugin: result.HasVAPlugin, | |||
| 50 | + | HasVAAPIPlugin: result.HasVAAPIPlugin, | |||
| 51 | + | HasNVCodecPlugin: result.HasNVCodecPlugin, | |||
| 52 | + | ||||
| 53 | + | AV1Decoders: result.AV1Decoders, | |||
| 54 | + | H264Decoders: result.H264Decoders, | |||
| 55 | + | H265Decoders: result.H265Decoders, | |||
| 56 | + | VP9Decoders: result.VP9Decoders, | |||
| 57 | + | ||||
| 58 | + | VAAPIAvailable: result.VAAPIAvailable, | |||
| 59 | + | VAAPIDriver: result.VAAPIDriver, | |||
| 60 | + | VAAPIVersion: result.VAAPIVersion, | |||
| 61 | + | ||||
| 62 | + | HWAccelAvailable: result.HWAccelAvailable, | |||
| 63 | + | AV1HWAvailable: result.AV1HWAvailable, | |||
| 64 | + | Warnings: result.Warnings, | |||
| 65 | + | }, nil | |||
| 66 | + | } | |||
A internal/cli/cmd/doctor.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/spf13/cobra" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var ( | |
| 15 | + | doctorOnlyRuntime bool | |
| 16 | + | doctorOnlyMedia bool | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | var doctorCmd = &cobra.Command{ | |
| 20 | + | Use: "doctor", | |
| 21 | + | Short: "Check runtime requirements and diagnose issues", | |
| 22 | + | Long: `Doctor checks system prerequisites for running the GUI browser. | |
| 23 | + | ||
| 24 | + | By default it runs both: | |
| 25 | + | - Runtime checks (GTK4 + WebKitGTK 6.0) | |
| 26 | + | - Media checks (GStreamer/VA-API) | |
| 27 | + | ||
| 28 | + | Use flags to run only one category. | |
| 29 | + | ||
| 30 | + | Examples: | |
| 31 | + | dumber doctor | |
| 32 | + | dumber doctor --runtime | |
| 33 | + | dumber doctor --media`, | |
| 34 | + | RunE: runDoctor, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func init() { | |
| 38 | + | rootCmd.AddCommand(doctorCmd) | |
| 39 | + | doctorCmd.Flags().BoolVar(&doctorOnlyRuntime, "runtime", false, "Only run runtime checks (GTK4/WebKitGTK)") | |
| 40 | + | doctorCmd.Flags().BoolVar(&doctorOnlyMedia, "media", false, "Only run media checks (GStreamer/VA-API)") | |
| 41 | + | } | |
| 42 | + | func runDoctor(cmd *cobra.Command, args []string) error { | |
| 43 | + | app := GetApp() | |
| 44 | + | if app == nil { | |
| 45 | + | return fmt.Errorf("app not initialized") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if doctorOnlyRuntime && doctorOnlyMedia { | |
| 49 | + | return fmt.Errorf("--runtime and --media are mutually exclusive") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | runRuntime := true | |
| 53 | + | runMedia := true | |
| 54 | + | if doctorOnlyRuntime { | |
| 55 | + | runMedia = false | |
| 56 | + | } | |
| 57 | + | if doctorOnlyMedia { | |
| 58 | + | runRuntime = false | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | report := styles.DoctorReport{OverallOK: true} | |
| 62 | + | ||
| 63 | + | runtimeOK := true | |
| 64 | + | if runRuntime { | |
| 65 | + | probe := deps.NewPkgConfigProbe() | |
| 66 | + | runtimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |
| 67 | + | runtimeOut, err := runtimeUC.Execute(app.Ctx(), usecase.CheckRuntimeDependenciesInput{ | |
| 68 | + | Prefix: app.Config.Runtime.Prefix, | |
| 69 | + | }) | |
| 70 | + | if err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | runtimeOK = runtimeOut.OK | |
| 75 | + | report.Runtime = styles.DoctorRuntimeReport{ | |
| 76 | + | Prefix: runtimeOut.Prefix, | |
| 77 | + | OK: runtimeOut.OK, | |
| 78 | + | Checks: make([]styles.DoctorRuntimeCheck, 0, len(runtimeOut.Checks)), | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | for _, c := range runtimeOut.Checks { | |
| 82 | + | report.Runtime.Checks = append(report.Runtime.Checks, styles.DoctorRuntimeCheck{ | |
| 83 | + | Name: c.DisplayName, | |
| 84 | + | PkgConfigName: c.PkgConfigName, | |
| 85 | + | Installed: c.Installed, | |
| 86 | + | Version: c.Version, | |
| 87 | + | RequiredVersion: c.RequiredVersion, | |
| 88 | + | OK: c.MeetsRequirement, | |
| 89 | + | Error: c.Error, | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | mediaOK := true | |
| 95 | + | if runMedia { | |
| 96 | + | adapter := media.New() | |
| 97 | + | mediaUC := usecase.NewRunMediaDiagnosticsUseCase(adapter) | |
| 98 | + | mediaOut, err := mediaUC.Execute(app.Ctx(), usecase.RunMediaDiagnosticsInput{}) | |
| 99 | + | if err != nil { | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | mediaOK = mediaOut.GStreamerAvailable | |
| 104 | + | report.Media = &styles.DoctorMediaReport{ | |
| 105 | + | GStreamerAvailable: mediaOut.GStreamerAvailable, | |
| 106 | + | HWAccelAvailable: mediaOut.HWAccelAvailable, | |
| 107 | + | AV1HWAvailable: mediaOut.AV1HWAvailable, | |
| 108 | + | HasVAPlugin: mediaOut.HasVAPlugin, | |
| 109 | + | HasVAAPIPlugin: mediaOut.HasVAAPIPlugin, | |
| 110 | + | HasNVCodecPlugin: mediaOut.HasNVCodecPlugin, | |
| 111 | + | AV1Decoders: mediaOut.AV1Decoders, | |
| 112 | + | H264Decoders: mediaOut.H264Decoders, | |
| 113 | + | H265Decoders: mediaOut.H265Decoders, | |
| 114 | + | VP9Decoders: mediaOut.VP9Decoders, | |
| 115 | + | VAAPIAvailable: mediaOut.VAAPIAvailable, | |
| 116 | + | VAAPIDriver: mediaOut.VAAPIDriver, | |
| 117 | + | VAAPIVersion: mediaOut.VAAPIVersion, | |
| 118 | + | Warnings: mediaOut.Warnings, | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | report.OverallOK = runtimeOK && mediaOK | |
| 123 | + | ||
| 124 | + | renderer := styles.NewDoctorRenderer(app.Theme) | |
| 125 | + | fmt.Println(renderer.Render(report)) | |
| 126 | + | ||
| 127 | + | if runRuntime && !runtimeOK { | |
| 128 | + | return fmt.Errorf("runtime requirements not met") | |
| 129 | + | } | |
| 130 | + | if runMedia && !mediaOK { | |
| 131 | + | return fmt.Errorf("media requirements not met") | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | return nil | |
| 135 | + | } | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/spf13/cobra" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 9 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/deps" | |||
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/media" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | var ( | |||
| 15 | + | doctorOnlyRuntime bool | |||
| 16 | + | doctorOnlyMedia bool | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | var doctorCmd = &cobra.Command{ | |||
| 20 | + | Use: "doctor", | |||
| 21 | + | Short: "Check runtime requirements and diagnose issues", | |||
| 22 | + | Long: `Doctor checks system prerequisites for running the GUI browser. | |||
| 23 | + | ||||
| 24 | + | By default it runs both: | |||
| 25 | + | - Runtime checks (GTK4 + WebKitGTK 6.0) | |||
| 26 | + | - Media checks (GStreamer/VA-API) | |||
| 27 | + | ||||
| 28 | + | Use flags to run only one category. | |||
| 29 | + | ||||
| 30 | + | Examples: | |||
| 31 | + | dumber doctor | |||
| 32 | + | dumber doctor --runtime | |||
| 33 | + | dumber doctor --media`, | |||
| 34 | + | RunE: runDoctor, | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func init() { | |||
| 38 | + | rootCmd.AddCommand(doctorCmd) | |||
| 39 | + | doctorCmd.Flags().BoolVar(&doctorOnlyRuntime, "runtime", false, "Only run runtime checks (GTK4/WebKitGTK)") | |||
| 40 | + | doctorCmd.Flags().BoolVar(&doctorOnlyMedia, "media", false, "Only run media checks (GStreamer/VA-API)") | |||
| 41 | + | } | |||
| 42 | + | func runDoctor(cmd *cobra.Command, args []string) error { | |||
| 43 | + | app := GetApp() | |||
| 44 | + | if app == nil { | |||
| 45 | + | return fmt.Errorf("app not initialized") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | if doctorOnlyRuntime && doctorOnlyMedia { | |||
| 49 | + | return fmt.Errorf("--runtime and --media are mutually exclusive") | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | runRuntime := true | |||
| 53 | + | runMedia := true | |||
| 54 | + | if doctorOnlyRuntime { | |||
| 55 | + | runMedia = false | |||
| 56 | + | } | |||
| 57 | + | if doctorOnlyMedia { | |||
| 58 | + | runRuntime = false | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | report := styles.DoctorReport{OverallOK: true} | |||
| 62 | + | ||||
| 63 | + | runtimeOK := true | |||
| 64 | + | if runRuntime { | |||
| 65 | + | probe := deps.NewPkgConfigProbe() | |||
| 66 | + | runtimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |||
| 67 | + | runtimeOut, err := runtimeUC.Execute(app.Ctx(), usecase.CheckRuntimeDependenciesInput{ | |||
| 68 | + | Prefix: app.Config.Runtime.Prefix, | |||
| 69 | + | }) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | runtimeOK = runtimeOut.OK | |||
| 75 | + | report.Runtime = styles.DoctorRuntimeReport{ | |||
| 76 | + | Prefix: runtimeOut.Prefix, | |||
| 77 | + | OK: runtimeOut.OK, | |||
| 78 | + | Checks: make([]styles.DoctorRuntimeCheck, 0, len(runtimeOut.Checks)), | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | for _, c := range runtimeOut.Checks { | |||
| 82 | + | report.Runtime.Checks = append(report.Runtime.Checks, styles.DoctorRuntimeCheck{ | |||
| 83 | + | Name: c.DisplayName, | |||
| 84 | + | PkgConfigName: c.PkgConfigName, | |||
| 85 | + | Installed: c.Installed, | |||
| 86 | + | Version: c.Version, | |||
| 87 | + | RequiredVersion: c.RequiredVersion, | |||
| 88 | + | OK: c.MeetsRequirement, | |||
| 89 | + | Error: c.Error, | |||
| 90 | + | }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | mediaOK := true | |||
| 95 | + | if runMedia { | |||
| 96 | + | adapter := media.New() | |||
| 97 | + | mediaUC := usecase.NewRunMediaDiagnosticsUseCase(adapter) | |||
| 98 | + | mediaOut, err := mediaUC.Execute(app.Ctx(), usecase.RunMediaDiagnosticsInput{}) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | mediaOK = mediaOut.GStreamerAvailable | |||
| 104 | + | report.Media = &styles.DoctorMediaReport{ | |||
| 105 | + | GStreamerAvailable: mediaOut.GStreamerAvailable, | |||
| 106 | + | HWAccelAvailable: mediaOut.HWAccelAvailable, | |||
| 107 | + | AV1HWAvailable: mediaOut.AV1HWAvailable, | |||
| 108 | + | HasVAPlugin: mediaOut.HasVAPlugin, | |||
| 109 | + | HasVAAPIPlugin: mediaOut.HasVAAPIPlugin, | |||
| 110 | + | HasNVCodecPlugin: mediaOut.HasNVCodecPlugin, | |||
| 111 | + | AV1Decoders: mediaOut.AV1Decoders, | |||
| 112 | + | H264Decoders: mediaOut.H264Decoders, | |||
| 113 | + | H265Decoders: mediaOut.H265Decoders, | |||
| 114 | + | VP9Decoders: mediaOut.VP9Decoders, | |||
| 115 | + | VAAPIAvailable: mediaOut.VAAPIAvailable, | |||
| 116 | + | VAAPIDriver: mediaOut.VAAPIDriver, | |||
| 117 | + | VAAPIVersion: mediaOut.VAAPIVersion, | |||
| 118 | + | Warnings: mediaOut.Warnings, | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | report.OverallOK = runtimeOK && mediaOK | |||
| 123 | + | ||||
| 124 | + | renderer := styles.NewDoctorRenderer(app.Theme) | |||
| 125 | + | fmt.Println(renderer.Render(report)) | |||
| 126 | + | ||||
| 127 | + | if runRuntime && !runtimeOK { | |||
| 128 | + | return fmt.Errorf("runtime requirements not met") | |||
| 129 | + | } | |||
| 130 | + | if runMedia && !mediaOK { | |||
| 131 | + | return fmt.Errorf("media requirements not met") | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | return nil | |||
| 135 | + | } | |||
D internal/cli/cmd/media.go +0 -142
| @@ -1,142 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "strings" | |
| 6 | - | ||
| 7 | - | "github.com/spf13/cobra" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | var mediaCmd = &cobra.Command{ | |
| 13 | - | Use: "media", | |
| 14 | - | Short: "Show media playback diagnostics", | |
| 15 | - | Long: `Display media playback diagnostics including GStreamer plugins and VA-API status. | |
| 16 | - | ||
| 17 | - | This command checks for hardware video acceleration support and reports: | |
| 18 | - | - Available GStreamer decoder plugins (VA, VAAPI, NVCodec) | |
| 19 | - | - Hardware video decoders (AV1, H.264, H.265, VP9) | |
| 20 | - | - VA-API driver detection and version | |
| 21 | - | - Warnings and recommendations | |
| 22 | - | ||
| 23 | - | Use this to troubleshoot video playback issues (e.g., Twitch Error #4000). | |
| 24 | - | ||
| 25 | - | Examples: | |
| 26 | - | dumber media # Show full diagnostics`, | |
| 27 | - | RunE: runMedia, | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | func init() { | |
| 31 | - | rootCmd.AddCommand(mediaCmd) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func runMedia(cmd *cobra.Command, args []string) error { | |
| 35 | - | app := GetApp() | |
| 36 | - | if app == nil { | |
| 37 | - | return fmt.Errorf("app not initialized") | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // Run diagnostics via adapter | |
| 41 | - | adapter := media.New() | |
| 42 | - | diag := adapter.RunDiagnostics(app.Ctx()) | |
| 43 | - | ||
| 44 | - | theme := app.Theme | |
| 45 | - | ||
| 46 | - | // Header | |
| 47 | - | fmt.Println(theme.Highlight.Render("Media Diagnostics")) | |
| 48 | - | fmt.Println(theme.Subtle.Render(strings.Repeat("─", 40))) | |
| 49 | - | fmt.Println() | |
| 50 | - | ||
| 51 | - | // GStreamer Status | |
| 52 | - | gstStatus := "No" | |
| 53 | - | gstStyle := theme.ErrorStyle | |
| 54 | - | if diag.GStreamerAvailable { | |
| 55 | - | gstStatus = "Yes" | |
| 56 | - | gstStyle = theme.SuccessStyle | |
| 57 | - | } | |
| 58 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("GStreamer Installed:"), gstStyle.Render(gstStatus)) | |
| 59 | - | ||
| 60 | - | // Hardware Acceleration Status | |
| 61 | - | hwStatus := "No" | |
| 62 | - | hwStyle := theme.WarningStyle | |
| 63 | - | if diag.HWAccelAvailable { | |
| 64 | - | hwStatus = "Yes" | |
| 65 | - | hwStyle = theme.SuccessStyle | |
| 66 | - | } | |
| 67 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("Hardware Acceleration:"), hwStyle.Render(hwStatus)) | |
| 68 | - | ||
| 69 | - | // AV1 Status (preferred codec) | |
| 70 | - | av1Status := "No" | |
| 71 | - | av1Style := theme.WarningStyle | |
| 72 | - | if diag.AV1HWAvailable { | |
| 73 | - | av1Status = "Yes" | |
| 74 | - | av1Style = theme.SuccessStyle | |
| 75 | - | } | |
| 76 | - | fmt.Printf("%s %s %s\n", theme.Subtle.Render("AV1 Hardware Decoder:"), av1Style.Render(av1Status), theme.Subtle.Render("(preferred codec)")) | |
| 77 | - | ||
| 78 | - | // VA-API Status | |
| 79 | - | if diag.VAAPIAvailable { | |
| 80 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("VA-API Driver:"), theme.Normal.Render(diag.VAAPIDriver)) | |
| 81 | - | if diag.VAAPIVersion != "" { | |
| 82 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("VA-API Version:"), theme.Normal.Render(diag.VAAPIVersion)) | |
| 83 | - | } | |
| 84 | - | } else { | |
| 85 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("VA-API:"), theme.WarningStyle.Render("Not detected")) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | fmt.Println() | |
| 89 | - | ||
| 90 | - | // GStreamer Plugins | |
| 91 | - | fmt.Println(theme.Highlight.Render("GStreamer Plugins")) | |
| 92 | - | fmt.Println(theme.Subtle.Render(strings.Repeat("─", 40))) | |
| 93 | - | ||
| 94 | - | printPluginStatus := func(name string, available bool, note string) { | |
| 95 | - | status := "Not found" | |
| 96 | - | style := theme.WarningStyle | |
| 97 | - | if available { | |
| 98 | - | status = "Available" | |
| 99 | - | style = theme.SuccessStyle | |
| 100 | - | } | |
| 101 | - | if note != "" { | |
| 102 | - | fmt.Printf(" %s %s %s\n", theme.Subtle.Render(name+":"), style.Render(status), theme.Subtle.Render(note)) | |
| 103 | - | } else { | |
| 104 | - | fmt.Printf(" %s %s\n", theme.Subtle.Render(name+":"), style.Render(status)) | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | printPluginStatus("VA (stateless)", diag.HasVAPlugin, "(modern, recommended)") | |
| 109 | - | printPluginStatus("VAAPI (legacy)", diag.HasVAAPIPlugin, "(gstreamer-vaapi)") | |
| 110 | - | printPluginStatus("NVCodec", diag.HasNVCodecPlugin, "(NVIDIA)") | |
| 111 | - | ||
| 112 | - | fmt.Println() | |
| 113 | - | ||
| 114 | - | // Hardware Decoders | |
| 115 | - | fmt.Println(theme.Highlight.Render("Hardware Decoders")) | |
| 116 | - | fmt.Println(theme.Subtle.Render(strings.Repeat("─", 40))) | |
| 117 | - | ||
| 118 | - | printDecoders := func(codec string, decoders []string) { | |
| 119 | - | if len(decoders) > 0 { | |
| 120 | - | fmt.Printf(" %s %s\n", theme.Subtle.Render(codec+":"), theme.SuccessStyle.Render(strings.Join(decoders, ", "))) | |
| 121 | - | } else { | |
| 122 | - | fmt.Printf(" %s %s\n", theme.Subtle.Render(codec+":"), theme.WarningStyle.Render("None")) | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | printDecoders("AV1", diag.AV1Decoders) | |
| 127 | - | printDecoders("H.264", diag.H264Decoders) | |
| 128 | - | printDecoders("H.265", diag.H265Decoders) | |
| 129 | - | printDecoders("VP9", diag.VP9Decoders) | |
| 130 | - | ||
| 131 | - | // Warnings | |
| 132 | - | if len(diag.Warnings) > 0 { | |
| 133 | - | fmt.Println() | |
| 134 | - | fmt.Println(theme.WarningStyle.Render("Warnings")) | |
| 135 | - | fmt.Println(theme.Subtle.Render(strings.Repeat("─", 40))) | |
| 136 | - | for _, w := range diag.Warnings { | |
| 137 | - | fmt.Printf(" %s %s\n", theme.WarningStyle.Render("!"), theme.Normal.Render(w)) | |
| 138 | - | } | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | return nil | |
| 142 | - | } | |
| @@ -1,142 +0,0 @@ | |||||
| 1 | - | package cmd | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "strings" | |||
| 6 | - | ||||
| 7 | - | "github.com/spf13/cobra" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/media" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | var mediaCmd = &cobra.Command{ | |||
| 13 | - | Use: "media", | |||
| 14 | - | Short: "Show media playback diagnostics", | |||
| 15 | - | Long: `Display media playback diagnostics including GStreamer plugins and VA-API status. | |||
| 16 | - | ||||
| 17 | - | This command checks for hardware video acceleration support and reports: | |||
| 18 | - | - Available GStreamer decoder plugins (VA, VAAPI, NVCodec) | |||
| 19 | - | - Hardware video decoders (AV1, H.264, H.265, VP9) | |||
| 20 | - | - VA-API driver detection and version | |||
| 21 | - | - Warnings and recommendations | |||
| 22 | - | ||||
| 23 | - | Use this to troubleshoot video playback issues (e.g., Twitch Error #4000). | |||
| 24 | - | ||||
| 25 | - | Examples: | |||
| 26 | - | dumber media # Show full diagnostics`, | |||
| 27 | - | RunE: runMedia, | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | func init() { | |||
| 31 | - | rootCmd.AddCommand(mediaCmd) | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | func runMedia(cmd *cobra.Command, args []string) error { | |||
| 35 | - | app := GetApp() | |||
| 36 | - | if app == nil { | |||
| 37 | - | return fmt.Errorf("app not initialized") | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | // Run diagnostics via adapter | |||
| 41 | - | adapter := media.New() | |||
| 42 | - | diag := adapter.RunDiagnostics(app.Ctx()) | |||
| 43 | - | ||||
| 44 | - | theme := app.Theme | |||
| 45 | - | ||||
| 46 | - | // Header | |||
| 47 | - | fmt.Println(theme.Highlight.Render("Media Diagnostics")) | |||
| 48 | - | fmt.Println(theme.Subtle.Render(strings.Repeat("─", 40))) | |||
| 49 | - | fmt.Println() | |||
| 50 | - | ||||
| 51 | - | // GStreamer Status | |||
| 52 | - | gstStatus := "No" | |||
| 53 | - | gstStyle := theme.ErrorStyle | |||
| 54 | - | if diag.GStreamerAvailable { | |||
| 55 | - | gstStatus = "Yes" | |||
| 56 | - | gstStyle = theme.SuccessStyle | |||
| 57 | - | } | |||
| 58 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("GStreamer Installed:"), gstStyle.Render(gstStatus)) | |||
| 59 | - | ||||
| 60 | - | // Hardware Acceleration Status | |||
| 61 | - | hwStatus := "No" | |||
| 62 | - | hwStyle := theme.WarningStyle | |||
| 63 | - | if diag.HWAccelAvailable { | |||
| 64 | - | hwStatus = "Yes" | |||
| 65 | - | hwStyle = theme.SuccessStyle | |||
| 66 | - | } | |||
| 67 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("Hardware Acceleration:"), hwStyle.Render(hwStatus)) | |||
| 68 | - | ||||
| 69 | - | // AV1 Status (preferred codec) | |||
| 70 | - | av1Status := "No" | |||
| 71 | - | av1Style := theme.WarningStyle | |||
| 72 | - | if diag.AV1HWAvailable { | |||
| 73 | - | av1Status = "Yes" | |||
| 74 | - | av1Style = theme.SuccessStyle | |||
| 75 | - | } | |||
| 76 | - | fmt.Printf("%s %s %s\n", theme.Subtle.Render("AV1 Hardware Decoder:"), av1Style.Render(av1Status), theme.Subtle.Render("(preferred codec)")) | |||
| 77 | - | ||||
| 78 | - | // VA-API Status | |||
| 79 | - | if diag.VAAPIAvailable { | |||
| 80 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("VA-API Driver:"), theme.Normal.Render(diag.VAAPIDriver)) | |||
| 81 | - | if diag.VAAPIVersion != "" { | |||
| 82 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("VA-API Version:"), theme.Normal.Render(diag.VAAPIVersion)) | |||
| 83 | - | } | |||
| 84 | - | } else { | |||
| 85 | - | fmt.Printf("%s %s\n", theme.Subtle.Render("VA-API:"), theme.WarningStyle.Render("Not detected")) | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | fmt.Println() | |||
| 89 | - | ||||
| 90 | - | // GStreamer Plugins | |||
| 91 | - | fmt.Println(theme.Highlight.Render("GStreamer Plugins")) | |||
| 92 | - | fmt.Println(theme.Subtle.Render(strings.Repeat("─", 40))) | |||
| 93 | - | ||||
| 94 | - | printPluginStatus := func(name string, available bool, note string) { | |||
| 95 | - | status := "Not found" | |||
| 96 | - | style := theme.WarningStyle | |||
| 97 | - | if available { | |||
| 98 | - | status = "Available" | |||
| 99 | - | style = theme.SuccessStyle | |||
| 100 | - | } | |||
| 101 | - | if note != "" { | |||
| 102 | - | fmt.Printf(" %s %s %s\n", theme.Subtle.Render(name+":"), style.Render(status), theme.Subtle.Render(note)) | |||
| 103 | - | } else { | |||
| 104 | - | fmt.Printf(" %s %s\n", theme.Subtle.Render(name+":"), style.Render(status)) | |||
| 105 | - | } | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | printPluginStatus("VA (stateless)", diag.HasVAPlugin, "(modern, recommended)") | |||
| 109 | - | printPluginStatus("VAAPI (legacy)", diag.HasVAAPIPlugin, "(gstreamer-vaapi)") | |||
| 110 | - | printPluginStatus("NVCodec", diag.HasNVCodecPlugin, "(NVIDIA)") | |||
| 111 | - | ||||
| 112 | - | fmt.Println() | |||
| 113 | - | ||||
| 114 | - | // Hardware Decoders | |||
| 115 | - | fmt.Println(theme.Highlight.Render("Hardware Decoders")) | |||
| 116 | - | fmt.Println(theme.Subtle.Render(strings.Repeat("─", 40))) | |||
| 117 | - | ||||
| 118 | - | printDecoders := func(codec string, decoders []string) { | |||
| 119 | - | if len(decoders) > 0 { | |||
| 120 | - | fmt.Printf(" %s %s\n", theme.Subtle.Render(codec+":"), theme.SuccessStyle.Render(strings.Join(decoders, ", "))) | |||
| 121 | - | } else { | |||
| 122 | - | fmt.Printf(" %s %s\n", theme.Subtle.Render(codec+":"), theme.WarningStyle.Render("None")) | |||
| 123 | - | } | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | - | printDecoders("AV1", diag.AV1Decoders) | |||
| 127 | - | printDecoders("H.264", diag.H264Decoders) | |||
| 128 | - | printDecoders("H.265", diag.H265Decoders) | |||
| 129 | - | printDecoders("VP9", diag.VP9Decoders) | |||
| 130 | - | ||||
| 131 | - | // Warnings | |||
| 132 | - | if len(diag.Warnings) > 0 { | |||
| 133 | - | fmt.Println() | |||
| 134 | - | fmt.Println(theme.WarningStyle.Render("Warnings")) | |||
| 135 | - | fmt.Println(theme.Subtle.Render(strings.Repeat("─", 40))) | |||
| 136 | - | for _, w := range diag.Warnings { | |||
| 137 | - | fmt.Printf(" %s %s\n", theme.WarningStyle.Render("!"), theme.Normal.Render(w)) | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | return nil | |||
| 142 | - | } | |||
A internal/cli/styles/doctor.go +202 -0
| @@ -0,0 +1,202 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/charmbracelet/lipgloss" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type DoctorRenderer struct { | |
| 11 | + | theme *Theme | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func NewDoctorRenderer(theme *Theme) *DoctorRenderer { | |
| 15 | + | return &DoctorRenderer{theme: theme} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type DoctorReport struct { | |
| 19 | + | OverallOK bool | |
| 20 | + | Runtime DoctorRuntimeReport | |
| 21 | + | Media *DoctorMediaReport | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type DoctorRuntimeReport struct { | |
| 25 | + | Prefix string | |
| 26 | + | OK bool | |
| 27 | + | Checks []DoctorRuntimeCheck | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type DoctorRuntimeCheck struct { | |
| 31 | + | Name string | |
| 32 | + | PkgConfigName string | |
| 33 | + | Installed bool | |
| 34 | + | Version string | |
| 35 | + | RequiredVersion string | |
| 36 | + | OK bool | |
| 37 | + | Error string | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type DoctorMediaReport struct { | |
| 41 | + | GStreamerAvailable bool | |
| 42 | + | HWAccelAvailable bool | |
| 43 | + | AV1HWAvailable bool | |
| 44 | + | ||
| 45 | + | HasVAPlugin bool | |
| 46 | + | HasVAAPIPlugin bool | |
| 47 | + | HasNVCodecPlugin bool | |
| 48 | + | ||
| 49 | + | AV1Decoders []string | |
| 50 | + | H264Decoders []string | |
| 51 | + | H265Decoders []string | |
| 52 | + | VP9Decoders []string | |
| 53 | + | ||
| 54 | + | VAAPIAvailable bool | |
| 55 | + | VAAPIDriver string | |
| 56 | + | VAAPIVersion string | |
| 57 | + | ||
| 58 | + | Warnings []string | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (r *DoctorRenderer) Render(report DoctorReport) string { | |
| 62 | + | header := r.renderHeader(report.OverallOK) | |
| 63 | + | ||
| 64 | + | sections := []string{} | |
| 65 | + | if len(report.Runtime.Checks) > 0 || strings.TrimSpace(report.Runtime.Prefix) != "" { | |
| 66 | + | sections = append(sections, r.renderRuntime(report.Runtime)) | |
| 67 | + | } | |
| 68 | + | if report.Media != nil { | |
| 69 | + | sections = append(sections, r.renderMedia(*report.Media)) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | return lipgloss.JoinVertical(lipgloss.Left, header, "", strings.Join(sections, "\n\n")) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (r *DoctorRenderer) renderHeader(ok bool) string { | |
| 76 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 77 | + | statusStyle := r.theme.SuccessStyle | |
| 78 | + | statusText := "OK" | |
| 79 | + | if !ok { | |
| 80 | + | statusStyle = r.theme.WarningStyle | |
| 81 | + | statusText = "Needs attention" | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | title := fmt.Sprintf("%s %s", iconStyle.Render(IconDoctor), r.theme.Title.Render("Doctor")) | |
| 85 | + | badge := r.theme.BadgeMuted.Render(statusStyle.Render(statusText)) | |
| 86 | + | return lipgloss.JoinHorizontal(lipgloss.Center, title, " ", badge) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (r *DoctorRenderer) renderRuntime(rt DoctorRuntimeReport) string { | |
| 90 | + | lines := make([]string, 0, len(rt.Checks)+2) | |
| 91 | + | ||
| 92 | + | if strings.TrimSpace(rt.Prefix) != "" { | |
| 93 | + | lines = append(lines, fmt.Sprintf("%s %s %s", r.theme.Subtle.Render("Prefix"), r.theme.Normal.Render(rt.Prefix), r.theme.Subtle.Render("(runtime override)"))) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | for _, c := range rt.Checks { | |
| 97 | + | lines = append(lines, r.renderRuntimeCheck(c)) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | body := strings.Join(lines, "\n") | |
| 101 | + | return r.theme.Box.Render(r.theme.BoxHeader.Render(fmt.Sprintf("%s Runtime", r.theme.Highlight.Render(IconPackage))) + "\n" + body) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (r *DoctorRenderer) renderRuntimeCheck(c DoctorRuntimeCheck) string { | |
| 105 | + | icon := IconCheck | |
| 106 | + | statusStyle := r.theme.SuccessStyle | |
| 107 | + | status := "OK" | |
| 108 | + | ||
| 109 | + | summary := "" | |
| 110 | + | if !c.Installed { | |
| 111 | + | icon = IconX | |
| 112 | + | statusStyle = r.theme.ErrorStyle | |
| 113 | + | status = "Missing" | |
| 114 | + | summary = c.Error | |
| 115 | + | } else if !c.OK { | |
| 116 | + | icon = IconWarning | |
| 117 | + | statusStyle = r.theme.WarningStyle | |
| 118 | + | status = "Too old" | |
| 119 | + | summary = fmt.Sprintf("have %s, need >= %s", c.Version, c.RequiredVersion) | |
| 120 | + | } else { | |
| 121 | + | summary = fmt.Sprintf("%s (>= %s)", c.Version, c.RequiredVersion) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | name := r.theme.Normal.Render(c.Name) | |
| 125 | + | badge := r.theme.BadgeMuted.Render(statusStyle.Render(status)) | |
| 126 | + | info := r.theme.Subtle.Render(summary) | |
| 127 | + | ||
| 128 | + | return fmt.Sprintf("%s %s %s\n %s", statusStyle.Render(icon), name, badge, info) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (r *DoctorRenderer) renderMedia(m DoctorMediaReport) string { | |
| 132 | + | lines := []string{} | |
| 133 | + | ||
| 134 | + | gstIcon := IconCheck | |
| 135 | + | gstStyle := r.theme.SuccessStyle | |
| 136 | + | gstText := "Yes" | |
| 137 | + | if !m.GStreamerAvailable { | |
| 138 | + | gstIcon = IconX | |
| 139 | + | gstStyle = r.theme.ErrorStyle | |
| 140 | + | gstText = "No" | |
| 141 | + | } | |
| 142 | + | lines = append(lines, fmt.Sprintf("%s %s %s", gstStyle.Render(gstIcon), r.theme.Subtle.Render("GStreamer"), gstStyle.Render(gstText))) | |
| 143 | + | ||
| 144 | + | hwIcon := IconWarning | |
| 145 | + | hwStyle := r.theme.WarningStyle | |
| 146 | + | hwText := "No" | |
| 147 | + | if m.HWAccelAvailable { | |
| 148 | + | hwIcon = IconCheck | |
| 149 | + | hwStyle = r.theme.SuccessStyle | |
| 150 | + | hwText = "Yes" | |
| 151 | + | } | |
| 152 | + | lines = append(lines, fmt.Sprintf("%s %s %s", hwStyle.Render(hwIcon), r.theme.Subtle.Render("HW decode"), hwStyle.Render(hwText))) | |
| 153 | + | ||
| 154 | + | av1Icon := IconWarning | |
| 155 | + | av1Style := r.theme.WarningStyle | |
| 156 | + | av1Text := "No" | |
| 157 | + | if m.AV1HWAvailable { | |
| 158 | + | av1Icon = IconCheck | |
| 159 | + | av1Style = r.theme.SuccessStyle | |
| 160 | + | av1Text = "Yes" | |
| 161 | + | } | |
| 162 | + | lines = append(lines, fmt.Sprintf("%s %s %s %s", av1Style.Render(av1Icon), r.theme.Subtle.Render("AV1"), av1Style.Render(av1Text), r.theme.Subtle.Render("(preferred)"))) | |
| 163 | + | ||
| 164 | + | plugins := []string{ | |
| 165 | + | fmt.Sprintf("%s VA (stateless): %s", r.theme.Subtle.Render("•"), pluginStatus(r.theme, m.HasVAPlugin, "recommended")), | |
| 166 | + | fmt.Sprintf("%s VAAPI (legacy): %s", r.theme.Subtle.Render("•"), pluginStatus(r.theme, m.HasVAAPIPlugin, "gstreamer-vaapi")), | |
| 167 | + | fmt.Sprintf("%s NVCodec: %s", r.theme.Subtle.Render("•"), pluginStatus(r.theme, m.HasNVCodecPlugin, "NVIDIA")), | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | lines = append(lines, "", r.theme.Subtle.Render("Plugins"), strings.Join(plugins, "\n")) | |
| 171 | + | ||
| 172 | + | if m.VAAPIAvailable { | |
| 173 | + | lines = append(lines, "", fmt.Sprintf("%s %s", r.theme.Subtle.Render("VA-API Driver"), r.theme.Normal.Render(m.VAAPIDriver))) | |
| 174 | + | if strings.TrimSpace(m.VAAPIVersion) != "" { | |
| 175 | + | lines = append(lines, fmt.Sprintf("%s %s", r.theme.Subtle.Render("VA-API Version"), r.theme.Normal.Render(m.VAAPIVersion))) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | if len(m.Warnings) > 0 { | |
| 180 | + | warnLines := make([]string, 0, len(m.Warnings)) | |
| 181 | + | for _, w := range m.Warnings { | |
| 182 | + | warnLines = append(warnLines, fmt.Sprintf("%s %s", r.theme.WarningStyle.Render(IconWarning), r.theme.Normal.Render(w))) | |
| 183 | + | } | |
| 184 | + | lines = append(lines, "", r.theme.WarningStyle.Render("Warnings"), strings.Join(warnLines, "\n")) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | body := strings.Join(lines, "\n") | |
| 188 | + | return r.theme.Box.Render(r.theme.BoxHeader.Render(fmt.Sprintf("%s Media", r.theme.Highlight.Render(IconVideo))) + "\n" + body) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func pluginStatus(theme *Theme, ok bool, hint string) string { | |
| 192 | + | if ok { | |
| 193 | + | if hint != "" { | |
| 194 | + | return theme.SuccessStyle.Render("Available") + " " + theme.Subtle.Render("("+hint+")") | |
| 195 | + | } | |
| 196 | + | return theme.SuccessStyle.Render("Available") | |
| 197 | + | } | |
| 198 | + | if hint != "" { | |
| 199 | + | return theme.WarningStyle.Render("Missing") + " " + theme.Subtle.Render("("+hint+")") | |
| 200 | + | } | |
| 201 | + | return theme.WarningStyle.Render("Missing") | |
| 202 | + | } | |
| @@ -0,0 +1,202 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/charmbracelet/lipgloss" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type DoctorRenderer struct { | |||
| 11 | + | theme *Theme | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func NewDoctorRenderer(theme *Theme) *DoctorRenderer { | |||
| 15 | + | return &DoctorRenderer{theme: theme} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | type DoctorReport struct { | |||
| 19 | + | OverallOK bool | |||
| 20 | + | Runtime DoctorRuntimeReport | |||
| 21 | + | Media *DoctorMediaReport | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type DoctorRuntimeReport struct { | |||
| 25 | + | Prefix string | |||
| 26 | + | OK bool | |||
| 27 | + | Checks []DoctorRuntimeCheck | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type DoctorRuntimeCheck struct { | |||
| 31 | + | Name string | |||
| 32 | + | PkgConfigName string | |||
| 33 | + | Installed bool | |||
| 34 | + | Version string | |||
| 35 | + | RequiredVersion string | |||
| 36 | + | OK bool | |||
| 37 | + | Error string | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type DoctorMediaReport struct { | |||
| 41 | + | GStreamerAvailable bool | |||
| 42 | + | HWAccelAvailable bool | |||
| 43 | + | AV1HWAvailable bool | |||
| 44 | + | ||||
| 45 | + | HasVAPlugin bool | |||
| 46 | + | HasVAAPIPlugin bool | |||
| 47 | + | HasNVCodecPlugin bool | |||
| 48 | + | ||||
| 49 | + | AV1Decoders []string | |||
| 50 | + | H264Decoders []string | |||
| 51 | + | H265Decoders []string | |||
| 52 | + | VP9Decoders []string | |||
| 53 | + | ||||
| 54 | + | VAAPIAvailable bool | |||
| 55 | + | VAAPIDriver string | |||
| 56 | + | VAAPIVersion string | |||
| 57 | + | ||||
| 58 | + | Warnings []string | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (r *DoctorRenderer) Render(report DoctorReport) string { | |||
| 62 | + | header := r.renderHeader(report.OverallOK) | |||
| 63 | + | ||||
| 64 | + | sections := []string{} | |||
| 65 | + | if len(report.Runtime.Checks) > 0 || strings.TrimSpace(report.Runtime.Prefix) != "" { | |||
| 66 | + | sections = append(sections, r.renderRuntime(report.Runtime)) | |||
| 67 | + | } | |||
| 68 | + | if report.Media != nil { | |||
| 69 | + | sections = append(sections, r.renderMedia(*report.Media)) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | return lipgloss.JoinVertical(lipgloss.Left, header, "", strings.Join(sections, "\n\n")) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (r *DoctorRenderer) renderHeader(ok bool) string { | |||
| 76 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |||
| 77 | + | statusStyle := r.theme.SuccessStyle | |||
| 78 | + | statusText := "OK" | |||
| 79 | + | if !ok { | |||
| 80 | + | statusStyle = r.theme.WarningStyle | |||
| 81 | + | statusText = "Needs attention" | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | title := fmt.Sprintf("%s %s", iconStyle.Render(IconDoctor), r.theme.Title.Render("Doctor")) | |||
| 85 | + | badge := r.theme.BadgeMuted.Render(statusStyle.Render(statusText)) | |||
| 86 | + | return lipgloss.JoinHorizontal(lipgloss.Center, title, " ", badge) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (r *DoctorRenderer) renderRuntime(rt DoctorRuntimeReport) string { | |||
| 90 | + | lines := make([]string, 0, len(rt.Checks)+2) | |||
| 91 | + | ||||
| 92 | + | if strings.TrimSpace(rt.Prefix) != "" { | |||
| 93 | + | lines = append(lines, fmt.Sprintf("%s %s %s", r.theme.Subtle.Render("Prefix"), r.theme.Normal.Render(rt.Prefix), r.theme.Subtle.Render("(runtime override)"))) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | for _, c := range rt.Checks { | |||
| 97 | + | lines = append(lines, r.renderRuntimeCheck(c)) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | body := strings.Join(lines, "\n") | |||
| 101 | + | return r.theme.Box.Render(r.theme.BoxHeader.Render(fmt.Sprintf("%s Runtime", r.theme.Highlight.Render(IconPackage))) + "\n" + body) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (r *DoctorRenderer) renderRuntimeCheck(c DoctorRuntimeCheck) string { | |||
| 105 | + | icon := IconCheck | |||
| 106 | + | statusStyle := r.theme.SuccessStyle | |||
| 107 | + | status := "OK" | |||
| 108 | + | ||||
| 109 | + | summary := "" | |||
| 110 | + | if !c.Installed { | |||
| 111 | + | icon = IconX | |||
| 112 | + | statusStyle = r.theme.ErrorStyle | |||
| 113 | + | status = "Missing" | |||
| 114 | + | summary = c.Error | |||
| 115 | + | } else if !c.OK { | |||
| 116 | + | icon = IconWarning | |||
| 117 | + | statusStyle = r.theme.WarningStyle | |||
| 118 | + | status = "Too old" | |||
| 119 | + | summary = fmt.Sprintf("have %s, need >= %s", c.Version, c.RequiredVersion) | |||
| 120 | + | } else { | |||
| 121 | + | summary = fmt.Sprintf("%s (>= %s)", c.Version, c.RequiredVersion) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | name := r.theme.Normal.Render(c.Name) | |||
| 125 | + | badge := r.theme.BadgeMuted.Render(statusStyle.Render(status)) | |||
| 126 | + | info := r.theme.Subtle.Render(summary) | |||
| 127 | + | ||||
| 128 | + | return fmt.Sprintf("%s %s %s\n %s", statusStyle.Render(icon), name, badge, info) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (r *DoctorRenderer) renderMedia(m DoctorMediaReport) string { | |||
| 132 | + | lines := []string{} | |||
| 133 | + | ||||
| 134 | + | gstIcon := IconCheck | |||
| 135 | + | gstStyle := r.theme.SuccessStyle | |||
| 136 | + | gstText := "Yes" | |||
| 137 | + | if !m.GStreamerAvailable { | |||
| 138 | + | gstIcon = IconX | |||
| 139 | + | gstStyle = r.theme.ErrorStyle | |||
| 140 | + | gstText = "No" | |||
| 141 | + | } | |||
| 142 | + | lines = append(lines, fmt.Sprintf("%s %s %s", gstStyle.Render(gstIcon), r.theme.Subtle.Render("GStreamer"), gstStyle.Render(gstText))) | |||
| 143 | + | ||||
| 144 | + | hwIcon := IconWarning | |||
| 145 | + | hwStyle := r.theme.WarningStyle | |||
| 146 | + | hwText := "No" | |||
| 147 | + | if m.HWAccelAvailable { | |||
| 148 | + | hwIcon = IconCheck | |||
| 149 | + | hwStyle = r.theme.SuccessStyle | |||
| 150 | + | hwText = "Yes" | |||
| 151 | + | } | |||
| 152 | + | lines = append(lines, fmt.Sprintf("%s %s %s", hwStyle.Render(hwIcon), r.theme.Subtle.Render("HW decode"), hwStyle.Render(hwText))) | |||
| 153 | + | ||||
| 154 | + | av1Icon := IconWarning | |||
| 155 | + | av1Style := r.theme.WarningStyle | |||
| 156 | + | av1Text := "No" | |||
| 157 | + | if m.AV1HWAvailable { | |||
| 158 | + | av1Icon = IconCheck | |||
| 159 | + | av1Style = r.theme.SuccessStyle | |||
| 160 | + | av1Text = "Yes" | |||
| 161 | + | } | |||
| 162 | + | lines = append(lines, fmt.Sprintf("%s %s %s %s", av1Style.Render(av1Icon), r.theme.Subtle.Render("AV1"), av1Style.Render(av1Text), r.theme.Subtle.Render("(preferred)"))) | |||
| 163 | + | ||||
| 164 | + | plugins := []string{ | |||
| 165 | + | fmt.Sprintf("%s VA (stateless): %s", r.theme.Subtle.Render("•"), pluginStatus(r.theme, m.HasVAPlugin, "recommended")), | |||
| 166 | + | fmt.Sprintf("%s VAAPI (legacy): %s", r.theme.Subtle.Render("•"), pluginStatus(r.theme, m.HasVAAPIPlugin, "gstreamer-vaapi")), | |||
| 167 | + | fmt.Sprintf("%s NVCodec: %s", r.theme.Subtle.Render("•"), pluginStatus(r.theme, m.HasNVCodecPlugin, "NVIDIA")), | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | lines = append(lines, "", r.theme.Subtle.Render("Plugins"), strings.Join(plugins, "\n")) | |||
| 171 | + | ||||
| 172 | + | if m.VAAPIAvailable { | |||
| 173 | + | lines = append(lines, "", fmt.Sprintf("%s %s", r.theme.Subtle.Render("VA-API Driver"), r.theme.Normal.Render(m.VAAPIDriver))) | |||
| 174 | + | if strings.TrimSpace(m.VAAPIVersion) != "" { | |||
| 175 | + | lines = append(lines, fmt.Sprintf("%s %s", r.theme.Subtle.Render("VA-API Version"), r.theme.Normal.Render(m.VAAPIVersion))) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | if len(m.Warnings) > 0 { | |||
| 180 | + | warnLines := make([]string, 0, len(m.Warnings)) | |||
| 181 | + | for _, w := range m.Warnings { | |||
| 182 | + | warnLines = append(warnLines, fmt.Sprintf("%s %s", r.theme.WarningStyle.Render(IconWarning), r.theme.Normal.Render(w))) | |||
| 183 | + | } | |||
| 184 | + | lines = append(lines, "", r.theme.WarningStyle.Render("Warnings"), strings.Join(warnLines, "\n")) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | body := strings.Join(lines, "\n") | |||
| 188 | + | return r.theme.Box.Render(r.theme.BoxHeader.Render(fmt.Sprintf("%s Media", r.theme.Highlight.Render(IconVideo))) + "\n" + body) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func pluginStatus(theme *Theme, ok bool, hint string) string { | |||
| 192 | + | if ok { | |||
| 193 | + | if hint != "" { | |||
| 194 | + | return theme.SuccessStyle.Render("Available") + " " + theme.Subtle.Render("("+hint+")") | |||
| 195 | + | } | |||
| 196 | + | return theme.SuccessStyle.Render("Available") | |||
| 197 | + | } | |||
| 198 | + | if hint != "" { | |||
| 199 | + | return theme.WarningStyle.Render("Missing") + " " + theme.Subtle.Render("("+hint+")") | |||
| 200 | + | } | |||
| 201 | + | return theme.WarningStyle.Render("Missing") | |||
| 202 | + | } | |||
M internal/cli/styles/icons.go +10 -0
| @@ -12,4 +12,14 @@ const ( | |||
| 12 | 12 | IconHeart = "\uf004" // heart | |
| 13 | 13 | IconGo = "\ue627" // go gopher | |
| 14 | 14 | IconArrow = "\uf061" // arrow right | |
| 15 | + | ||
| 16 | + | // Doctor / diagnostics | |
| 17 | + | IconDoctor = "\uf0f1" // stethoscope | |
| 18 | + | IconCheck = "\uf00c" // check | |
| 19 | + | IconX = "\uf00d" // x | |
| 20 | + | IconWarning = "\uf071" // warning | |
| 21 | + | IconInfo = "\uf05a" // info | |
| 22 | + | IconWrench = "\uf0ad" // wrench | |
| 23 | + | IconPackage = "\uf187" // archive/package | |
| 24 | + | IconVideo = "\uf03d" // video camera | |
| 15 | 25 | ) | |
| @@ -12,4 +12,14 @@ const ( | |||||
| 12 | IconHeart = "\uf004" // heart | 12 | IconHeart = "\uf004" // heart | ||
| 13 | IconGo = "\ue627" // go gopher | 13 | IconGo = "\ue627" // go gopher | ||
| 14 | IconArrow = "\uf061" // arrow right | 14 | IconArrow = "\uf061" // arrow right | ||
| 15 | + | ||||
| 16 | + | // Doctor / diagnostics | |||
| 17 | + | IconDoctor = "\uf0f1" // stethoscope | |||
| 18 | + | IconCheck = "\uf00c" // check | |||
| 19 | + | IconX = "\uf00d" // x | |||
| 20 | + | IconWarning = "\uf071" // warning | |||
| 21 | + | IconInfo = "\uf05a" // info | |||
| 22 | + | IconWrench = "\uf0ad" // wrench | |||
| 23 | + | IconPackage = "\uf187" // archive/package | |||
| 24 | + | IconVideo = "\uf03d" // video camera | |||
| 15 | ) | 25 | ) | ||
M internal/infrastructure/config/defaults.go +3 -0
| @@ -185,6 +185,9 @@ func DefaultConfig() *Config { | |||
| 185 | 185 | PreferAV1: true, // AV1 is most efficient codec | |
| 186 | 186 | ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable | |
| 187 | 187 | }, | |
| 188 | + | Runtime: RuntimeConfig{ | |
| 189 | + | Prefix: "", | |
| 190 | + | }, | |
| 188 | 191 | } | |
| 189 | 192 | } | |
| 190 | 193 | ||
| @@ -185,6 +185,9 @@ func DefaultConfig() *Config { | |||||
| 185 | PreferAV1: true, // AV1 is most efficient codec | 185 | PreferAV1: true, // AV1 is most efficient codec | ||
| 186 | ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable | 186 | ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable | ||
| 187 | }, | 187 | }, | ||
| 188 | + | Runtime: RuntimeConfig{ | |||
| 189 | + | Prefix: "", | |||
| 190 | + | }, | |||
| 188 | } | 191 | } | ||
| 189 | } | 192 | } | ||
| 190 | 193 | ||||
M internal/infrastructure/config/loader.go +6 -0
| @@ -158,6 +158,9 @@ func (m *Manager) Load() error { | |||
| 158 | 158 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | + | // Normalize runtime prefix | |
| 162 | + | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | |
| 163 | + | ||
| 161 | 164 | // Validate all config values | |
| 162 | 165 | if err := validateConfig(config); err != nil { | |
| 163 | 166 | return fmt.Errorf("configuration validation failed: %w", err) | |
| @@ -293,6 +296,9 @@ func (m *Manager) setDefaults() { | |||
| 293 | 296 | m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode)) | |
| 294 | 297 | m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1) | |
| 295 | 298 | m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup) | |
| 299 | + | ||
| 300 | + | // Runtime defaults | |
| 301 | + | m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix) | |
| 296 | 302 | } | |
| 297 | 303 | ||
| 298 | 304 | // New returns a new default configuration instance. | |
| @@ -158,6 +158,9 @@ func (m *Manager) Load() error { | |||||
| 158 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | 158 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | ||
| 159 | } | 159 | } | ||
| 160 | 160 | ||||
| 161 | + | // Normalize runtime prefix | |||
| 162 | + | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | |||
| 163 | + | ||||
| 161 | // Validate all config values | 164 | // Validate all config values | ||
| 162 | if err := validateConfig(config); err != nil { | 165 | if err := validateConfig(config); err != nil { | ||
| 163 | return fmt.Errorf("configuration validation failed: %w", err) | 166 | return fmt.Errorf("configuration validation failed: %w", err) | ||
| @@ -293,6 +296,9 @@ func (m *Manager) setDefaults() { | |||||
| 293 | m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode)) | 296 | m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode)) | ||
| 294 | m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1) | 297 | m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1) | ||
| 295 | m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup) | 298 | m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup) | ||
| 299 | + | ||||
| 300 | + | // Runtime defaults | |||
| 301 | + | m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix) | |||
| 296 | } | 302 | } | ||
| 297 | 303 | ||||
| 298 | // New returns a new default configuration instance. | 304 | // New returns a new default configuration instance. | ||
M internal/infrastructure/config/schema.go +9 -0
| @@ -25,6 +25,8 @@ type Config struct { | |||
| 25 | 25 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | |
| 26 | 26 | // Media controls video playback and hardware acceleration | |
| 27 | 27 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | |
| 28 | + | // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK). | |
| 29 | + | Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"` | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 30 | 32 | // RenderingMode selects GPU vs CPU rendering. | |
| @@ -62,6 +64,13 @@ type MediaConfig struct { | |||
| 62 | 64 | ShowDiagnosticsOnStartup bool `mapstructure:"show_diagnostics" yaml:"show_diagnostics" toml:"show_diagnostics"` | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 67 | + | // RuntimeConfig holds optional runtime overrides. | |
| 68 | + | type RuntimeConfig struct { | |
| 69 | + | // Prefix points to a custom runtime prefix (e.g., /opt/webkitgtk). | |
| 70 | + | // This is used to influence pkg-config lookup and dynamic library loading. | |
| 71 | + | Prefix string `mapstructure:"prefix" yaml:"prefix" toml:"prefix"` | |
| 72 | + | } | |
| 73 | + | ||
| 65 | 74 | // DatabaseConfig holds database-related configuration. | |
| 66 | 75 | type DatabaseConfig struct { | |
| 67 | 76 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | |
| @@ -25,6 +25,8 @@ type Config struct { | |||||
| 25 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | 25 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | ||
| 26 | // Media controls video playback and hardware acceleration | 26 | // Media controls video playback and hardware acceleration | ||
| 27 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | 27 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | ||
| 28 | + | // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK). | |||
| 29 | + | Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"` | |||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 30 | // RenderingMode selects GPU vs CPU rendering. | 32 | // RenderingMode selects GPU vs CPU rendering. | ||
| @@ -62,6 +64,13 @@ type MediaConfig struct { | |||||
| 62 | ShowDiagnosticsOnStartup bool `mapstructure:"show_diagnostics" yaml:"show_diagnostics" toml:"show_diagnostics"` | 64 | ShowDiagnosticsOnStartup bool `mapstructure:"show_diagnostics" yaml:"show_diagnostics" toml:"show_diagnostics"` | ||
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 67 | + | // RuntimeConfig holds optional runtime overrides. | |||
| 68 | + | type RuntimeConfig struct { | |||
| 69 | + | // Prefix points to a custom runtime prefix (e.g., /opt/webkitgtk). | |||
| 70 | + | // This is used to influence pkg-config lookup and dynamic library loading. | |||
| 71 | + | Prefix string `mapstructure:"prefix" yaml:"prefix" toml:"prefix"` | |||
| 72 | + | } | |||
| 73 | + | ||||
| 65 | // DatabaseConfig holds database-related configuration. | 74 | // DatabaseConfig holds database-related configuration. | ||
| 66 | type DatabaseConfig struct { | 75 | type DatabaseConfig struct { | ||
| 67 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | 76 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | ||
A internal/infrastructure/deps/env.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 1 | + | package deps | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ApplyPrefixEnv prepends environment variables derived from prefix. | |
| 10 | + | // | |
| 11 | + | // This is used to support manual runtime installs under /opt. | |
| 12 | + | func ApplyPrefixEnv(prefix string) { | |
| 13 | + | if strings.TrimSpace(prefix) == "" { | |
| 14 | + | return | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | for key, values := range prefixEnv(prefix) { | |
| 18 | + | os.Setenv(key, prependPathList(os.Getenv(key), values...)) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // CommandEnvWithPrefix returns an environment suitable for exec.Cmd.Env. | |
| 23 | + | // It prepends prefix-derived paths to the current environment. | |
| 24 | + | func CommandEnvWithPrefix(prefix string) []string { | |
| 25 | + | if strings.TrimSpace(prefix) == "" { | |
| 26 | + | return os.Environ() | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | updates := prefixEnv(prefix) | |
| 30 | + | base := os.Environ() | |
| 31 | + | ||
| 32 | + | // Convert base env to map. | |
| 33 | + | m := make(map[string]string, len(base)) | |
| 34 | + | for _, kv := range base { | |
| 35 | + | k, v, ok := strings.Cut(kv, "=") | |
| 36 | + | if ok { | |
| 37 | + | m[k] = v | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for k, values := range updates { | |
| 42 | + | m[k] = prependPathList(m[k], values...) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | out := make([]string, 0, len(m)) | |
| 46 | + | for k, v := range m { | |
| 47 | + | out = append(out, k+"="+v) | |
| 48 | + | } | |
| 49 | + | return out | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func prefixEnv(prefix string) map[string][]string { | |
| 53 | + | prefix = filepath.Clean(prefix) | |
| 54 | + | ||
| 55 | + | pkgConfig := []string{ | |
| 56 | + | filepath.Join(prefix, "lib", "pkgconfig"), | |
| 57 | + | filepath.Join(prefix, "lib64", "pkgconfig"), | |
| 58 | + | filepath.Join(prefix, "share", "pkgconfig"), | |
| 59 | + | filepath.Join(prefix, "lib", "x86_64-linux-gnu", "pkgconfig"), | |
| 60 | + | filepath.Join(prefix, "lib64", "x86_64-linux-gnu", "pkgconfig"), | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | ldLibrary := []string{ | |
| 64 | + | filepath.Join(prefix, "lib"), | |
| 65 | + | filepath.Join(prefix, "lib64"), | |
| 66 | + | filepath.Join(prefix, "lib", "x86_64-linux-gnu"), | |
| 67 | + | filepath.Join(prefix, "lib64", "x86_64-linux-gnu"), | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | giTypelib := []string{ | |
| 71 | + | filepath.Join(prefix, "lib", "girepository-1.0"), | |
| 72 | + | filepath.Join(prefix, "lib64", "girepository-1.0"), | |
| 73 | + | filepath.Join(prefix, "lib", "x86_64-linux-gnu", "girepository-1.0"), | |
| 74 | + | filepath.Join(prefix, "lib64", "x86_64-linux-gnu", "girepository-1.0"), | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | xdgData := []string{ | |
| 78 | + | filepath.Join(prefix, "share"), | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return map[string][]string{ | |
| 82 | + | "PKG_CONFIG_PATH": pkgConfig, | |
| 83 | + | "LD_LIBRARY_PATH": ldLibrary, | |
| 84 | + | "GI_TYPELIB_PATH": giTypelib, | |
| 85 | + | "XDG_DATA_DIRS": xdgData, | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func prependPathList(existing string, values ...string) string { | |
| 90 | + | seen := map[string]struct{}{} | |
| 91 | + | out := make([]string, 0, len(values)+4) | |
| 92 | + | ||
| 93 | + | add := func(p string) { | |
| 94 | + | p = strings.TrimSpace(p) | |
| 95 | + | if p == "" { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | if _, ok := seen[p]; ok { | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | seen[p] = struct{}{} | |
| 102 | + | out = append(out, p) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | for _, v := range values { | |
| 106 | + | add(v) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | if existing != "" { | |
| 110 | + | for _, v := range strings.Split(existing, ":") { | |
| 111 | + | add(v) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | return strings.Join(out, ":") | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | package deps | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ApplyPrefixEnv prepends environment variables derived from prefix. | |||
| 10 | + | // | |||
| 11 | + | // This is used to support manual runtime installs under /opt. | |||
| 12 | + | func ApplyPrefixEnv(prefix string) { | |||
| 13 | + | if strings.TrimSpace(prefix) == "" { | |||
| 14 | + | return | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | for key, values := range prefixEnv(prefix) { | |||
| 18 | + | os.Setenv(key, prependPathList(os.Getenv(key), values...)) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // CommandEnvWithPrefix returns an environment suitable for exec.Cmd.Env. | |||
| 23 | + | // It prepends prefix-derived paths to the current environment. | |||
| 24 | + | func CommandEnvWithPrefix(prefix string) []string { | |||
| 25 | + | if strings.TrimSpace(prefix) == "" { | |||
| 26 | + | return os.Environ() | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | updates := prefixEnv(prefix) | |||
| 30 | + | base := os.Environ() | |||
| 31 | + | ||||
| 32 | + | // Convert base env to map. | |||
| 33 | + | m := make(map[string]string, len(base)) | |||
| 34 | + | for _, kv := range base { | |||
| 35 | + | k, v, ok := strings.Cut(kv, "=") | |||
| 36 | + | if ok { | |||
| 37 | + | m[k] = v | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | for k, values := range updates { | |||
| 42 | + | m[k] = prependPathList(m[k], values...) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | out := make([]string, 0, len(m)) | |||
| 46 | + | for k, v := range m { | |||
| 47 | + | out = append(out, k+"="+v) | |||
| 48 | + | } | |||
| 49 | + | return out | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func prefixEnv(prefix string) map[string][]string { | |||
| 53 | + | prefix = filepath.Clean(prefix) | |||
| 54 | + | ||||
| 55 | + | pkgConfig := []string{ | |||
| 56 | + | filepath.Join(prefix, "lib", "pkgconfig"), | |||
| 57 | + | filepath.Join(prefix, "lib64", "pkgconfig"), | |||
| 58 | + | filepath.Join(prefix, "share", "pkgconfig"), | |||
| 59 | + | filepath.Join(prefix, "lib", "x86_64-linux-gnu", "pkgconfig"), | |||
| 60 | + | filepath.Join(prefix, "lib64", "x86_64-linux-gnu", "pkgconfig"), | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | ldLibrary := []string{ | |||
| 64 | + | filepath.Join(prefix, "lib"), | |||
| 65 | + | filepath.Join(prefix, "lib64"), | |||
| 66 | + | filepath.Join(prefix, "lib", "x86_64-linux-gnu"), | |||
| 67 | + | filepath.Join(prefix, "lib64", "x86_64-linux-gnu"), | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | giTypelib := []string{ | |||
| 71 | + | filepath.Join(prefix, "lib", "girepository-1.0"), | |||
| 72 | + | filepath.Join(prefix, "lib64", "girepository-1.0"), | |||
| 73 | + | filepath.Join(prefix, "lib", "x86_64-linux-gnu", "girepository-1.0"), | |||
| 74 | + | filepath.Join(prefix, "lib64", "x86_64-linux-gnu", "girepository-1.0"), | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | xdgData := []string{ | |||
| 78 | + | filepath.Join(prefix, "share"), | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return map[string][]string{ | |||
| 82 | + | "PKG_CONFIG_PATH": pkgConfig, | |||
| 83 | + | "LD_LIBRARY_PATH": ldLibrary, | |||
| 84 | + | "GI_TYPELIB_PATH": giTypelib, | |||
| 85 | + | "XDG_DATA_DIRS": xdgData, | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func prependPathList(existing string, values ...string) string { | |||
| 90 | + | seen := map[string]struct{}{} | |||
| 91 | + | out := make([]string, 0, len(values)+4) | |||
| 92 | + | ||||
| 93 | + | add := func(p string) { | |||
| 94 | + | p = strings.TrimSpace(p) | |||
| 95 | + | if p == "" { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | if _, ok := seen[p]; ok { | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | seen[p] = struct{}{} | |||
| 102 | + | out = append(out, p) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | for _, v := range values { | |||
| 106 | + | add(v) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | if existing != "" { | |||
| 110 | + | for _, v := range strings.Split(existing, ":") { | |||
| 111 | + | add(v) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | return strings.Join(out, ":") | |||
| 116 | + | } | |||
A internal/infrastructure/deps/pkgconfig.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package deps | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os/exec" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // PkgConfigProbe uses pkg-config to query module versions. | |
| 12 | + | type PkgConfigProbe struct{} | |
| 13 | + | ||
| 14 | + | func NewPkgConfigProbe() *PkgConfigProbe { | |
| 15 | + | return &PkgConfigProbe{} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (p *PkgConfigProbe) PkgConfigModVersion(ctx context.Context, pkgName string, prefix string) (string, error) { | |
| 19 | + | pc, err := exec.LookPath("pkg-config") | |
| 20 | + | if err != nil { | |
| 21 | + | return "", &port.PkgConfigError{ | |
| 22 | + | Kind: port.PkgConfigErrorKindCommandMissing, | |
| 23 | + | Package: pkgName, | |
| 24 | + | Err: port.ErrPkgConfigMissing, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | cmd := exec.CommandContext(ctx, pc, "--modversion", pkgName) | |
| 29 | + | cmd.Env = CommandEnvWithPrefix(prefix) | |
| 30 | + | ||
| 31 | + | out, err := cmd.CombinedOutput() | |
| 32 | + | if err != nil { | |
| 33 | + | output := strings.TrimSpace(string(out)) | |
| 34 | + | return "", &port.PkgConfigError{ | |
| 35 | + | Kind: port.PkgConfigErrorKindPackageMissing, | |
| 36 | + | Package: pkgName, | |
| 37 | + | Output: output, | |
| 38 | + | Err: port.ErrPkgConfigPackageMissing, | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | return string(out), nil | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package deps | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os/exec" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // PkgConfigProbe uses pkg-config to query module versions. | |||
| 12 | + | type PkgConfigProbe struct{} | |||
| 13 | + | ||||
| 14 | + | func NewPkgConfigProbe() *PkgConfigProbe { | |||
| 15 | + | return &PkgConfigProbe{} | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (p *PkgConfigProbe) PkgConfigModVersion(ctx context.Context, pkgName string, prefix string) (string, error) { | |||
| 19 | + | pc, err := exec.LookPath("pkg-config") | |||
| 20 | + | if err != nil { | |||
| 21 | + | return "", &port.PkgConfigError{ | |||
| 22 | + | Kind: port.PkgConfigErrorKindCommandMissing, | |||
| 23 | + | Package: pkgName, | |||
| 24 | + | Err: port.ErrPkgConfigMissing, | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | cmd := exec.CommandContext(ctx, pc, "--modversion", pkgName) | |||
| 29 | + | cmd.Env = CommandEnvWithPrefix(prefix) | |||
| 30 | + | ||||
| 31 | + | out, err := cmd.CombinedOutput() | |||
| 32 | + | if err != nil { | |||
| 33 | + | output := strings.TrimSpace(string(out)) | |||
| 34 | + | return "", &port.PkgConfigError{ | |||
| 35 | + | Kind: port.PkgConfigErrorKindPackageMissing, | |||
| 36 | + | Package: pkgName, | |||
| 37 | + | Output: output, | |||
| 38 | + | Err: port.ErrPkgConfigPackageMissing, | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | return string(out), nil | |||
| 43 | + | } | |||