diff --git a/.surface b/.surface index 7d78570d..372f0fed 100644 --- a/.surface +++ b/.surface @@ -158,6 +158,8 @@ hey search filters hey seen hey setup hey setup omarchy +hey setup omarchy --no-notify +hey setup omarchy --notify hey setup omarchy --remove hey skill hey skill install diff --git a/README.md b/README.md index da0c1b49..c687e198 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,7 @@ read from `~/.local/state/omarchy/current/theme/`, and restyles live when you ru ```bash yay -S hey-cli # hey-cli is on the AUR hey setup omarchy # install into the desktop +hey setup omarchy --notify # also toast new Imbox mail (--no-notify turns it off) hey setup omarchy --remove # take it all out again ``` @@ -361,7 +362,12 @@ indicator that lights when the Imbox has unread mail (no count, by design), and `hey.toml.tpl` theme template so theme authors can tune the overlay. It prints the `bindings.lua` snippet for a keybinding rather than editing your file. Omarchy's shipped HEY web app, its SUPER+SHIFT+E binding and the mailto handler are left untouched. -See [docs/omarchy.md](docs/omarchy.md) for the details and what is planned next. + +`--notify` turns on new-mail toasts, off by default: the bar indicator's poll also sends +at most one notification per interval — `Sender — Subject` for one new thread, `N new in +Imbox` for more — replacing the previous toast rather than stacking, and clicking it +focuses the TUI. Omarchy's notification silencing (SUPER+CTRL+comma) mutes them like any +other app. See [docs/omarchy.md](docs/omarchy.md) for the details and what is planned next. ## Agent Skill diff --git a/docs/omarchy.md b/docs/omarchy.md index ea43daa5..fd6ad376 100644 --- a/docs/omarchy.md +++ b/docs/omarchy.md @@ -68,7 +68,7 @@ failing step does not stop the others. |---|---|---| | Desktop entry | `~/.local/share/applications/HEY TUI.desktop` | Distinct from Omarchy's shipped `HEY.desktop` web app. Launches under app-id `org.omarchy.hey` | | Menu row | marker block in `~/.config/omarchy/extensions/omarchy-menu.jsonc` | one root `HEY` row that focuses or launches the TUI; its guard is a PATH lookup, never network or `hey` itself. Becomes a submenu once there is more than one thing to open | -| Bar indicator | inline command module `hey-unread` in `~/.config/omarchy/shell.json` | runs `hey omarchy bar-status` every 3 minutes; click focuses or launches the TUI | +| Bar indicator | inline command module `hey-unread` in `~/.config/omarchy/shell.json` | runs `hey omarchy bar-status` every 3 minutes; click focuses or launches the TUI. `--notify` / `--no-notify` toggle new-mail toasts by rewriting the module's exec — enablement lives where it acts, no config key | | Theme template | `~/.config/omarchy/themed/hey.toml.tpl` | renders `hey.toml` into every theme so theme authors can override the overlay; triggers `omarchy-theme-refresh` | | Keybinding | printed, never written | `o.bind("SUPER + SHIFT + ALT + H", "HEY TUI", "omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey tui")`; SUPER+SHIFT+E keeps opening the web app unless you `hl.unbind` it. Spelled out rather than `{ tui = "hey tui" }` because the lua helper quotes that into one word and the app-id derived from it would never match | @@ -93,6 +93,52 @@ is no place for an error message. Credentials come from the keyring or the `credentials.json` fallback exactly as for any other command, so it works from the shell's headless context; token refresh happens in-process. +### New-mail toasts (default off) + +`hey setup omarchy --notify` rewrites the bar module's exec to +`hey omarchy bar-status --notify`: the same 3-minute poll that lights the indicator also +diffs the unseen Imbox postings against a fingerprint file +(`~/.local/state/hey-cli/omarchy-poll.json`) and sends **at most one toast per tick** via +`omarchy-notification-send` — sparse notices, never a per-message firehose. One Imbox +fetch serves both the indicator and the toasts. + +- **What counts as new**: an unseen posting not fingerprinted yet, or one whose + `visible_entry_count` grew (a new reply on a known thread). Fingerprints avoid + `updated_at` (it churns) and `seen` (it flips on read). Muted threads are fingerprinted + but never toast. +- **First run seeds silently.** No state file means write the fingerprints and toast + nothing — never toast the backlog. The fingerprints carry the identity they were taken + for — server, account filter and the signed-in user's id — so after + `hey accounts use`, a base URL change, or signing in as someone else by any route + (login, logout, `HEY_TOKEN`) the next tick reseeds silently instead of toasting the + other identity's backlog. Re-enabling with `--notify` after a `--no-notify` stretch + drops stale fingerprints for the same reason, and `--remove` keeps them while the bar + module could not actually be removed. +- **The whole unseen set is read when seeding.** HEY sorts Imbox postings unseen-first, + so the poll follows pages while they are all-unseen and stops at the first seen + posting. A seed (first run, or a new identity) reads them all, so no pre-existing + thread can later surface as new; a steady-state tick stops at ten pages, because new + mail always lands on page 1 and older threads are already fingerprinted. The + indicator-only path reads one page. Fingerprints prune to the postings still unseen + once the snapshot is complete; a truncated snapshot (cap reached, a page fetch + failed) keeps absent fingerprints instead. +- **One toast, replaced not stacked.** `Sender — Subject` for one new thread, `N new in + Imbox` with the first few senders for more. The daemon's printed id (`-r -p`, the + `omarchy-display-text-size` pattern) is cached so the next tick replaces the on-screen + toast instead of stacking; a stale id after a shell restart just makes a fresh toast. +- **DND is honored.** The toast passes `--app-name HEY` deliberately: omarchy's default + app-name `omarchy-action` bypasses notification silencing, so identifying as HEY is + what makes SUPER+CTRL+comma mute the toasts (into history) like any other app. +- **Clicking focuses the TUI** via the shared `omarchy-launch-or-focus-tui` exec hint, + which the shell runs itself so it survives shell restarts. +- **Same silence discipline as the bar**: any error — auth, network, a failed send — + produces no output beyond the bar JSON and exits 0. A failed fetch leaves the + fingerprints untouched, and a failed send keeps the undelivered postings out of them + so the toast retries on the next tick. + +`hey setup omarchy --no-notify` reverts the exec; a plain re-run leaves it as it is; +`--remove` deletes the state file along with everything else. + ## Decisions - **Indicator, not count.** Pending screener mail is not what people mean by "important", @@ -108,19 +154,20 @@ shell's headless context; token refresh happens in-process. ## Follow-ups, in rough order -1. **New-mail toasts** via `omarchy-notification-send --glyph --exec`, default-off, - sharing one poller with the bar so the Imbox is fetched once per interval. -2. **mailto: handler** that opens a floating compose (`hey compose --mailto`), opt-in +1. **mailto: handler** that opens a floating compose (`hey compose --mailto`), opt-in against the incumbent `omarchy-webapp-handler-hey`. -3. **Agent-triage digests**: `hey --json` feeding a system agent that emits sparse +2. **Agent-triage digests**: `hey --json` feeding a system agent that emits sparse toasts instead of per-message noise. -4. **Upstream contributions**: a `default/themed/hey.toml.tpl` PR alongside +3. **Upstream contributions**: a `default/themed/hey.toml.tpl` PR alongside `claude.json.tpl`; an Install-menu TUI row; possibly branching the mailto handler to the TUI when installed. (An AUR package already ships: `yay -S hey-cli`, published by the release workflow.) -5. **Shell plugin graduation** for the bar widget: `manifest.json`, a settings panel, - IPC refresh when a thread is archived from the TUI instead of waiting for the next - poll. +4. **Shell plugin graduation** for the bar widget: `manifest.json`, a settings panel, + and event-driven freshness — refreshing the indicator the moment a thread is + archived in the TUI. That needs a real widget plugin: inline `command` modules are + interval-only, with no IPC to force a re-run (`Bar.qml` has no `IpcHandler` and + `omarchy bar` has no refresh verb), which is also why the toasts share the interval + poll rather than pushing. ## Anti-features, recorded diff --git a/internal/cmd/omarchy.go b/internal/cmd/omarchy.go index 20a5a6f9..5b5b4ee1 100644 --- a/internal/cmd/omarchy.go +++ b/internal/cmd/omarchy.go @@ -16,6 +16,8 @@ import ( "github.com/spf13/cobra" + "github.com/basecamp/hey-sdk/go/pkg/generated" + "github.com/basecamp/hey-cli/internal/output" ) @@ -61,6 +63,7 @@ type omarchyEnv struct { omarchyPath string iconRoots []string // icon theme roots searched for Omarchy's HEY icon run func(name string, args ...string) error + runOutput func(name string, args ...string) (string, error) } func liveOmarchyEnv() omarchyEnv { @@ -79,6 +82,15 @@ func liveOmarchyEnv() omarchyEnv { cmd.Stdout, cmd.Stderr = io.Discard, io.Discard return cmd.Run() }, + runOutput: func(name string, args ...string) (string, error) { + if _, err := exec.LookPath(name); err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(context.Background(), omarchyCommandTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() //nolint:gosec // G204: fixed omarchy command names + return string(out), err + }, } } @@ -146,7 +158,8 @@ type omarchyStep struct { } type omarchySetup struct { - env omarchyEnv + env omarchyEnv + notify *bool // nil keeps the bar module's current exec as it is } func (s omarchySetup) apply() []omarchyStep { @@ -159,12 +172,20 @@ func (s omarchySetup) apply() []omarchyStep { } func (s omarchySetup) remove() []omarchyStep { - return []omarchyStep{ + steps := []omarchyStep{ s.removeDesktop(), s.removeMenu(), s.removeBar(), s.removeTemplate(), } + // The fingerprints go only once the module that uses them is gone: while a + // failed bar removal leaves the poller scheduled, deleting them would make + // its next tick reseed and swallow the mail that arrived in between. + if steps[2].failure != nil { + return append(steps, omarchyStep{Name: "poll state", Path: omarchyPollStatePath(), Status: "kept", + Detail: "bar module still installed; fingerprints kept for its next tick"}) + } + return append(steps, s.removePollState()) } func stepResult(name, path string, changed bool, err error, installed, unchanged string) omarchyStep { @@ -321,19 +342,35 @@ func stripMenuBlock(content string) string { } // Bar: an inline command module in shell.json's bar layout. The shell hot-reloads -// the file, so the indicator appears as soon as it is written. +// the file, so the indicator appears as soon as it is written. Toast enablement +// lives in the module's exec string — no config key, visible where it acts, +// removed with --remove. + +func omarchyBarExec(notify bool) string { + if notify { + return "hey omarchy bar-status --notify" + } + return "hey omarchy bar-status" +} -func omarchyBarModule() map[string]any { +func omarchyBarModule(notify bool) map[string]any { return map[string]any{ "id": omarchyBarModuleID, "type": "command", - "exec": "hey omarchy bar-status", + "exec": omarchyBarExec(notify), "interval": 180, "tooltip": "HEY", "onClick": omarchyFocusCommand, } } +func notifyDetail(notify bool) string { + if notify { + return "notifications on" + } + return "notifications off" +} + func (s omarchySetup) installBar() omarchyStep { path := s.env.shellPath() shell, err := s.loadShellConfig() @@ -345,19 +382,46 @@ func (s omarchySetup) installBar() omarchyStep { return stepResult("bar indicator", path, false, err, "", "") } module := barLayoutModule(layout, omarchyBarModuleID) + notify := s.notify != nil && *s.notify if module == nil { + if notify { + // Enabling toasts with stale fingerprints around would toast the + // accumulated diff; if they cannot be dropped, do not enable. + if _, err := removeFileIfPresent(omarchyPollStatePath()); err != nil { + return stepResult("bar indicator", path, false, fmt.Errorf("cannot drop stale poll state: %w", err), "", "") + } + } right, ok := layout["right"].([]any) if raw, present := layout["right"]; present && raw != nil && !ok { return stepResult("bar indicator", path, false, fmt.Errorf("shell.json: bar.layout.right is %T, not a list", raw), "", "") } - layout["right"] = append([]any{omarchyBarModule()}, right...) + layout["right"] = append([]any{omarchyBarModule(notify)}, right...) changed, err := writeJSONFile(path, shell) - return stepResult("bar indicator", path, changed, err, "installed", "unchanged") + step := stepResult("bar indicator", path, changed, err, "installed", "unchanged") + if err == nil && s.notify != nil { + step.Detail = notifyDetail(notify) + } + return step } // An existing module is reconciled field by field, keeping its section and // position, so a re-run after an upgrade picks up a changed exec, click - // command or interval instead of reporting a stale module unchanged. - desired := omarchyBarModule() + // command or interval; only the notify choice is preserved when the caller + // did not state one. + exec, _ := module["exec"].(string) + wasNotifying := strings.HasSuffix(exec, " --notify") + if s.notify == nil { + notify = wasNotifying + } + if notify && !wasNotifying { + // Turning toasts (back) on: drop any stale fingerprints so the first + // tick reseeds from the current Imbox instead of toasting whatever + // accumulated while they were off. If they cannot be dropped, fail + // rather than enable a poller that would toast the backlog. + if _, err := removeFileIfPresent(omarchyPollStatePath()); err != nil { + return stepResult("bar indicator", path, false, fmt.Errorf("cannot drop stale poll state: %w", err), "", "") + } + } + desired := omarchyBarModule(notify) changed := !sameJSON(module, desired) if changed { clear(module) @@ -368,21 +432,19 @@ func (s omarchySetup) installBar() omarchyStep { return stepResult("bar indicator", path, false, err, "", "") } } - return stepResult("bar indicator", path, changed, nil, "installed", "unchanged") + step := stepResult("bar indicator", path, changed, nil, "installed", "unchanged") + if s.notify != nil { + step.Detail = notifyDetail(notify) + } + return step } -// barLayoutModule finds our inline module map by id. String-form entries are -// not ours — setup always writes maps — so they are ignored here. -func barLayoutModule(layout map[string]any, id string) map[string]any { - for _, entries := range layout { - list, _ := entries.([]any) - for _, entry := range list { - if module, ok := entry.(map[string]any); ok && barEntryID(module) == id { - return module - } - } - } - return nil +// sameJSON compares two values by their JSON encoding, which is what makes a +// decoded float64(180) and a literal 180 read as equal. +func sameJSON(a, b any) bool { + left, errA := json.Marshal(a) + right, errB := json.Marshal(b) + return errA == nil && errB == nil && bytes.Equal(left, right) } func (s omarchySetup) removeBar() omarchyStep { @@ -467,14 +529,6 @@ func decodeJSONObject(data []byte) (map[string]any, error) { return object, nil } -// sameJSON compares two values by their JSON encoding, which is what makes a -// decoded json.Number("180") and a literal 180 read as equal. -func sameJSON(a, b any) bool { - left, errA := json.Marshal(a) - right, errB := json.Marshal(b) - return errA == nil && errB == nil && bytes.Equal(left, right) -} - // barLayout returns the user's bar layout, seeding it from Omarchy's default layout // when the user has never customized the bar — the shell treats a missing layout // as "use the defaults", so adding one module means spelling the rest out too. @@ -522,6 +576,20 @@ func (s omarchySetup) defaultBarLayout() (map[string]any, error) { return layout, nil } +// barLayoutModule finds our inline module map by id. String-form entries are +// not ours — setup always writes maps — so they are ignored here. +func barLayoutModule(layout map[string]any, id string) map[string]any { + for _, entries := range layout { + list, _ := entries.([]any) + for _, entry := range list { + if module, ok := entry.(map[string]any); ok && barEntryID(module) == id { + return module + } + } + } + return nil +} + func barEntryID(entry any) string { switch v := entry.(type) { case string: @@ -600,6 +668,15 @@ func (s omarchySetup) removeTemplate() omarchyStep { return stepResult("theme template", path, changed, err, "removed", "absent") } +// Poll state: the new-mail fingerprint file bar-status --notify keeps. Setup +// never creates it, but --remove takes it out with everything else. + +func (s omarchySetup) removePollState() omarchyStep { + path := omarchyPollStatePath() + changed, err := removeFileIfPresent(path) + return stepResult("poll state", path, changed, err, "removed", "absent") +} + // --- File helpers --- // writeFileIfChanged writes via a temp file and rename, the way Omarchy's own @@ -664,9 +741,11 @@ func writeJSONFile(path string, value any) (bool, error) { // --- hey setup omarchy --- type setupOmarchyCommand struct { - cmd *cobra.Command - remove bool - env omarchyEnv + cmd *cobra.Command + remove bool + notify bool + noNotify bool + env omarchyEnv } func newSetupOmarchyCommand() *setupOmarchyCommand { @@ -679,8 +758,13 @@ func newSetupOmarchyCommand() *setupOmarchyCommand { menu, an unread indicator on the bar, and a theme template so themes can tune the TUI's accent colors. Every piece is idempotent and --remove takes them all out again. +--notify also toasts new Imbox mail on the indicator's poll — at most one toast per +interval, replaced rather than stacked, silenced by the notification DND toggle. +--no-notify turns the toasts back off; a plain re-run leaves them as they are. + Theming needs none of this: on Omarchy the TUI already follows the active theme.`, Example: ` hey setup omarchy + hey setup omarchy --notify hey setup omarchy --remove`, Annotations: map[string]string{ "agent_notes": "Only meaningful on Omarchy Linux. Writes to ~/.config/omarchy and ~/.local/share/applications; never edits Hyprland keybindings.", @@ -688,6 +772,11 @@ Theming needs none of this: on Omarchy the TUI already follows the active theme. RunE: setupOmarchyCommand.run, } setupOmarchyCommand.cmd.Flags().BoolVar(&setupOmarchyCommand.remove, "remove", false, "Remove everything hey setup omarchy installed") + setupOmarchyCommand.cmd.Flags().BoolVar(&setupOmarchyCommand.notify, "notify", false, "Toast new Imbox mail when the bar indicator polls") + setupOmarchyCommand.cmd.Flags().BoolVar(&setupOmarchyCommand.noNotify, "no-notify", false, "Turn new-mail toasts back off") + setupOmarchyCommand.cmd.MarkFlagsMutuallyExclusive("notify", "no-notify") + setupOmarchyCommand.cmd.MarkFlagsMutuallyExclusive("notify", "remove") + setupOmarchyCommand.cmd.MarkFlagsMutuallyExclusive("no-notify", "remove") return setupOmarchyCommand } @@ -709,6 +798,9 @@ func (c *setupOmarchyCommand) run(cmd *cobra.Command, args []string) error { } setup := omarchySetup{env: c.env} + if c.notify || c.noNotify { + setup.notify = &c.notify + } var steps []omarchyStep if c.remove { steps = setup.remove() @@ -782,20 +874,26 @@ func newOmarchyCommand() *omarchyCommand { } type omarchyBarStatusCommand struct { - cmd *cobra.Command + cmd *cobra.Command + notify bool + env omarchyEnv } func newOmarchyBarStatusCommand() *omarchyBarStatusCommand { - omarchyBarStatusCommand := &omarchyBarStatusCommand{} + omarchyBarStatusCommand := &omarchyBarStatusCommand{env: liveOmarchyEnv()} omarchyBarStatusCommand.cmd = &cobra.Command{ Use: "bar-status", Short: "Print the bar indicator for unread Imbox mail", Long: `Print a Waybar-style JSON module when the Imbox has unread mail and nothing when it does not. Never fails: when hey is logged out or offline the indicator simply -stays dark, because a bar is no place for an error message.`, +stays dark, because a bar is no place for an error message. + +With --notify, also toast newly unseen Imbox mail via omarchy-notification-send — +at most one toast per run, replacing the previous one rather than stacking.`, Args: cobra.NoArgs, RunE: omarchyBarStatusCommand.run, } + omarchyBarStatusCommand.cmd.Flags().BoolVar(&omarchyBarStatusCommand.notify, "notify", false, "Toast new unseen Imbox mail") return omarchyBarStatusCommand } @@ -815,7 +913,29 @@ func (c *omarchyBarStatusCommand) run(cmd *cobra.Command, args []string) error { if err := selectConfiguredAccount(cmd.Context()); err != nil { return nil //nolint:nilerr // a bar is no place for an error message } - if !imboxHasUnread(cmd.Context()) { + // The indicator only needs to know whether anything is unseen, which the + // first page answers. Toasts need the unseen set: capped on a steady-state + // tick (new mail always lands on page 1), exhaustive when seeding — a first + // run or a new identity — so no pre-existing thread can later read as new. + pages, identity, notify := 1, "", c.notify + if notify { + var ok bool + if identity, ok = omarchyPollIdentity(cmd.Context()); !ok { + notify = false + } else if state, existed := loadOmarchyPollState(); !existed || state.Identity != identity { + pages = unseenSeedPageCap + } else { + pages = unseenPageCap + } + } + unseen, complete, ok := unseenImboxPostings(cmd.Context(), pages) + if !ok { + return nil + } + if notify { + notifyNewMail(c.env, identity, unseen, complete) + } + if len(unseen) == 0 { return nil } _, err := fmt.Fprintln(cmd.OutOrStdout(), omarchyBarModuleJSON()) @@ -833,17 +953,50 @@ func omarchyBarModuleJSON() string { return string(module) } -// imboxHasUnread reports unread Imbox mail. Unknown (offline, server error) -// counts as clear: the indicator stays dark rather than lying either way loudly. -func imboxHasUnread(ctx context.Context) bool { - resp, err := sdk.Boxes().GetImbox(ctx, nil) - if err != nil || resp == nil { - return false - } - for _, posting := range resp.Postings { - if !posting.Seen { - return true +// Page limits for following an all-unseen Imbox. A steady-state tick stops at +// ten pages — three hundred unseen threads — because new mail always lands on +// page 1 and older threads are already fingerprinted. Seeding reads the whole +// unseen set (bounded only as the box command is) so that no pre-existing +// thread can later surface as new; it happens once per identity. +var ( + unseenPageCap = 10 + // The +1 is the initial page: maxAdditionalPages caps pages fetched after + // it (as in paginateBoxPostings), so a box whose unseen set spans exactly + // the cap still finds its closing seen page and seeds completely. + unseenSeedPageCap = maxAdditionalPages + 1 +) + +// unseenImboxPostings returns the unseen Imbox postings, whether they are the +// complete unseen set, and whether the fetch succeeded. HEY orders Imbox +// postings unseen-first, so a page holding any seen posting (or nothing at +// all) closes the unseen set; while a page is all unseen the next one is +// fetched, up to maxPages. Unknown (offline, server error) counts as clear: +// the indicator stays dark rather than lying either way loudly, and the notify +// fingerprints stay untouched. +func unseenImboxPostings(ctx context.Context, maxPages int) (unseen []generated.Posting, complete, ok bool) { + page, err := sdk.Boxes().GetImbox(ctx, nil) + if err != nil || page == nil { + return nil, false, false + } + for pages := 1; ; pages++ { + seenOnPage := false + for _, posting := range page.Postings { + if posting.Seen { + seenOnPage = true + } else { + unseen = append(unseen, posting) + } + } + if seenOnPage || len(page.Postings) == 0 || page.NextHistoryUrl == "" { + return unseen, true, true + } + if pages >= maxPages { + return unseen, false, true + } + // A page that cannot be fetched leaves what was read as a truncated + // snapshot: still enough to light the bar, not enough to prune by. + if page, err = fetchNextBoxPage(ctx, page.NextHistoryUrl); err != nil || page == nil { + return unseen, false, true } } - return false } diff --git a/internal/cmd/omarchy_notify.go b/internal/cmd/omarchy_notify.go new file mode 100644 index 00000000..41e87abf --- /dev/null +++ b/internal/cmd/omarchy_notify.go @@ -0,0 +1,257 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/config" +) + +// New-mail toasts, driven by the same bar tick as the unread indicator so the +// Imbox is fetched once per interval. `hey omarchy bar-status --notify` diffs +// the unseen postings against a fingerprint file and sends at most one toast +// per tick via omarchy-notification-send — sparse notices, never a firehose. +// +// The app-name matters: omarchy's default `omarchy-action` deliberately pops +// through Do Not Disturb, so the toast identifies as HEY and +// omarchy-toggle-notification-silencing is honored for free. + +const omarchyNotifyAppName = "HEY" + +// omarchyPollState fingerprints the unseen Imbox postings a previous tick saw. +// Seen maps posting id to its visible entry count, so a new reply on a known +// thread (count grew) toasts like a new thread does. ToastID is the daemon's +// id for our last toast; passing it back with -r replaces the on-screen toast +// instead of stacking a new one each tick. Identity records which server and +// account the fingerprints belong to: after `hey accounts use` or a base URL +// change the state reseeds silently instead of toasting the other account's +// backlog. +type omarchyPollState struct { + Identity string `json:"identity,omitempty"` + Seen map[string]int32 `json:"seen"` + ToastID int `json:"toast_id,omitempty"` + ToastAt int64 `json:"toast_at,omitempty"` // unix seconds the toast was sent +} + +// toastReplaceWindow bounds how long a cached toast id is reused. Notification +// ids are daemon-local, not stable identities: after a reboot or a shell +// restart the same number may belong to another application's notification, +// and -r would overwrite that instead of replacing ours. Replacement only ever +// matters for back-to-back ticks, so a short window loses nothing. +const toastReplaceWindow = 10 * time.Minute + +// replaceableToastID returns the cached toast id when it is recent enough to +// trust, and 0 otherwise. +func (s omarchyPollState) replaceableToastID(now time.Time) int { + if s.ToastID <= 0 || now.Sub(time.Unix(s.ToastAt, 0)) > toastReplaceWindow { + return 0 + } + return s.ToastID +} + +// omarchyPollIdentity names who the poll runs as: the server (spelled the way +// auth.Manager keys credentials, without a trailing slash), the account +// filter, and the signed-in user's id from the identity endpoint. Keying on +// the user rather than on credentials is what makes every way of becoming +// someone else — login, logout, HEY_TOKEN set, changed or unset — reseed +// silently on the next tick, while token refreshes and rotations change +// nothing. When the identity cannot be fetched the tick skips notifying and +// leaves the fingerprints untouched, exactly as a failed Imbox fetch does. +func omarchyPollIdentity(ctx context.Context) (string, bool) { + identity, err := rootSDK.Identity().GetIdentity(ctx) + if err != nil || identity == nil || identity.Id == 0 { + return "", false + } + return pollIdentity(cfg.BaseURL, cfg.AccountID, strconv.FormatInt(identity.Id, 10)), true +} + +func pollIdentity(baseURL, account, userID string) string { + identity := strings.TrimRight(baseURL, "/") + " " + account + if userID == "" { + return identity + } + return identity + " user:" + userID +} + +func omarchyPollStatePath() string { + return filepath.Join(config.StateDir(), "omarchy-poll.json") +} + +// loadOmarchyPollState returns the saved state and whether a state file +// existed. No file means first run: seed the fingerprints, toast nothing — +// never toast the backlog. +func loadOmarchyPollState() (omarchyPollState, bool) { + state := omarchyPollState{Seen: map[string]int32{}} + data, err := os.ReadFile(omarchyPollStatePath()) + if err != nil { + return state, false + } + if err := json.Unmarshal(data, &state); err != nil { + return omarchyPollState{Seen: map[string]int32{}}, false + } + if state.Seen == nil { + state.Seen = map[string]int32{} + } + return state, true +} + +func saveOmarchyPollState(state omarchyPollState) error { + _, err := writeJSONFile(omarchyPollStatePath(), state) + return err +} + +// notifyNewMail diffs the unseen postings against the fingerprint file and +// sends at most one toast. Errors are swallowed: the bar tick must never turn +// into an error message, and a failed send retries on the next tick because +// the undelivered postings keep their previous fingerprints. complete reports +// whether unseen is the whole unseen Imbox: HEY sorts unseen postings first, +// so a page with any seen posting (or none at all) proves completeness, while +// an all-unseen page may be truncated and then pruning must wait — a thread +// pushed off the page would otherwise toast again when it comes back. +func notifyNewMail(env omarchyEnv, identity string, unseen []generated.Posting, complete bool) { + previous, existed := loadOmarchyPollState() + if previous.Identity != identity { + // Another server or account's fingerprints: reseed silently. + previous, existed = omarchyPollState{Seen: map[string]int32{}}, false + } + + next := omarchyPollState{Identity: identity, Seen: make(map[string]int32, len(unseen)), ToastID: previous.ToastID, ToastAt: previous.ToastAt} + var fresh []generated.Posting + for _, posting := range unseen { + id := strconv.FormatInt(posting.Id, 10) + next.Seen[id] = posting.VisibleEntryCount + known, seenBefore := previous.Seen[id] + if posting.Muted || (seenBefore && posting.VisibleEntryCount <= known) { + continue + } + fresh = append(fresh, posting) + } + if !complete { + for id, count := range previous.Seen { + if _, onPage := next.Seen[id]; !onPage { + next.Seen[id] = count + } + } + } + + if !existed { + // A seed must be the whole unseen set: persisting a snapshot that lost a + // page would leave every thread beyond it unknown, to be toasted as + // backlog the moment it surfaces. Leave no state and seed next tick. + if complete { + _ = saveOmarchyPollState(next) + } + return + } + if len(fresh) == 0 { + _ = saveOmarchyPollState(next) + return + } + // Persist before delivering: a toast whose fingerprints could not be saved + // would come back every tick, so an unsaveable state means no toast at all. + if err := saveOmarchyPollState(next); err != nil { + return + } + now := time.Now() + if id, err := sendMailToast(env, fresh, previous.replaceableToastID(now)); err == nil { + next.ToastID, next.ToastAt = id, now.Unix() + } else { + // Undelivered: restore the fresh postings' previous fingerprints so + // they still diff as new next tick. + for _, posting := range fresh { + id := strconv.FormatInt(posting.Id, 10) + if known, ok := previous.Seen[id]; ok { + next.Seen[id] = known + } else { + delete(next.Seen, id) + } + } + } + _ = saveOmarchyPollState(next) +} + +func sendMailToast(env omarchyEnv, fresh []generated.Posting, replaceID int) (int, error) { + headline, description := composeMailToast(fresh) + args := []string{ + "--glyph", omarchyBarGlyph, + "--app-name", omarchyNotifyAppName, + "-u", "low", + "--exec", omarchyFocusCommand, + notificationText(headline), + } + if description != "" { + args = append(args, notificationText(description)) + } + if replaceID > 0 { + args = append(args, "-r", strconv.Itoa(replaceID)) + } + args = append(args, "-p") + out, err := env.runOutput("omarchy-notification-send", args...) + if err != nil { + return replaceID, err + } + if id, err := strconv.Atoi(strings.TrimSpace(out)); err == nil && id > 0 { + return id, nil + } + return replaceID, nil +} + +// notificationText keeps mail-derived text from being read as an option: +// omarchy-notification-send and notify-send both parse a leading dash, and a +// subject or summary can start with one. A word joiner is invisible on screen +// but makes the argument a plain positional. +func notificationText(text string) string { + if strings.HasPrefix(text, "-") { + return "\u2060" + text + } + return text +} + +// composeMailToast turns the fresh postings into one headline and description: +// `Sender — Subject` for a single thread, a count with the first few senders +// for more. +func composeMailToast(fresh []generated.Posting) (string, string) { + if len(fresh) == 1 { + posting := fresh[0] + description := posting.Summary + if description == postingSubject(posting) { + description = "" // Summary already stood in for a missing subject + } + return postingSender(posting) + " — " + postingSubject(posting), description + } + senders := make([]string, 0, 3) + for _, posting := range fresh { + if len(senders) == 3 { + senders = append(senders, "…") + break + } + senders = append(senders, postingSender(posting)) + } + return fmt.Sprintf("%d new in Imbox", len(fresh)), strings.Join(senders, ", ") +} + +func postingSender(posting generated.Posting) string { + switch { + case posting.AlternativeSenderName != "": + return posting.AlternativeSenderName + case posting.Creator.Name != "": + return posting.Creator.Name + default: + return posting.Creator.EmailAddress + } +} + +func postingSubject(posting generated.Posting) string { + if posting.Name != "" { + return posting.Name + } + return posting.Summary +} diff --git a/internal/cmd/omarchy_notify_test.go b/internal/cmd/omarchy_notify_test.go new file mode 100644 index 00000000..12a49ee2 --- /dev/null +++ b/internal/cmd/omarchy_notify_test.go @@ -0,0 +1,723 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +// testNotifyEnv records every runOutput invocation and answers with the given +// stdout, the way omarchy-notification-send -p prints the toast id. +func testNotifyEnv(stdout string) (omarchyEnv, *[][]string) { + var calls [][]string + env := omarchyEnv{ + runOutput: func(name string, args ...string) (string, error) { + calls = append(calls, append([]string{name}, args...)) + return stdout, nil + }, + } + return env, &calls +} + +func unseenPosting(id int64, sender, name string, entries int32) generated.Posting { + return generated.Posting{Id: id, Name: name, VisibleEntryCount: entries, + Creator: generated.Contact{Name: sender}} +} + +// notifyAll runs a tick with a fixed identity and a complete unseen snapshot — +// the common case the older tests exercise. +func notifyAll(env omarchyEnv, unseen []generated.Posting) { + notifyNewMail(env, "test", unseen, true) +} + +func TestNotifyNewMailSeedsStateWithoutToasting(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + notifyAll(env, []generated.Posting{ + unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1), + unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 3), + }) + + if len(*calls) != 0 { + t.Errorf("first run must never toast the backlog, ran %v", *calls) + } + state, existed := loadOmarchyPollState() + if !existed || state.Seen["101"] != 1 || state.Seen["102"] != 3 { + t.Errorf("first run should seed the fingerprints, got %+v (existed=%v)", state, existed) + } +} + +func TestNotifyNewMailToastsOneNewThread(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("42\n") + + known := unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1) + notifyAll(env, []generated.Posting{known}) + fresh := unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1) + fresh.Summary = "Your August invoice is attached." + notifyAll(env, []generated.Posting{known, fresh}) + + if len(*calls) != 1 { + t.Fatalf("want exactly one toast, ran %v", *calls) + } + argv := (*calls)[0] + if argv[0] != "omarchy-notification-send" { + t.Errorf("wrong command: %v", argv) + } + for flag, value := range map[string]string{ + "--glyph": omarchyBarGlyph, "--app-name": "HEY", "-u": "low", "--exec": omarchyFocusCommand, + } { + i := slices.Index(argv, flag) + if i < 0 || argv[i+1] != value { + t.Errorf("%s %q missing from %v", flag, value, argv) + } + } + if !slices.Contains(argv, "Northwind Invoicing — Invoice #4021") || + !slices.Contains(argv, "Your August invoice is attached.") { + t.Errorf("headline/description missing from %v", argv) + } + if slices.Contains(argv, "-r") { + t.Errorf("no cached toast id yet, must not pass -r: %v", argv) + } + if argv[len(argv)-1] != "-p" { + t.Errorf("-p must be passed to learn the toast id: %v", argv) + } + if state, _ := loadOmarchyPollState(); state.ToastID != 42 { + t.Errorf("printed toast id should be cached, got %+v", state) + } +} + +func TestNotifyNewMailReplacesThePreviousToast(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("42\n") + + notifyAll(env, nil) + notifyAll(env, []generated.Posting{unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1)}) + notifyAll(env, []generated.Posting{ + unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1), + unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1), + }) + + if len(*calls) != 2 { + t.Fatalf("want two toasts, ran %v", *calls) + } + argv := (*calls)[1] + i := slices.Index(argv, "-r") + if i < 0 || argv[i+1] != "42" { + t.Errorf("second toast should replace the first via -r 42: %v", argv) + } +} + +func TestNotifyNewMailDoesNotReuseAStaleToastID(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + // A toast id from before a reboot may now belong to another application. + if err := saveOmarchyPollState(omarchyPollState{Identity: "test", Seen: map[string]int32{}, + ToastID: 42, ToastAt: time.Now().Add(-time.Hour).Unix()}); err != nil { + t.Fatal(err) + } + notifyAll(env, []generated.Posting{unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1)}) + + if len(*calls) != 1 || slices.Contains((*calls)[0], "-r") { + t.Errorf("a stale toast id must not be passed as -r, ran %v", *calls) + } + if state, _ := loadOmarchyPollState(); state.ToastID != 7 || state.ToastAt == 0 { + t.Errorf("the fresh toast id and time should be cached, got %+v", state) + } +} + +func TestNotifyNewMailKeepsMailTextOutOfOptionParsing(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + notifyAll(env, nil) + fresh := unseenPosting(101, "-r Systems Ltd", "--help with the quarterly numbers", 1) + fresh.Summary = "-p please see attached" + notifyAll(env, []generated.Posting{fresh}) + + if len(*calls) != 1 { + t.Fatalf("want one toast, ran %v", *calls) + } + argv := (*calls)[0] + for _, arg := range argv[1:] { + if strings.HasPrefix(arg, "-") && !slices.Contains([]string{"--glyph", "--app-name", "-u", "--exec", "-r", "-p"}, arg) { + t.Errorf("mail-derived text must never arrive as an option-looking argument: %q in %v", arg, argv) + } + } + if !slices.Contains(argv, "\u2060-r Systems Ltd — --help with the quarterly numbers") { + t.Errorf("the text itself must be preserved behind the word joiner: %v", argv) + } +} + +func TestPollIdentityIsKeyedOnTheUser(t *testing.T) { + alice := pollIdentity("https://app.hey.com/", "all", "1001") + bob := pollIdentity("https://app.hey.com", "all", "1002") + if alice == bob { + t.Error("a different user on the same server and account is a different identity") + } + if alice != pollIdentity("https://app.hey.com", "all", "1001") { + t.Error("the server spelling must be normalized and the identity stable for one user") + } + if pollIdentity("https://app.hey.com", "all", "") != "https://app.hey.com all" { + t.Error("without a user the identity is just server and account") + } +} + +func TestBarStatusNotifySkipsWhenTheIdentityIsUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/imbox.json" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": [{"id": 5, "name": "Invoice #4021", "seen": false}]}`)) + return + } + w.WriteHeader(500) // the identity endpoint is down + })) + defer server.Close() + + out, err := runBarStatus(t, server.URL, true, "--notify") + if err != nil || !strings.Contains(out, "active") { + t.Errorf("the bar must still light, got %q, %v", out, err) + } + if _, existed := loadOmarchyPollState(); existed { + t.Error("without knowing who the poll runs as, the fingerprints must stay untouched") + } +} + +func TestOmarchyRemoveKeepsPollStateWhileBarRemovalFails(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can write anywhere") + } + env, _ := testOmarchyEnv(t) + setup := omarchySetup{env: env} + setup.apply() + if err := saveOmarchyPollState(omarchyPollState{Identity: "test", Seen: map[string]int32{"1": 1}}); err != nil { + t.Fatal(err) + } + if err := os.Chmod(env.configDir(), 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(env.configDir(), 0o700) }) + + steps := statuses(setup.remove()) + if steps["bar indicator"] != "failed" || steps["poll state"] != "kept" { + t.Errorf("while the bar module cannot be removed the fingerprints must stay, got %v", steps) + } + if _, existed := loadOmarchyPollState(); !existed { + t.Error("poll state was deleted although the poller is still scheduled") + } +} + +func TestNotifyNewMailToastsWhenAThreadGrows(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + thread := unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1) + notifyAll(env, []generated.Posting{thread}) + notifyAll(env, []generated.Posting{thread}) + if len(*calls) != 0 { + t.Fatalf("an unchanged thread must not toast, ran %v", *calls) + } + + thread.VisibleEntryCount = 2 + notifyAll(env, []generated.Posting{thread}) + if len(*calls) != 1 || !slices.Contains((*calls)[0], "Maria Delgado — Lunch on Thursday?") { + t.Errorf("a new reply on a known thread should toast, ran %v", *calls) + } +} + +func TestNotifyNewMailSkipsMutedButRemembersThem(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + notifyAll(env, nil) + muted := unseenPosting(103, "Weekend Deals", "48 hours only", 1) + muted.Muted = true + notifyAll(env, []generated.Posting{muted}) + + if len(*calls) != 0 { + t.Errorf("muted threads must never toast, ran %v", *calls) + } + if state, _ := loadOmarchyPollState(); state.Seen["103"] != 1 { + t.Errorf("muted threads should still be fingerprinted, got %+v", state) + } +} + +func TestNotifyNewMailBatchesIntoOneToast(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + notifyAll(env, nil) + batch := []generated.Posting{ + unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1), + unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1), + unseenPosting(103, "Sam Whitfield", "Draft agenda for Monday", 1), + unseenPosting(104, "Priya Raman", "Photos from the offsite", 1), + } + batch[0].AlternativeSenderName = "Maria (personal)" + notifyAll(env, batch) + + if len(*calls) != 1 { + t.Fatalf("a batch must collapse to one toast, ran %v", *calls) + } + argv := (*calls)[0] + if !slices.Contains(argv, "4 new in Imbox") { + t.Errorf("batch headline missing: %v", argv) + } + if !slices.Contains(argv, "Maria (personal), Northwind Invoicing, Sam Whitfield, …") { + t.Errorf("batch description should list the first senders: %v", argv) + } +} + +func TestNotifyNewMailPrunesDepartedThreads(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, _ := testNotifyEnv("7\n") + + notifyAll(env, []generated.Posting{ + unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1), + unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1), + }) + notifyAll(env, []generated.Posting{unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1)}) + + state, _ := loadOmarchyPollState() + if _, kept := state.Seen["101"]; kept || state.Seen["102"] != 1 { + t.Errorf("fingerprints should prune to the postings still unseen, got %+v", state) + } +} + +func TestNotifyNewMailDoesNotPersistATruncatedSeed(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + // The first tick could not read every unseen page: no seed is written. + notifyNewMail(env, "test", []generated.Posting{unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1)}, false) + if _, existed := loadOmarchyPollState(); existed { + t.Fatal("an incomplete seed must not be persisted") + } + + // The next complete tick seeds silently, including the thread the failed + // page had hidden — which therefore never reads as new. + notifyAll(env, []generated.Posting{ + unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1), + unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1), + }) + if len(*calls) != 0 { + t.Errorf("the retried seed must still be silent, ran %v", *calls) + } + if state, existed := loadOmarchyPollState(); !existed || state.Seen["102"] != 1 { + t.Errorf("the retried seed should fingerprint everything, got %+v", state) + } +} + +func TestNotifyNewMailKeepsFingerprintsOffATruncatedPage(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + notifyAll(env, []generated.Posting{ + unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1), + unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1), + }) + + // An all-unseen page may be cut off; 101 falling off it must not be pruned… + notifyNewMail(env, "test", []generated.Posting{unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1)}, false) + state, _ := loadOmarchyPollState() + if state.Seen["101"] != 1 { + t.Errorf("a thread off a truncated page must keep its fingerprint, got %+v", state) + } + + // …so its return to the page is not mistaken for new mail. + notifyAll(env, []generated.Posting{ + unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1), + unseenPosting(102, "Northwind Invoicing", "Invoice #4021", 1), + }) + if len(*calls) != 0 { + t.Errorf("a re-appearing known thread must not toast, ran %v", *calls) + } +} + +func TestNotifyNewMailRetriesAfterAFailedSend(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + var calls [][]string + failing := omarchyEnv{runOutput: func(name string, args ...string) (string, error) { + calls = append(calls, append([]string{name}, args...)) + return "", errors.New("no notification daemon") + }} + + notifyAll(failing, nil) + fresh := unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1) + notifyAll(failing, []generated.Posting{fresh}) + if len(calls) != 1 { + t.Fatalf("the failed send should have been attempted once, ran %v", calls) + } + if state, _ := loadOmarchyPollState(); state.Seen["101"] != 0 { + t.Errorf("an undelivered posting must not be fingerprinted, got %+v", state) + } + + working, sent := testNotifyEnv("42\n") + notifyAll(working, []generated.Posting{fresh}) + if len(*sent) != 1 { + t.Errorf("the toast must retry on the next tick, ran %v", *sent) + } +} + +func TestNotifyNewMailReseedsWhenIdentityChanges(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + env, calls := testNotifyEnv("7\n") + + notifyNewMail(env, "https://app.hey.com all", nil, true) + backlog := unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1) + notifyNewMail(env, "https://app.hey.com 12345", []generated.Posting{backlog}, true) + + if len(*calls) != 0 { + t.Errorf("another account's backlog must reseed silently, ran %v", *calls) + } + state, _ := loadOmarchyPollState() + if state.Identity != "https://app.hey.com 12345" || state.Seen["101"] != 1 { + t.Errorf("state should now fingerprint the new identity, got %+v", state) + } +} + +func TestBarStatusNotifySeedsEveryUnseenPage(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/identity.json": + _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) + case r.URL.Path == "/imbox.json" && r.URL.Query().Get("page") == "": + // An all-unseen first page: the unseen set may continue. + fmt.Fprintf(w, `{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": %q, + "postings": [{"id": 1, "name": "Newest", "seen": false, "visible_entry_count": 1}, + {"id": 2, "name": "Newer", "seen": false, "visible_entry_count": 1}]}`, + server.URL+"/imbox.json?page=2") + case r.URL.Path == "/imbox.json" && r.URL.Query().Get("page") == "2": + // Older unseen threads, then the first seen one closes the set. + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": "", + "postings": [{"id": 3, "name": "Older but unseen", "seen": false, "visible_entry_count": 4}, + {"id": 4, "name": "Already read", "seen": true, "visible_entry_count": 1}]}`)) + default: + w.WriteHeader(404) + } + })) + defer server.Close() + + out, err := runBarStatus(t, server.URL, true, "--notify") + if err != nil || !strings.Contains(out, "active") { + t.Fatalf("bar JSON unchanged by pagination, got %q, %v", out, err) + } + state, existed := loadOmarchyPollState() + if !existed || state.Seen["1"] != 1 || state.Seen["2"] != 1 || state.Seen["3"] != 4 { + t.Errorf("the first seed must fingerprint every unseen thread across pages, got %+v", state) + } + if _, fingerprinted := state.Seen["4"]; fingerprinted { + t.Errorf("seen threads are not fingerprinted: %+v", state) + } +} + +// unseenPagesServer serves `pages` all-unseen Imbox pages (one posting each, +// ids 1..pages) followed by a page whose first posting is seen, and counts +// the page fetches. +func unseenPagesServer(t *testing.T, pages int) (*httptest.Server, *int) { + t.Helper() + fetched := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/identity.json" { + _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) + return + } + if r.URL.Path != "/imbox.json" { + w.WriteHeader(404) + return + } + fetched++ + page := 1 + if p := r.URL.Query().Get("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if page > pages { + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": [{"id": 999, "name": "Already read", "seen": true}]}`)) + return + } + fmt.Fprintf(w, `{"id": 1, "name": "Imbox", "kind": "inbox", "next_history_url": %q, + "postings": [{"id": %d, "name": "Thread %d", "seen": false, "visible_entry_count": 1}]}`, + fmt.Sprintf("%s/imbox.json?page=%d", server.URL, page+1), page, page) + })) + t.Cleanup(server.Close) + return server, &fetched +} + +func TestBarStatusWithoutNotifyReadsOnlyOnePage(t *testing.T) { + server, fetched := unseenPagesServer(t, 3) + + out, err := runBarStatus(t, server.URL, true) + if err != nil || !strings.Contains(out, "active") { + t.Fatalf("bar should light, got %q, %v", out, err) + } + if *fetched != 1 { + t.Errorf("the indicator needs one page, fetched %d", *fetched) + } +} + +func TestBarStatusNotifySeedsExhaustivelyThenCapsSteadyTicks(t *testing.T) { + server, fetched := unseenPagesServer(t, 3) + old := unseenPageCap + unseenPageCap = 2 + t.Cleanup(func() { unseenPageCap = old }) + + // First seed: every unseen page is read, cap or no cap. + stateHome := t.TempDir() + if _, err := runBarStatusWithState(t, stateHome, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + state, _ := loadOmarchyPollState() + if len(state.Seen) != 3 { + t.Errorf("the seed must fingerprint every unseen thread, got %+v", state) + } + if *fetched != 4 { + t.Errorf("the seed should read all three unseen pages and the closing one, fetched %d", *fetched) + } + + // Steady tick: the cap applies and the snapshot is incomplete, so the + // thread beyond the cap keeps its fingerprint rather than being pruned. + *fetched = 0 + if _, err := runBarStatusWithState(t, stateHome, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if *fetched != 2 { + t.Errorf("a steady tick stops at the cap, fetched %d", *fetched) + } + if state, _ = loadOmarchyPollState(); len(state.Seen) != 3 { + t.Errorf("an incomplete snapshot must keep the fingerprints it could not see, got %+v", state) + } +} + +func TestSeedPageCapCountsTheInitialPage(t *testing.T) { + // Two all-unseen pages plus the closing seen page: a seed cap of three + // total pages must reach the closing page and complete. + server, fetched := unseenPagesServer(t, 2) + old := unseenSeedPageCap + unseenSeedPageCap = 3 + t.Cleanup(func() { unseenSeedPageCap = old }) + + if _, err := runBarStatus(t, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if state, existed := loadOmarchyPollState(); !existed || len(state.Seen) != 2 { + t.Errorf("a seed whose unseen set fits the cap must complete, got existed=%v %+v", existed, state) + } + if *fetched != 3 { + t.Errorf("the closing page is within the cap, fetched %d", *fetched) + } + + // One page tighter and the seed is incomplete: nothing may be persisted. + unseenSeedPageCap = 2 + if _, err := runBarStatus(t, server.URL, true, "--notify"); err != nil { + t.Fatal(err) + } + if _, existed := loadOmarchyPollState(); existed { + t.Error("an over-cap seed is incomplete and must not be persisted") + } +} + +func TestOmarchySetupNotifyFailsWhenStaleStateCannotBeDropped(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can remove anything") + } + env, _ := testOmarchyEnv(t) + on := true + omarchySetup{env: env}.apply() + if err := saveOmarchyPollState(omarchyPollState{Identity: "test", Seen: map[string]int32{"1": 1}}); err != nil { + t.Fatal(err) + } + stateDir := filepath.Dir(omarchyPollStatePath()) + if err := os.Chmod(stateDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(stateDir, 0o700) }) + + steps := statuses(omarchySetup{env: env, notify: &on}.apply()) + if steps["bar indicator"] != "failed" { + t.Errorf("enabling toasts over undroppable stale fingerprints must fail, got %q", steps["bar indicator"]) + } + if module := readText(t, env.shellPath()); strings.Contains(module, "--notify") { + t.Error("the module must not be switched to --notify when the reseed could not be prepared") + } +} + +func TestBarStatusNotifySeedsStateAndPrintsBarJSON(t *testing.T) { + server := imboxServer(t, `[{"id": 5, "name": "Invoice #4021", "seen": false, "visible_entry_count": 2}]`) + defer server.Close() + + out, err := runBarStatus(t, server.URL, true, "--notify") + if err != nil || !strings.Contains(out, "active") { + t.Errorf("bar JSON must be unchanged by --notify, got %q, %v", out, err) + } + state, existed := loadOmarchyPollState() + if !existed || state.Seen["5"] != 2 { + t.Errorf("--notify should seed state on first run, got %+v (existed=%v)", state, existed) + } +} + +func TestBarStatusNotifySilentWhenUnauthenticatedOrOffline(t *testing.T) { + server := imboxServer(t, `[]`) + + out, err := runBarStatus(t, server.URL, false, "--notify") + if err != nil || out != "" { + t.Errorf("logged out should stay silent, got %q, %v", out, err) + } + if _, existed := loadOmarchyPollState(); existed { + t.Error("logged out must not touch state") + } + + server.Close() + out, err = runBarStatus(t, server.URL, true, "--notify") + if err != nil || out != "" { + t.Errorf("offline should stay silent, got %q, %v", out, err) + } + if _, existed := loadOmarchyPollState(); existed { + t.Error("offline must not touch state") + } +} + +func TestOmarchySetupNotifyTogglesBarExec(t *testing.T) { + env, _ := testOmarchyEnv(t) + on, off := true, false + + barExec := func() string { + var shell map[string]any + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { + t.Fatal(err) + } + layout := shell["bar"].(map[string]any)["layout"].(map[string]any) + return barLayoutModule(layout, omarchyBarModuleID)["exec"].(string) + } + + omarchySetup{env: env}.apply() + if barExec() != "hey omarchy bar-status" { + t.Fatalf("default install must not notify, exec = %q", barExec()) + } + + steps := statuses(omarchySetup{env: env, notify: &on}.apply()) + if steps["bar indicator"] != "installed" || barExec() != "hey omarchy bar-status --notify" { + t.Errorf("--notify should rewrite the exec, got %q / %q", steps["bar indicator"], barExec()) + } + + steps = statuses(omarchySetup{env: env, notify: &on}.apply()) + if steps["bar indicator"] != "unchanged" { + t.Errorf("--notify twice should be idempotent, got %q", steps["bar indicator"]) + } + + steps = statuses(omarchySetup{env: env}.apply()) + if steps["bar indicator"] != "unchanged" || barExec() != "hey omarchy bar-status --notify" { + t.Errorf("a plain re-run must leave notifications as they are, got %q / %q", steps["bar indicator"], barExec()) + } + + steps = statuses(omarchySetup{env: env, notify: &off}.apply()) + if steps["bar indicator"] != "installed" || barExec() != "hey omarchy bar-status" { + t.Errorf("--no-notify should revert the exec, got %q / %q", steps["bar indicator"], barExec()) + } +} + +func TestNotifyNewMailSkipsTheToastWhenStateCannotBeSaved(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can write anywhere") + } + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + env, calls := testNotifyEnv("7\n") + + notifyAll(env, nil) + stateDir := filepath.Dir(omarchyPollStatePath()) + if err := os.Chmod(stateDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(stateDir, 0o700) }) + + notifyAll(env, []generated.Posting{unseenPosting(101, "Maria Delgado", "Lunch on Thursday?", 1)}) + if len(*calls) != 0 { + t.Errorf("a toast whose fingerprints cannot be saved would repeat every tick; it must be skipped, ran %v", *calls) + } +} + +func TestOmarchySetupNotifyReenableReseedsState(t *testing.T) { + env, _ := testOmarchyEnv(t) + on, off := true, false + + omarchySetup{env: env, notify: &on}.apply() + if err := saveOmarchyPollState(omarchyPollState{Identity: "test", Seen: map[string]int32{"101": 1}}); err != nil { + t.Fatal(err) + } + + omarchySetup{env: env, notify: &off}.apply() + if _, existed := loadOmarchyPollState(); !existed { + t.Fatal("turning toasts off should not touch the fingerprints") + } + + omarchySetup{env: env, notify: &on}.apply() + if _, existed := loadOmarchyPollState(); existed { + t.Error("turning toasts back on must drop stale fingerprints so the first tick reseeds") + } +} + +func TestOmarchySetupNotifyOnFreshInstall(t *testing.T) { + env, _ := testOmarchyEnv(t) + on := true + + omarchySetup{env: env, notify: &on}.apply() + + shell := readText(t, env.shellPath()) + if !strings.Contains(shell, "hey omarchy bar-status --notify") { + t.Errorf("fresh install with --notify should enable toasts:\n%s", shell) + } +} + +func TestOmarchySetupReconcileKeepsNotifyChoice(t *testing.T) { + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(env.configDir(), 0o755); err != nil { + t.Fatal(err) + } + stale := `{"version":1,"bar":{"layout":{"left":[],"center":[],"right":[ + {"id":"hey-unread","type":"command","exec":"hey omarchy bar-status --notify","interval":60, + "tooltip":"HEY","onClick":"omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey"}, + {"id":"omarchy.tray"}]}}}` + if err := os.WriteFile(env.shellPath(), []byte(stale), 0o644); err != nil { + t.Fatal(err) + } + + steps := statuses(omarchySetup{env: env}.apply()) + if steps["bar indicator"] != "installed" { + t.Errorf("a stale module should be rewritten on a plain re-run, got %q", steps["bar indicator"]) + } + var shell map[string]any + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { + t.Fatal(err) + } + layout := shell["bar"].(map[string]any)["layout"].(map[string]any) + module := barLayoutModule(layout, omarchyBarModuleID) + if module["onClick"] != omarchyFocusCommand || module["interval"] != float64(180) { + t.Errorf("click command and interval should be reconciled: %v", module) + } + if module["exec"] != "hey omarchy bar-status --notify" { + t.Errorf("the notify choice must survive a plain re-run: %v", module) + } + if right := layout["right"].([]any); len(right) != 2 || barEntryID(right[1]) != "omarchy.tray" { + t.Errorf("module position and neighbours must be kept: %v", right) + } + + if again := statuses(omarchySetup{env: env}.apply()); again["bar indicator"] != "unchanged" { + t.Errorf("reconciled module must be stable, got %q", again["bar indicator"]) + } +} diff --git a/internal/cmd/omarchy_test.go b/internal/cmd/omarchy_test.go index 8c270949..e6c4a6db 100644 --- a/internal/cmd/omarchy_test.go +++ b/internal/cmd/omarchy_test.go @@ -29,9 +29,11 @@ const defaultShellJSON = `{ ` // testOmarchyEnv fakes an Omarchy install: a home dir, an OMARCHY_PATH with the -// default shell.json, and a recorder for the commands setup would run. +// default shell.json, a sandboxed state dir, and a recorder for the commands +// setup would run. func testOmarchyEnv(t *testing.T) (omarchyEnv, *[]string) { t.Helper() + t.Setenv("XDG_STATE_HOME", t.TempDir()) home := t.TempDir() omarchyPath := t.TempDir() if err := os.MkdirAll(filepath.Join(omarchyPath, "config", "omarchy"), 0o755); err != nil { @@ -135,6 +137,9 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { t.Fatal(err) } setup.apply() + if err := saveOmarchyPollState(omarchyPollState{Seen: map[string]int32{"1": 1}}); err != nil { + t.Fatal(err) + } if menu := readText(t, env.menuPath()); !strings.Contains(menu, `"notes"`) || !strings.Contains(menu, `"hey-tui"`) { t.Errorf("install should keep the user's rows alongside ours:\n%s", menu) @@ -146,6 +151,9 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { t.Errorf("%s: remove = %q, want removed", name, status) } } + if _, err := os.Stat(omarchyPollStatePath()); !os.IsNotExist(err) { + t.Error("poll state still present") + } if _, err := os.Stat(env.desktopPath()); !os.IsNotExist(err) { t.Error("desktop entry still present") } @@ -843,6 +851,9 @@ func imboxServer(t *testing.T, postings string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == "GET" && r.URL.Path == "/identity.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 7, "name": "Maria Delgado"}`)) case r.Method == "GET" && r.URL.Path == "/imbox.json": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": ` + postings + `}`)) @@ -852,7 +863,14 @@ func imboxServer(t *testing.T, postings string) *httptest.Server { })) } -func runBarStatus(t *testing.T, serverURL string, authenticated bool) (string, error) { +func runBarStatus(t *testing.T, serverURL string, authenticated bool, extraArgs ...string) (string, error) { + t.Helper() + return runBarStatusWithState(t, t.TempDir(), serverURL, authenticated, extraArgs...) +} + +// runBarStatusWithState runs bar-status against a given state directory, so a +// test can tick more than once over the same fingerprints. +func runBarStatusWithState(t *testing.T, stateHome, serverURL string, authenticated bool, extraArgs ...string) (string, error) { t.Helper() if authenticated { t.Setenv("HEY_TOKEN", "test-token") @@ -863,14 +881,14 @@ func runBarStatus(t *testing.T, serverURL string, authenticated bool) (string, e t.Setenv("HEY_BASE_URL", "") tmpDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tmpDir) - t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", stateHome) t.Setenv("XDG_CACHE_HOME", tmpDir) root := newRootCmd() var buf bytes.Buffer root.SetOut(&buf) root.SetErr(&buf) - root.SetArgs([]string{"omarchy", "bar-status", "--base-url", serverURL}) + root.SetArgs(append([]string{"omarchy", "bar-status", "--base-url", serverURL}, extraArgs...)) err := root.Execute() return buf.String(), err }