detached 5bd0e17

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:
118118 - Go 1.25+
119119 - Node.js 20+ and npm (for building the embedded TypeScript frontend)
120120 - 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`
121122 - Debian/Ubuntu: `libwebkitgtk-6.0-dev libgtk-4-dev build-essential`
122123 - Arch: `webkitgtk-6.0 gtk4 base-devel`
124+ - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs)
123125
124126 Build options:
125127 - GUI (default, recommended):
@@ -191,8 +193,10 @@ sudo apt install va-driver-all
191193 - Go 1.25+
192194 - Node.js 20+ and npm (for building the embedded TypeScript frontend)
193195 - WebKitGTK 6 and GTK4 dev packages:
196+ - Minimum runtime versions: WebKitGTK 6.0 >= `2.50`, GTK4 >= `4.20`, GLib >= `2.84`
194197 - Debian/Ubuntu: `libwebkitgtk-6.0-dev libgtk-4-dev build-essential`
195198 - Arch: `webkitgtk-6.0 gtk4 base-devel`
199+ - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs)
196200
197201 **Build options:**
198202 - 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 (
1313 "github.com/bnema/dumber/internal/domain/build"
1414 "github.com/bnema/dumber/internal/infrastructure/clipboard"
1515 "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/bnema/dumber/internal/infrastructure/deps"
1617 "github.com/bnema/dumber/internal/infrastructure/favicon"
1718 "github.com/bnema/dumber/internal/infrastructure/filtering"
1819 "github.com/bnema/dumber/internal/infrastructure/media"
@@ -70,6 +71,9 @@ func runGUI() {
7071
7172 cfg := config.Get()
7273
74+ // Apply optional /opt-style runtime prefix overrides (if configured)
75+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
76+
7377 // Generate session ID for this browser run
7478 sessionID := logging.GenerateSessionID()
7579
@@ -104,6 +108,36 @@ func runGUI() {
104108 // Create root context with logger
105109 ctx := logging.WithContext(context.Background(), logger)
106110
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+
107141 // Check media playback requirements (GStreamer/VA-API)
108142 mediaDiagAdapter := media.New()
109143 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 run77 // 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 logger108 // 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
292292 **Diagnostics CLI:**
293293 ```bash
294294 # 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"
296314 ```
297315
298316 ## Content Filtering
@@ -292,7 +292,25 @@ show_diagnostics = true # Log warnings if HW accel unavailable
292 **Diagnostics CLI:**292 **Diagnostics CLI:**
293 ```bash293 ```bash
294 # Check GStreamer plugins and VA-API status294 # Check GStreamer plugins and VA-API status
295-dumber media295+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 Filtering316 ## 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 (
1212 IconHeart = "\uf004" // heart
1313 IconGo = "\ue627" // go gopher
1414 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
1525 )
@@ -12,4 +12,14 @@ const (
12 IconHeart = "\uf004" // heart12 IconHeart = "\uf004" // heart
13 IconGo = "\ue627" // go gopher13 IconGo = "\ue627" // go gopher
14 IconArrow = "\uf061" // arrow right14 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 {
185185 PreferAV1: true, // AV1 is most efficient codec
186186 ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable
187187 },
188+ Runtime: RuntimeConfig{
189+ Prefix: "",
190+ },
188191 }
189192 }
190193
@@ -185,6 +185,9 @@ func DefaultConfig() *Config {
185 PreferAV1: true, // AV1 is most efficient codec185 PreferAV1: true, // AV1 is most efficient codec
186 ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable186 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 {
158158 config.Media.HardwareDecodingMode = HardwareDecodingAuto
159159 }
160160
161+ // Normalize runtime prefix
162+ config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
163+
161164 // Validate all config values
162165 if err := validateConfig(config); err != nil {
163166 return fmt.Errorf("configuration validation failed: %w", err)
@@ -293,6 +296,9 @@ func (m *Manager) setDefaults() {
293296 m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode))
294297 m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1)
295298 m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup)
299+
300+ // Runtime defaults
301+ m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
296302 }
297303
298304 // New returns a new default configuration instance.
@@ -158,6 +158,9 @@ func (m *Manager) Load() error {
158 config.Media.HardwareDecodingMode = HardwareDecodingAuto158 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 values164 // 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 {
2525 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
2626 // Media controls video playback and hardware acceleration
2727 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"`
2830 }
2931
3032 // RenderingMode selects GPU vs CPU rendering.
@@ -62,6 +64,13 @@ type MediaConfig struct {
6264 ShowDiagnosticsOnStartup bool `mapstructure:"show_diagnostics" yaml:"show_diagnostics" toml:"show_diagnostics"`
6365 }
6466
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+
6574 // DatabaseConfig holds database-related configuration.
6675 type DatabaseConfig struct {
6776 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 acceleration26 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close