From fe9d91ddb33509efb722f23590495becb976ab9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 8 Mar 2026 18:59:56 +0000 Subject: [PATCH] fix: system notifications, test buttons, apprise in Docker, and logo icon (#391) --- Dockerfile | 8 +- .../notifications/controller/controller.go | 4 + .../notifications/handlers/handlers.go | 17 +++ .../notifications/providers/assets/icon.png | Bin 0 -> 3894 bytes .../notifications/providers/system_assets.go | 53 +++++++-- .../providers/system_provider.go | 37 ++++++- .../internal/notifications/service/service.go | 17 +++ .../notifications-settings-actions.ts | 10 ++ .../settings/notifications-settings.tsx | 104 ++++++++++++++---- apps/web/lib/api/domains/settings-api.ts | 7 ++ 10 files changed, 219 insertions(+), 38 deletions(-) create mode 100644 apps/backend/internal/notifications/providers/assets/icon.png diff --git a/Dockerfile b/Dockerfile index 2f96199a52..70a9dfe76e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,8 +61,12 @@ RUN apt-get update && \ git \ ca-certificates \ gosu \ - tini && \ - rm -rf /var/lib/apt/lists/* + tini \ + python3 \ + python3-venv \ + pipx && \ + rm -rf /var/lib/apt/lists/* && \ + PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise # Replace the node base image's default user so we own uid 1000 RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -m kandev diff --git a/apps/backend/internal/notifications/controller/controller.go b/apps/backend/internal/notifications/controller/controller.go index 8307338e86..18b4c59956 100644 --- a/apps/backend/internal/notifications/controller/controller.go +++ b/apps/backend/internal/notifications/controller/controller.go @@ -84,6 +84,10 @@ func (c *Controller) DeleteProvider(ctx context.Context, providerID string) erro return c.service.DeleteProvider(ctx, providerID) } +func (c *Controller) TestProvider(ctx context.Context, providerID string) error { + return c.service.TestProvider(ctx, providerID) +} + func defaultNameForType(providerType string) string { switch providerType { case string(models.ProviderTypeApprise): diff --git a/apps/backend/internal/notifications/handlers/handlers.go b/apps/backend/internal/notifications/handlers/handlers.go index 5d4041e9f0..24f73e26db 100644 --- a/apps/backend/internal/notifications/handlers/handlers.go +++ b/apps/backend/internal/notifications/handlers/handlers.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -8,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/notifications/controller" "github.com/kandev/kandev/internal/notifications/dto" + "github.com/kandev/kandev/internal/notifications/service" "go.uber.org/zap" ) @@ -26,6 +28,7 @@ func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, log *logger api.POST("/notification-providers", h.httpCreateProvider) api.PATCH("/notification-providers/:id", h.httpUpdateProvider) api.DELETE("/notification-providers/:id", h.httpDeleteProvider) + api.POST("/notification-providers/:id/test", h.httpTestProvider) } func (h *Handlers) httpListProviders(c *gin.Context) { @@ -69,6 +72,20 @@ func (h *Handlers) httpUpdateProvider(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *Handlers) httpTestProvider(c *gin.Context) { + providerID := c.Param("id") + if err := h.controller.TestProvider(c.Request.Context(), providerID); err != nil { + if errors.Is(err, service.ErrProviderNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + h.logger.Error("test notification failed", zap.String("provider_id", providerID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send test notification"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + func (h *Handlers) httpDeleteProvider(c *gin.Context) { providerID := c.Param("id") if err := h.controller.DeleteProvider(c.Request.Context(), providerID); err != nil { diff --git a/apps/backend/internal/notifications/providers/assets/icon.png b/apps/backend/internal/notifications/providers/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1f6a01077cfe264533c9f9e0eeba52db40446e12 GIT binary patch literal 3894 zcmV-656SR}P){ zOOG8#700WGO_ap35l9vYPy|vWHi%6W?=n`hV8f@d74j`S^DRh}>_K9I#D+y8Pj*J$ zu|YzL1PCDpY-}g^g~W8u?@XPZ>eJQt_M>j!dmXD!*XvZ(ssI1fqx;^uJ_rN;y>spE z?b7n@wY!G{x-Oz}^Zf^hH{N?xFDk$I;`R{Z2N4iLEntp`Wv_Aw=$xI zsyg}jgV(MPsWFg%Ax-V3&J8@V#(9>rZz%X!G=YHXqzd)1$(5_$4liH1)XK9mAJXBH za`U}MM5od1{k{zHD8(sjCf~->QSNL7soE9l~e=DjC2hciuWeUjOME zCs(e1D_s4-cRwS&POQ@xLA4wU_A4q4=*2?s2!1@s`~%22_o4y|Lrnm#rF4t-OGW8m4?V`Zir&JGTV zvrkZma?;c#L;$}JaAIZ`wg&rW7RrThudz1mX;$#5AR?oD2o0uBx|HH}gtD^Eow%%>avh{Nd#a zyIF#a5bIyZq6ORA6tAU}M)T0+_mXV%V64vEdvz-ZQ-p=~VE1 z1z1izox1X=nU=-=2y%M^`6Xo*0c>P8z>-G!w&-IBI2<>vF8W^Ze3q1)SKFRo^>v20 zP2I{$hLZ_UP~?rG4HEAvX_&PI$@F1q!gb)_xqXrY-`lS3$D=C!ZBix^V4&zgLpa8} z0IT2FX(d_DTmueEW1n~{a3pNE{3XGs&&dQ(Ik1+>G;P*Xd!*OG?(L;lU+!kE$SL8O zI0cgH2Jp?1w4Cv*Ay@SZ-lh{E#^N~)!UI*pKH8;yYb)oOeZX2B+Rz!4aSScv6@)3v zcyh~=6>ln>OaQNLnE~oS?h${ku{m&>StgAGJfs6Ix{}qo$XDA0*t9N+O&N(O+W?=g zClkOLt0d4$W{_l0ksLVmW1#C>R@wZU*%4nQr+qbTh+Q67+EHSX3hZM^a?$8y0ybeK z;5>c&tQ(h1-|HX_N+mCHAPAug>z`^=g&PnR~?$I`qd<2ywF;Joz8#YXMH@!q@& zC|?)dCEjk*&luSm0$9^i!xcbxB4KG!9a&mhp(f-edy(8jZ{1klr$heIvJ(U-uxr3U zhouE@nF)mH=MIzu9{KAw-N%oT>q-~Ji1=$(_>ENF| zeiH1sndal=q!qYiL#|~;kjHGH9y<2j9*xV*h?A&Z==&L>1zW!D1Oca?|LxeH=grIe zfNNeoXI?WHafWSQ8&q&C7FM3!p4Px%HpAlv#V|=Gw6GZHHSW zU={eBR{DS=A7BymSc(7=@F9-4itG{SYJ5j;TO%L~e)6%Pf!c=~z#^uX1K-N}+SrBs zEfG+ibe0{!=Ovq<(rl~}A98iTmrDLrBw0IoZH0hO@4VT05VBJX%ZhEt93|JGhE8kQ zkqT)WlHBQQaRRDPb-=e%OC=v|vaA3qd62?Ji--52h)!B&ZKQd-9AA`x@BHxl$DiDJ zv-L-83a&gNfJgM7z+-zy?c4AQiQYZirpaCDC(=zh-TmT*O@>efkYW~=Re64Gh_Tac_ zruuNR1Y|xg!~oWK1JIvVC^OGiowyX)Oz40w4R#2WynVSQ697=nCrknozzi9*)=Q3? zcH*(R)+Xz@%E8%n16b?Idmz D8J?&MwJh0u(3#p5=l8;jI8fe24(92Va*PX;aJO zOB4OS!*L!z4(T7NT0i?GnoLE2>`%58!zPavSyS3iay|`syc%N{Ju}C-nn+S z3%-z)wy}@#=>*7VSG|#x;@%4q(n7#z266JqeOko_H$i_g=e!ogdjii(_U}HK06<_C z@Sp^EEJag5G4O%z^z*-W9?}56mJe?zfA-&};nBbD>5poVei39c0e}q&D#=DU1{C&; zI&P`}>mSlm@M-sd&!3~&k zUBQwQPY!%qse=cnH~)M_@L8E2~4e zrR`hB9)!gS004ks?DLPC{ovC+;$LOZH#ShV4^or>-B`s788{DHgeHIjIHcDdH_^#` zWJ$gkNcMfv54L#ST}&4vfC1ZSMA!D@WTa)0m?6i_5V{{(h86#m1o<#0RWV!Ayj_eJ zBVg63#=wA>A;(Sh#)8F3%GcA)er!Vv>lYz_<%Q!Q002Zf1_L2ihF(4J#Di`sJb}|M zv&-_eZ~1J%(%6JflitC|Yyub{Yix zqNpT9Z_b^JQi76d8a0A6AvYz;%@)aC{Mso3hIlGEx}>Zi$cXT0`g|q9%@$v0)9K;_ zur$c}afsos|Ktq~iFf6owLx3?5Kn~!T~dSuzm!nMOtJRTkbEUC$;|CHN%6*`2PJy| z3jHjZbL`j)nN|b~+lOTa6Qn6WLdAZqQWpl&5B!EE=@l;4q zqJ#unN?3Lf%9J2!EK5+FB~q*xL{U!@1#f#x02mDMR3i#6255k^WeE~IL9~mcvxGEa z^@x{}c?1mjo3K)b=Gsw^26$>il*|O*jwc9`Y_>p<%@VCA^(ohQq1$8vFcPm)J2qgL zwmaaaB*gXH)V-(rG!JB^OV}bv#=^J^Xh_gmf+8a2jGbr+89_k1JB%cU$9(k?3|sIK zp1w^0No}wUvBdyn-EMu-W;&UWHh7Cm2uajM&1MO4gt3<*VKM<2f@k1?A$~)_|8Sn_ zEOdhe>}k3a5&#MNC~JU?3I=XWYG7}3HQ{c}EQ$F_LIH=HP5|$FWRQ58O>qVq?b}MQ z&Qr0^h=3R~Qse1(Lbyel<`WSi&TN-xbFrc4h-?%FL?#o!U~Q_>k2j9IZO9N$g$Qq3 zGad?rjNEvXgf;sO)yK$W0_cQ+;87TlapY}Vu)~8l)KhglAH1f&O+h=kv7n=lXN7SU zu;kcq76D^BTS_1BS|CZswoFQN_5}&K{bowTFC+l~0Z1(pW_l`ZroyaGNzhH%y{Br`j5khr}u)_-4xKP+Rb??dqNS3r=_%i+J)RSZ&iFW0jnC@)iQrF+O(Zx_M5K- z0lF!>cSb`=n4~vizxfIP0RR7JPWs&d000I_L_t&o0Hy2dYZs9ot^fc407*qoM6N<$ Eg0vemBme*a literal 0 HcmV?d00001 diff --git a/apps/backend/internal/notifications/providers/system_assets.go b/apps/backend/internal/notifications/providers/system_assets.go index 0cce8ec10c..e1a0cf0bce 100644 --- a/apps/backend/internal/notifications/providers/system_assets.go +++ b/apps/backend/internal/notifications/providers/system_assets.go @@ -8,38 +8,69 @@ import ( "sync" ) -//go:embed assets/toast-notification.ps1 +//go:embed assets/toast-notification.ps1 assets/icon.png var systemAssetsFS embed.FS +const assetDir = "kandev-notifications" + type systemAssets struct { - once sync.Once + scriptOnce sync.Once scriptPath string - err error + scriptErr error + + iconOnce sync.Once + iconPath string + iconErr error } func (a *systemAssets) ensureScript() (string, error) { - a.once.Do(func() { - dir := filepath.Join(os.TempDir(), "kandev-notifications") + a.scriptOnce.Do(func() { + dir := filepath.Join(os.TempDir(), assetDir) if err := os.MkdirAll(dir, 0o755); err != nil { - a.err = fmt.Errorf("create notifications temp dir: %w", err) + a.scriptErr = fmt.Errorf("create notifications temp dir: %w", err) return } - const scriptName = "toast-notification.ps1" - scriptPath := filepath.Join(dir, scriptName) + scriptPath := filepath.Join(dir, "toast-notification.ps1") if _, err := os.Stat(scriptPath); err == nil { a.scriptPath = scriptPath return } data, err := systemAssetsFS.ReadFile("assets/toast-notification.ps1") if err != nil { - a.err = fmt.Errorf("read toast script: %w", err) + a.scriptErr = fmt.Errorf("read toast script: %w", err) return } if err := os.WriteFile(scriptPath, data, 0o644); err != nil { - a.err = fmt.Errorf("write toast script: %w", err) + a.scriptErr = fmt.Errorf("write toast script: %w", err) return } a.scriptPath = scriptPath }) - return a.scriptPath, a.err + return a.scriptPath, a.scriptErr +} + +func (a *systemAssets) ensureIcon() (string, error) { + a.iconOnce.Do(func() { + dir := filepath.Join(os.TempDir(), assetDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + a.iconErr = fmt.Errorf("create notifications temp dir: %w", err) + return + } + iconPath := filepath.Join(dir, "icon.png") + if _, err := os.Stat(iconPath); err == nil { + a.iconPath = iconPath + return + } + data, err := systemAssetsFS.ReadFile("assets/icon.png") + if err != nil { + a.iconErr = fmt.Errorf("read icon: %w", err) + return + } + if err := os.WriteFile(iconPath, data, 0o644); err != nil { + a.iconErr = fmt.Errorf("write icon: %w", err) + return + } + a.iconPath = iconPath + }) + return a.iconPath, a.iconErr } diff --git a/apps/backend/internal/notifications/providers/system_provider.go b/apps/backend/internal/notifications/providers/system_provider.go index 092194d33e..be5d7bc0c8 100644 --- a/apps/backend/internal/notifications/providers/system_provider.go +++ b/apps/backend/internal/notifications/providers/system_provider.go @@ -194,14 +194,19 @@ func parseTimeoutMS(cfg *systemConfig, raw map[string]interface{}) error { } func (p *SystemProvider) sendNotification(ctx context.Context, cfg systemConfig, title, body string) error { + if cfg.IconPath == "" { + if iconPath, err := p.assets.ensureIcon(); err == nil { + cfg.IconPath = iconPath + } + } switch runtime.GOOS { case osDarwin: - return runCommand(ctx, "osascript", "-e", buildAppleScript(title, body)) + return p.sendDarwinNotification(ctx, cfg, title, body) case osLinux: if isWSL() { return p.sendWindowsNotification(ctx, cfg, title, body) } - return sendLinuxNotification(ctx, cfg, title, body) + return p.sendLinuxNotification(ctx, cfg, title, body) case osWindows: return p.sendWindowsNotification(ctx, cfg, title, body) default: @@ -209,6 +214,17 @@ func (p *SystemProvider) sendNotification(ctx context.Context, cfg systemConfig, } } +func (p *SystemProvider) sendDarwinNotification(ctx context.Context, cfg systemConfig, title, body string) error { + if _, err := exec.LookPath("terminal-notifier"); err == nil { + args := []string{"-title", title, "-message", body, "-group", "kandev"} + if cfg.IconPath != "" { + args = append(args, "-contentImage", cfg.IconPath) + } + return runCommand(ctx, "terminal-notifier", args...) + } + return runCommand(ctx, "osascript", "-e", buildAppleScript(title, body)) +} + func (p *SystemProvider) sendWindowsNotification(ctx context.Context, cfg systemConfig, title, body string) error { scriptPath, err := p.assets.ensureScript() if err != nil { @@ -247,13 +263,22 @@ func (p *SystemProvider) sendWindowsNotification(ctx context.Context, cfg system return runCommand(ctx, "powershell.exe", args...) } -func sendLinuxNotification(ctx context.Context, cfg systemConfig, title, body string) error { +func (p *SystemProvider) sendLinuxNotification(ctx context.Context, cfg systemConfig, title, body string) error { if _, err := exec.LookPath("notify-send"); err == nil { - return runCommand(ctx, "notify-send", "-t", strconv.Itoa(cfg.TimeoutMS), title, body) + args := []string{"-t", strconv.Itoa(cfg.TimeoutMS)} + if cfg.IconPath != "" { + args = append(args, "-i", cfg.IconPath) + } + args = append(args, title, body) + return runCommand(ctx, "notify-send", args...) } if _, err := exec.LookPath("zenity"); err == nil { message := strings.TrimSpace(fmt.Sprintf("%s\n%s", title, body)) - return runCommand(ctx, "zenity", "--notification", "--text", message) + args := []string{"--notification", "--text", message} + if cfg.IconPath != "" { + args = append(args, "--window-icon="+cfg.IconPath) + } + return runCommand(ctx, "zenity", args...) } return fmt.Errorf("notify-send or zenity is required for system notifications") } @@ -321,7 +346,7 @@ func runCommand(ctx context.Context, name string, args ...string) error { defer cancel() } cmd := exec.CommandContext(ctx, name, args...) - return cmd.Start() + return cmd.Run() } func isWSL() bool { diff --git a/apps/backend/internal/notifications/service/service.go b/apps/backend/internal/notifications/service/service.go index 0e4e20c53e..362cbd9638 100644 --- a/apps/backend/internal/notifications/service/service.go +++ b/apps/backend/internal/notifications/service/service.go @@ -301,6 +301,23 @@ func (s *Service) ensureSystemProvider(ctx context.Context, userID string) error return s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{EventTaskSessionWaitingForInput}) } +func (s *Service) TestProvider(ctx context.Context, providerID string) error { + provider, err := s.repo.GetProvider(ctx, providerID) + if err != nil { + return ErrProviderNotFound + } + adapter := s.providers[provider.Type] + if adapter == nil { + return fmt.Errorf("unknown provider type: %s", provider.Type) + } + return adapter.Send(ctx, providers.Message{ + EventType: EventTaskSessionWaitingForInput, + Title: "Test notification", + Body: "If you can read this, notifications are working.", + Config: provider.Config, + }) +} + func (s *Service) validateProvider(providerType models.ProviderType, config map[string]interface{}) error { adapter := s.providers[providerType] if adapter == nil { diff --git a/apps/web/components/settings/notifications-settings-actions.ts b/apps/web/components/settings/notifications-settings-actions.ts index fd6ae10de3..24e6ccc051 100644 --- a/apps/web/components/settings/notifications-settings-actions.ts +++ b/apps/web/components/settings/notifications-settings-actions.ts @@ -4,6 +4,7 @@ import { useState } from "react"; import { createNotificationProvider, deleteNotificationProvider, + testNotificationProvider, updateNotificationProvider, } from "@/lib/api"; import { useRequest } from "@/lib/http/use-request"; @@ -346,11 +347,20 @@ export function useNotificationsActions(state: NotificationsState, bumpPermissio }); }; + const handleTestProvider = async (providerId: string) => { + try { + await testNotificationProvider(providerId); + } catch (error) { + console.error("[NotificationsSettings] Test notification failed", error); + } + }; + return { handleToggleEvent, handleRequestPermission, handleRefreshPermission, handleTestNotification, + handleTestProvider, ...appriseActions, }; } diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index 2daf89f8aa..802707f069 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -110,12 +110,14 @@ type NotificationEventsTableProps = { tableProviders: NotificationProvider[]; tableEvents: string[]; onToggleEvent: (provider: NotificationProvider, eventType: string) => void; + onTestProvider: (providerId: string) => Promise; }; function NotificationEventsTable({ tableProviders, tableEvents, onToggleEvent, + onTestProvider, }: NotificationEventsTableProps) { if (tableProviders.length === 0) { return

No providers configured yet.

; @@ -129,7 +131,27 @@ function NotificationEventsTable({ Notification type {tableProviders.map((provider) => ( - {provider.name} +
+ {provider.name} + {provider.type !== "local" && ( + + + + + + Send test notification + + + )} +
))} @@ -165,6 +187,55 @@ function NotificationEventsTable({ ); } +function AppriseProviderCardActions({ + provider, + onOpenForm, + onDeleteProvider, + onTestProvider, +}: { + provider: NotificationProvider; + onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void; + onDeleteProvider: (providerId: string) => void; + onTestProvider: (providerId: string) => Promise; +}) { + return ( +
+ + + + + + Send test notification + + + + +
+ ); +} + function AppriseProviderList({ providers, appriseFormMode, @@ -178,6 +249,7 @@ function AppriseProviderList({ onOpenForm, onCloseForm, onDeleteProvider, + onTestProvider, onTextareaInput, }: { providers: NotificationProvider[]; @@ -192,6 +264,7 @@ function AppriseProviderList({ onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void; onCloseForm: () => void; onDeleteProvider: (providerId: string) => void; + onTestProvider: (providerId: string) => Promise; onTextareaInput: (event: FormEvent) => void; }) { return ( @@ -223,24 +296,12 @@ function AppriseProviderList({
{provider.name}
Apprise
-
- - -
+ )} @@ -279,6 +340,7 @@ type ExternalProvidersSectionProps = { onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void; onCloseForm: () => void; onDeleteProvider: (id: string) => void; + onTestProvider: (id: string) => Promise; onTextareaInput: (e: FormEvent) => void; onCreateAppriseProvider: () => Promise; }; @@ -298,6 +360,7 @@ function ExternalProvidersSection({ onOpenForm, onCloseForm, onDeleteProvider, + onTestProvider, onTextareaInput, onCreateAppriseProvider, }: ExternalProvidersSectionProps) { @@ -339,6 +402,7 @@ function ExternalProvidersSection({ onOpenForm={onOpenForm} onCloseForm={onCloseForm} onDeleteProvider={onDeleteProvider} + onTestProvider={onTestProvider} onTextareaInput={onTextareaInput} /> {appriseAvailable && ( @@ -448,6 +512,7 @@ export function NotificationsSettings() { onOpenForm={actions.openAppriseForm} onCloseForm={actions.closeAppriseForm} onDeleteProvider={actions.handleDeleteProvider} + onTestProvider={actions.handleTestProvider} onTextareaInput={handleTextareaInput} onCreateAppriseProvider={actions.handleCreateAppriseProvider} /> @@ -464,6 +529,7 @@ export function NotificationsSettings() { tableProviders={tableProviders} tableEvents={tableEvents} onToggleEvent={actions.handleToggleEvent} + onTestProvider={actions.handleTestProvider} /> )} diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 1879249ae5..37637cfd1b 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -331,6 +331,13 @@ export async function updateNotificationProvider( }); } +export async function testNotificationProvider(providerId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/notification-providers/${providerId}/test`, { + ...options, + init: { method: "POST", ...(options?.init ?? {}) }, + }); +} + export async function deleteNotificationProvider(providerId: string, options?: ApiRequestOptions) { return fetchJson(`/api/v1/notification-providers/${providerId}`, { ...options,