feat: add configurable notification messages

Allow users to customize notification messages via config.toml.
Messages fall back to defaults when not configured, preserving
existing behavior. Supports custom text or emoji-only notifications.

Configuration example:
  [notifications.messages.recording_started]
    title = ""
    body = "🎤"
This commit is contained in:
Gero Hillebrandt
2025-12-23 19:40:07 +01:00
parent fcbb73474e
commit 8929edfe59
3 changed files with 250 additions and 11 deletions
+94
View File
@@ -47,6 +47,69 @@ type InjectionConfig struct {
type NotificationsConfig struct { type NotificationsConfig struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
Type string `toml:"type"` // "desktop", "log", "none" Type string `toml:"type"` // "desktop", "log", "none"
Messages MessagesConfig `toml:"messages"`
}
type MessageConfig struct {
Title string `toml:"title"`
Body string `toml:"body"`
}
type MessagesConfig struct {
RecordingStarted MessageConfig `toml:"recording_started"`
Transcribing MessageConfig `toml:"transcribing"`
ConfigReloaded MessageConfig `toml:"config_reloaded"`
OperationCancelled MessageConfig `toml:"operation_cancelled"`
RecordingAborted MessageConfig `toml:"recording_aborted"`
InjectionAborted MessageConfig `toml:"injection_aborted"`
}
func (c *Config) GetRecordingStarted() (title, body string) {
m := c.Notifications.Messages.RecordingStarted
if m.Title == "" && m.Body == "" {
return "Hyprvoice", "Recording Started"
}
return m.Title, m.Body
}
func (c *Config) GetTranscribing() (title, body string) {
m := c.Notifications.Messages.Transcribing
if m.Title == "" && m.Body == "" {
return "Hyprvoice", "Recording Ended... Transcribing"
}
return m.Title, m.Body
}
func (c *Config) GetConfigReloaded() (title, body string) {
m := c.Notifications.Messages.ConfigReloaded
if m.Title == "" && m.Body == "" {
return "Hyprvoice", "Config Reloaded"
}
return m.Title, m.Body
}
func (c *Config) GetOperationCancelled() (title, body string) {
m := c.Notifications.Messages.OperationCancelled
if m.Title == "" && m.Body == "" {
return "Hyprvoice", "Operation Cancelled"
}
return m.Title, m.Body
}
func (c *Config) GetRecordingAborted() string {
m := c.Notifications.Messages.RecordingAborted
if m.Body == "" {
return "Recording Aborted"
}
return m.Body
}
func (c *Config) GetInjectionAborted() string {
m := c.Notifications.Messages.InjectionAborted
if m.Body == "" {
return "Injection Aborted"
}
return m.Body
} }
func (c *Config) ToRecordingConfig() recording.Config { func (c *Config) ToRecordingConfig() recording.Config {
@@ -358,6 +421,37 @@ func SaveDefaultConfig() error {
enabled = true # Enable desktop notifications enabled = true # Enable desktop notifications
type = "desktop" # Notification type ("desktop", "log", "none") type = "desktop" # Notification type ("desktop", "log", "none")
# Custom notification messages (optional - defaults shown below)
# Uncomment and modify to customize notification text
# [notifications.messages]
# [notifications.messages.recording_started]
# title = "Hyprvoice"
# body = "Recording Started"
# [notifications.messages.transcribing]
# title = "Hyprvoice"
# body = "Recording Ended... Transcribing"
# [notifications.messages.config_reloaded]
# title = "Hyprvoice"
# body = "Config Reloaded"
# [notifications.messages.operation_cancelled]
# title = "Hyprvoice"
# body = "Operation Cancelled"
# [notifications.messages.recording_aborted]
# body = "Recording Aborted"
# [notifications.messages.injection_aborted]
# body = "Injection Aborted"
#
# Emoji-only example (for minimal pill-style notifications):
# [notifications.messages.recording_started]
# title = ""
# body = "🎤"
# [notifications.messages.transcribing]
# title = ""
# body = "⏳"
# [notifications.messages.config_reloaded]
# title = ""
# body = "🔧"
# Backend explanations: # Backend explanations:
# - "ydotool": Uses ydotool (requires ydotoold daemon running). Most compatible with Chromium/Electron apps. # - "ydotool": Uses ydotool (requires ydotoold daemon running). Most compatible with Chromium/Electron apps.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps. # - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps.
+140
View File
@@ -1228,3 +1228,143 @@ func TestConfig_Validate_GroqTranslation_RejectsTurbo(t *testing.T) {
t.Errorf("Unexpected error message: %v", err) t.Errorf("Unexpected error message: %v", err)
} }
} }
func TestConfig_MessageGetters_Defaults(t *testing.T) {
config := createTestConfig()
t.Run("GetRecordingStarted returns defaults", func(t *testing.T) {
title, body := config.GetRecordingStarted()
if title != "Hyprvoice" {
t.Errorf("GetRecordingStarted() title = %q, want %q", title, "Hyprvoice")
}
if body != "Recording Started" {
t.Errorf("GetRecordingStarted() body = %q, want %q", body, "Recording Started")
}
})
t.Run("GetTranscribing returns defaults", func(t *testing.T) {
title, body := config.GetTranscribing()
if title != "Hyprvoice" {
t.Errorf("GetTranscribing() title = %q, want %q", title, "Hyprvoice")
}
if body != "Recording Ended... Transcribing" {
t.Errorf("GetTranscribing() body = %q, want %q", body, "Recording Ended... Transcribing")
}
})
t.Run("GetConfigReloaded returns defaults", func(t *testing.T) {
title, body := config.GetConfigReloaded()
if title != "Hyprvoice" {
t.Errorf("GetConfigReloaded() title = %q, want %q", title, "Hyprvoice")
}
if body != "Config Reloaded" {
t.Errorf("GetConfigReloaded() body = %q, want %q", body, "Config Reloaded")
}
})
t.Run("GetOperationCancelled returns defaults", func(t *testing.T) {
title, body := config.GetOperationCancelled()
if title != "Hyprvoice" {
t.Errorf("GetOperationCancelled() title = %q, want %q", title, "Hyprvoice")
}
if body != "Operation Cancelled" {
t.Errorf("GetOperationCancelled() body = %q, want %q", body, "Operation Cancelled")
}
})
t.Run("GetRecordingAborted returns default", func(t *testing.T) {
body := config.GetRecordingAborted()
if body != "Recording Aborted" {
t.Errorf("GetRecordingAborted() = %q, want %q", body, "Recording Aborted")
}
})
t.Run("GetInjectionAborted returns default", func(t *testing.T) {
body := config.GetInjectionAborted()
if body != "Injection Aborted" {
t.Errorf("GetInjectionAborted() = %q, want %q", body, "Injection Aborted")
}
})
}
func TestConfig_MessageGetters_Custom(t *testing.T) {
config := createTestConfig()
config.Notifications.Messages = MessagesConfig{
RecordingStarted: MessageConfig{
Title: "",
Body: "🎤",
},
Transcribing: MessageConfig{
Title: "",
Body: "⏳",
},
ConfigReloaded: MessageConfig{
Title: "",
Body: "🔧",
},
OperationCancelled: MessageConfig{
Title: "Custom",
Body: "Cancelled!",
},
RecordingAborted: MessageConfig{
Body: "Recording stopped",
},
InjectionAborted: MessageConfig{
Body: "Inject failed",
},
}
t.Run("GetRecordingStarted returns custom emoji", func(t *testing.T) {
title, body := config.GetRecordingStarted()
if title != "" {
t.Errorf("GetRecordingStarted() title = %q, want %q", title, "")
}
if body != "🎤" {
t.Errorf("GetRecordingStarted() body = %q, want %q", body, "🎤")
}
})
t.Run("GetTranscribing returns custom emoji", func(t *testing.T) {
title, body := config.GetTranscribing()
if title != "" {
t.Errorf("GetTranscribing() title = %q, want %q", title, "")
}
if body != "⏳" {
t.Errorf("GetTranscribing() body = %q, want %q", body, "⏳")
}
})
t.Run("GetConfigReloaded returns custom emoji", func(t *testing.T) {
title, body := config.GetConfigReloaded()
if title != "" {
t.Errorf("GetConfigReloaded() title = %q, want %q", title, "")
}
if body != "🔧" {
t.Errorf("GetConfigReloaded() body = %q, want %q", body, "🔧")
}
})
t.Run("GetOperationCancelled returns custom values", func(t *testing.T) {
title, body := config.GetOperationCancelled()
if title != "Custom" {
t.Errorf("GetOperationCancelled() title = %q, want %q", title, "Custom")
}
if body != "Cancelled!" {
t.Errorf("GetOperationCancelled() body = %q, want %q", body, "Cancelled!")
}
})
t.Run("GetRecordingAborted returns custom value", func(t *testing.T) {
body := config.GetRecordingAborted()
if body != "Recording stopped" {
t.Errorf("GetRecordingAborted() = %q, want %q", body, "Recording stopped")
}
})
t.Run("GetInjectionAborted returns custom value", func(t *testing.T) {
body := config.GetInjectionAborted()
if body != "Inject failed" {
t.Errorf("GetInjectionAborted() = %q, want %q", body, "Inject failed")
}
})
}
+14 -9
View File
@@ -57,10 +57,12 @@ func (d *Daemon) onConfigReload() {
log.Printf("Config reloaded, restarting pipeline") log.Printf("Config reloaded, restarting pipeline")
d.stopPipeline() d.stopPipeline()
d.notifier.Notify("Hyprvoice", "Config Reloaded") conf := d.configMgr.GetConfig()
title, body := conf.GetConfigReloaded()
d.notifier.Notify(title, body)
d.mu.Lock() d.mu.Lock()
d.notifier = notify.GetNotifierBasedOnConfig(d.configMgr.GetConfig()) d.notifier = notify.GetNotifierBasedOnConfig(conf)
d.mu.Unlock() d.mu.Unlock()
} }
@@ -180,22 +182,23 @@ func (d *Daemon) handle(c net.Conn) {
} }
func (d *Daemon) toggle() { func (d *Daemon) toggle() {
conf := d.configMgr.GetConfig()
switch d.status() { switch d.status() {
case pipeline.Idle: case pipeline.Idle:
config := d.configMgr.GetConfig() p := pipeline.New(conf)
p := pipeline.New(config)
p.Run(d.ctx) p.Run(d.ctx)
d.mu.Lock() d.mu.Lock()
d.pipeline = p d.pipeline = p
d.mu.Unlock() d.mu.Unlock()
go d.notifier.Notify("Hyprvoice", "Recording Started") title, body := conf.GetRecordingStarted()
go d.notifier.Notify(title, body)
go d.monitorPipelineErrors(p) go d.monitorPipelineErrors(p)
case pipeline.Recording: case pipeline.Recording:
d.stopPipeline() d.stopPipeline()
go d.notifier.Error("Recording Aborted") go d.notifier.Error(conf.GetRecordingAborted())
case pipeline.Transcribing: case pipeline.Transcribing:
d.mu.RLock() d.mu.RLock()
@@ -207,11 +210,12 @@ func (d *Daemon) toggle() {
} else { } else {
d.mu.RUnlock() d.mu.RUnlock()
} }
go d.notifier.Notify("Hyprvoice", "Recording Ended... Transcribing") title, body := conf.GetTranscribing()
go d.notifier.Notify(title, body)
case pipeline.Injecting: case pipeline.Injecting:
d.stopPipeline() d.stopPipeline()
go d.notifier.Error("Injection Aborted") go d.notifier.Error(conf.GetInjectionAborted())
} }
} }
@@ -221,7 +225,8 @@ func (d *Daemon) cancelPipeline() {
log.Printf("Daemon: Cancel requested but pipeline is idle, ignoring") log.Printf("Daemon: Cancel requested but pipeline is idle, ignoring")
default: default:
d.stopPipeline() d.stopPipeline()
go d.notifier.Notify("Hyprvoice", "Operation Cancelled") title, body := d.configMgr.GetConfig().GetOperationCancelled()
go d.notifier.Notify(title, body)
} }
} }