diff --git a/cmd/launch.go b/cmd/launch.go index 2c29e2f..9095216 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -1,11 +1,15 @@ package cmd -import "os/exec" +import ( + "os/exec" + "syscall" +) // launchSubprocess starts a detached child process with the given binary -// and arguments. The process inherits the current user's session (because -// the service daemon already runs per-user). +// and arguments. Setsid puts the child in its own session so parent signals +// (e.g. SIGINT on daemon stop) don't kill in-flight UI subprocesses. func launchSubprocess(binary string, args []string) error { cmd := exec.Command(binary, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} return cmd.Start() } diff --git a/cmd/motd.go b/cmd/motd.go index 72535a1..92aeaec 100644 --- a/cmd/motd.go +++ b/cmd/motd.go @@ -5,7 +5,6 @@ import ( "os" "strings" - "github.com/TsekNet/hermes/internal/app" "github.com/spf13/cobra" ) @@ -75,10 +74,3 @@ func sanitize(s string) string { } return b.String() } - -// inboxEntryHeading extracts the heading from an InboxEntry. -// This avoids importing the full app package just for the field name. -func init() { - // Verify at compile time that app.InboxEntry has a Heading field. - var _ = app.InboxEntry{}.Heading -} diff --git a/cmd/notify.go b/cmd/notify.go index e3141ef..5b821e6 100644 --- a/cmd/notify.go +++ b/cmd/notify.go @@ -128,5 +128,8 @@ func writeTempConfig(cfg *config.NotificationConfig) (string, error) { return "", fmt.Errorf("write temp config: %w", err) } f.Close() + // Make readable by all users so child processes in other sessions + // (launched via CreateProcessAsUser / setuid) can read the config. + os.Chmod(f.Name(), 0644) return f.Name(), nil } diff --git a/cmd/root.go b/cmd/root.go index 49792f5..e6af70f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -300,7 +300,7 @@ func resolveConfig(configFlag string, args []string) (*config.NotificationConfig if trimmed == "" { return nil, nil } - return config.LoadJSON([]byte(trimmed)) + return config.Load([]byte(trimmed)) } return nil, nil } @@ -313,12 +313,16 @@ func loadFromArg(arg string) (*config.NotificationConfig, error) { return nil, fmt.Errorf("read %s: %w", arg, err) } deck.Infof("loaded config from file: %s", arg) - return config.LoadJSON(data) + return config.Load(data) } trimmed := strings.TrimSpace(arg) - if strings.HasPrefix(trimmed, "{") { - return config.LoadJSON([]byte(trimmed)) + if strings.HasPrefix(trimmed, "{") || strings.Contains(trimmed, ":") { + cfg, err := config.Load([]byte(trimmed)) + if err == nil { + return cfg, nil + } + return nil, fmt.Errorf("not a file or valid config: %s", arg) } return nil, fmt.Errorf("not a file or valid config: %s", arg) @@ -339,7 +343,15 @@ func waitForDND(cfg *config.NotificationConfig) error { } return nil default: // "respect" + const maxDNDWait = 24 * time.Hour + deadline := time.Now().Add(maxDNDWait) for dnd.Active() { + if time.Now().After(deadline) { + deck.Warningf("notification: dnd=respect, max wait (%s) exceeded heading=%q", maxDNDWait, cfg.Heading) + fmt.Print("dnd_timeout") + os.Stdout.Sync() + os.Exit(int(exitcodes.Timeout)) + } deck.Infof("notification: dnd=respect, waiting 60s heading=%q", cfg.Heading) time.Sleep(60 * time.Second) } @@ -406,7 +418,7 @@ func webview2DataPath() string { return p } -// prepareConfig applies defaults and locale resolution to a config. +// prepareConfig applies defaults, locale resolution, and HTML escaping to a config. func prepareConfig(cfg *config.NotificationConfig) { cfg.ApplyDefaults() locale := flagLocale @@ -414,6 +426,7 @@ func prepareConfig(cfg *config.NotificationConfig) { locale = config.DetectLocale() } cfg.ApplyLocale(locale) + cfg.SanitizeText() } // respond prints the value to stdout and exits with the appropriate code. diff --git a/cmd/sessionlaunch_windows.go b/cmd/sessionlaunch_windows.go index 8cf6c9a..f6ffa9c 100644 --- a/cmd/sessionlaunch_windows.go +++ b/cmd/sessionlaunch_windows.go @@ -206,21 +206,27 @@ func quoteArg(s string) string { if s == "" || strings.ContainsAny(s, ` "\`) { var b strings.Builder b.WriteByte('"') - nbs := 0 + backslashes := 0 for i := 0; i < len(s); i++ { switch s[i] { case '\\': - nbs++ + backslashes++ case '"': - b.WriteString(strings.Repeat(`\`, nbs+1)) - nbs = 0 + // Double backslashes before a quote, then escape the quote. + b.WriteString(strings.Repeat(`\`, backslashes*2+1)) + backslashes = 0 b.WriteByte('"') default: - nbs = 0 + // Flush accumulated backslashes as-is (not before a quote). + if backslashes > 0 { + b.WriteString(strings.Repeat(`\`, backslashes)) + backslashes = 0 + } b.WriteByte(s[i]) } } - b.WriteString(strings.Repeat(`\`, nbs)) + // Double trailing backslashes (they precede the closing quote). + b.WriteString(strings.Repeat(`\`, backslashes*2)) b.WriteByte('"') return b.String() } diff --git a/docs/architecture.md b/docs/architecture.md index f1ba1d7..2ce8e52 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ The notification UI is a web page, not a native dialog. HTML, CSS, and JavaScrip |---------|------| | [Wails v2](https://wails.io) | Frameless webview with Go<->JS bindings | | [Cobra](https://github.com/spf13/cobra) | CLI framework (flags, subcommands, help) | -| [google/deck](https://github.com/google/deck) | Structured logging (stderr, Windows Event Log, syslog) | +| [google/deck](https://github.com/google/deck) | Leveled logging (stderr, Windows Event Log, syslog) | | [gRPC](https://grpc.io) | Service<->CLI and service<->UI communication | | [fsnotify](https://github.com/fsnotify/fsnotify) | Cross-platform filesystem event monitoring | @@ -355,16 +355,15 @@ Positioning is handled entirely from Go using Wails runtime APIs. This avoids DP The algorithm uses `WindowCenter()` as a reference point, then derives the notification corner: 1. `WindowCenter()` — Wails handles DPI scaling, work area, and multi-monitor -2. `WindowGetPosition()` → centered position `(cx, cy)` -3. `WindowSetPosition(0, 0)` → probe the coordinate origin `(ox, oy)` -4. Right-aligned: `x = 2*(cx-ox) - margin` -5. Bottom-aligned (Windows) or top-aligned (macOS/Linux): `y = 2*(cy-oy) - margin` +2. `WindowGetPosition()` → centered position `(cx, cy)`, `WindowGetSize()` → `(w, h)` +3. Derive work-area dimensions: `waW = 2*cx + w`, `waH = 2*cy + h` +4. Right-aligned: `x = waW - w - margin` +5. Bottom-aligned (Windows): `y = waH - h - margin`, top-aligned (macOS/Linux): `y = margin` | Platform | Corner | Why | |----------|--------|-----| | Windows | Bottom-right | Matches Action Center / native toasts | -| macOS | Top-right | Cocoa y-axis: origin at bottom-left, `y = oy + margin` places window just below menu bar | -| Linux | Top-right | GTK y-down: `y = oy + margin` from top edge | +| macOS/Linux | Top-right | Wails normalizes native coordinate systems, `y = margin` from top edge | --- diff --git a/docs/usage.md b/docs/usage.md index e81ee6f..4018271 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -66,7 +66,7 @@ hermes accepts a single JSON or YAML config with these fields: | `buttons` | array | no | `[]` | Button definitions (see below) | | `timeout` | int | no | `300` | Seconds until auto-action | | `timeout_value` | string | no | `""` | Value returned on timeout | -| `esc_value` | string | no | `""` | Value returned on ESC (defaults to `timeout_value`) | +| `esc_value` | string | no | `""` | Value returned on ESC (defaults to `timeout_value` when set, otherwise empty) | | `title` | string | no | `IT Department` | Small uppercase label at the top | | `accent_color` | string | no | `#D4A843` | Theme accent color (hex) | | `help_url` | string | no | `""` | "Need help?" link URL | @@ -363,6 +363,7 @@ The second notification is held in `waiting_on_dependency` state until the first | `hermes install` | Configure MOTD hook and launch daemon in active user sessions (when elevated). Called by package postinstall. | | `hermes uninstall` | Remove MOTD hook. Called by package removal scripts. | | `hermes stop` | Graceful daemon shutdown (gRPC then fallback kill) | +| `hermes motd` | Print pending notification summary for SSH login banners (called by profile.d scripts) | | `hermes demo` | Show a demo notification | | `hermes version` | Print version, build date, Go, and OS info | @@ -375,8 +376,8 @@ The second notification is held in `waiting_on_dependency` state until the first | `--config ` | root | config file or inline JSON/YAML — routes to service | | `--local` | root | Render locally in current session (skip service) | | `--locale ` | root | Override locale for localized notifications (e.g. `ja`, `de`) | -| `--port ` | serve, notify, list, cancel | gRPC port (default: 4770) | -| `--db ` | serve, inbox | Bolt database path (default: platform-specific, see [Architecture](architecture.md#persistence)) | +| `--port ` | serve, notify, list, cancel, stop, inbox | gRPC port (default: 4770) | +| `--db ` | serve, inbox, motd | Bolt database path (default: platform-specific, see [Architecture](architecture.md#persistence)) | | `--json` | inbox | Print history as JSON instead of opening the UI | | `--help` | all | Print help | diff --git a/frontend/main.js b/frontend/main.js index 2bf05ec..a80424e 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -354,7 +354,7 @@ if (!document.hasFocus()) return; if (e.keyCode === 27) respond(escValue); if (e.keyCode === 13) { - var primary = document.querySelector(".btn-primary"); + var primary = document.querySelector(".btn-primary[data-value]"); if (primary) respond(primary.getAttribute("data-value")); } if (carouselTotal > 1) { diff --git a/internal/action/action.go b/internal/action/action.go index a807f22..cfe9e5f 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -65,11 +65,6 @@ func AllowedOn(value, goos string) bool { return false } -// Classify returns the Kind of a value on the current OS. -func Classify(value string) Kind { - return ClassifyOn(value, runtime.GOOS) -} - // ClassifyOn returns the Kind of a value on the given OS. func ClassifyOn(value, goos string) Kind { lower := strings.ToLower(value) diff --git a/internal/app/app.go b/internal/app/app.go index bd8ca68..7d9a59e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -39,19 +39,15 @@ type App struct { } // New creates the App with the parsed config (local mode). +// Caller must call cfg.ApplyDefaults() first (sets Platform if empty). func New(cfg *config.NotificationConfig) *App { - if cfg.Platform == "" { - cfg.Platform = goRuntime.GOOS - } return &App{cfg: cfg, deferAllowed: true} } // NewWithGRPC creates the App in service mode. The gRPC client is used to // report the user's choice back to the service daemon. +// Caller must call cfg.ApplyDefaults() first (sets Platform if empty). func NewWithGRPC(cfg *config.NotificationConfig, gc grpcReporter, notifID string, deferAllowed bool) *App { - if cfg.Platform == "" { - cfg.Platform = goRuntime.GOOS - } return &App{ cfg: cfg, grpcClient: gc, diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 74d377a..5cedfa3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -10,6 +10,7 @@ import ( func TestNew(t *testing.T) { t.Parallel() cfg := &config.NotificationConfig{Heading: "Test", Message: "Body"} + cfg.ApplyDefaults() a := New(cfg) if a.cfg != cfg { t.Error("cfg not set") @@ -25,6 +26,7 @@ func TestNew(t *testing.T) { func TestNewWithGRPC(t *testing.T) { t.Parallel() cfg := &config.NotificationConfig{Heading: "Test", Message: "Body"} + cfg.ApplyDefaults() a := NewWithGRPC(cfg, nil, "notif-1", false) if a.notificationID != "notif-1" { diff --git a/internal/client/client.go b/internal/client/client.go index c986218..ac47d9e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -82,7 +82,7 @@ func (c *Client) GetUIConfig(ctx context.Context, notificationID string) (*confi if err != nil { return nil, false, fmt.Errorf("get ui config rpc: %w", err) } - cfg, err := config.LoadJSON(resp.ConfigJson) + cfg, err := config.Load(resp.ConfigJson) if err != nil { return nil, false, fmt.Errorf("parse config from service: %w", err) } @@ -177,14 +177,6 @@ func (c *Client) ListHistory(ctx context.Context) ([]HistoryEntry, error) { return out, nil } -// Ping attempts a quick List RPC to check if the service is running. -func (c *Client) Ping(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - _, err := c.svc.List(ctx, &pb.ListRequest{}) - return err -} - // Shutdown requests a graceful daemon shutdown via gRPC. func (c *Client) Shutdown(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 0814902..f908e7c 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -51,8 +51,8 @@ func TestDialAndPing(t *testing.T) { } defer c.Close() - if err := c.Ping(context.Background()); err != nil { - t.Errorf("Ping: %v", err) + if _, err := c.List(context.Background()); err != nil { + t.Errorf("List: %v", err) } } diff --git a/internal/config/config.go b/internal/config/config.go index 3f1791e..0800a97 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "html" "net/url" "regexp" + "runtime" "strconv" "strings" "time" @@ -211,6 +212,9 @@ type DropdownOption struct { // MaxConfigSize is the maximum allowed config payload (64 KB). const MaxConfigSize = 64 * 1024 +// DefaultPriority is applied when the config omits priority (zero value). +const DefaultPriority = 5 + // Load parses raw JSON or YAML bytes into a NotificationConfig. func Load(data []byte) (*NotificationConfig, error) { data = bytes.TrimSpace(data) @@ -249,7 +253,10 @@ func (c *NotificationConfig) ApplyDefaults() { c.DND = DNDRespect } if c.Priority == 0 { - c.Priority = 5 + c.Priority = DefaultPriority + } + if c.Platform == "" { + c.Platform = runtime.GOOS } for i := range c.Buttons { if c.Buttons[i].Style == "" { @@ -304,8 +311,8 @@ func matchLocale(m map[string]string, locale string) string { } // ApplyEscalation mutates the config based on the current defer count. -// The highest matching threshold wins. This is called by the manager -// before re-showing a deferred notification. +// The highest matching threshold wins regardless of array order. +// Called by the manager before re-showing a deferred notification. func (c *NotificationConfig) ApplyEscalation(deferCount int) { if len(c.Escalation) == 0 || deferCount == 0 { return @@ -313,7 +320,9 @@ func (c *NotificationConfig) ApplyEscalation(deferCount int) { var active *EscalationStep for i := range c.Escalation { if deferCount >= c.Escalation[i].AfterDefers { - active = &c.Escalation[i] + if active == nil || c.Escalation[i].AfterDefers > active.AfterDefers { + active = &c.Escalation[i] + } } } if active == nil { @@ -330,14 +339,33 @@ func (c *NotificationConfig) ApplyEscalation(deferCount int) { } } +// SanitizeText HTML-escapes all user-visible text fields as defense-in-depth. +// The frontend uses textContent, but this guards against regressions. +// Call once before first display. Safe to call multiple times (idempotent). +func (c *NotificationConfig) SanitizeText() { + c.Heading = safeEscape(c.Heading) + c.Message = safeEscape(c.Message) + c.Title = safeEscape(c.Title) + for i := range c.Buttons { + c.Buttons[i].Label = safeEscape(c.Buttons[i].Label) + for j := range c.Buttons[i].Dropdown { + c.Buttons[i].Dropdown[j].Label = safeEscape(c.Buttons[i].Dropdown[j].Label) + } + } +} + +// safeEscape applies html.EscapeString only if the string does not already +// contain HTML entities, preventing double-escaping on repeated calls. +func safeEscape(s string) string { + if strings.Contains(s, "&") || strings.Contains(s, "<") || strings.Contains(s, ">") || strings.Contains(s, "&#") { + return s + } + return html.EscapeString(s) +} + // Validate checks that required fields are present and values are safe. -// All user-visible text fields are HTML-escaped as defense-in-depth -// (the frontend uses textContent, but this guards against regressions). +// Does NOT mutate text fields: call SanitizeText separately before display. func (c *NotificationConfig) Validate() error { - c.Heading = html.EscapeString(c.Heading) - c.Message = html.EscapeString(c.Message) - c.Title = html.EscapeString(c.Title) - var errs []string if strings.TrimSpace(c.Heading) == "" { errs = append(errs, `"heading" is required`) @@ -346,12 +374,10 @@ func (c *NotificationConfig) Validate() error { errs = append(errs, `"message" is required`) } for i := range c.Buttons { - c.Buttons[i].Label = html.EscapeString(c.Buttons[i].Label) if strings.ContainsAny(c.Buttons[i].Value, "\n\r") { errs = append(errs, "button values must not contain newlines") } for j := range c.Buttons[i].Dropdown { - c.Buttons[i].Dropdown[j].Label = html.EscapeString(c.Buttons[i].Dropdown[j].Label) if strings.ContainsAny(c.Buttons[i].Dropdown[j].Value, "\n\r") { errs = append(errs, "dropdown values must not contain newlines") } @@ -431,6 +457,9 @@ func (c *NotificationConfig) Validate() error { // deferRe matches "defer_Xh", "defer_Xd", "defer_Xm", "defer_Xs" where X is an integer. var deferRe = regexp.MustCompile(`^defer_(\d+)([hdms])$`) +// dayRe matches "Nd" shorthand for day durations (used by ParseDeadline). +var dayRe = regexp.MustCompile(`^(\d+)d$`) + // ParseDeferValue extracts the duration from a defer response value like // "defer_4h", "defer_1d", "defer_30m", "defer_30s". Returns 0 if the value is not a // recognized defer pattern (e.g. plain "defer"). @@ -478,7 +507,7 @@ func ParseDeadline(s string) time.Duration { return d } // Handle "Nd" shorthand for days. - m := regexp.MustCompile(`^(\d+)d$`).FindStringSubmatch(s) + m := dayRe.FindStringSubmatch(s) if m != nil { n, _ := strconv.Atoi(m[1]) // Check for overflow diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 022867a..c7952fa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -902,6 +902,7 @@ func TestValidate_HTMLEscaping(t *testing.T) { if err := cfg.Validate(); err != nil { t.Fatalf("unexpected error: %v", err) } + cfg.SanitizeText() if strings.Contains(cfg.Heading, "