@livx.cc/appwrap 0.61.4 → 0.61.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.61.4",
3
+ "version": "0.61.6",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -38,7 +38,7 @@ function buildBootstrapJs(): string {
38
38
  * class it synthesizes for `extend({...})` keyed by the methods-object shape, so calling
39
39
  * extend() again per-webview REUSES the first invocation's class — including its captured
40
40
  * closure. A per-instance `owner` WeakRef baked into the closure therefore cross-wires every
41
- * later webview's prompt() transport to the FIRST instance (device-proven in the feedox
41
+ * later webview's prompt() transport to the FIRST instance (device-proven in a multi-webview
42
42
  * mini-app spike: webview-2's bridge request was answered as webview-1). Route by the native
43
43
  * view the callback is given instead, resolved against a per-view registry.
44
44
  */
@@ -1,7 +1,7 @@
1
1
  import { Application, Utils } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { requestPermissions, startActivityForResult, uriToDataUrl, bitmapToDataUrl } from './android-helpers';
4
- import { notifIdentity } from './notif-identity';
4
+ import { notifIdentity, notifActions } from './notif-identity';
5
5
 
6
6
  // no NS types: android-32 typings omit ContactsContract/MediaStore column + ACTION_PICK_IMAGES constants this file reads
7
7
  declare const android: any, androidx: any;
@@ -66,8 +66,9 @@ export function registerAndroidHandlers(): void {
66
66
  return ok ? 'granted' : 'denied';
67
67
  });
68
68
 
69
- bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon, silent }: any) => {
69
+ bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon, silent, image, actions }: any) => {
70
70
  const ident = notifIdentity({ title, body, sender, icon });
71
+ const buttons = notifActions(actions);
71
72
  // Per-sender channel gives the mini-app its own identity + settings row; else the shared one.
72
73
  const channelId = ident.useIdentity && ident.senderName ? senderChannelId(ident.senderName) : CHANNEL_ID;
73
74
  ensureChannel(channelId, ident.useIdentity && ident.senderName ? ident.senderName : 'Notifications');
@@ -89,10 +90,13 @@ export function registerAndroidHandlers(): void {
89
90
  if (ident.subtitle) builder.setSubText(ident.subtitle);
90
91
  if (ident.body) builder.setContentText(ident.body);
91
92
  // Tap → re-open the (singleTask) activity with a VIEW intent; onNewIntent
92
- // routes it through the same deep-link path as an external open.
93
- if (deepLink) {
93
+ // routes it through the same deep-link path as an external open. Each action button gets its
94
+ // OWN PendingIntent — same shape, different target, and a distinct request code (a shared one
95
+ // would make FLAG_UPDATE_CURRENT overwrite every earlier button's target with the last).
96
+ let reqCode = nid * 8;
97
+ const openIntent = (link: string) => {
94
98
  const viewIntent = new android.content.Intent(
95
- android.content.Intent.ACTION_VIEW, android.net.Uri.parse(String(deepLink))
99
+ android.content.Intent.ACTION_VIEW, android.net.Uri.parse(String(link))
96
100
  );
97
101
  viewIntent.setPackage(ctx.getPackageName());
98
102
  viewIntent.addFlags(
@@ -101,15 +105,35 @@ export function registerAndroidHandlers(): void {
101
105
  const piFlags = android.os.Build.VERSION.SDK_INT >= 23
102
106
  ? android.app.PendingIntent.FLAG_IMMUTABLE | android.app.PendingIntent.FLAG_UPDATE_CURRENT
103
107
  : android.app.PendingIntent.FLAG_UPDATE_CURRENT;
104
- builder.setContentIntent(android.app.PendingIntent.getActivity(ctx, nid, viewIntent, piFlags));
108
+ return android.app.PendingIntent.getActivity(ctx, reqCode++, viewIntent, piFlags);
109
+ };
110
+ if (deepLink) builder.setContentIntent(openIntent(String(deepLink)));
111
+ for (const b of buttons) {
112
+ if (!b.deepLink) continue;
113
+ // Icon 0 — modern Android draws action buttons as text only, and a bogus resource id crashes
114
+ // the builder. `Notification.Action.Builder` needs an Icon object on API 23+; the deprecated
115
+ // three-arg overload is the one that accepts 0 and is still honoured.
116
+ builder.addAction(0, b.title, openIntent(b.deepLink));
105
117
  }
106
- // With an icon: fetch the bitmap off the main thread (URL fetch would crash on it),
107
- // set it as the large icon, then post. Without: post inline.
108
- if (ident.iconUrl) {
118
+ // Artwork and the sender icon are both network reads — do them on a background thread (a URL
119
+ // fetch on the main thread throws NetworkOnMainThread) and post once both have resolved.
120
+ if (ident.iconUrl || image) {
109
121
  new java.lang.Thread(new java.lang.Runnable({
110
122
  run: () => {
111
- const bmp = loadBitmap(ident.iconUrl);
112
- if (bmp) builder.setLargeIcon(bmp);
123
+ if (ident.iconUrl) {
124
+ const bmp = loadBitmap(ident.iconUrl);
125
+ if (bmp) builder.setLargeIcon(bmp);
126
+ }
127
+ if (image) {
128
+ const hero = loadBitmap(String(image));
129
+ // BigPictureStyle IS the rich card: collapsed it shows the large icon, expanded the
130
+ // hero. Keep the large icon on expand so the mini-app's identity survives the expand.
131
+ if (hero) {
132
+ const style = new android.app.Notification.BigPictureStyle().bigPicture(hero);
133
+ if (ident.body) style.setSummaryText(ident.body);
134
+ builder.setStyle(style);
135
+ }
136
+ }
113
137
  notificationManager().notify(nid, builder.build());
114
138
  },
115
139
  })).start();
@@ -4,8 +4,10 @@ import { onDeepLink } from './events';
4
4
  import { geoAuthAction } from './geo-auth';
5
5
  import { onRemoteMessage } from './handlers-push';
6
6
  import { uiImageToDataUrl } from './ios-image';
7
- import { notifIdentity, type NotifIdentity } from './notif-identity';
7
+ import { notifIdentity, notifActions, bestEffort, bestEffortAsync, type NotifIdentity, type NotifAction } from './notif-identity';
8
8
  import { resolveSoundName } from './notif-sound';
9
+ import { resolveAttachment } from './notif-attachment';
10
+ import { sha256Hex } from './sha256';
9
11
  import { maskForLock, setIosOrientationMask } from './orientation';
10
12
 
11
13
  interface GeoResult { lat: number; lng: number; accuracy: number; }
@@ -130,12 +132,21 @@ function ensureIosDelegates(): void {
130
132
  // userInfo may come back as an NSDictionary or an auto-marshalled JS object.
131
133
  // any: dual-path payload (NSDictionary vs marshalled JS object) probed dynamically.
132
134
  const info: any = response.notification.request.content.userInfo;
133
- const url = info ? (typeof info.objectForKey === 'function' ? info.objectForKey('url') : info.url) : null;
135
+ const at = (k: string) => (info ? (typeof info.objectForKey === 'function' ? info.objectForKey(k) : info[k]) : null);
136
+ // A BUTTON tap carries the action's own identifier; the banner body carries the default one.
137
+ // Each button's target was stamped as `a:<id>` at schedule time — fall back to the body's
138
+ // `url` so a button with no target of its own still opens the app rather than doing nothing.
139
+ const action = String(response.actionIdentifier ?? '');
140
+ const isButton = !!action && action !== UNNotificationDefaultActionIdentifier;
141
+ // A swipe-away cannot reach here at all: the categories we register pass no
142
+ // `.customDismissAction`, so iOS never wakes the delegate for a dismissal — which is
143
+ // exactly the wanted behaviour (a dismiss must not fall through to `url` and launch).
144
+ const url = (isButton && at(`a:${action}`)) || at('url');
134
145
  // Diagnostic breadcrumb (persists across the cold relaunch) — surfaced in the handshake's debug field.
135
146
  try {
136
147
  ApplicationSettings.setString(
137
148
  'kit:__notifTap',
138
- JSON.stringify({ at: Date.now(), url: url ? String(url) : null, hadInfo: !!info })
149
+ JSON.stringify({ at: Date.now(), url: url ? String(url) : null, action: action || null, hadInfo: !!info })
139
150
  );
140
151
  } catch {
141
152
  /* diagnostic only */
@@ -228,14 +239,33 @@ export function registerExtendedHandlers(): void {
228
239
  });
229
240
  });
230
241
 
231
- bridge.register('notifications.schedule', async ({ id, title, body, delaySec, deepLink, sender, icon, badge, silent, sound }: { id?: number; title?: string; body?: string; delaySec?: number; deepLink?: string; sender?: string; icon?: string; badge?: number; silent?: boolean; sound?: string }) => {
242
+ bridge.register('notifications.schedule', async ({ id, title, body, delaySec, deepLink, sender, icon, badge, silent, sound, image, actions }: { id?: number; title?: string; body?: string; delaySec?: number; deepLink?: string; sender?: string; icon?: string; badge?: number; silent?: boolean; sound?: string; image?: string; actions?: NotifAction[] }) => {
232
243
  if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
233
244
  const nid = id ?? Math.floor(Math.random() * 100000);
245
+ // The OS accepts a request it will never present when authorization is missing (a reinstall
246
+ // resets it). Say so instead of resolving a success the user will never see.
247
+ if (!(await notificationsAuthorized())) {
248
+ throw Object.assign(
249
+ new Error('Notifications are not authorized for this app'),
250
+ { code: 'NOT_AUTHORIZED' }
251
+ );
252
+ }
234
253
  const ident = notifIdentity({ title, body, sender, icon });
254
+ const buttons = notifActions(actions);
235
255
  // A custom sound is a FILE the OS reads, never a URL it fetches — resolve (download + transcode +
236
256
  // cache) before building the content. Null means "unusable", and the default alert takes over
237
257
  // below: the app rings with the wrong sound rather than not at all.
238
- const soundName = !silent && sound ? await resolveSoundName(String(sound)) : null;
258
+ const soundName = !silent && sound
259
+ ? await bestEffortAsync('sound', () => resolveSoundName(String(sound)), null)
260
+ : null;
261
+ // Same rule as the sound: artwork is a FILE the OS reads, never a URL it fetches. Resolve it up
262
+ // front so the content is built once. `image` wins; otherwise the sender icon rides along as the
263
+ // banner thumbnail when communication styling is unavailable (no entitlement / pre-iOS-15) —
264
+ // without it, a mini-app notification would carry NO app artwork at all on those builds.
265
+ const artwork = image ? String(image) : (ident.iconUrl && !communicationStylingAvailable() ? ident.iconUrl : '');
266
+ const attachment = artwork
267
+ ? await bestEffortAsync('artwork', () => resolveAttachment(artwork, `art-${nid}`), null)
268
+ : null;
239
269
  return new Promise((resolve, reject) => {
240
270
  const content = UNMutableNotificationContent.new();
241
271
  content.title = ident.title;
@@ -261,13 +291,32 @@ export function registerExtendedHandlers(): void {
261
291
  // Plain JS object → NativeScript marshals it to NSDictionary (more reliable
262
292
  // than dictionaryWithObjectForKey across NS versions).
263
293
  // cast: NS marshals a plain JS object → NSDictionary at the interop boundary (typed NSDictionary).
264
- if (deepLink) content.userInfo = { url: String(deepLink) } as any;
294
+ // Carry the tap target AND each button's target: the delegate reads `actionIdentifier` and
295
+ // picks `a:<id>`, falling back to `url` for a tap on the banner body itself.
296
+ const info: Record<string, string> = {};
297
+ if (deepLink) info.url = String(deepLink);
298
+ for (const b of buttons) if (b.deepLink) info[`a:${b.id}`] = b.deepLink;
299
+ if (Object.keys(info).length) content.userInfo = info as any;
300
+
301
+ // Buttons live on a CATEGORY, not on the content — iOS looks the category up by id at
302
+ // delivery time, so it must be registered before the request is added.
303
+ if (buttons.length) {
304
+ const category = bestEffort('buttons', () => registerActionCategory(buttons), '');
305
+ if (category) content.categoryIdentifier = category;
306
+ }
307
+
308
+ // ARTWORK. A caller-supplied `image` is the hero. With no image, the sender's ICON becomes
309
+ // the attachment whenever the communication path declined — that thumbnail is then the only
310
+ // place the mini-app's own artwork appears, so it is what "identity" degrades to.
311
+ if (attachment) content.attachments = [attachment] as any;
265
312
 
266
313
  // iOS 15+ COMMUNICATION notification: present the mini-app as the sender (name +
267
314
  // circular avatar) via an INSendMessageIntent. Degrades to the plain `content`
268
315
  // above pre-iOS-15 or when styling declines (e.g. missing communication entitlement).
269
316
  const finalContent: UNNotificationContent =
270
- (ident.useIdentity && communicationContent(content, String(nid), ident)) || content;
317
+ (ident.useIdentity &&
318
+ bestEffort('sender identity', () => communicationContent(content, String(nid), ident), null)) ||
319
+ content;
271
320
 
272
321
  const trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeIntervalRepeats(
273
322
  Math.max(1, delaySec ?? 1),
@@ -509,17 +558,97 @@ function iconNSData(icon: string): NSData | null {
509
558
  }
510
559
  }
511
560
 
561
+ /**
562
+ * Has the user actually granted notifications to THIS install?
563
+ *
564
+ * `addNotificationRequest` succeeds with no error when authorization is missing — the request is
565
+ * accepted and then never presented. A reinstall (or a capability change that forces one) resets
566
+ * authorization, so a schedule call reporting `{id}` is not evidence anything will arrive. Read the
567
+ * center's own answer and fail loudly instead of lying to the caller.
568
+ */
569
+ function notificationsAuthorized(): Promise<boolean> {
570
+ return new Promise((resolve) => {
571
+ UNUserNotificationCenter.currentNotificationCenter().getNotificationSettingsWithCompletionHandler(
572
+ (settings) => {
573
+ const s = settings?.authorizationStatus;
574
+ resolve(
575
+ s === UNAuthorizationStatus.Authorized ||
576
+ s === UNAuthorizationStatus.Provisional ||
577
+ s === UNAuthorizationStatus.Ephemeral
578
+ );
579
+ }
580
+ );
581
+ });
582
+ }
583
+
584
+ /**
585
+ * Can this build present a COMMUNICATION notification (mini-app name + circular avatar)?
586
+ *
587
+ * Two independent things must be true, and BOTH are build-time facts, so this is cheap and exact:
588
+ * 1. iOS 15+ — where INSendMessageIntent conforms to UNNotificationContentProviding.
589
+ * 2. The app declares `NSUserActivityTypes` containing `INSendMessageIntent`. The appwrap CLI
590
+ * stamps that key ONLY when `com.apple.developer.usernotifications.communication` is configured,
591
+ * and SpringBoard denies the API without the pair — so its absence means the styling WILL
592
+ * decline at runtime, which is exactly when the icon has to reach the banner some other way.
593
+ */
594
+ function communicationStylingAvailable(): boolean {
595
+ if (!isIOS) return false;
596
+ if (!NSProcessInfo.processInfo.isOperatingSystemAtLeastVersion({ majorVersion: 15, minorVersion: 0, patchVersion: 0 })) {
597
+ return false;
598
+ }
599
+ try {
600
+ const types = NSBundle.mainBundle.objectForInfoDictionaryKey('NSUserActivityTypes') as NSArray<string> | null;
601
+ return !!types && types.containsObject('INSendMessageIntent');
602
+ } catch {
603
+ return false;
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Register (idempotently) a UNNotificationCategory carrying `buttons`, and return its identifier.
609
+ *
610
+ * iOS resolves a notification's buttons by looking its `categoryIdentifier` up in the center's
611
+ * category SET at delivery time, and `setNotificationCategories` REPLACES that set — so categories
612
+ * are accumulated here and re-set as a union. The identifier is a hash of the buttons, so the same
613
+ * button set reuses one category instead of growing the set on every schedule call.
614
+ */
615
+ const notifCategories = new Map<string, UNNotificationCategory>();
616
+ function registerActionCategory(buttons: NotifAction[]): string {
617
+ const key = 'awcat-' + sha256Hex(buttons.map((b) => `${b.id}\u0000${b.title}`).join('\u0001')).slice(0, 24);
618
+ if (!notifCategories.has(key)) {
619
+ const actions = NSMutableArray.alloc().init() as NSMutableArray<UNNotificationAction>;
620
+ for (const b of buttons) {
621
+ // .Foreground: every button here deep-links back into the app, so the tap must bring it up.
622
+ actions.addObject(
623
+ UNNotificationAction.actionWithIdentifierTitleOptions(b.id, b.title, UNNotificationActionOptions.Foreground)
624
+ );
625
+ }
626
+ notifCategories.set(
627
+ key,
628
+ UNNotificationCategory.categoryWithIdentifierActionsIntentIdentifiersOptions(
629
+ // 0 = no category options: the buttons are the whole point, and CustomDismissAction would
630
+ // wake the delegate on a swipe-away for nothing.
631
+ key, actions as never, NSArray.array() as never, 0 as UNNotificationCategoryOptions
632
+ )
633
+ );
634
+ }
635
+ const set = NSMutableSet.alloc().init() as NSMutableSet<UNNotificationCategory>;
636
+ for (const c of notifCategories.values()) set.addObject(c);
637
+ UNUserNotificationCenter.currentNotificationCenter().setNotificationCategories(set as never);
638
+ return key;
639
+ }
640
+
512
641
  /**
513
642
  * Build an iOS-15+ communication notification: an INSendMessageIntent whose sender IS
514
643
  * the mini-app (name + INImage avatar), donated, then `content.updating(from:)` so the
515
- * banner renders with the sender's identity. Mirrors feedox's NotificationService.swift.
644
+ * banner renders with the sender's identity. Mirrors the NotificationService.swift pattern a
645
+ * downstream consumer ships.
516
646
  * Returns null pre-iOS-15 or if any step declines (caller falls back to plain content).
517
647
  */
518
648
  function communicationContent(content: UNMutableNotificationContent, id: string, ident: NotifIdentity): UNNotificationContent | null {
519
- // iOS 15.0+ only — INSendMessageIntent conforms to UNNotificationContentProviding there.
520
- if (!NSProcessInfo.processInfo.isOperatingSystemAtLeastVersion({ majorVersion: 15, minorVersion: 0, patchVersion: 0 })) {
521
- return null;
522
- }
649
+ // iOS 15+ AND the NSUserActivityTypes/entitlement pair — without both, SpringBoard denies the
650
+ // API and this would burn an INInteraction donation to learn what the bundle already states.
651
+ if (!communicationStylingAvailable()) return null;
523
652
  try {
524
653
  const displayName = ident.senderName || ident.title;
525
654
  const handleValue = ident.senderName || id;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Rich-notification ARTWORK on iOS — turn an image URL (or data-URI) into a
3
+ * `UNNotificationAttachment`.
4
+ *
5
+ * WHY THIS IS NOT JUST "pass the url": like sounds, iOS never fetches a notification attachment.
6
+ * `UNNotificationAttachment` takes a *file URL* the app owns, validates it by extension/UTI, and
7
+ * then MOVES it into its own store — so a `image: <url>` option that forwards the string renders
8
+ * nothing at all, silently. Download once, cache under a hash of the URL, and hand the OS a path.
9
+ *
10
+ * The attachment is what makes a banner a card: iOS shows it as the thumbnail on a collapsed
11
+ * banner and as the hero image when the banner is expanded. It is ALSO the only way a mini-app's
12
+ * own artwork reaches the banner when the communication-notification path is unavailable (no
13
+ * `com.apple.developer.usernotifications.communication` entitlement), which is why the
14
+ * notification handler falls back to attaching the sender's icon.
15
+ *
16
+ * Every failure path returns null and the caller posts a plain banner — a card without its picture
17
+ * is a cosmetic miss; a dropped notification is not.
18
+ */
19
+ import { sha256Hex } from './sha256';
20
+
21
+ /** Artwork is decoration on a one-shot alert, not a download: never hold the schedule call open. */
22
+ const FETCH_TIMEOUT_MS = 10_000;
23
+
24
+ /** Extensions UNNotificationAttachment accepts for an image. Anything else is coerced to .png. */
25
+ const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'heic', 'heif', 'webp'];
26
+
27
+ /** `<app container>/Library/Caches/appwrap-notif`, created on demand. */
28
+ function mediaDir(): string | null {
29
+ const caches = NSSearchPathForDirectoriesInDomains(
30
+ NSSearchPathDirectory.CachesDirectory, NSSearchPathDomainMask.UserDomainMask, true
31
+ );
32
+ if (!caches || caches.count === 0) return null;
33
+ const dir = `${caches.objectAtIndex(0)}/appwrap-notif`;
34
+ const fm = NSFileManager.defaultManager;
35
+ if (!fm.fileExistsAtPath(dir)) {
36
+ fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(dir, true, null);
37
+ }
38
+ return fm.fileExistsAtPath(dir) ? dir : null;
39
+ }
40
+
41
+ /** Best-effort extension from a URL path; falls back to png (which iOS sniffs happily). */
42
+ function extOf(url: string): string {
43
+ const path = url.split('?')[0].split('#')[0];
44
+ const ext = (path.split('.').pop() ?? '').toLowerCase();
45
+ return IMAGE_EXTS.includes(ext) ? ext : 'png';
46
+ }
47
+
48
+ /** Write bytes to `dest`; true when the file lands. */
49
+ function writeData(data: NSData | null, dest: string): boolean {
50
+ if (!data || data.length === 0) return false;
51
+ return data.writeToFileAtomically(dest, true);
52
+ }
53
+
54
+ /**
55
+ * ONE session for the process, not one per call. A session created as a local was free to be
56
+ * collected while its task was still in flight, which lands as an intermittent, SILENT "download
57
+ * failed" — the notification then posts with no artwork and nothing says why. Held at module scope
58
+ * it stays alive for the task's lifetime.
59
+ */
60
+ let sharedSession: NSURLSession | null = null;
61
+ function session(): NSURLSession {
62
+ if (!sharedSession) {
63
+ const cfg = NSURLSessionConfiguration.defaultSessionConfiguration;
64
+ cfg.timeoutIntervalForRequest = FETCH_TIMEOUT_MS / 1000;
65
+ sharedSession = NSURLSession.sessionWithConfiguration(cfg);
66
+ }
67
+ return sharedSession;
68
+ }
69
+
70
+ /** Download to `dest`. Resolves false on any transport failure (offline, 404, timeout). */
71
+ function downloadTo(url: string, dest: string): Promise<boolean> {
72
+ return new Promise((resolve) => {
73
+ const nsUrl = NSURL.URLWithString(url);
74
+ if (!nsUrl) return resolve(false);
75
+ const task = session().dataTaskWithURLCompletionHandler(nsUrl, (data, response, error) => {
76
+ const status = (response as NSHTTPURLResponse)?.statusCode ?? 0;
77
+ if (error || !data || (status && (status < 200 || status >= 300))) {
78
+ console.warn(`[appwrap] notification image download failed (${status || error?.localizedDescription})`);
79
+ return resolve(false);
80
+ }
81
+ resolve(writeData(data, dest));
82
+ });
83
+ task.resume();
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Resolve an image URL / data-URI to a `UNNotificationAttachment`, or null to post without artwork.
89
+ * `id` is the attachment identifier (unique per notification content).
90
+ */
91
+ export async function resolveAttachment(image: string, id: string): Promise<UNNotificationAttachment | null> {
92
+ const value = String(image ?? '').trim();
93
+ if (!value) return null;
94
+ const dir = mediaDir();
95
+ if (!dir) return null;
96
+
97
+ const isData = value.startsWith('data:');
98
+ const ext = isData ? (/^data:image\/(\w+)/.exec(value)?.[1] ?? 'png').toLowerCase() : extOf(value);
99
+ const dest = `${dir}/${sha256Hex(value).slice(0, 32)}.${IMAGE_EXTS.includes(ext) ? ext : 'png'}`;
100
+
101
+ const fm = NSFileManager.defaultManager;
102
+ if (!fm.fileExistsAtPath(dest)) {
103
+ let ok = false;
104
+ if (isData) {
105
+ const comma = value.indexOf(',');
106
+ const b64 = comma >= 0 ? value.slice(comma + 1) : '';
107
+ ok = !!b64 && writeData(
108
+ NSData.alloc().initWithBase64EncodedStringOptions(b64, NSDataBase64DecodingOptions.IgnoreUnknownCharacters),
109
+ dest
110
+ );
111
+ } else if (/^https?:\/\//i.test(value)) {
112
+ ok = await downloadTo(value, dest);
113
+ }
114
+ if (!ok) return null;
115
+ }
116
+
117
+ try {
118
+ // iOS MOVES the file into its own attachment store, so hand it a COPY — otherwise the cache
119
+ // entry vanishes and every later notification re-downloads (or, worse, finds a half-moved file).
120
+ const copy = `${dir}/use-${id}-${Date.now()}.${dest.split('.').pop()}`;
121
+ if (!fm.copyItemAtPathToPathError(dest, copy, null)) return null;
122
+ try {
123
+ // iOS moves the file ONLY on success; a rejected/throwing attachment leaves the copy
124
+ // behind, so every failed schedule would grow the cache. Reclaim it here.
125
+ const att = UNNotificationAttachment.attachmentWithIdentifierURLOptionsError(
126
+ id, NSURL.fileURLWithPath(copy), null, null
127
+ );
128
+ if (!att) fm.removeItemAtPathError(copy, null);
129
+ return att;
130
+ } catch (e) {
131
+ fm.removeItemAtPathError(copy, null);
132
+ throw e;
133
+ }
134
+ } catch (e) {
135
+ console.warn('[appwrap] notification attachment rejected', String(e));
136
+ return null;
137
+ }
138
+ }
@@ -10,6 +10,13 @@
10
10
  * present a custom-sender notification instead of the plain host-app one.
11
11
  */
12
12
 
13
+ /** One tappable button on a rich notification. `deepLink` is where the tap lands. */
14
+ export interface NotifAction {
15
+ id: string;
16
+ title: string;
17
+ deepLink?: string;
18
+ }
19
+
13
20
  export interface NotifIdentityInput {
14
21
  title?: string;
15
22
  body?: string;
@@ -31,6 +38,34 @@ export interface NotifIdentity {
31
38
  useIdentity: boolean;
32
39
  }
33
40
 
41
+ /**
42
+ * The OS ceiling that actually renders: iOS shows 2 buttons on a collapsed banner (4 expanded),
43
+ * Android 3. Three is the widest set every platform draws without silently dropping one.
44
+ */
45
+ export const MAX_NOTIF_ACTIONS = 3;
46
+
47
+ /**
48
+ * Normalize a caller's `actions` into at most {@link MAX_NOTIF_ACTIONS} well-formed buttons.
49
+ * Entries with no id or no title are DROPPED rather than rendered as a blank button, and ids are
50
+ * deduped — a repeated id would make two buttons indistinguishable to the tap router.
51
+ */
52
+ export function notifActions(actions: unknown): NotifAction[] {
53
+ if (!Array.isArray(actions)) return [];
54
+ const seen = new Set<string>();
55
+ const out: NotifAction[] = [];
56
+ for (const raw of actions) {
57
+ const a = raw as Partial<NotifAction> | null;
58
+ const id = String(a?.id ?? '').trim();
59
+ const title = String(a?.title ?? '').trim();
60
+ if (!id || !title || seen.has(id)) continue;
61
+ seen.add(id);
62
+ const deepLink = a?.deepLink ? String(a.deepLink) : undefined;
63
+ out.push(deepLink ? { id, title, deepLink } : { id, title });
64
+ if (out.length >= MAX_NOTIF_ACTIONS) break;
65
+ }
66
+ return out;
67
+ }
68
+
34
69
  export function notifIdentity(o: NotifIdentityInput): NotifIdentity {
35
70
  const title = String(o.title ?? '');
36
71
  const body = String(o.body ?? '');
@@ -43,3 +78,32 @@ export function notifIdentity(o: NotifIdentityInput): NotifIdentity {
43
78
  const subtitle = senderName && title && title !== senderName ? title : '';
44
79
  return { title: displayTitle, subtitle, body, senderName, iconUrl, useIdentity };
45
80
  }
81
+
82
+ /**
83
+ * DECORATION MUST NEVER COST THE NOTIFICATION.
84
+ *
85
+ * Everything a rich banner adds — a custom sound, hero artwork, the button CATEGORY, the
86
+ * communication-style sender identity — is a nicety layered onto one alert. Each of those steps
87
+ * calls into ObjC and each can throw (a rejected attachment, a category the center refuses, an
88
+ * intent SpringBoard denies). Left unguarded, any one of those throws escapes `notifications.schedule`
89
+ * and the user gets NOTHING — a cosmetic failure silently promoted to a dropped notification.
90
+ * Run every decorative step through here: it degrades to `fallback` and says why.
91
+ */
92
+ export function bestEffort<T>(what: string, fn: () => T, fallback: T): T {
93
+ try {
94
+ return fn();
95
+ } catch (e) {
96
+ console.warn(`[appwrap] notification ${what} skipped — posting without it: ${String(e)}`);
97
+ return fallback;
98
+ }
99
+ }
100
+
101
+ /** Async twin of `bestEffort` for the steps that download (sound, artwork). */
102
+ export async function bestEffortAsync<T>(what: string, fn: () => Promise<T>, fallback: T): Promise<T> {
103
+ try {
104
+ return await fn();
105
+ } catch (e) {
106
+ console.warn(`[appwrap] notification ${what} skipped — posting without it: ${String(e)}`);
107
+ return fallback;
108
+ }
109
+ }
@@ -143,7 +143,7 @@ export interface DeclaredCaps {
143
143
  * WKWebView, injected at document-start. Takes the build's declared-capability snapshot and, for every
144
144
  * capability the build did NOT declare, prevents the app-killing (camera/mic) or hanging (geolocation)
145
145
  * failure while letting DECLARED capabilities pass straight through to their native prompt. Composes the
146
- * individual guards so every consumer's foreign webview (shell + feedox mini-app views) is covered from
146
+ * individual guards so every consumer's foreign webview (shell + embedded mini-app views) is covered from
147
147
  * one call site. Other TCC-gated surfaces (getDisplayMedia, SpeechRecognition, WebMIDI/BT/NFC/USB) are
148
148
  * simply UNIMPLEMENTED by WKWebView — they resolve to `undefined`, so feature-detection degrades cleanly
149
149
  * and there is nothing to guard.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { notifIdentity } from '../app/shell/notif-identity';
2
+ import { notifIdentity, notifActions, bestEffort, bestEffortAsync, MAX_NOTIF_ACTIONS } from '../app/shell/notif-identity';
3
3
 
4
4
  describe('notifIdentity', () => {
5
5
  test('no sender/icon → plain, identity not used', () => {
@@ -41,3 +41,46 @@ describe('notifIdentity', () => {
41
41
  expect(r.useIdentity).toBe(false);
42
42
  });
43
43
  });
44
+
45
+ describe('notifActions', () => {
46
+ test('drops entries with no id or title, and dedupes ids', () => {
47
+ const r = notifActions([
48
+ { id: 'later', title: 'Maybe later' },
49
+ { id: '', title: 'nameless' },
50
+ { id: 'chat', title: '' },
51
+ { id: 'later', title: 'duplicate' },
52
+ { id: 'chat', title: "Let's chat!", deepLink: 'blank://a/x?screen=chat' },
53
+ ]);
54
+ expect(r.map((a) => a.id)).toEqual(['later', 'chat']);
55
+ expect(r[1].deepLink).toBe('blank://a/x?screen=chat');
56
+ expect(r[0].deepLink).toBeUndefined();
57
+ });
58
+
59
+ test('caps at 3 — the widest set every platform actually draws', () => {
60
+ const many = [1, 2, 3, 4, 5].map((n) => ({ id: `a${n}`, title: `A${n}` }));
61
+ expect(notifActions(many)).toHaveLength(MAX_NOTIF_ACTIONS);
62
+ expect(MAX_NOTIF_ACTIONS).toBe(3);
63
+ });
64
+
65
+ test('a missing / non-array actions option is simply no buttons', () => {
66
+ expect(notifActions(undefined)).toEqual([]);
67
+ expect(notifActions('nope' as unknown)).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe('bestEffort — decoration never costs the notification', () => {
72
+ test('a throwing decorative step degrades to the fallback instead of propagating', () => {
73
+ expect(bestEffort('buttons', () => { throw new Error('category refused'); }, '')).toBe('');
74
+ expect(bestEffort('sender identity', () => { throw new Error('SpringBoard denied'); }, null)).toBeNull();
75
+ });
76
+
77
+ test('the value passes through untouched when the step succeeds', () => {
78
+ expect(bestEffort('buttons', () => 'awcat-abc', '')).toBe('awcat-abc');
79
+ });
80
+
81
+ test('async: a rejected artwork/sound resolve degrades to null, it does not reject', async () => {
82
+ await expect(bestEffortAsync('artwork', async () => { throw new Error('download blew up'); }, null))
83
+ .resolves.toBeNull();
84
+ await expect(bestEffortAsync('sound', async () => 'ding.caf', null)).resolves.toBe('ding.caf');
85
+ });
86
+ });
package/src/cli.ts CHANGED
@@ -107,8 +107,16 @@ const ANDROID_PERMISSION_KEYS: Record<string, string[]> = {
107
107
  * REAL path — a dev checkout may stage the package-root dir as a symlink to the repo-root source (what
108
108
  * prepack does with a copy), and Bun's cpSync refuses a symlink as a copy-source root. */
109
109
  function resolveAssetRoot(rel: string): string {
110
+ // In a MONOREPO checkout the repo-root source wins, always. `prepack` stages a COPY of runtime/
111
+ // and templates/ at the package root and `postpack` deletes it — but a postpack that never ran
112
+ // (an interrupted publish) leaves that copy behind, and every later local build silently compiled
113
+ // the STALE snapshot instead of the source, with no warning and a perfectly successful build. The
114
+ // monorepo is identified structurally (this file lives at <root>/packages/appwrap-cli/src), never
115
+ // by "is there a runtime/ dir three levels up" — a consumer project may well have one of its own.
116
+ const inMonorepo = basename(resolve(import.meta.dir, '../..')) === 'packages';
110
117
  const local = resolve(import.meta.dir, '..', rel);
111
- const dir = existsSync(local) ? local : resolve(import.meta.dir, '../../..', rel);
118
+ const root = resolve(import.meta.dir, '../../..', rel);
119
+ const dir = inMonorepo && existsSync(root) ? root : existsSync(local) ? local : root;
112
120
  try { return realpathSync(dir); } catch { return dir; }
113
121
  }
114
122
  const TEMPLATE_DIR = resolveAssetRoot('runtime');
@@ -904,13 +912,20 @@ export async function loadConfig(cwd: string, flags: Record<string, string>): Pr
904
912
  }
905
913
  const cfg = await readConfigFile(configPath);
906
914
 
907
- // Warn (never fail) on keys this appwrap doesn't recognize. A config authored for a NEWER appwrap
908
- // silently no-ops its unknown keys on an older install (e.g. `targetedDevices` before 0.39 → wrong
909
- // device family, no error). Turn that silent no-op into a signal.
915
+ // FAIL on keys this appwrap doesn't recognize. A config authored for a NEWER appwrap silently
916
+ // no-ops its unknown keys on an older install, and a WARNING is not enough: it scrolls past in a
917
+ // CI log and the lane goes green. That shipped a real dead capability — Blank declared
918
+ // `iosInfoPlist: { NSSupportsLiveActivities: true }` against a CLI that predated the key, so the
919
+ // plist key was absent, the build/signing/upload all succeeded, and every Live Activity call was
920
+ // refused on the phone with nothing anywhere saying why. A config key that does nothing is a lie
921
+ // about what the binary contains, so it stops the build.
910
922
  const stray = unknownConfigKeys(cfg as unknown as Record<string, unknown>);
911
923
  if (stray.length) {
912
924
  const ver = pkgVersion(resolve(import.meta.dir, '../package.json'));
913
- for (const k of stray) console.warn(`⚠ appwrap: unrecognized config key '${k}' — ignored. If you expect it to apply, your installed @livx.cc/appwrap (${ver}) may predate it — upgrade.`);
925
+ console.error(`✖ appwrap: unrecognized config key(s): ${stray.map((k) => `'${k}'`).join(', ')}\n`
926
+ + ` Installed @livx.cc/appwrap is ${ver}. Either it predates these keys (upgrade), or they are typos (remove them).\n`
927
+ + ` Refusing to build: an ignored key means the shell does NOT carry what the config claims.`);
928
+ process.exit(1);
914
929
  }
915
930
 
916
931
  // Manifest as source: the appwrap config wins, the PWA manifest fills the gaps, template default last.
@@ -1076,6 +1091,21 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
1076
1091
  // styling). Declaring INStartCallIntent in an app with no calling feature is an App Review flag.
1077
1092
  extras.push(` <key>NSUserActivityTypes</key>\n <array>\n <string>INSendMessageIntent</string>\n </array>`);
1078
1093
  }
1094
+ // Config-declared plist keys (`iosInfoPlist`). Inside the idempotent block, so removing the config
1095
+ // key removes the plist key — the same both-ways contract as every other stamp here. A key the
1096
+ // TEMPLATE already declares outside the block would produce a duplicate <key> in one dict, which is
1097
+ // an invalid plist that Xcode accepts and the App Store rejects, so that case throws instead.
1098
+ for (const [key, value] of Object.entries(cfg.iosInfoPlist ?? {})) {
1099
+ if (new RegExp(`<key>${key}</key>`).test(src)) {
1100
+ throw new Error(`iosInfoPlist: "${key}" is already declared in the app's Info.plist — remove it from the config (a duplicate <key> is an invalid plist).`);
1101
+ }
1102
+ const body = typeof value === 'boolean' ? ` <${value}/>`
1103
+ : typeof value === 'number' ? ` <integer>${value}</integer>`
1104
+ : Array.isArray(value) ? ` <array>\n${value.map((v) => ` <string>${v}</string>`).join('\n')}\n </array>`
1105
+ : ` <string>${value}</string>`;
1106
+ extras.push(` <key>${key}</key>\n${body}`);
1107
+ }
1108
+
1079
1109
  if (extras.length) {
1080
1110
  src = src.replace(
1081
1111
  /<\/dict>\s*<\/plist>\s*$/,
package/src/config.ts CHANGED
@@ -286,6 +286,14 @@ export interface AppwrapConfig {
286
286
  * string / string[]. NOTE: the entitlement must also be enabled on the App ID / provisioning profile
287
287
  * (some need Apple approval) or a distribution build won't sign. Absent → no change. */
288
288
  iosEntitlements?: Record<string, boolean | string | string[]>;
289
+ /** Extra keys merged into the app's generated `Info.plist`, for plist facts the module system
290
+ * doesn't model — e.g. `{ NSSupportsLiveActivities: true }`, which is what ActivityKit requires and
291
+ * which carries NO entitlement and NO portal step. Stamped inside the idempotent
292
+ * `<!-- appwrap:begin -->` block, so it is added and removed with the config rather than accumulating.
293
+ * Value shapes: boolean → `<true/>`/`<false/>`, number → `<integer>`, string → `<string>`,
294
+ * string[] → `<array>` of strings. A key the template already declares OUTSIDE the block (e.g.
295
+ * `CFBundleName`) is refused loudly — two `<key>`s in one dict is an invalid plist. */
296
+ iosInfoPlist?: Record<string, boolean | number | string | string[]>;
289
297
  /** iOS code-signing style for device / `deploy` builds. Default `'auto'` (Xcode automatic signing —
290
298
  * requires the team's Apple ID signed into Xcode's GUI to mint profiles). Set `'manual'` to sign
291
299
  * device builds with provisioning profiles ALREADY installed on this machine (matched by
@@ -380,15 +388,18 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
380
388
 
381
389
  /**
382
390
  * Top-level keys appwrap recognizes — KEEP IN SYNC with `AppwrapConfig` above (top-level only; nested
383
- * keys like `push.ios` are not listed). Drives `unknownConfigKeys`, which warns (never fails) on stray
391
+ * keys like `push.ios` are not listed). Drives `unknownConfigKeys`, which FAILS the build on stray
384
392
  * keys at load time. The motivating bug: a config written for a NEWER appwrap silently no-ops its
385
393
  * unknown keys on an OLDER installed version (e.g. `targetedDevices` before 0.39 → a universal build
386
- * with no error, then an App Store rejection). A loud warning turns that silent no-op into a signal.
394
+ * with no error, then an App Store rejection; `iosInfoPlist` before 0.61.5 → a Live Activity refused
395
+ * on the device while every gate stayed green). This was a warning until 0.61.5 and a warning is not
396
+ * a gate — it scrolls past in CI. Adding a key here is therefore MANDATORY when adding it to
397
+ * `AppwrapConfig`, or every config that uses it stops building.
387
398
  */
388
399
  export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
389
400
  'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'ci', 'debug',
390
401
  'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'modulePacks', 'name',
391
- 'androidLockTextZoom', 'envSwitcher', 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
402
+ 'androidLockTextZoom', 'envSwitcher', 'iosEntitlements', 'iosInfoPlist', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
392
403
  'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle', 'store',
393
404
  'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
394
405
  'usesNonExemptEncryption', 'vendorPaths', 'version',