@livx.cc/appwrap 0.61.4 → 0.61.5
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 +1 -1
- package/runtime/app/shell/handlers-android.ts +35 -11
- package/runtime/app/shell/handlers-extended.ts +139 -11
- package/runtime/app/shell/notif-attachment.ts +138 -0
- package/runtime/app/shell/notif-identity.ts +64 -0
- package/runtime/tests/notif-identity.test.ts +44 -1
- package/src/cli.ts +35 -5
- package/src/config.ts +14 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
107
|
-
//
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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,6 +558,86 @@ 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
|
|
@@ -516,10 +645,9 @@ function iconNSData(icon: string): NSData | null {
|
|
|
516
645
|
* Returns null pre-iOS-15 or if any step declines (caller falls back to plain content).
|
|
517
646
|
*/
|
|
518
647
|
function communicationContent(content: UNMutableNotificationContent, id: string, ident: NotifIdentity): UNNotificationContent | null {
|
|
519
|
-
// iOS 15
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
648
|
+
// iOS 15+ AND the NSUserActivityTypes/entitlement pair — without both, SpringBoard denies the
|
|
649
|
+
// API and this would burn an INInteraction donation to learn what the bundle already states.
|
|
650
|
+
if (!communicationStylingAvailable()) return null;
|
|
523
651
|
try {
|
|
524
652
|
const displayName = ident.senderName || ident.title;
|
|
525
653
|
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
|
+
}
|
|
@@ -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
|
|
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
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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',
|