|
4 | 4 | "fmt" |
5 | 5 | "image" |
6 | 6 | "os" |
| 7 | + "os/exec" |
7 | 8 | "os/signal" |
8 | 9 | "path/filepath" |
9 | 10 | "strings" |
@@ -161,12 +162,15 @@ func NewApp(cfg *config.Config) (*App, error) { |
161 | 162 | if app.eventTap == nil { |
162 | 163 | log.Warn("Event tap creation failed - key capture won't work") |
163 | 164 | } else { |
| 165 | + |
| 166 | + keys := make([]string, 0, len(cfg.Hotkeys.Bindings)) |
| 167 | + for k := range cfg.Hotkeys.Bindings { |
| 168 | + keys = append(keys, k) |
| 169 | + } |
| 170 | + |
164 | 171 | // Configure hotkeys that should pass through to the global hotkey system |
165 | | - app.eventTap.SetHotkeys( |
166 | | - cfg.Hotkeys.ActivateHintMode, |
167 | | - cfg.Hotkeys.ActivateHintModeWithActions, |
168 | | - cfg.Hotkeys.ActivateScrollMode, |
169 | | - ) |
| 172 | + app.eventTap.SetHotkeys(keys) |
| 173 | + |
170 | 174 | // Ensure event tap is disabled initially (only enable in active modes) |
171 | 175 | app.eventTap.Disable() |
172 | 176 | } |
@@ -232,15 +236,15 @@ func (a *App) Run() error { |
232 | 236 | a.logger.Info("Neru is running") |
233 | 237 | fmt.Println("✓ Neru is running") |
234 | 238 |
|
235 | | - // Print configured hotkeys |
236 | | - if key := strings.TrimSpace(a.config.Hotkeys.ActivateHintMode); key != "" { |
237 | | - fmt.Printf(" Hint mode (direct): %s\n", key) |
238 | | - } |
239 | | - if key := strings.TrimSpace(a.config.Hotkeys.ActivateHintModeWithActions); key != "" { |
240 | | - fmt.Printf(" Hint mode (with actions): %s\n", key) |
241 | | - } |
242 | | - if key := strings.TrimSpace(a.config.Hotkeys.ActivateScrollMode); key != "" { |
243 | | - fmt.Printf(" Scroll mode: %s\n", key) |
| 239 | + for k, v := range a.config.Hotkeys.Bindings { |
| 240 | + toShow := v |
| 241 | + if strings.HasPrefix(v, "exec") { |
| 242 | + runes := []rune(v) |
| 243 | + if len(runes) > 30 { |
| 244 | + toShow = string(runes[:30]) + "..." |
| 245 | + } |
| 246 | + } |
| 247 | + fmt.Printf(" %s: %s\n", k, toShow) |
244 | 248 | } |
245 | 249 |
|
246 | 250 | // Wait for interrupt signal with force-quit support |
@@ -282,35 +286,67 @@ func (a *App) Run() error { |
282 | 286 |
|
283 | 287 | // registerHotkeys registers all global hotkeys |
284 | 288 | func (a *App) registerHotkeys() error { |
285 | | - // Hint mode hotkey (direct click) |
286 | | - if key := strings.TrimSpace(a.config.Hotkeys.ActivateHintMode); key != "" { |
287 | | - a.logger.Info("Registering hint mode hotkey", zap.String("key", key)) |
288 | | - if _, err := a.hotkeyManager.Register(key, func() { |
289 | | - a.activateHintMode(false) |
290 | | - }); err != nil { |
291 | | - return fmt.Errorf("failed to register hint mode hotkey: %w", err) |
292 | | - } |
293 | | - } |
| 289 | + // Note: Escape key for exiting modes is hardcoded in handleKeyPress, not registered as global hotkey |
294 | 290 |
|
295 | | - // Hint mode with actions hotkey |
296 | | - if key := strings.TrimSpace(a.config.Hotkeys.ActivateHintModeWithActions); key != "" { |
297 | | - a.logger.Info("Registering hint mode with actions hotkey", zap.String("key", key)) |
298 | | - if _, err := a.hotkeyManager.Register(key, func() { |
299 | | - a.activateHintMode(true) |
300 | | - }); err != nil { |
301 | | - return fmt.Errorf("failed to register hint mode with actions hotkey: %w", err) |
| 291 | + // Register arbitrary bindings from config.Hotkeys.Bindings |
| 292 | + // We intentionally don't fail the entire registration process if one binding fails; |
| 293 | + // instead we log the error and continue so the daemon remains running. |
| 294 | + for k, v := range a.config.Hotkeys.Bindings { |
| 295 | + key := strings.TrimSpace(k) |
| 296 | + action := strings.TrimSpace(v) |
| 297 | + if key == "" || action == "" { |
| 298 | + continue |
302 | 299 | } |
303 | | - } |
304 | 300 |
|
305 | | - // Scroll mode hotkey |
306 | | - if key := strings.TrimSpace(a.config.Hotkeys.ActivateScrollMode); key != "" { |
307 | | - a.logger.Info("Registering scroll mode hotkey", zap.String("key", key)) |
308 | | - if _, err := a.hotkeyManager.Register(key, a.activateScrollMode); err != nil { |
309 | | - return fmt.Errorf("failed to register scroll mode hotkey: %w", err) |
| 301 | + a.logger.Info("Registering hotkey binding", zap.String("key", key), zap.String("action", action)) |
| 302 | + |
| 303 | + // Capture values for closure |
| 304 | + bindKey := key |
| 305 | + bindAction := action |
| 306 | + |
| 307 | + if _, err := a.hotkeyManager.Register(bindKey, func() { |
| 308 | + // Run handler in separate goroutine so the hotkey callback returns quickly. |
| 309 | + go func() { |
| 310 | + defer func() { |
| 311 | + if r := recover(); r != nil { |
| 312 | + a.logger.Error("panic in hotkey handler", zap.Any("recover", r), zap.String("key", bindKey)) |
| 313 | + } |
| 314 | + }() |
| 315 | + |
| 316 | + // Exec mode: run arbitrary bash command |
| 317 | + if strings.HasPrefix(bindAction, "exec ") { |
| 318 | + cmdStr := strings.TrimSpace(strings.TrimPrefix(bindAction, "exec")) |
| 319 | + if cmdStr == "" { |
| 320 | + a.logger.Error("hotkey exec has empty command", zap.String("key", bindKey)) |
| 321 | + return |
| 322 | + } |
| 323 | + |
| 324 | + a.logger.Debug("Executing shell command from hotkey", zap.String("key", bindKey), zap.String("cmd", cmdStr)) |
| 325 | + cmd := exec.Command("/bin/bash", "-lc", cmdStr) |
| 326 | + out, err := cmd.CombinedOutput() |
| 327 | + if err != nil { |
| 328 | + a.logger.Error("hotkey exec failed", zap.String("key", bindKey), zap.String("cmd", cmdStr), zap.ByteString("output", out), zap.Error(err)) |
| 329 | + } else { |
| 330 | + a.logger.Info("hotkey exec completed", zap.String("key", bindKey), zap.String("cmd", cmdStr), zap.ByteString("output", out)) |
| 331 | + } |
| 332 | + return |
| 333 | + } |
| 334 | + |
| 335 | + // Otherwise treat the action as an internal neru command and dispatch it |
| 336 | + resp := a.handleIPCCommand(ipc.Command{Action: bindAction}) |
| 337 | + if !resp.Success { |
| 338 | + a.logger.Error("hotkey action failed", zap.String("key", bindKey), zap.String("action", bindAction), zap.String("message", resp.Message)) |
| 339 | + } else { |
| 340 | + a.logger.Info("hotkey action executed", zap.String("key", bindKey), zap.String("action", bindAction)) |
| 341 | + } |
| 342 | + }() |
| 343 | + }); err != nil { |
| 344 | + a.logger.Error("Failed to register hotkey binding", zap.String("key", key), zap.String("action", action), zap.Error(err)) |
| 345 | + // continue registering other bindings |
| 346 | + continue |
310 | 347 | } |
311 | 348 | } |
312 | 349 |
|
313 | | - // Note: Escape key for exiting modes is hardcoded in handleKeyPress, not registered as global hotkey |
314 | 350 | return nil |
315 | 351 | } |
316 | 352 |
|
|
0 commit comments