@camstack/addon-notifiers 1.2.19 → 1.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +308 -32
  2. package/dist/addon.mjs +308 -32
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -13111,11 +13111,33 @@ var NotificationFormatSchema = _enum([
13111
13111
  * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
13112
13112
  * renderer's icon set; "acknowledge" survives an adapter that draws it
13113
13113
  * differently.
13114
+ *
13115
+ * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
13116
+ *
13117
+ * These names are for US. **No adapter may forward one verbatim.** Each maps
13118
+ * the whole set onto its own renderer's vocabulary through a
13119
+ * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
13120
+ * fallback, so adding a member here fails every adapter's build until someone
13121
+ * decides its glyph, which is the only place that decision can be made
13122
+ * honestly.
13123
+ *
13124
+ * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
13125
+ * `disarm` straight through; iOS feeds that string to
13126
+ * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
13127
+ * and every snooze and alarm button arrived BLANK. A pass-through is not a
13128
+ * mapping, and "the field is documented" is not "the value renders".
13129
+ *
13130
+ * Adding a member is TRAIN-BOUND. The enum lives in the published
13131
+ * `@camstack/server` closure and the cap seam validates against the HUB's copy,
13132
+ * so an addon that emits a token the running hub does not know does not lose an
13133
+ * icon — its whole `send` fails Zod validation and the notification never
13134
+ * arrives. Never emit a new token from an addon before the train carrying it.
13114
13135
  */
13115
13136
  var NotificationActionIconSchema = _enum([
13116
13137
  "acknowledge",
13117
13138
  "dismiss",
13118
13139
  "silence",
13140
+ "snooze",
13119
13141
  "view",
13120
13142
  "play",
13121
13143
  "open",
@@ -13123,9 +13145,13 @@ var NotificationActionIconSchema = _enum([
13123
13145
  "lock",
13124
13146
  "unlock",
13125
13147
  "arm",
13148
+ "arm-home",
13149
+ "arm-away",
13150
+ "arm-night",
13126
13151
  "disarm",
13127
13152
  "light",
13128
- "alert"
13153
+ "alert",
13154
+ "camera"
13129
13155
  ]);
13130
13156
  /** A single tap-through action button. */
13131
13157
  var NotificationActionSchema = object({
@@ -13141,7 +13167,23 @@ var NotificationActionSchema = object({
13141
13167
  * else — see `notification-center/action-token.ts` for what that does and
13142
13168
  * does not buy.
13143
13169
  */
13144
- destructive: boolean().optional()
13170
+ destructive: boolean().optional(),
13171
+ /**
13172
+ * How the tap should REACH the url.
13173
+ *
13174
+ * `navigate` (absent, and every button authored before this field) opens it:
13175
+ * the phone leaves the notification and shows whatever the callback returns.
13176
+ * That is right for a button whose answer the operator wants to read.
13177
+ *
13178
+ * `background` fires it as a POST and stays put. It exists for the buttons
13179
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
13180
+ * an answer to the notification, and being thrown into a browser tab to
13181
+ * confirm it costs more attention than the notification did. A backend that
13182
+ * cannot do a background call renders it as an ordinary link (the adapters
13183
+ * fall back rather than dropping the button), so this is a preference, never
13184
+ * a requirement.
13185
+ */
13186
+ mode: _enum(["navigate", "background"]).optional()
13145
13187
  });
13146
13188
  /**
13147
13189
  * The canonical notification. `body` is the only hard field (Apprise model).
@@ -13323,6 +13365,24 @@ var notificationOutputCapability = {
13323
13365
  }), _void(), { kind: "mutation" })
13324
13366
  }
13325
13367
  };
13368
+ new Set([
13369
+ {
13370
+ id: "person",
13371
+ name: "Person"
13372
+ },
13373
+ {
13374
+ id: "vehicle",
13375
+ name: "Vehicle"
13376
+ },
13377
+ {
13378
+ id: "animal",
13379
+ name: "Animal"
13380
+ },
13381
+ {
13382
+ id: "package",
13383
+ name: "Package"
13384
+ }
13385
+ ].map((l) => l.id));
13326
13386
  var COCO_TO_MACRO = {
13327
13387
  mapping: {
13328
13388
  person: "person",
@@ -14120,6 +14180,8 @@ var NcSystemEventKindSchema = _enum([
14120
14180
  "alarm-triggered",
14121
14181
  "alarm-armed",
14122
14182
  "alarm-disarmed",
14183
+ "alarm-arming",
14184
+ "alarm-arm-refused",
14123
14185
  "camera-online",
14124
14186
  "camera-offline",
14125
14187
  "camera-disabled",
@@ -14156,6 +14218,9 @@ var NcSystemEventConditionSchema = object({
14156
14218
  nodeIds: array(string().min(1)).min(1).optional(),
14157
14219
  packageNames: array(string().min(1)).min(1).optional()
14158
14220
  });
14221
+ /** Hard ceiling on a window (24h). A snooze that could not expire would be an
14222
+ * outage the operator asked for once and forgot. */
14223
+ var NC_SNOOZE_MAX_MINUTES = 1440;
14159
14224
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
14160
14225
  var NcScheduleSchema = object({
14161
14226
  windows: array(object({
@@ -14532,15 +14597,15 @@ var NcConditionsSchema = object({
14532
14597
  * (an `immediate` rule naming an `audio-*` class, one notification per
14533
14598
  * classified sample) stays exactly as it was for rules that already use it.
14534
14599
  *
14535
- * NOT in {@link NC_CONDITION_CATALOG} yet, and that is the sequencing rule
14536
- * rather than an oversight: the viewer mirrors the descriptor enums BY HAND
14537
- * (`camstack/src/data/notification-center.ts`, guarded by
14538
- * `scripts/check-viewer-condition-mirror.ts`) and its rule editor STRIPS the
14600
+ * In {@link NC_CONDITION_CATALOG} since P2, and the ORDER it got there is the
14601
+ * rule rather than an accident: the viewer mirrors the descriptor enums BY
14602
+ * HAND (`camstack/src/data/notification-center.ts`, guarded by
14603
+ * `scripts/check-viewer-condition-mirror.ts`) and its rule editor strips the
14539
14604
  * condition fields it does not know when a rule is saved from the phone.
14540
14605
  * Publishing an editor for a condition the app cannot round-trip is how an
14541
- * operator loses a rule's conditions by opening it — so the descriptor, the
14542
- * admin widget and the viewer mirror land together (P2 + P3), and only then
14543
- * does an audio rule become authorable.
14606
+ * operator loses a rule's conditions by opening it — so the viewer mirror
14607
+ * (P3, shipped) went FIRST, and the descriptor an editor renders from
14608
+ * follows here.
14544
14609
  */
14545
14610
  audio: NcAudioConditionSchema.optional()
14546
14611
  });
@@ -14776,6 +14841,30 @@ var NcRuleInputSchema = object({
14776
14841
  */
14777
14842
  snoozeAllowGlobal: boolean().optional(),
14778
14843
  /**
14844
+ * The snooze durations THIS rule's notification offers as buttons, in
14845
+ * minutes.
14846
+ *
14847
+ * Three states, and all three are distinct — which is exactly why this is
14848
+ * `.optional()` and never `.default()`. A Zod default does not run on the
14849
+ * addon cap path (three production failures in one day), so a schema default
14850
+ * would collapse the first two:
14851
+ *
14852
+ * | value | meaning |
14853
+ * | --- | --- |
14854
+ * | absent | the operator never said ⇒ {@link NC_DEFAULT_SNOOZE_MINUTES} |
14855
+ * | `[]` | **no snooze buttons on this rule** — the explicit override |
14856
+ * | a list | these choices, de-duplicated and sorted, at most four |
14857
+ *
14858
+ * `.max(4)` because the notifier's own action budget is small (ntfy allows
14859
+ * three buttons in total) and a rule that spent it all on snooze choices
14860
+ * would push its own tap-through actions off the notification.
14861
+ *
14862
+ * An empty list is NOT an alarm exemption: a rule the alarm is about, or
14863
+ * that arms the panel, is exempt automatically and cannot be silenced by a
14864
+ * window from anywhere (D133).
14865
+ */
14866
+ snoozeOptions: array(number().int().min(1).max(NC_SNOOZE_MAX_MINUTES)).max(4).optional(),
14867
+ /**
14779
14868
  * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
14780
14869
  *
14781
14870
  * This is what makes the rule set the alarm's trigger set without the alarm
@@ -14875,6 +14964,7 @@ var NcConditionDescriptorSchema = object({
14875
14964
  "device",
14876
14965
  "package",
14877
14966
  "occupancy",
14967
+ "audio",
14878
14968
  "system"
14879
14969
  ]),
14880
14970
  label: string(),
@@ -14893,6 +14983,7 @@ var NcConditionDescriptorSchema = object({
14893
14983
  "crossingSelect",
14894
14984
  "polygonDraw",
14895
14985
  "occupancy",
14986
+ "audio",
14896
14987
  "deviceState",
14897
14988
  "systemEvent"
14898
14989
  ]),
@@ -15044,7 +15135,20 @@ var NcSnoozeInputSchema = object({
15044
15135
  ruleId: string().optional(),
15045
15136
  /** Required when `scope: 'device'`. */
15046
15137
  deviceId: number().int().optional(),
15047
- durationMinutes: number().int().min(1).max(1440),
15138
+ /**
15139
+ * Narrow the window to these subject classes — "the cat, not the person".
15140
+ *
15141
+ * ORTHOGONAL to `scope`, deliberately, and absent means EVERY class: that is
15142
+ * what every window authored before this field meant, so no persisted row
15143
+ * changes meaning and no client has to learn anything to keep working.
15144
+ *
15145
+ * It is what makes the window's real key `(deviceId, classes[])` and lets it
15146
+ * cross rules (D133): the operator points at a camera and a kind of thing,
15147
+ * not at whichever of their four rules happened to produce the notification
15148
+ * they are dismissing.
15149
+ */
15150
+ classes: array(string().min(1)).min(1).optional(),
15151
+ durationMinutes: number().int().min(1).max(NC_SNOOZE_MAX_MINUTES),
15048
15152
  /**
15049
15153
  * Silence this for EVERY recipient, not just the caller. Permission is
15050
15154
  * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
@@ -15069,6 +15173,10 @@ var NcSnoozeSchema = object({
15069
15173
  scope: NcSnoozeScopeSchema,
15070
15174
  ruleId: string().optional(),
15071
15175
  deviceId: number().int().optional(),
15176
+ /** Subject classes this window covers. ABSENT = every class — see
15177
+ * {@link NcSnoozeInputSchema.shape.classes}. Lives in the JSON blob and has
15178
+ * no SQLite column: nothing queries a window by class. */
15179
+ classes: array(string().min(1)).min(1).optional(),
15072
15180
  startedAt: number(),
15073
15181
  /** Exclusive: at exactly this instant the snooze is over. Expiry is a
15074
15182
  * COMPARISON, not a job — no sweeper can leave the operator silenced. */
@@ -17306,6 +17414,22 @@ var detectionFpsField = {
17306
17414
  default: 10,
17307
17415
  step: 1
17308
17416
  };
17417
+ /**
17418
+ * The occupancy re-check interval. DEFAULT 300 s (2026-08-13 — was 30 s).
17419
+ *
17420
+ * The recheck is now on by default (a parked car is invisible to occupancy
17421
+ * rules until the stationary registry has been rebuilt by motion, which after a
17422
+ * restart may be never on a quiet camera). Each cycle re-subscribes a detection
17423
+ * session — an RTSP re-dial — so the switch is only affordable at a WIDE
17424
+ * interval: 300 s is ~12 re-dials an hour per camera, against 120 at the old
17425
+ * 30 s. A parked car is therefore counted within 5 minutes of a restart.
17426
+ *
17427
+ * Why not wider: `max` is 300 and raising it is TRAIN-BOUND, not addon-bound —
17428
+ * the host validates `attachCamera` against ITS copy of this schema, so a
17429
+ * runner asked for 600 would be rejected by the hub until a `@camstack/server`
17430
+ * carrying the wider bound is installed everywhere. 300 is the widest value
17431
+ * that ships with an addon deploy.
17432
+ */
17309
17433
  var occupancyRecheckSecField = {
17310
17434
  min: 0,
17311
17435
  max: 300,
@@ -17507,15 +17631,21 @@ var RunnerCameraConfigSchema = object({
17507
17631
  */
17508
17632
  onboardMotionDrivesAnalyzer: boolean().default(true),
17509
17633
  /**
17510
- * Master toggle for the occupancy re-check. When `false` (DEFAULT) the runner
17511
- * never arms the periodic recheck timer, regardless of `occupancyRecheckSec`
17512
- * this is off by default because the recheck re-subscribes a detection session
17513
- * every N seconds while `watching`, a major source of pull-decoder re-dial
17514
- * churn (each cycle creates+tears a session → RTSP re-dial → latency). The
17634
+ * Master toggle for the occupancy re-check. When `false` the runner never arms
17635
+ * the periodic recheck timer, regardless of `occupancyRecheckSec`; the
17515
17636
  * `occupancyRecheckSec` / `occupancyRecheckFrames` sliders only take effect
17516
17637
  * (and only render) when this is enabled.
17517
- */
17518
- occupancyRecheckEnabled: boolean().default(false),
17638
+ *
17639
+ * DEFAULT `true` since 2026-08-13 (was `false`). It was off because the
17640
+ * recheck re-subscribes a detection session every N seconds while `watching`
17641
+ * — each cycle creates+tears a session ⇒ an RTSP re-dial ⇒ latency, a major
17642
+ * pull-decoder churn source. What that bought was a blind spot: a STATIONARY
17643
+ * object is counted only while the stationary registry holds it, and the
17644
+ * registry rebuilds from motion, so after a restart a parked car was invisible
17645
+ * to every occupancy rule until something moved in front of it. The churn is
17646
+ * now paid on the interval instead — see `occupancyRecheckSecField`.
17647
+ */
17648
+ occupancyRecheckEnabled: boolean().default(true),
17519
17649
  occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
17520
17650
  occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
17521
17651
  /**
@@ -30641,6 +30771,7 @@ Object.freeze({
30641
30771
  "network-access": "ingress",
30642
30772
  "smtp-provider": "email"
30643
30773
  });
30774
+ new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
30644
30775
  new Set(["devices", "classes"]);
30645
30776
  /** Strip a small, safe subset of Markdown down to plain text. */
30646
30777
  function markdownToText(md) {
@@ -32221,11 +32352,24 @@ function nativePriority$1(prepared) {
32221
32352
  }
32222
32353
  function viewActions(prepared) {
32223
32354
  const out = [];
32224
- for (const action of prepared.actions.slice(0, MAX_ACTIONS$1)) if (action.url !== void 0 && action.url.length > 0) out.push({
32225
- action: "view",
32226
- label: action.label,
32227
- url: action.url
32228
- });
32355
+ for (const action of prepared.actions.slice(0, MAX_ACTIONS$1)) {
32356
+ if (action.url === void 0 || action.url.length === 0) continue;
32357
+ if (action.mode === "background") {
32358
+ out.push({
32359
+ action: "http",
32360
+ label: action.label,
32361
+ url: action.url,
32362
+ method: "POST",
32363
+ clear: true
32364
+ });
32365
+ continue;
32366
+ }
32367
+ out.push({
32368
+ action: "view",
32369
+ label: action.label,
32370
+ url: action.url
32371
+ });
32372
+ }
32229
32373
  return out;
32230
32374
  }
32231
32375
  /** The single attachment (caps.max = 1), if any. */
@@ -32280,7 +32424,7 @@ function buildUploadRequest(config, prepared, attachment) {
32280
32424
  if (tags.length > 0) headers["x-tags"] = tags.join(",");
32281
32425
  if (prepared.clickUrl !== null) headers["x-click"] = prepared.clickUrl;
32282
32426
  if (attachment.name !== void 0) headers["x-filename"] = attachment.name;
32283
- if (actions.length > 0) headers["x-actions"] = actions.map((a) => `view, ${a.label}, ${a.url}`).join("; ");
32427
+ if (actions.length > 0) headers["x-actions"] = actions.map((a) => a.action === "http" ? `http, ${a.label}, ${a.url}, method=POST, clear=true` : `view, ${a.label}, ${a.url}`).join("; ");
32284
32428
  return {
32285
32429
  url: `${serverUrl}/${topic}`,
32286
32430
  method: "PUT",
@@ -32357,6 +32501,21 @@ var ntfyAdapter = {
32357
32501
  max: 1
32358
32502
  },
32359
32503
  actions: MAX_ACTIONS$1,
32504
+ /**
32505
+ * ntfy has NO per-action icon, and this says so out loud.
32506
+ *
32507
+ * Its action object is `{action, label, url, clear, headers, body}` and the
32508
+ * `X-Actions` header form is `action, label, url[, extras]` — there is no
32509
+ * icon field in either, and the `icon` ntfy does have is the NOTIFICATION's
32510
+ * icon (an attachment URL, handled above), not a per-button one.
32511
+ *
32512
+ * Left EXPLICIT rather than absent: absent and `false` behave identically
32513
+ * (the degrade engine reads a missing value as unsupported), but only one
32514
+ * of them proves somebody checked. The degrade engine then strips the icon
32515
+ * centrally and puts `actionIcons` in `renderedAs.dropped`, so the loss is
32516
+ * reported instead of being discovered on a phone.
32517
+ */
32518
+ actionIcons: false,
32360
32519
  levels: [
32361
32520
  {
32362
32521
  id: "min",
@@ -33539,17 +33698,123 @@ function userIdsFrom(config) {
33539
33698
  if (typeof raw === "string") return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
33540
33699
  return [];
33541
33700
  }
33701
+ /**
33702
+ * Canonical icon token → the string Zentik's client actually renders.
33703
+ *
33704
+ * ── EVIDENCE (read from the Zentik sources, not guessed) ────────────
33705
+ * `NotificationActionDto.icon` is `@IsOptional() @IsString()` — the BACKEND
33706
+ * validates nothing, so a wrong value is accepted, stored and delivered. The
33707
+ * rendering happens on the client, and both iOS paths do the same thing
33708
+ * (`ZentikNotificationService/NotificationService.swift`,
33709
+ * `ZentikNotificationContentExtension/NotificationViewController.swift`):
33710
+ *
33711
+ * let actualIconName = iconName.hasPrefix("sfsymbols:")
33712
+ * ? String(iconName.dropFirst("sfsymbols:".count)) : iconName
33713
+ * icon = UNNotificationActionIcon(systemImageName: actualIconName)
33714
+ *
33715
+ * So the value is an **SF Symbol name**, with an optional `sfsymbols:` prefix
33716
+ * that is stripped. Zentik's own UI confirms the convention: its icon field
33717
+ * placeholder is "e.g., house.fill, heart, gear" and its picker writes
33718
+ * `sfsymbols:${iconName}`.
33719
+ *
33720
+ * `UNNotificationActionIcon(systemImageName:)` with a name iOS does not know
33721
+ * renders NOTHING — silently. That is the whole failure: we sent `silence`,
33722
+ * `arm` and `disarm`, none of which are SF Symbols, and every button arrived
33723
+ * blank with nothing anywhere saying why.
33724
+ *
33725
+ * The prefix is emitted explicitly even though bare names work, because the
33726
+ * bare form is indistinguishable from an un-translated intent token in a
33727
+ * captured payload — and telling those two apart is what cost the time.
33728
+ *
33729
+ * A `Record` over the closed enum, NOT a lookup with a fallback: adding a
33730
+ * vocabulary member without deciding its glyph fails the build here.
33731
+ *
33732
+ * Android: Zentik's Android client does not render per-action icons at all
33733
+ * (an Android notification action needs a bundled drawable, not a name), so
33734
+ * this is an iOS/watchOS enrichment and a no-op elsewhere — which is a
33735
+ * cosmetic loss, not a wrong glyph.
33736
+ */
33737
+ var ZENTIK_ACTION_ICONS = {
33738
+ acknowledge: "checkmark.circle",
33739
+ dismiss: "xmark.circle",
33740
+ silence: "bell.slash",
33741
+ snooze: "zzz",
33742
+ view: "eye",
33743
+ play: "play.fill",
33744
+ open: "door.left.hand.open",
33745
+ close: "door.left.hand.closed",
33746
+ lock: "lock.fill",
33747
+ unlock: "lock.open.fill",
33748
+ arm: "shield.fill",
33749
+ "arm-home": "house.fill",
33750
+ "arm-away": "figure.walk",
33751
+ "arm-night": "moon.fill",
33752
+ disarm: "shield.slash.fill",
33753
+ light: "lightbulb.fill",
33754
+ alert: "exclamationmark.triangle.fill",
33755
+ camera: "video.fill"
33756
+ };
33757
+ /** The `sfsymbols:` prefix Zentik's clients strip before resolving the symbol. */
33758
+ var SFSYMBOLS_PREFIX = "sfsymbols:";
33759
+ /**
33760
+ * The Zentik value for one icon token, or `undefined` when there is none.
33761
+ *
33762
+ * Typed on `string`, not on `NotificationActionIcon`, because that is what this
33763
+ * boundary actually receives: an addon deployed against a hub whose vocabulary
33764
+ * moved on hands over a token this build has never heard of. Omitting it is the
33765
+ * honest answer — forwarding it raw would put a string iOS ignores on the wire
33766
+ * and produce exactly the blank button this map exists to fix.
33767
+ *
33768
+ * `Object.hasOwn` and not a truthiness check: an inherited `toString` must not
33769
+ * read as a mapping.
33770
+ */
33771
+ function zentikActionIcon(icon) {
33772
+ const table = ZENTIK_ACTION_ICONS;
33773
+ if (!Object.hasOwn(ZENTIK_ACTION_ICONS, icon)) return void 0;
33774
+ const symbol = table[icon];
33775
+ return symbol === void 0 ? void 0 : `${SFSYMBOLS_PREFIX}${symbol}`;
33776
+ }
33777
+ /**
33778
+ * How a `BACKGROUND_CALL` names its request: `METHOD::url`.
33779
+ *
33780
+ * The whole reason the type exists here: a "silence this for 30 minutes"
33781
+ * button that opens a browser tab has already cost more attention than the
33782
+ * notification it was dismissing. `BACKGROUND_CALL` fires the callback and
33783
+ * leaves the phone where it is.
33784
+ */
33785
+ var BACKGROUND_CALL_PREFIX = "POST::";
33542
33786
  function actionsFor(prepared) {
33543
33787
  const out = [];
33544
- for (const action of prepared.actions.slice(0, MAX_ACTIONS)) if (action.url !== void 0 && action.url.length > 0) out.push({
33545
- type: "NAVIGATE",
33546
- title: action.label,
33547
- value: action.url
33548
- });
33549
- else out.push({
33550
- type: "OPEN_NOTIFICATION",
33551
- title: action.label
33552
- });
33788
+ for (const action of prepared.actions.slice(0, MAX_ACTIONS)) {
33789
+ const symbol = action.icon === void 0 ? void 0 : zentikActionIcon(action.icon);
33790
+ const decoration = {
33791
+ ...action.destructive !== void 0 ? { destructive: action.destructive } : {},
33792
+ ...symbol !== void 0 ? { icon: symbol } : {}
33793
+ };
33794
+ if (action.url === void 0 || action.url.length === 0) {
33795
+ out.push({
33796
+ type: "OPEN_NOTIFICATION",
33797
+ title: action.label,
33798
+ ...decoration
33799
+ });
33800
+ continue;
33801
+ }
33802
+ if (action.mode === "background") {
33803
+ out.push({
33804
+ type: "BACKGROUND_CALL",
33805
+ title: action.label,
33806
+ value: `${BACKGROUND_CALL_PREFIX}${action.url}`,
33807
+ ...decoration
33808
+ });
33809
+ continue;
33810
+ }
33811
+ out.push({
33812
+ type: "NAVIGATE",
33813
+ title: action.label,
33814
+ value: action.url,
33815
+ ...decoration
33816
+ });
33817
+ }
33553
33818
  return out;
33554
33819
  }
33555
33820
  function attachmentsFor(prepared) {
@@ -33715,6 +33980,17 @@ var zentikAdapter = {
33715
33980
  ],
33716
33981
  format: ["text"],
33717
33982
  clickUrl: true,
33983
+ /**
33984
+ * A RENDERING claim, which it was not before.
33985
+ *
33986
+ * This used to say the intent name "travels verbatim … a name the app does
33987
+ * not know renders as no icon rather than as a wrong one" — true, and it
33988
+ * described a feature that never once drew an icon, because NO intent name
33989
+ * is an SF Symbol. The engine handed the icon over and the adapter put it
33990
+ * on the wire untranslated. `ZENTIK_ACTION_ICONS` is what makes this claim
33991
+ * honest; keep it exhaustive or this reverts to a pass-through that lies.
33992
+ */
33993
+ actionIcons: true,
33718
33994
  sound: true,
33719
33995
  ttl: true,
33720
33996
  bodyMaxLen: BODY_MAX_LEN
package/dist/addon.mjs CHANGED
@@ -13084,11 +13084,33 @@ var NotificationFormatSchema = _enum([
13084
13084
  * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
13085
13085
  * renderer's icon set; "acknowledge" survives an adapter that draws it
13086
13086
  * differently.
13087
+ *
13088
+ * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
13089
+ *
13090
+ * These names are for US. **No adapter may forward one verbatim.** Each maps
13091
+ * the whole set onto its own renderer's vocabulary through a
13092
+ * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
13093
+ * fallback, so adding a member here fails every adapter's build until someone
13094
+ * decides its glyph, which is the only place that decision can be made
13095
+ * honestly.
13096
+ *
13097
+ * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
13098
+ * `disarm` straight through; iOS feeds that string to
13099
+ * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
13100
+ * and every snooze and alarm button arrived BLANK. A pass-through is not a
13101
+ * mapping, and "the field is documented" is not "the value renders".
13102
+ *
13103
+ * Adding a member is TRAIN-BOUND. The enum lives in the published
13104
+ * `@camstack/server` closure and the cap seam validates against the HUB's copy,
13105
+ * so an addon that emits a token the running hub does not know does not lose an
13106
+ * icon — its whole `send` fails Zod validation and the notification never
13107
+ * arrives. Never emit a new token from an addon before the train carrying it.
13087
13108
  */
13088
13109
  var NotificationActionIconSchema = _enum([
13089
13110
  "acknowledge",
13090
13111
  "dismiss",
13091
13112
  "silence",
13113
+ "snooze",
13092
13114
  "view",
13093
13115
  "play",
13094
13116
  "open",
@@ -13096,9 +13118,13 @@ var NotificationActionIconSchema = _enum([
13096
13118
  "lock",
13097
13119
  "unlock",
13098
13120
  "arm",
13121
+ "arm-home",
13122
+ "arm-away",
13123
+ "arm-night",
13099
13124
  "disarm",
13100
13125
  "light",
13101
- "alert"
13126
+ "alert",
13127
+ "camera"
13102
13128
  ]);
13103
13129
  /** A single tap-through action button. */
13104
13130
  var NotificationActionSchema = object({
@@ -13114,7 +13140,23 @@ var NotificationActionSchema = object({
13114
13140
  * else — see `notification-center/action-token.ts` for what that does and
13115
13141
  * does not buy.
13116
13142
  */
13117
- destructive: boolean().optional()
13143
+ destructive: boolean().optional(),
13144
+ /**
13145
+ * How the tap should REACH the url.
13146
+ *
13147
+ * `navigate` (absent, and every button authored before this field) opens it:
13148
+ * the phone leaves the notification and shows whatever the callback returns.
13149
+ * That is right for a button whose answer the operator wants to read.
13150
+ *
13151
+ * `background` fires it as a POST and stays put. It exists for the buttons
13152
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
13153
+ * an answer to the notification, and being thrown into a browser tab to
13154
+ * confirm it costs more attention than the notification did. A backend that
13155
+ * cannot do a background call renders it as an ordinary link (the adapters
13156
+ * fall back rather than dropping the button), so this is a preference, never
13157
+ * a requirement.
13158
+ */
13159
+ mode: _enum(["navigate", "background"]).optional()
13118
13160
  });
13119
13161
  /**
13120
13162
  * The canonical notification. `body` is the only hard field (Apprise model).
@@ -13296,6 +13338,24 @@ var notificationOutputCapability = {
13296
13338
  }), _void(), { kind: "mutation" })
13297
13339
  }
13298
13340
  };
13341
+ new Set([
13342
+ {
13343
+ id: "person",
13344
+ name: "Person"
13345
+ },
13346
+ {
13347
+ id: "vehicle",
13348
+ name: "Vehicle"
13349
+ },
13350
+ {
13351
+ id: "animal",
13352
+ name: "Animal"
13353
+ },
13354
+ {
13355
+ id: "package",
13356
+ name: "Package"
13357
+ }
13358
+ ].map((l) => l.id));
13299
13359
  var COCO_TO_MACRO = {
13300
13360
  mapping: {
13301
13361
  person: "person",
@@ -14093,6 +14153,8 @@ var NcSystemEventKindSchema = _enum([
14093
14153
  "alarm-triggered",
14094
14154
  "alarm-armed",
14095
14155
  "alarm-disarmed",
14156
+ "alarm-arming",
14157
+ "alarm-arm-refused",
14096
14158
  "camera-online",
14097
14159
  "camera-offline",
14098
14160
  "camera-disabled",
@@ -14129,6 +14191,9 @@ var NcSystemEventConditionSchema = object({
14129
14191
  nodeIds: array(string().min(1)).min(1).optional(),
14130
14192
  packageNames: array(string().min(1)).min(1).optional()
14131
14193
  });
14194
+ /** Hard ceiling on a window (24h). A snooze that could not expire would be an
14195
+ * outage the operator asked for once and forgot. */
14196
+ var NC_SNOOZE_MAX_MINUTES = 1440;
14132
14197
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
14133
14198
  var NcScheduleSchema = object({
14134
14199
  windows: array(object({
@@ -14505,15 +14570,15 @@ var NcConditionsSchema = object({
14505
14570
  * (an `immediate` rule naming an `audio-*` class, one notification per
14506
14571
  * classified sample) stays exactly as it was for rules that already use it.
14507
14572
  *
14508
- * NOT in {@link NC_CONDITION_CATALOG} yet, and that is the sequencing rule
14509
- * rather than an oversight: the viewer mirrors the descriptor enums BY HAND
14510
- * (`camstack/src/data/notification-center.ts`, guarded by
14511
- * `scripts/check-viewer-condition-mirror.ts`) and its rule editor STRIPS the
14573
+ * In {@link NC_CONDITION_CATALOG} since P2, and the ORDER it got there is the
14574
+ * rule rather than an accident: the viewer mirrors the descriptor enums BY
14575
+ * HAND (`camstack/src/data/notification-center.ts`, guarded by
14576
+ * `scripts/check-viewer-condition-mirror.ts`) and its rule editor strips the
14512
14577
  * condition fields it does not know when a rule is saved from the phone.
14513
14578
  * Publishing an editor for a condition the app cannot round-trip is how an
14514
- * operator loses a rule's conditions by opening it — so the descriptor, the
14515
- * admin widget and the viewer mirror land together (P2 + P3), and only then
14516
- * does an audio rule become authorable.
14579
+ * operator loses a rule's conditions by opening it — so the viewer mirror
14580
+ * (P3, shipped) went FIRST, and the descriptor an editor renders from
14581
+ * follows here.
14517
14582
  */
14518
14583
  audio: NcAudioConditionSchema.optional()
14519
14584
  });
@@ -14749,6 +14814,30 @@ var NcRuleInputSchema = object({
14749
14814
  */
14750
14815
  snoozeAllowGlobal: boolean().optional(),
14751
14816
  /**
14817
+ * The snooze durations THIS rule's notification offers as buttons, in
14818
+ * minutes.
14819
+ *
14820
+ * Three states, and all three are distinct — which is exactly why this is
14821
+ * `.optional()` and never `.default()`. A Zod default does not run on the
14822
+ * addon cap path (three production failures in one day), so a schema default
14823
+ * would collapse the first two:
14824
+ *
14825
+ * | value | meaning |
14826
+ * | --- | --- |
14827
+ * | absent | the operator never said ⇒ {@link NC_DEFAULT_SNOOZE_MINUTES} |
14828
+ * | `[]` | **no snooze buttons on this rule** — the explicit override |
14829
+ * | a list | these choices, de-duplicated and sorted, at most four |
14830
+ *
14831
+ * `.max(4)` because the notifier's own action budget is small (ntfy allows
14832
+ * three buttons in total) and a rule that spent it all on snooze choices
14833
+ * would push its own tap-through actions off the notification.
14834
+ *
14835
+ * An empty list is NOT an alarm exemption: a rule the alarm is about, or
14836
+ * that arms the panel, is exempt automatically and cannot be silenced by a
14837
+ * window from anywhere (D133).
14838
+ */
14839
+ snoozeOptions: array(number().int().min(1).max(NC_SNOOZE_MAX_MINUTES)).max(4).optional(),
14840
+ /**
14752
14841
  * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
14753
14842
  *
14754
14843
  * This is what makes the rule set the alarm's trigger set without the alarm
@@ -14848,6 +14937,7 @@ var NcConditionDescriptorSchema = object({
14848
14937
  "device",
14849
14938
  "package",
14850
14939
  "occupancy",
14940
+ "audio",
14851
14941
  "system"
14852
14942
  ]),
14853
14943
  label: string(),
@@ -14866,6 +14956,7 @@ var NcConditionDescriptorSchema = object({
14866
14956
  "crossingSelect",
14867
14957
  "polygonDraw",
14868
14958
  "occupancy",
14959
+ "audio",
14869
14960
  "deviceState",
14870
14961
  "systemEvent"
14871
14962
  ]),
@@ -15017,7 +15108,20 @@ var NcSnoozeInputSchema = object({
15017
15108
  ruleId: string().optional(),
15018
15109
  /** Required when `scope: 'device'`. */
15019
15110
  deviceId: number().int().optional(),
15020
- durationMinutes: number().int().min(1).max(1440),
15111
+ /**
15112
+ * Narrow the window to these subject classes — "the cat, not the person".
15113
+ *
15114
+ * ORTHOGONAL to `scope`, deliberately, and absent means EVERY class: that is
15115
+ * what every window authored before this field meant, so no persisted row
15116
+ * changes meaning and no client has to learn anything to keep working.
15117
+ *
15118
+ * It is what makes the window's real key `(deviceId, classes[])` and lets it
15119
+ * cross rules (D133): the operator points at a camera and a kind of thing,
15120
+ * not at whichever of their four rules happened to produce the notification
15121
+ * they are dismissing.
15122
+ */
15123
+ classes: array(string().min(1)).min(1).optional(),
15124
+ durationMinutes: number().int().min(1).max(NC_SNOOZE_MAX_MINUTES),
15021
15125
  /**
15022
15126
  * Silence this for EVERY recipient, not just the caller. Permission is
15023
15127
  * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
@@ -15042,6 +15146,10 @@ var NcSnoozeSchema = object({
15042
15146
  scope: NcSnoozeScopeSchema,
15043
15147
  ruleId: string().optional(),
15044
15148
  deviceId: number().int().optional(),
15149
+ /** Subject classes this window covers. ABSENT = every class — see
15150
+ * {@link NcSnoozeInputSchema.shape.classes}. Lives in the JSON blob and has
15151
+ * no SQLite column: nothing queries a window by class. */
15152
+ classes: array(string().min(1)).min(1).optional(),
15045
15153
  startedAt: number(),
15046
15154
  /** Exclusive: at exactly this instant the snooze is over. Expiry is a
15047
15155
  * COMPARISON, not a job — no sweeper can leave the operator silenced. */
@@ -17279,6 +17387,22 @@ var detectionFpsField = {
17279
17387
  default: 10,
17280
17388
  step: 1
17281
17389
  };
17390
+ /**
17391
+ * The occupancy re-check interval. DEFAULT 300 s (2026-08-13 — was 30 s).
17392
+ *
17393
+ * The recheck is now on by default (a parked car is invisible to occupancy
17394
+ * rules until the stationary registry has been rebuilt by motion, which after a
17395
+ * restart may be never on a quiet camera). Each cycle re-subscribes a detection
17396
+ * session — an RTSP re-dial — so the switch is only affordable at a WIDE
17397
+ * interval: 300 s is ~12 re-dials an hour per camera, against 120 at the old
17398
+ * 30 s. A parked car is therefore counted within 5 minutes of a restart.
17399
+ *
17400
+ * Why not wider: `max` is 300 and raising it is TRAIN-BOUND, not addon-bound —
17401
+ * the host validates `attachCamera` against ITS copy of this schema, so a
17402
+ * runner asked for 600 would be rejected by the hub until a `@camstack/server`
17403
+ * carrying the wider bound is installed everywhere. 300 is the widest value
17404
+ * that ships with an addon deploy.
17405
+ */
17282
17406
  var occupancyRecheckSecField = {
17283
17407
  min: 0,
17284
17408
  max: 300,
@@ -17480,15 +17604,21 @@ var RunnerCameraConfigSchema = object({
17480
17604
  */
17481
17605
  onboardMotionDrivesAnalyzer: boolean().default(true),
17482
17606
  /**
17483
- * Master toggle for the occupancy re-check. When `false` (DEFAULT) the runner
17484
- * never arms the periodic recheck timer, regardless of `occupancyRecheckSec`
17485
- * this is off by default because the recheck re-subscribes a detection session
17486
- * every N seconds while `watching`, a major source of pull-decoder re-dial
17487
- * churn (each cycle creates+tears a session → RTSP re-dial → latency). The
17607
+ * Master toggle for the occupancy re-check. When `false` the runner never arms
17608
+ * the periodic recheck timer, regardless of `occupancyRecheckSec`; the
17488
17609
  * `occupancyRecheckSec` / `occupancyRecheckFrames` sliders only take effect
17489
17610
  * (and only render) when this is enabled.
17490
- */
17491
- occupancyRecheckEnabled: boolean().default(false),
17611
+ *
17612
+ * DEFAULT `true` since 2026-08-13 (was `false`). It was off because the
17613
+ * recheck re-subscribes a detection session every N seconds while `watching`
17614
+ * — each cycle creates+tears a session ⇒ an RTSP re-dial ⇒ latency, a major
17615
+ * pull-decoder churn source. What that bought was a blind spot: a STATIONARY
17616
+ * object is counted only while the stationary registry holds it, and the
17617
+ * registry rebuilds from motion, so after a restart a parked car was invisible
17618
+ * to every occupancy rule until something moved in front of it. The churn is
17619
+ * now paid on the interval instead — see `occupancyRecheckSecField`.
17620
+ */
17621
+ occupancyRecheckEnabled: boolean().default(true),
17492
17622
  occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
17493
17623
  occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
17494
17624
  /**
@@ -30614,6 +30744,7 @@ Object.freeze({
30614
30744
  "network-access": "ingress",
30615
30745
  "smtp-provider": "email"
30616
30746
  });
30747
+ new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
30617
30748
  new Set(["devices", "classes"]);
30618
30749
  /** Strip a small, safe subset of Markdown down to plain text. */
30619
30750
  function markdownToText(md) {
@@ -32194,11 +32325,24 @@ function nativePriority$1(prepared) {
32194
32325
  }
32195
32326
  function viewActions(prepared) {
32196
32327
  const out = [];
32197
- for (const action of prepared.actions.slice(0, MAX_ACTIONS$1)) if (action.url !== void 0 && action.url.length > 0) out.push({
32198
- action: "view",
32199
- label: action.label,
32200
- url: action.url
32201
- });
32328
+ for (const action of prepared.actions.slice(0, MAX_ACTIONS$1)) {
32329
+ if (action.url === void 0 || action.url.length === 0) continue;
32330
+ if (action.mode === "background") {
32331
+ out.push({
32332
+ action: "http",
32333
+ label: action.label,
32334
+ url: action.url,
32335
+ method: "POST",
32336
+ clear: true
32337
+ });
32338
+ continue;
32339
+ }
32340
+ out.push({
32341
+ action: "view",
32342
+ label: action.label,
32343
+ url: action.url
32344
+ });
32345
+ }
32202
32346
  return out;
32203
32347
  }
32204
32348
  /** The single attachment (caps.max = 1), if any. */
@@ -32253,7 +32397,7 @@ function buildUploadRequest(config, prepared, attachment) {
32253
32397
  if (tags.length > 0) headers["x-tags"] = tags.join(",");
32254
32398
  if (prepared.clickUrl !== null) headers["x-click"] = prepared.clickUrl;
32255
32399
  if (attachment.name !== void 0) headers["x-filename"] = attachment.name;
32256
- if (actions.length > 0) headers["x-actions"] = actions.map((a) => `view, ${a.label}, ${a.url}`).join("; ");
32400
+ if (actions.length > 0) headers["x-actions"] = actions.map((a) => a.action === "http" ? `http, ${a.label}, ${a.url}, method=POST, clear=true` : `view, ${a.label}, ${a.url}`).join("; ");
32257
32401
  return {
32258
32402
  url: `${serverUrl}/${topic}`,
32259
32403
  method: "PUT",
@@ -32330,6 +32474,21 @@ var ntfyAdapter = {
32330
32474
  max: 1
32331
32475
  },
32332
32476
  actions: MAX_ACTIONS$1,
32477
+ /**
32478
+ * ntfy has NO per-action icon, and this says so out loud.
32479
+ *
32480
+ * Its action object is `{action, label, url, clear, headers, body}` and the
32481
+ * `X-Actions` header form is `action, label, url[, extras]` — there is no
32482
+ * icon field in either, and the `icon` ntfy does have is the NOTIFICATION's
32483
+ * icon (an attachment URL, handled above), not a per-button one.
32484
+ *
32485
+ * Left EXPLICIT rather than absent: absent and `false` behave identically
32486
+ * (the degrade engine reads a missing value as unsupported), but only one
32487
+ * of them proves somebody checked. The degrade engine then strips the icon
32488
+ * centrally and puts `actionIcons` in `renderedAs.dropped`, so the loss is
32489
+ * reported instead of being discovered on a phone.
32490
+ */
32491
+ actionIcons: false,
32333
32492
  levels: [
32334
32493
  {
32335
32494
  id: "min",
@@ -33512,17 +33671,123 @@ function userIdsFrom(config) {
33512
33671
  if (typeof raw === "string") return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
33513
33672
  return [];
33514
33673
  }
33674
+ /**
33675
+ * Canonical icon token → the string Zentik's client actually renders.
33676
+ *
33677
+ * ── EVIDENCE (read from the Zentik sources, not guessed) ────────────
33678
+ * `NotificationActionDto.icon` is `@IsOptional() @IsString()` — the BACKEND
33679
+ * validates nothing, so a wrong value is accepted, stored and delivered. The
33680
+ * rendering happens on the client, and both iOS paths do the same thing
33681
+ * (`ZentikNotificationService/NotificationService.swift`,
33682
+ * `ZentikNotificationContentExtension/NotificationViewController.swift`):
33683
+ *
33684
+ * let actualIconName = iconName.hasPrefix("sfsymbols:")
33685
+ * ? String(iconName.dropFirst("sfsymbols:".count)) : iconName
33686
+ * icon = UNNotificationActionIcon(systemImageName: actualIconName)
33687
+ *
33688
+ * So the value is an **SF Symbol name**, with an optional `sfsymbols:` prefix
33689
+ * that is stripped. Zentik's own UI confirms the convention: its icon field
33690
+ * placeholder is "e.g., house.fill, heart, gear" and its picker writes
33691
+ * `sfsymbols:${iconName}`.
33692
+ *
33693
+ * `UNNotificationActionIcon(systemImageName:)` with a name iOS does not know
33694
+ * renders NOTHING — silently. That is the whole failure: we sent `silence`,
33695
+ * `arm` and `disarm`, none of which are SF Symbols, and every button arrived
33696
+ * blank with nothing anywhere saying why.
33697
+ *
33698
+ * The prefix is emitted explicitly even though bare names work, because the
33699
+ * bare form is indistinguishable from an un-translated intent token in a
33700
+ * captured payload — and telling those two apart is what cost the time.
33701
+ *
33702
+ * A `Record` over the closed enum, NOT a lookup with a fallback: adding a
33703
+ * vocabulary member without deciding its glyph fails the build here.
33704
+ *
33705
+ * Android: Zentik's Android client does not render per-action icons at all
33706
+ * (an Android notification action needs a bundled drawable, not a name), so
33707
+ * this is an iOS/watchOS enrichment and a no-op elsewhere — which is a
33708
+ * cosmetic loss, not a wrong glyph.
33709
+ */
33710
+ var ZENTIK_ACTION_ICONS = {
33711
+ acknowledge: "checkmark.circle",
33712
+ dismiss: "xmark.circle",
33713
+ silence: "bell.slash",
33714
+ snooze: "zzz",
33715
+ view: "eye",
33716
+ play: "play.fill",
33717
+ open: "door.left.hand.open",
33718
+ close: "door.left.hand.closed",
33719
+ lock: "lock.fill",
33720
+ unlock: "lock.open.fill",
33721
+ arm: "shield.fill",
33722
+ "arm-home": "house.fill",
33723
+ "arm-away": "figure.walk",
33724
+ "arm-night": "moon.fill",
33725
+ disarm: "shield.slash.fill",
33726
+ light: "lightbulb.fill",
33727
+ alert: "exclamationmark.triangle.fill",
33728
+ camera: "video.fill"
33729
+ };
33730
+ /** The `sfsymbols:` prefix Zentik's clients strip before resolving the symbol. */
33731
+ var SFSYMBOLS_PREFIX = "sfsymbols:";
33732
+ /**
33733
+ * The Zentik value for one icon token, or `undefined` when there is none.
33734
+ *
33735
+ * Typed on `string`, not on `NotificationActionIcon`, because that is what this
33736
+ * boundary actually receives: an addon deployed against a hub whose vocabulary
33737
+ * moved on hands over a token this build has never heard of. Omitting it is the
33738
+ * honest answer — forwarding it raw would put a string iOS ignores on the wire
33739
+ * and produce exactly the blank button this map exists to fix.
33740
+ *
33741
+ * `Object.hasOwn` and not a truthiness check: an inherited `toString` must not
33742
+ * read as a mapping.
33743
+ */
33744
+ function zentikActionIcon(icon) {
33745
+ const table = ZENTIK_ACTION_ICONS;
33746
+ if (!Object.hasOwn(ZENTIK_ACTION_ICONS, icon)) return void 0;
33747
+ const symbol = table[icon];
33748
+ return symbol === void 0 ? void 0 : `${SFSYMBOLS_PREFIX}${symbol}`;
33749
+ }
33750
+ /**
33751
+ * How a `BACKGROUND_CALL` names its request: `METHOD::url`.
33752
+ *
33753
+ * The whole reason the type exists here: a "silence this for 30 minutes"
33754
+ * button that opens a browser tab has already cost more attention than the
33755
+ * notification it was dismissing. `BACKGROUND_CALL` fires the callback and
33756
+ * leaves the phone where it is.
33757
+ */
33758
+ var BACKGROUND_CALL_PREFIX = "POST::";
33515
33759
  function actionsFor(prepared) {
33516
33760
  const out = [];
33517
- for (const action of prepared.actions.slice(0, MAX_ACTIONS)) if (action.url !== void 0 && action.url.length > 0) out.push({
33518
- type: "NAVIGATE",
33519
- title: action.label,
33520
- value: action.url
33521
- });
33522
- else out.push({
33523
- type: "OPEN_NOTIFICATION",
33524
- title: action.label
33525
- });
33761
+ for (const action of prepared.actions.slice(0, MAX_ACTIONS)) {
33762
+ const symbol = action.icon === void 0 ? void 0 : zentikActionIcon(action.icon);
33763
+ const decoration = {
33764
+ ...action.destructive !== void 0 ? { destructive: action.destructive } : {},
33765
+ ...symbol !== void 0 ? { icon: symbol } : {}
33766
+ };
33767
+ if (action.url === void 0 || action.url.length === 0) {
33768
+ out.push({
33769
+ type: "OPEN_NOTIFICATION",
33770
+ title: action.label,
33771
+ ...decoration
33772
+ });
33773
+ continue;
33774
+ }
33775
+ if (action.mode === "background") {
33776
+ out.push({
33777
+ type: "BACKGROUND_CALL",
33778
+ title: action.label,
33779
+ value: `${BACKGROUND_CALL_PREFIX}${action.url}`,
33780
+ ...decoration
33781
+ });
33782
+ continue;
33783
+ }
33784
+ out.push({
33785
+ type: "NAVIGATE",
33786
+ title: action.label,
33787
+ value: action.url,
33788
+ ...decoration
33789
+ });
33790
+ }
33526
33791
  return out;
33527
33792
  }
33528
33793
  function attachmentsFor(prepared) {
@@ -33688,6 +33953,17 @@ var zentikAdapter = {
33688
33953
  ],
33689
33954
  format: ["text"],
33690
33955
  clickUrl: true,
33956
+ /**
33957
+ * A RENDERING claim, which it was not before.
33958
+ *
33959
+ * This used to say the intent name "travels verbatim … a name the app does
33960
+ * not know renders as no icon rather than as a wrong one" — true, and it
33961
+ * described a feature that never once drew an icon, because NO intent name
33962
+ * is an SF Symbol. The engine handed the icon over and the adapter put it
33963
+ * on the wire untranslated. `ZENTIK_ACTION_ICONS` is what makes this claim
33964
+ * honest; keep it exhaustive or this reverts to a pass-through that lies.
33965
+ */
33966
+ actionIcons: true,
33691
33967
  sound: true,
33692
33968
  ttl: true,
33693
33969
  bodyMaxLen: BODY_MAX_LEN
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-notifiers",
3
- "version": "1.2.19",
3
+ "version": "1.2.21",
4
4
  "description": "System notifiers addon for CamStack — a `notification-output` collection provider hosting per-kind notifier adapters (ntfy, pushover, gotify, telegram, discord, webhook, zentik).",
5
5
  "keywords": [
6
6
  "camstack",