detached 1628a6b

fix: add idle inhibition for fullscreen video playback (fixes #18)

commit
1628a6ba103bd74e656e931155dec6dec58b6688
author
brice · · 2025-12-24 06:26 UTC
committer
brice · · 2025-12-24 16:11 UTC
parent
5dc745a
signature
unsigned
10 files · +308 -1
M CHANGELOG.md +1 -0
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
66
77 ### Fixed
88 - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
9+- **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.).
910
1011 ### Added
1112 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
6 6
7 ### Fixed7 ### Fixed
8 - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.8 - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
9+- **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.).
9 10
10 ### Added11 ### Added
11 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.12 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
M cmd/dumber/main.go +10 -1
@@ -20,6 +20,7 @@ import (
2020 "github.com/bnema/dumber/internal/infrastructure/config"
2121 "github.com/bnema/dumber/internal/infrastructure/deps"
2222 "github.com/bnema/dumber/internal/infrastructure/favicon"
23+ "github.com/bnema/dumber/internal/infrastructure/idle"
2324 "github.com/bnema/dumber/internal/infrastructure/media"
2425 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2526 "github.com/bnema/dumber/internal/logging"
@@ -109,7 +110,13 @@ func runGUI() int {
109110 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)
110111 repos := createRepositories(db)
111112 useCases := createUseCases(repos, cfg.DefaultWebpageZoom)
112- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases)
113+ idleInhibitor := idle.NewPortalInhibitor(ctx)
114+ defer func() {
115+ if idleInhibitor != nil {
116+ _ = idleInhibitor.Close()
117+ }
118+ }()
119+ uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor)
113120
114121 app, err := ui.New(uiDeps)
115122 if err != nil {
@@ -278,6 +285,7 @@ func buildUIDependencies(
278285 stack *bootstrap.WebKitStack,
279286 repos *repositories,
280287 uc *useCases,
288+ idleInhibitor port.IdleInhibitor,
281289 ) *ui.Dependencies {
282290 return &ui.Dependencies{
283291 Ctx: ctx,
@@ -302,5 +310,6 @@ func buildUIDependencies(
302310 CopyURLUC: uc.copyURL,
303311 Clipboard: uc.clipboard,
304312 FaviconService: uc.favicon,
313+ IdleInhibitor: idleInhibitor,
305314 }
306315 }
@@ -20,6 +20,7 @@ import (
20 "github.com/bnema/dumber/internal/infrastructure/config"20 "github.com/bnema/dumber/internal/infrastructure/config"
21 "github.com/bnema/dumber/internal/infrastructure/deps"21 "github.com/bnema/dumber/internal/infrastructure/deps"
22 "github.com/bnema/dumber/internal/infrastructure/favicon"22 "github.com/bnema/dumber/internal/infrastructure/favicon"
23+ "github.com/bnema/dumber/internal/infrastructure/idle"
23 "github.com/bnema/dumber/internal/infrastructure/media"24 "github.com/bnema/dumber/internal/infrastructure/media"
24 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"25 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
25 "github.com/bnema/dumber/internal/logging"26 "github.com/bnema/dumber/internal/logging"
@@ -109,7 +110,13 @@ func runGUI() int {
109 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)110 stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)
110 repos := createRepositories(db)111 repos := createRepositories(db)
111 useCases := createUseCases(repos, cfg.DefaultWebpageZoom)112 useCases := createUseCases(repos, cfg.DefaultWebpageZoom)
112- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases)113+ idleInhibitor := idle.NewPortalInhibitor(ctx)
114+ defer func() {
115+ if idleInhibitor != nil {
116+ _ = idleInhibitor.Close()
117+ }
118+ }()
119+ uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor)
113 120
114 app, err := ui.New(uiDeps)121 app, err := ui.New(uiDeps)
115 if err != nil {122 if err != nil {
@@ -278,6 +285,7 @@ func buildUIDependencies(
278 stack *bootstrap.WebKitStack,285 stack *bootstrap.WebKitStack,
279 repos *repositories,286 repos *repositories,
280 uc *useCases,287 uc *useCases,
288+ idleInhibitor port.IdleInhibitor,
281 ) *ui.Dependencies {289 ) *ui.Dependencies {
282 return &ui.Dependencies{290 return &ui.Dependencies{
283 Ctx: ctx,291 Ctx: ctx,
@@ -302,5 +310,6 @@ func buildUIDependencies(
302 CopyURLUC: uc.copyURL,310 CopyURLUC: uc.copyURL,
303 Clipboard: uc.clipboard,311 Clipboard: uc.clipboard,
304 FaviconService: uc.favicon,312 FaviconService: uc.favicon,
313+ IdleInhibitor: idleInhibitor,
305 }314 }
306 }315 }
M go.mod +1 -0
@@ -30,6 +30,7 @@ require (
3030 github.com/davecgh/go-spew v1.1.1 // indirect
3131 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3232 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
33+ github.com/godbus/dbus/v5 v5.2.1 // indirect
3334 github.com/inconshreveable/mousetrap v1.1.0 // indirect
3435 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
3536 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -30,6 +30,7 @@ require (
30 github.com/davecgh/go-spew v1.1.1 // indirect30 github.com/davecgh/go-spew v1.1.1 // indirect
31 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect31 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect32 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
33+ github.com/godbus/dbus/v5 v5.2.1 // indirect
33 github.com/inconshreveable/mousetrap v1.1.0 // indirect34 github.com/inconshreveable/mousetrap v1.1.0 // indirect
34 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect35 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
35 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect36 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
M go.sum +2 -0
@@ -39,6 +39,8 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8
3939 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
4040 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
4141 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
42+github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk=
43+github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
4244 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
4345 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
4446 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -39,6 +39,8 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8
39 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=39 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
40 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=40 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
41 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=41 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
42+github.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk=
43+github.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
42 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=44 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
43 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=45 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
44 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=46 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
A internal/application/port/idle_inhibit.go +22 -0
@@ -0,0 +1,22 @@
1+package port
2+
3+import "context"
4+
5+// IdleInhibitor prevents system idle/screensaver during fullscreen video playback.
6+// Implementations use refcounting internally - multiple Inhibit calls require
7+// matching Uninhibit calls before inhibition is released.
8+type IdleInhibitor interface {
9+ // Inhibit increments the inhibit refcount. First call activates inhibition.
10+ // Safe to call multiple times (refcounted).
11+ Inhibit(ctx context.Context, reason string) error
12+
13+ // Uninhibit decrements the refcount. When zero, releases inhibition.
14+ // Safe to call even if not currently inhibited (no-op).
15+ Uninhibit(ctx context.Context) error
16+
17+ // IsInhibited returns true if currently inhibiting idle.
18+ IsInhibited() bool
19+
20+ // Close releases any held resources. Should be called on application shutdown.
21+ Close() error
22+}
@@ -0,0 +1,22 @@
1+package port
2+
3+import "context"
4+
5+// IdleInhibitor prevents system idle/screensaver during fullscreen video playback.
6+// Implementations use refcounting internally - multiple Inhibit calls require
7+// matching Uninhibit calls before inhibition is released.
8+type IdleInhibitor interface {
9+ // Inhibit increments the inhibit refcount. First call activates inhibition.
10+ // Safe to call multiple times (refcounted).
11+ Inhibit(ctx context.Context, reason string) error
12+
13+ // Uninhibit decrements the refcount. When zero, releases inhibition.
14+ // Safe to call even if not currently inhibited (no-op).
15+ Uninhibit(ctx context.Context) error
16+
17+ // IsInhibited returns true if currently inhibiting idle.
18+ IsInhibited() bool
19+
20+ // Close releases any held resources. Should be called on application shutdown.
21+ Close() error
22+}
A internal/infrastructure/idle/portal_inhibitor.go +191 -0
@@ -0,0 +1,191 @@
1+// Package idle provides system idle/screensaver inhibition via XDG Desktop Portal.
2+package idle
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/godbus/dbus/v5"
12+)
13+
14+const (
15+ portalDest = "org.freedesktop.portal.Desktop"
16+ portalPath = "/org/freedesktop/portal/desktop"
17+ portalInterface = "org.freedesktop.portal.Inhibit"
18+ requestIface = "org.freedesktop.portal.Request"
19+
20+ // Inhibit flags from portal spec
21+ flagLogout = 1
22+ flagUserSwitch = 2
23+ flagSuspend = 4
24+ flagIdle = 8
25+)
26+
27+// Compile-time interface check.
28+var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
29+
30+// PortalInhibitor implements idle inhibition using XDG Desktop Portal.
31+// This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
32+type PortalInhibitor struct {
33+ conn *dbus.Conn
34+ requestPath dbus.ObjectPath // Active inhibit request handle
35+ refcount int
36+ supported bool
37+ mu sync.Mutex
38+}
39+
40+// NewPortalInhibitor creates a new portal-based idle inhibitor.
41+// Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
42+func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
43+ log := logging.FromContext(ctx)
44+
45+ inhibitor := &PortalInhibitor{
46+ supported: false,
47+ }
48+
49+ // Connect to session bus
50+ conn, err := dbus.ConnectSessionBus()
51+ if err != nil {
52+ log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
53+ return inhibitor
54+ }
55+ inhibitor.conn = conn
56+
57+ // Check if portal is available
58+ obj := conn.Object(portalDest, portalPath)
59+ var version uint32
60+ err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,
61+ portalInterface, "version").Store(&version)
62+ if err != nil {
63+ log.Debug().Err(err).Msg("idle inhibitor: portal not available")
64+ return inhibitor
65+ }
66+
67+ inhibitor.supported = true
68+ log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
69+
70+ return inhibitor
71+}
72+
73+// Inhibit increments the inhibit refcount. First call activates inhibition.
74+func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
75+ log := logging.FromContext(ctx)
76+
77+ p.mu.Lock()
78+ defer p.mu.Unlock()
79+
80+ p.refcount++
81+ log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called")
82+
83+ // Already inhibiting
84+ if p.refcount > 1 {
85+ return nil
86+ }
87+
88+ // First inhibit request
89+ if !p.supported || p.conn == nil {
90+ log.Debug().Msg("idle inhibitor: not supported, skipping")
91+ return nil
92+ }
93+
94+ // Call portal Inhibit method
95+ // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
96+ obj := p.conn.Object(portalDest, portalPath)
97+
98+ options := map[string]dbus.Variant{
99+ "reason": dbus.MakeVariant(reason),
100+ }
101+
102+ var handlePath dbus.ObjectPath
103+ err := obj.Call(portalInterface+".Inhibit", 0,
104+ "", // window identifier (empty for non-sandboxed)
105+ uint32(flagIdle|flagSuspend), // inhibit idle and suspend
106+ options,
107+ ).Store(&handlePath)
108+
109+ if err != nil {
110+ p.refcount--
111+ log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
112+ return fmt.Errorf("portal inhibit: %w", err)
113+ }
114+
115+ p.requestPath = handlePath
116+ log.Info().
117+ Str("handle", string(handlePath)).
118+ Str("reason", reason).
119+ Msg("idle inhibitor: activated")
120+
121+ return nil
122+}
123+
124+// Uninhibit decrements the refcount. When zero, releases inhibition.
125+func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
126+ log := logging.FromContext(ctx)
127+
128+ p.mu.Lock()
129+ defer p.mu.Unlock()
130+
131+ if p.refcount <= 0 {
132+ return nil // Nothing to uninhibit
133+ }
134+
135+ p.refcount--
136+ log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
137+
138+ // Still have active inhibitors
139+ if p.refcount > 0 {
140+ return nil
141+ }
142+
143+ // Release the inhibition
144+ if !p.supported || p.conn == nil || p.requestPath == "" {
145+ return nil
146+ }
147+
148+ // Close the request to release inhibition
149+ obj := p.conn.Object(portalDest, p.requestPath)
150+ err := obj.Call(requestIface+".Close", 0).Err
151+
152+ if err != nil {
153+ log.Warn().Err(err).Msg("idle inhibitor: failed to close request")
154+ // Don't return error - the request may have already been closed
155+ } else {
156+ log.Info().Msg("idle inhibitor: deactivated")
157+ }
158+
159+ p.requestPath = ""
160+ return nil
161+}
162+
163+// IsInhibited returns true if currently inhibiting idle.
164+func (p *PortalInhibitor) IsInhibited() bool {
165+ p.mu.Lock()
166+ defer p.mu.Unlock()
167+ return p.refcount > 0
168+}
169+
170+// Close releases D-Bus resources and any active inhibition.
171+func (p *PortalInhibitor) Close() error {
172+ p.mu.Lock()
173+ defer p.mu.Unlock()
174+
175+ // Release any active inhibition
176+ if p.conn != nil && p.requestPath != "" {
177+ obj := p.conn.Object(portalDest, p.requestPath)
178+ _ = obj.Call(requestIface+".Close", 0).Err
179+ p.requestPath = ""
180+ }
181+
182+ p.refcount = 0
183+
184+ if p.conn != nil {
185+ err := p.conn.Close()
186+ p.conn = nil
187+ return err
188+ }
189+
190+ return nil
191+}
@@ -0,0 +1,191 @@
1+// Package idle provides system idle/screensaver inhibition via XDG Desktop Portal.
2+package idle
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/godbus/dbus/v5"
12+)
13+
14+const (
15+ portalDest = "org.freedesktop.portal.Desktop"
16+ portalPath = "/org/freedesktop/portal/desktop"
17+ portalInterface = "org.freedesktop.portal.Inhibit"
18+ requestIface = "org.freedesktop.portal.Request"
19+
20+ // Inhibit flags from portal spec
21+ flagLogout = 1
22+ flagUserSwitch = 2
23+ flagSuspend = 4
24+ flagIdle = 8
25+)
26+
27+// Compile-time interface check.
28+var _ port.IdleInhibitor = (*PortalInhibitor)(nil)
29+
30+// PortalInhibitor implements idle inhibition using XDG Desktop Portal.
31+// This works on Wayland with any compositor (GNOME, KDE, sway, hyprland, etc.).
32+type PortalInhibitor struct {
33+ conn *dbus.Conn
34+ requestPath dbus.ObjectPath // Active inhibit request handle
35+ refcount int
36+ supported bool
37+ mu sync.Mutex
38+}
39+
40+// NewPortalInhibitor creates a new portal-based idle inhibitor.
41+// Returns a functional inhibitor even if D-Bus is unavailable (graceful degradation).
42+func NewPortalInhibitor(ctx context.Context) *PortalInhibitor {
43+ log := logging.FromContext(ctx)
44+
45+ inhibitor := &PortalInhibitor{
46+ supported: false,
47+ }
48+
49+ // Connect to session bus
50+ conn, err := dbus.ConnectSessionBus()
51+ if err != nil {
52+ log.Debug().Err(err).Msg("idle inhibitor: cannot connect to D-Bus session bus")
53+ return inhibitor
54+ }
55+ inhibitor.conn = conn
56+
57+ // Check if portal is available
58+ obj := conn.Object(portalDest, portalPath)
59+ var version uint32
60+ err = obj.Call("org.freedesktop.DBus.Properties.Get", 0,
61+ portalInterface, "version").Store(&version)
62+ if err != nil {
63+ log.Debug().Err(err).Msg("idle inhibitor: portal not available")
64+ return inhibitor
65+ }
66+
67+ inhibitor.supported = true
68+ log.Debug().Uint32("version", version).Msg("idle inhibitor: portal available")
69+
70+ return inhibitor
71+}
72+
73+// Inhibit increments the inhibit refcount. First call activates inhibition.
74+func (p *PortalInhibitor) Inhibit(ctx context.Context, reason string) error {
75+ log := logging.FromContext(ctx)
76+
77+ p.mu.Lock()
78+ defer p.mu.Unlock()
79+
80+ p.refcount++
81+ log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: inhibit called")
82+
83+ // Already inhibiting
84+ if p.refcount > 1 {
85+ return nil
86+ }
87+
88+ // First inhibit request
89+ if !p.supported || p.conn == nil {
90+ log.Debug().Msg("idle inhibitor: not supported, skipping")
91+ return nil
92+ }
93+
94+ // Call portal Inhibit method
95+ // Inhibit(window: s, flags: u, options: a{sv}) -> handle: o
96+ obj := p.conn.Object(portalDest, portalPath)
97+
98+ options := map[string]dbus.Variant{
99+ "reason": dbus.MakeVariant(reason),
100+ }
101+
102+ var handlePath dbus.ObjectPath
103+ err := obj.Call(portalInterface+".Inhibit", 0,
104+ "", // window identifier (empty for non-sandboxed)
105+ uint32(flagIdle|flagSuspend), // inhibit idle and suspend
106+ options,
107+ ).Store(&handlePath)
108+
109+ if err != nil {
110+ p.refcount--
111+ log.Warn().Err(err).Msg("idle inhibitor: failed to inhibit")
112+ return fmt.Errorf("portal inhibit: %w", err)
113+ }
114+
115+ p.requestPath = handlePath
116+ log.Info().
117+ Str("handle", string(handlePath)).
118+ Str("reason", reason).
119+ Msg("idle inhibitor: activated")
120+
121+ return nil
122+}
123+
124+// Uninhibit decrements the refcount. When zero, releases inhibition.
125+func (p *PortalInhibitor) Uninhibit(ctx context.Context) error {
126+ log := logging.FromContext(ctx)
127+
128+ p.mu.Lock()
129+ defer p.mu.Unlock()
130+
131+ if p.refcount <= 0 {
132+ return nil // Nothing to uninhibit
133+ }
134+
135+ p.refcount--
136+ log.Debug().Int("refcount", p.refcount).Msg("idle inhibitor: uninhibit called")
137+
138+ // Still have active inhibitors
139+ if p.refcount > 0 {
140+ return nil
141+ }
142+
143+ // Release the inhibition
144+ if !p.supported || p.conn == nil || p.requestPath == "" {
145+ return nil
146+ }
147+
148+ // Close the request to release inhibition
149+ obj := p.conn.Object(portalDest, p.requestPath)
150+ err := obj.Call(requestIface+".Close", 0).Err
151+
152+ if err != nil {
153+ log.Warn().Err(err).Msg("idle inhibitor: failed to close request")
154+ // Don't return error - the request may have already been closed
155+ } else {
156+ log.Info().Msg("idle inhibitor: deactivated")
157+ }
158+
159+ p.requestPath = ""
160+ return nil
161+}
162+
163+// IsInhibited returns true if currently inhibiting idle.
164+func (p *PortalInhibitor) IsInhibited() bool {
165+ p.mu.Lock()
166+ defer p.mu.Unlock()
167+ return p.refcount > 0
168+}
169+
170+// Close releases D-Bus resources and any active inhibition.
171+func (p *PortalInhibitor) Close() error {
172+ p.mu.Lock()
173+ defer p.mu.Unlock()
174+
175+ // Release any active inhibition
176+ if p.conn != nil && p.requestPath != "" {
177+ obj := p.conn.Object(portalDest, p.requestPath)
178+ _ = obj.Call(requestIface+".Close", 0).Err
179+ p.requestPath = ""
180+ }
181+
182+ p.refcount = 0
183+
184+ if p.conn != nil {
185+ err := p.conn.Close()
186+ p.conn = nil
187+ return err
188+ }
189+
190+ return nil
191+}
M internal/infrastructure/webkit/webview.go +28 -0
@@ -107,6 +107,8 @@ type WebView struct {
107107 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
108108 OnReadyToShow func() // Called when popup is ready to display
109109 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
110+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
111+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
110112
111113 logger zerolog.Logger
112114 mu sync.RWMutex
@@ -314,6 +316,8 @@ func (wv *WebView) connectSignals() {
314316 wv.connectFaviconSignal()
315317 wv.connectProgressSignal()
316318 wv.connectDecidePolicySignal()
319+ wv.connectEnterFullscreenSignal()
320+ wv.connectLeaveFullscreenSignal()
317321 }
318322
319323 func (wv *WebView) connectLoadChangedSignal() {
@@ -520,6 +524,30 @@ func (wv *WebView) connectDecidePolicySignal() {
520524 wv.signalIDs = append(wv.signalIDs, sigID)
521525 }
522526
527+func (wv *WebView) connectEnterFullscreenSignal() {
528+ enterFullscreenCb := func(_ webkit.WebView) bool {
529+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
530+ if wv.OnEnterFullscreen != nil {
531+ return wv.OnEnterFullscreen()
532+ }
533+ return false // Allow fullscreen
534+ }
535+ sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
536+ wv.signalIDs = append(wv.signalIDs, sigID)
537+}
538+
539+func (wv *WebView) connectLeaveFullscreenSignal() {
540+ leaveFullscreenCb := func(_ webkit.WebView) bool {
541+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
542+ if wv.OnLeaveFullscreen != nil {
543+ return wv.OnLeaveFullscreen()
544+ }
545+ return false // Allow leaving fullscreen
546+ }
547+ sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
548+ wv.signalIDs = append(wv.signalIDs, sigID)
549+}
550+
523551 // ID returns the unique identifier for this WebView.
524552 func (wv *WebView) ID() WebViewID {
525553 return wv.id
@@ -107,6 +107,8 @@ type WebView struct {
107 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup107 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
108 OnReadyToShow func() // Called when popup is ready to display108 OnReadyToShow func() // Called when popup is ready to display
109 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)109 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
110+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
111+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
110 112
111 logger zerolog.Logger113 logger zerolog.Logger
112 mu sync.RWMutex114 mu sync.RWMutex
@@ -314,6 +316,8 @@ func (wv *WebView) connectSignals() {
314 wv.connectFaviconSignal()316 wv.connectFaviconSignal()
315 wv.connectProgressSignal()317 wv.connectProgressSignal()
316 wv.connectDecidePolicySignal()318 wv.connectDecidePolicySignal()
319+ wv.connectEnterFullscreenSignal()
320+ wv.connectLeaveFullscreenSignal()
317 }321 }
318 322
319 func (wv *WebView) connectLoadChangedSignal() {323 func (wv *WebView) connectLoadChangedSignal() {
@@ -520,6 +524,30 @@ func (wv *WebView) connectDecidePolicySignal() {
520 wv.signalIDs = append(wv.signalIDs, sigID)524 wv.signalIDs = append(wv.signalIDs, sigID)
521 }525 }
522 526
527+func (wv *WebView) connectEnterFullscreenSignal() {
528+ enterFullscreenCb := func(_ webkit.WebView) bool {
529+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
530+ if wv.OnEnterFullscreen != nil {
531+ return wv.OnEnterFullscreen()
532+ }
533+ return false // Allow fullscreen
534+ }
535+ sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
536+ wv.signalIDs = append(wv.signalIDs, sigID)
537+}
538+
539+func (wv *WebView) connectLeaveFullscreenSignal() {
540+ leaveFullscreenCb := func(_ webkit.WebView) bool {
541+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
542+ if wv.OnLeaveFullscreen != nil {
543+ return wv.OnLeaveFullscreen()
544+ }
545+ return false // Allow leaving fullscreen
546+ }
547+ sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
548+ wv.signalIDs = append(wv.signalIDs, sigID)
549+}
550+
523 // ID returns the unique identifier for this WebView.551 // ID returns the unique identifier for this WebView.
524 func (wv *WebView) ID() WebViewID {552 func (wv *WebView) ID() WebViewID {
525 return wv.id553 return wv.id
M internal/ui/app.go +11 -0
@@ -412,6 +412,12 @@ func (a *App) onShutdown(ctx context.Context) {
412412 if a.deps.Pool != nil {
413413 a.deps.Pool.Close(ctx)
414414 }
415+ // Close idle inhibitor to release D-Bus connection
416+ if a.deps.IdleInhibitor != nil {
417+ if err := a.deps.IdleInhibitor.Close(); err != nil {
418+ log.Warn().Err(err).Msg("failed to close idle inhibitor")
419+ }
420+ }
415421
416422 log.Info().Msg("application shutdown complete")
417423 }
@@ -443,6 +449,11 @@ func (a *App) initCoordinators(ctx context.Context) {
443449 a.deps.ZoomUC,
444450 )
445451
452+ // Set idle inhibitor for fullscreen video playback
453+ if a.deps.IdleInhibitor != nil {
454+ a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
455+ }
456+
446457 // 2. Tab Coordinator
447458 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
448459 TabsUC: a.tabsUC,
@@ -412,6 +412,12 @@ func (a *App) onShutdown(ctx context.Context) {
412 if a.deps.Pool != nil {412 if a.deps.Pool != nil {
413 a.deps.Pool.Close(ctx)413 a.deps.Pool.Close(ctx)
414 }414 }
415+ // Close idle inhibitor to release D-Bus connection
416+ if a.deps.IdleInhibitor != nil {
417+ if err := a.deps.IdleInhibitor.Close(); err != nil {
418+ log.Warn().Err(err).Msg("failed to close idle inhibitor")
419+ }
420+ }
415 421
416 log.Info().Msg("application shutdown complete")422 log.Info().Msg("application shutdown complete")
417 }423 }
@@ -443,6 +449,11 @@ func (a *App) initCoordinators(ctx context.Context) {
443 a.deps.ZoomUC,449 a.deps.ZoomUC,
444 )450 )
445 451
452+ // Set idle inhibitor for fullscreen video playback
453+ if a.deps.IdleInhibitor != nil {
454+ a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
455+ }
456+
446 // 2. Tab Coordinator457 // 2. Tab Coordinator
447 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{458 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
448 TabsUC: a.tabsUC,459 TabsUC: a.tabsUC,
M internal/ui/coordinator/content.go +41 -0
@@ -63,6 +63,9 @@ type ContentCoordinator struct {
6363
6464 // ID generator for popup panes
6565 generateID func() string
66+
67+ // Idle inhibitor for fullscreen video playback
68+ idleInhibitor port.IdleInhibitor
6669 }
6770
6871 // NewContentCoordinator creates a new ContentCoordinator.
@@ -105,6 +108,11 @@ func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler
105108 c.gestureActionHandler = handler
106109 }
107110
111+// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
112+func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
113+ c.idleInhibitor = inhibitor
114+}
115+
108116 // EnsureWebView acquires or reuses a WebView for the given pane.
109117 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
110118 log := logging.FromContext(ctx)
@@ -166,6 +174,9 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
166174 return c.handleLinkMiddleClick(ctx, paneID, uri)
167175 }
168176
177+ // Set up fullscreen handlers for idle inhibition
178+ c.setupFullscreenHandlers(ctx, paneID, wv)
179+
169180 // Set up popup handling for this WebView
170181 c.SetupPopupHandling(ctx, paneID, wv)
171182
@@ -988,6 +999,9 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
988999 wv.OnLinkMiddleClick = func(uri string) bool {
9891000 return c.handleLinkMiddleClick(ctx, paneID, uri)
9901001 }
1002+
1003+ // Fullscreen handlers for idle inhibition
1004+ c.setupFullscreenHandlers(ctx, paneID, wv)
9911005 }
9921006
9931007 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
@@ -1138,3 +1152,30 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
11381152
11391153 return true
11401154 }
1155+
1156+// setupFullscreenHandlers configures fullscreen callbacks for idle inhibition.
1157+func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1158+ log := logging.FromContext(ctx)
1159+
1160+ if wv == nil {
1161+ return
1162+ }
1163+
1164+ wv.OnEnterFullscreen = func() bool {
1165+ if c.idleInhibitor != nil {
1166+ if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
1167+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1168+ }
1169+ }
1170+ return false // Allow fullscreen
1171+ }
1172+
1173+ wv.OnLeaveFullscreen = func() bool {
1174+ if c.idleInhibitor != nil {
1175+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1176+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1177+ }
1178+ }
1179+ return false // Allow leaving fullscreen
1180+ }
1181+}
@@ -63,6 +63,9 @@ type ContentCoordinator struct {
63 63
64 // ID generator for popup panes64 // ID generator for popup panes
65 generateID func() string65 generateID func() string
66+
67+ // Idle inhibitor for fullscreen video playback
68+ idleInhibitor port.IdleInhibitor
66 }69 }
67 70
68 // NewContentCoordinator creates a new ContentCoordinator.71 // NewContentCoordinator creates a new ContentCoordinator.
@@ -105,6 +108,11 @@ func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler
105 c.gestureActionHandler = handler108 c.gestureActionHandler = handler
106 }109 }
107 110
111+// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
112+func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
113+ c.idleInhibitor = inhibitor
114+}
115+
108 // EnsureWebView acquires or reuses a WebView for the given pane.116 // EnsureWebView acquires or reuses a WebView for the given pane.
109 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {117 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
110 log := logging.FromContext(ctx)118 log := logging.FromContext(ctx)
@@ -166,6 +174,9 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
166 return c.handleLinkMiddleClick(ctx, paneID, uri)174 return c.handleLinkMiddleClick(ctx, paneID, uri)
167 }175 }
168 176
177+ // Set up fullscreen handlers for idle inhibition
178+ c.setupFullscreenHandlers(ctx, paneID, wv)
179+
169 // Set up popup handling for this WebView180 // Set up popup handling for this WebView
170 c.SetupPopupHandling(ctx, paneID, wv)181 c.SetupPopupHandling(ctx, paneID, wv)
171 182
@@ -988,6 +999,9 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
988 wv.OnLinkMiddleClick = func(uri string) bool {999 wv.OnLinkMiddleClick = func(uri string) bool {
989 return c.handleLinkMiddleClick(ctx, paneID, uri)1000 return c.handleLinkMiddleClick(ctx, paneID, uri)
990 }1001 }
1002+
1003+ // Fullscreen handlers for idle inhibition
1004+ c.setupFullscreenHandlers(ctx, paneID, wv)
991 }1005 }
992 1006
993 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.1007 // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
@@ -1138,3 +1152,30 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
1138 1152
1139 return true1153 return true
1140 }1154 }
1155+
1156+// setupFullscreenHandlers configures fullscreen callbacks for idle inhibition.
1157+func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1158+ log := logging.FromContext(ctx)
1159+
1160+ if wv == nil {
1161+ return
1162+ }
1163+
1164+ wv.OnEnterFullscreen = func() bool {
1165+ if c.idleInhibitor != nil {
1166+ if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
1167+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1168+ }
1169+ }
1170+ return false // Allow fullscreen
1171+ }
1172+
1173+ wv.OnLeaveFullscreen = func() bool {
1174+ if c.idleInhibitor != nil {
1175+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1176+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1177+ }
1178+ }
1179+ return false // Allow leaving fullscreen
1180+ }
1181+}
M internal/ui/deps.go +1 -0
@@ -52,6 +52,7 @@ type Dependencies struct {
5252 Clipboard port.Clipboard
5353 FaviconService *favicon.Service
5454 FilterManager *filtering.Manager
55+ IdleInhibitor port.IdleInhibitor
5556 }
5657
5758 // Validate checks that all required dependencies are set.
@@ -52,6 +52,7 @@ type Dependencies struct {
52 Clipboard port.Clipboard52 Clipboard port.Clipboard
53 FaviconService *favicon.Service53 FaviconService *favicon.Service
54 FilterManager *filtering.Manager54 FilterManager *filtering.Manager
55+ IdleInhibitor port.IdleInhibitor
55 }56 }
56 57
57 // Validate checks that all required dependencies are set.58 // Validate checks that all required dependencies are set.

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