Skip to content

Commit 040cff3

Browse files
committed
fix: guard hotkey close(nil) panic, non-blocking tray sends, D-Bus leak cleanup
1 parent e3b18e5 commit 040cff3

11 files changed

Lines changed: 119 additions & 34 deletions

File tree

infra/autostart/autostart_linux.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func Enable() error {
3535
content := "[Desktop Entry]\n" +
3636
"Type=Application\n" +
3737
"Name=Debrief\n" +
38-
"Exec=" + exePath + "\n" +
38+
"Exec=\"" + exePath + "\"\n" +
3939
"X-GNOME-Autostart-enabled=true\n" +
4040
"StartupNotify=false\n" +
4141
"Terminal=false\n"

infra/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,21 @@ func LoadConfig(path string) (*Config, error) {
5252
return DefaultConfig(), nil
5353
}
5454

55+
cfg.clamp()
56+
5557
log.Printf("Loaded config from %s", path)
5658

5759
return &cfg, nil
5860
}
5961

62+
// clamp ensures all fields are within valid ranges.
63+
func (c *Config) clamp() {
64+
if c.HotkeyPreset < 0 || c.HotkeyPreset > MaxHotkeyPreset {
65+
log.Printf("Config: HotkeyPreset %d out of range [0, %d], resetting to 0", c.HotkeyPreset, MaxHotkeyPreset)
66+
c.HotkeyPreset = 0
67+
}
68+
}
69+
6070
// SaveConfig writes configuration to disk, creating the directory if needed.
6171
func (c *Config) SaveConfig(path string) error {
6272
dir := filepath.Dir(path)

infra/config/constants.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const ConfigFileName = "config.json"
2424
// LogFileName is the name of the application log file.
2525
const LogFileName = "debrief.log"
2626

27+
// MaxHotkeyPreset is the maximum valid hotkey preset index.
28+
// Must match hotkey.PresetCount - 1.
29+
const MaxHotkeyPreset = 2
30+
2731
// PollingInterval is the fallback interval for polling history file changes
2832
// when filesystem notifications (fsnotify) are unavailable.
2933
const PollingInterval = 5 * time.Second

infra/hotkey/gnome_linux.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@ func (g *gnomeBackend) Register() error {
8484

8585
g.conn = conn
8686

87+
// closeOnError releases D-Bus resources if registration fails partway.
88+
cleanup := true
89+
defer func() {
90+
if cleanup {
91+
if closeErr := conn.Close(); closeErr != nil {
92+
log.Printf("Hotkey GNOME: error closing D-Bus connection: %v", closeErr)
93+
}
94+
95+
g.conn = nil
96+
}
97+
}()
98+
8799
reply, err := conn.RequestName(gnomeDBusName, dbus.NameFlagDoNotQueue|dbus.NameFlagReplaceExisting)
88100
if err != nil {
89101
return fmt.Errorf("failed to request D-Bus name %s: %w", gnomeDBusName, err)
@@ -150,6 +162,8 @@ func (g *gnomeBackend) Register() error {
150162

151163
log.Printf("Hotkey GNOME: Registered shortcut with binding %s", trigger)
152164

165+
cleanup = false // Success — don't close the connection
166+
153167
return nil
154168
}
155169

infra/hotkey/hotkey.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,24 @@ func (m *Manager) UpdateHotkey(mods []hk.Modifier, key hk.Key, modStrs []string,
166166
m.registered = false
167167
}
168168

169-
m.b = newBackend(mods, key, modStrs, keyStr)
169+
b := newBackend(mods, key, modStrs, keyStr)
170170

171171
log.Printf("Registering hotkey: %v + %s", modStrs, keyStr)
172172

173-
if err := m.b.Register(); err != nil {
173+
if err := b.Register(); err != nil {
174+
// Clean up any resources the backend acquired during construction
175+
// (e.g., portal backend opens a D-Bus connection in newPortalBackend).
176+
if unregErr := b.Unregister(); unregErr != nil {
177+
log.Printf("Failed to clean up failed hotkey backend: %v", unregErr)
178+
}
179+
174180
log.Printf("Failed to register hotkey: %v", err)
181+
175182
return fmt.Errorf("failed to register hotkey (%v + %s): %w", modStrs, keyStr, err)
176183
}
177184

185+
m.b = b
186+
178187
m.registered = true
179188
m.done = make(chan struct{})
180189

infra/hotkey/portal_linux.go

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type portalBackend struct {
3434
sessionPath dbus.ObjectPath
3535
preferredTrigger string
3636
keyChan chan struct{}
37+
done chan struct{}
3738
mu sync.Mutex
3839
closed bool
3940
}
@@ -81,6 +82,7 @@ func newPortalBackend(modStrs []string, keyStr string) (*portalBackend, error) {
8182
conn: conn,
8283
preferredTrigger: buildTriggerString(modStrs, keyStr),
8384
keyChan: make(chan struct{}, 1),
85+
done: make(chan struct{}),
8486
}, nil
8587
}
8688

@@ -169,6 +171,7 @@ func (p *portalBackend) Register() error {
169171
call = obj.Call(portalShortcutIf+".BindShortcuts", 0,
170172
p.sessionPath, shortcuts, "", bindOpts)
171173
if call.Err != nil {
174+
p.closeSession()
172175
return fmt.Errorf("BindShortcuts call failed: %w", call.Err)
173176
}
174177

@@ -178,10 +181,12 @@ func (p *portalBackend) Register() error {
178181

179182
responseCode, _, err = waitResponse(sigChan, expectedBindPath)
180183
if err != nil {
184+
p.closeSession()
181185
return fmt.Errorf("BindShortcuts response failed: %w", err)
182186
}
183187

184188
if responseCode != 0 {
189+
p.closeSession()
185190
return fmt.Errorf("BindShortcuts denied (response code: %d)", responseCode)
186191
}
187192

@@ -218,31 +223,58 @@ func (p *portalBackend) listenActivated() {
218223

219224
log.Println("Hotkey portal: Listening for Activated signals")
220225

221-
for sig := range sigChan {
222-
if sig.Name != portalShortcutIf+".Activated" {
223-
continue
224-
}
226+
for {
227+
select {
228+
case <-p.done:
229+
log.Println("Hotkey portal: Listener stopped")
230+
return
231+
case sig, ok := <-sigChan:
232+
if !ok {
233+
log.Println("Hotkey portal: Signal channel closed")
234+
return
235+
}
225236

226-
// Activated signal body: (session_handle, shortcut_id, timestamp, options)
227-
if len(sig.Body) < minResponseBodyLen {
228-
continue
229-
}
237+
if sig.Name != portalShortcutIf+".Activated" {
238+
continue
239+
}
230240

231-
id, ok := sig.Body[1].(string)
232-
if !ok || id != shortcutID {
233-
continue
234-
}
241+
// Activated signal body: (session_handle, shortcut_id, timestamp, options)
242+
if len(sig.Body) < minResponseBodyLen {
243+
continue
244+
}
245+
246+
id, ok := sig.Body[1].(string)
247+
if !ok || id != shortcutID {
248+
continue
249+
}
235250

236-
log.Println("Hotkey portal: Shortcut activated")
251+
log.Println("Hotkey portal: Shortcut activated")
237252

238-
select {
239-
case p.keyChan <- struct{}{}:
240-
default:
241-
// Channel full, skip duplicate
253+
select {
254+
case p.keyChan <- struct{}{}:
255+
default:
256+
// Channel full, skip duplicate
257+
}
242258
}
243259
}
244260
}
245261

262+
// closeSession closes the portal session via D-Bus.
263+
// Safe to call even if no session was created.
264+
func (p *portalBackend) closeSession() {
265+
if p.sessionPath == "" {
266+
return
267+
}
268+
269+
sessionObj := p.conn.Object(portalDest, p.sessionPath)
270+
271+
if err := sessionObj.Call(portalSessionIf+".Close", 0).Err; err != nil {
272+
log.Printf("Hotkey portal: warning: failed to close session: %v", err)
273+
}
274+
275+
p.sessionPath = ""
276+
}
277+
246278
// Unregister closes the portal session and D-Bus connection.
247279
func (p *portalBackend) Unregister() error {
248280
p.mu.Lock()
@@ -253,15 +285,9 @@ func (p *portalBackend) Unregister() error {
253285
}
254286

255287
p.closed = true
288+
close(p.done)
256289

257-
// Close the portal session
258-
if p.sessionPath != "" {
259-
sessionObj := p.conn.Object(portalDest, p.sessionPath)
260-
261-
if err := sessionObj.Call(portalSessionIf+".Close", 0).Err; err != nil {
262-
log.Printf("Hotkey portal: warning: failed to close session: %v", err)
263-
}
264-
}
290+
p.closeSession()
265291

266292
if err := p.conn.Close(); err != nil {
267293
return fmt.Errorf("failed to close D-Bus connection: %w", err)

infra/hotkey/x11.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ func (b *nativeBackend) Register() error {
5858

5959
func (b *nativeBackend) Unregister() error {
6060
b.once.Do(func() {
61-
close(b.done)
61+
if b.done != nil {
62+
close(b.done)
63+
}
6264
})
6365

6466
if err := b.hk.Unregister(); err != nil {

infra/platform/platform.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package platform
22

33
import (
4+
"log"
45
"os"
56
"path/filepath"
67
"runtime"
@@ -29,6 +30,8 @@ func ExpandPath(path string) string {
2930
if path[1] == '/' || path[1] == '\\' {
3031
return filepath.Join(home, path[2:])
3132
}
33+
} else {
34+
log.Printf("Warning: failed to expand ~ in path %q: %v", path, err)
3235
}
3336
}
3437

infra/tray/dispatch_darwin.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ static void dispatchTrayStart(void) {
2121
*/
2222
import "C"
2323

24-
var startFunc func()
24+
import "sync"
25+
26+
var (
27+
startFunc func()
28+
startFuncOnce sync.Once
29+
)
2530

2631
//export goTrayStartCallback
2732
func goTrayStartCallback() {
@@ -34,6 +39,8 @@ func goTrayStartCallback() {
3439
// thread via dispatch_async(dispatch_get_main_queue(), ...).
3540
// This is required because NSStatusItem/NSMenu must be created on the main thread.
3641
func dispatchStartOnMainThread(f func()) {
37-
startFunc = f
38-
C.dispatchTrayStart()
42+
startFuncOnce.Do(func() {
43+
startFunc = f
44+
C.dispatchTrayStart()
45+
})
3946
}

infra/tray/menu.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,16 @@ func SetupMenu(windowSignalChan chan<- string, shouldQuit chan<- bool) {
5151
for {
5252
select {
5353
case <-mShow.ClickedCh:
54-
handlers.WindowSignal <- "show"
54+
select {
55+
case handlers.WindowSignal <- "show":
56+
default:
57+
}
5558

5659
case <-mHide.ClickedCh:
57-
handlers.WindowSignal <- "hide"
60+
select {
61+
case handlers.WindowSignal <- "hide":
62+
default:
63+
}
5864

5965
case <-mQuit.ClickedCh:
6066
handlers.ShouldQuit <- true

0 commit comments

Comments
 (0)