@livx.cc/appwrap 0.23.2 → 0.25.0
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_Resources/Android/src/main/res/xml/file_paths.xml +7 -1
- package/runtime/app/main-page.ts +4 -0
- package/runtime/app/shell/capabilities.manifest.ts +64 -12
- package/runtime/app/shell/config.ts +9 -0
- package/runtime/app/shell/custom-webview.android.ts +2 -2
- package/runtime/app/shell/custom-webview.ios.ts +3 -2
- package/runtime/app/shell/events.ts +11 -0
- package/runtime/app/shell/handlers-android.ts +43 -0
- package/runtime/app/shell/handlers-extended.ts +4 -1
- package/runtime/app/shell/handlers-fs.ts +372 -0
- package/runtime/app/shell/handlers-oauth.ts +90 -1
- package/runtime/app/shell/handlers-parity.ts +41 -13
- package/runtime/app/shell/handlers-push.ts +24 -1
- package/runtime/app/shell/handlers-reviews.ts +73 -0
- package/runtime/app/shell/handlers-scanner.ts +222 -0
- package/runtime/app/shell/handlers-speech.ts +336 -0
- package/runtime/app/shell/ios-frameworks.d.ts +8 -0
- package/runtime/app/shell/keyboard.ts +85 -0
- package/runtime/app/shell/web-quirks.ts +58 -0
- package/runtime/package.json +1 -1
- package/src/cli.ts +29 -2
- package/src/config.ts +11 -0
package/package.json
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
-
<!-- Paths FileProvider may expose.
|
|
2
|
+
<!-- Paths FileProvider may expose.
|
|
3
|
+
- cacheDir/shared: kit.share.files staging area (keep exact, share writes here).
|
|
4
|
+
- The three roots below back kit.fs.getUri() so content:// resolves for every FsDirectory:
|
|
5
|
+
cache→getCacheDir, data→getFilesDir, documents→getExternalFilesDir(null). -->
|
|
3
6
|
<paths>
|
|
4
7
|
<cache-path name="shared" path="shared/" />
|
|
8
|
+
<cache-path name="fs_cache" path="." />
|
|
9
|
+
<files-path name="fs_data" path="." />
|
|
10
|
+
<external-files-path name="fs_documents" path="." />
|
|
5
11
|
</paths>
|
package/runtime/app/main-page.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { registerParityHandlers } from './shell/handlers-parity';
|
|
|
6
6
|
import { registerSystemHandlers } from './shell/handlers-system';
|
|
7
7
|
import { registerBillingHandlers } from './shell/handlers-billing';
|
|
8
8
|
import { registerMediaHandlers } from './shell/handlers-media';
|
|
9
|
+
import { registerKeyboardHandlers } from './shell/keyboard';
|
|
10
|
+
import { registerFsHandlers } from './shell/handlers-fs';
|
|
9
11
|
import { registerPushHandlers } from './shell/handlers-push';
|
|
10
12
|
import { registerAndroidHandlers } from './shell/handlers-android';
|
|
11
13
|
import { registerOptionalHandlers } from './shell/optional-handlers.generated';
|
|
@@ -41,6 +43,8 @@ export function onPageLoaded(args: EventData): void {
|
|
|
41
43
|
registerSystemHandlers();
|
|
42
44
|
registerBillingHandlers();
|
|
43
45
|
registerMediaHandlers();
|
|
46
|
+
registerKeyboardHandlers();
|
|
47
|
+
registerFsHandlers();
|
|
44
48
|
registerPushHandlers();
|
|
45
49
|
// Last on purpose: overrides the iOS-only placeholders with Android impls
|
|
46
50
|
if (isAndroid) registerAndroidHandlers();
|
|
@@ -68,13 +68,18 @@ export const MODULES: ModuleManifest[] = [
|
|
|
68
68
|
{ name: 'haptics', core: true, group: 'core', capabilities: { haptics: 'native' } },
|
|
69
69
|
{ name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native' } },
|
|
70
70
|
{ name: 'storage', core: true, group: 'core', capabilities: { storage: 'native', secureStorage: 'native' } },
|
|
71
|
+
// fs: app-sandbox file I/O (documents/data/cache) + system document picker. Core — every root is
|
|
72
|
+
// inside the app sandbox and the picker returns user-chosen security-scoped URIs → zero perms.
|
|
73
|
+
{ name: 'fs', core: true, group: 'core', capabilities: { fs: 'native' } },
|
|
71
74
|
{ name: 'toast', core: true, group: 'core', capabilities: { toast: 'native', banner: 'native', updates: 'native' } },
|
|
72
75
|
{ name: 'statusBar', core: true, group: 'core', capabilities: { statusBar: 'native', themeColor: 'native' } },
|
|
73
76
|
{ name: 'device', core: true, group: 'extended', capabilities: { device: 'native' } },
|
|
74
77
|
{ name: 'clipboard', core: true, group: 'extended', capabilities: { clipboard: 'native' } },
|
|
75
78
|
{ name: 'network', core: true, group: 'extended', capabilities: { network: 'native' } },
|
|
76
|
-
{ name: 'screen', core: true, group: 'extended', capabilities: { screen: 'native', dialogs: 'native', orientation: 'native' } },
|
|
77
|
-
|
|
79
|
+
{ name: 'screen', core: true, group: 'extended', capabilities: { screen: 'native', dialogs: 'native', orientation: 'native', keyboard: 'native' } },
|
|
80
|
+
// badge: app-icon badge via the always-bundled notifications.setBadge handler — iOS sets the
|
|
81
|
+
// springboard badge; Android is an honest no-op (launchers own badges) → ios:true / android:false.
|
|
82
|
+
{ name: 'app', core: true, group: 'system', capabilities: { app: 'native', browser: 'native', badge: { ios: true, android: false } } },
|
|
78
83
|
|
|
79
84
|
// ── opt-in: permission / dependency / weight-bearing ───────────────────
|
|
80
85
|
// POST_NOTIFICATIONS / VIBRATE / USE_BIOMETRIC etc. are in the template's baseline manifest already.
|
|
@@ -118,6 +123,7 @@ export const MODULES: ModuleManifest[] = [
|
|
|
118
123
|
{
|
|
119
124
|
name: 'contacts', group: 'parity',
|
|
120
125
|
capabilities: { contacts: 'native' },
|
|
126
|
+
ios: { permissions: [{ key: 'NSContactsUsageDescription', domain: 'contacts', defaultUsage: 'Find which of your contacts already play, and invite the rest.' }] },
|
|
121
127
|
android: { permissions: ['android.permission.READ_CONTACTS'] },
|
|
122
128
|
},
|
|
123
129
|
{
|
|
@@ -129,23 +135,69 @@ export const MODULES: ModuleManifest[] = [
|
|
|
129
135
|
] },
|
|
130
136
|
android: { permissions: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'] },
|
|
131
137
|
},
|
|
132
|
-
{
|
|
133
|
-
name: 'reviews', group: 'system',
|
|
134
|
-
capabilities: { reviews: { ios: true, android: false } },
|
|
135
|
-
},
|
|
136
138
|
{
|
|
137
139
|
name: 'billing', group: 'billing',
|
|
138
140
|
capabilities: { billing: { ios: true, android: false } },
|
|
139
141
|
},
|
|
140
142
|
|
|
141
|
-
// ──
|
|
142
|
-
//
|
|
143
|
-
//
|
|
143
|
+
// ── reviews — in-app store review prompt — opt-in, STRIPPABLE (own handler + group) ──
|
|
144
|
+
// Moved out of the always-bundled parity/system handlers: the Android Play In-App Review path
|
|
145
|
+
// references `com.google.android.play:review` classes — a gradle dep that must NOT land in builds
|
|
146
|
+
// without `reviews` (missing-class crash / dead weight). iOS: SKStoreReviewController (no dep).
|
|
147
|
+
// HONEST LIMIT (Android): the dialog only surfaces for a Play-Store-track install; on a bare
|
|
148
|
+
// emulator / sideload the API resolves WITHOUT showing UI (documented; same class as billing).
|
|
149
|
+
{
|
|
150
|
+
name: 'reviews', group: 'reviews',
|
|
151
|
+
capabilities: { reviews: { ios: true, android: true } },
|
|
152
|
+
android: { gradleDeps: ['com.google.android.play:review:2.0.2'] },
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// ── oauth — system-browser OAuth (iOS ASWebAuthenticationSession / Android Chrome Custom Tabs) ──
|
|
156
|
+
// Opt-in, strippable (own handler file). iOS: ASWebAuthenticationSession (auto-closes on redirect).
|
|
157
|
+
// Android: a Chrome Custom Tab — it does NOT auto-close on the provider redirect, so the callback
|
|
158
|
+
// (`callbackScheme://…`) returns via the app's EXISTING urlScheme deep-link path; the handler hooks
|
|
159
|
+
// that delivery, matches the scheme, and resolves. androidx.browser is already a baseline dep
|
|
160
|
+
// (browser.open); declared here too so the module stays self-contained (CLI dedups). No permission.
|
|
144
161
|
// Lets Google et al. complete sign-in they reject inside an embedded WebView (disallowed_useragent).
|
|
145
|
-
// Android (Custom Tabs + intent callback) is a future addition.
|
|
146
162
|
{
|
|
147
163
|
name: 'oauth', group: 'oauth',
|
|
148
|
-
capabilities: { oauth: { ios: true, android:
|
|
164
|
+
capabilities: { oauth: { ios: true, android: true } },
|
|
165
|
+
android: { gradleDeps: ['androidx.browser:browser:1.8.0'] },
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// ── scanner — camera barcode/QR decode — opt-in (camera permission + decoder weight) ──
|
|
169
|
+
// Reuses the SAME camera permission as media/camera (the CLI dedups across active modules), so a
|
|
170
|
+
// scanner-only app still gets NSCameraUsageDescription / CAMERA without a second permission.
|
|
171
|
+
// iOS: AVCaptureMetadataOutput (no extra dep). Android: ZXing-android-embedded — it ships its own
|
|
172
|
+
// capture Activity, so the handler just launches it via startActivityForResult and reads the
|
|
173
|
+
// result (fewest moving parts vs ML Kit, which needs a hand-built Camera2/CameraX preview).
|
|
174
|
+
{
|
|
175
|
+
name: 'scanner', group: 'scanner',
|
|
176
|
+
capabilities: { scanner: 'native' },
|
|
177
|
+
ios: { permissions: [{ key: 'NSCameraUsageDescription', domain: 'camera', defaultUsage: 'Scan barcodes and QR codes with the camera.' }] },
|
|
178
|
+
android: {
|
|
179
|
+
permissions: ['android.permission.CAMERA'],
|
|
180
|
+
gradleDeps: ['com.journeyapps:zxing-android-embedded:4.3.0'],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// ── speech (TTS + STT) — opt-in module; ONE coherent kit.speech, TWO honest capabilities ──
|
|
185
|
+
// ONE module (not split TTS-core / STT-opt-in): TTS-only apps are rare and a split fractures
|
|
186
|
+
// `kit.speech` across tiers. The module declares the STT perms (mic + speech-recognition); the
|
|
187
|
+
// CLI only stamps them when `speech` is active, and TTS (`speak`/`voices`) rides along perm-free.
|
|
188
|
+
// It advertises TWO handshake caps so the kit gates each concern honestly:
|
|
189
|
+
// `speech` → TTS (synthesis), always native on a shell.
|
|
190
|
+
// `speechRecognition` → STT (transcription), always native on a shell.
|
|
191
|
+
// iOS: AVSpeechSynthesizer (TTS, no dep) + SFSpeechRecognizer/AVAudioEngine (STT). Android:
|
|
192
|
+
// TextToSpeech + SpeechRecognizer — both plain Java → NO kotlin flag, NO gradle dep.
|
|
193
|
+
{
|
|
194
|
+
name: 'speech', group: 'speech',
|
|
195
|
+
capabilities: { speech: 'native', speechRecognition: 'native' },
|
|
196
|
+
ios: { permissions: [
|
|
197
|
+
{ key: 'NSSpeechRecognitionUsageDescription', domain: 'speechRecognition', defaultUsage: 'Transcribe your speech to text.' },
|
|
198
|
+
{ key: 'NSMicrophoneUsageDescription', domain: 'microphone', defaultUsage: 'Listen to your voice for speech-to-text.' },
|
|
199
|
+
] },
|
|
200
|
+
android: { permissions: ['android.permission.RECORD_AUDIO'] },
|
|
149
201
|
},
|
|
150
202
|
|
|
151
203
|
// ── health (steps) — opt-in heavy module; FG live + BG via OS step store ──
|
|
@@ -198,7 +250,7 @@ export const MODULES: ModuleManifest[] = [
|
|
|
198
250
|
|
|
199
251
|
/** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
|
|
200
252
|
* groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
|
|
201
|
-
export const OPTIONAL_GROUPS = ['health', 'oauth'] as const;
|
|
253
|
+
export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech'] as const;
|
|
202
254
|
|
|
203
255
|
/** Resolve the active capability map for the handshake from a set of active capability names. */
|
|
204
256
|
export function buildCapabilityMap(
|
|
@@ -40,9 +40,18 @@ export const SHELL_CONFIG = {
|
|
|
40
40
|
* fights the app:// handler / remote-update detection). On by default; set false to opt out and keep
|
|
41
41
|
* the SW (e.g. for in-WebView web-push). See `serviceWorkerGuardJs`. */
|
|
42
42
|
neutralizeServiceWorker: true,
|
|
43
|
+
/** Open external-origin navigations (`<a>` to another origin, incl. `target="_blank"`, and
|
|
44
|
+
* `window.open(...)`) in the OS default browser (Safari / Chrome) instead of inside the shell
|
|
45
|
+
* WebView — regular-native-app behavior. Same-origin SPA navigation is untouched. Off by default.
|
|
46
|
+
* See `externalNavGuardJs`. */
|
|
47
|
+
openNewWindowsInBrowser: false,
|
|
43
48
|
/** Remote push configured, per platform (iOS aps-environment entitlement / Android FCM). Drives the
|
|
44
49
|
* `push` capability flag at runtime by platform — off unless `appwrap.json.push` enables it, so an
|
|
45
50
|
* un-provisioned build honestly reports 'none' (and a personal-team iOS build keeps `pushIos:false`). */
|
|
46
51
|
pushIos: false,
|
|
47
52
|
pushAndroid: false,
|
|
53
|
+
/** Optional backend URL that the shell POSTs the device token to NATIVELY as soon as it's acquired —
|
|
54
|
+
* `{ token, platform: 'ios'|'android' }`. Native HTTP avoids the WKWebView `app://` cross-origin/CORS
|
|
55
|
+
* wall, so a token reaches your server without any WebView fetch. Empty = the app handles sending. */
|
|
56
|
+
pushRegistrationUrl: '',
|
|
48
57
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WebView, Utils, knownFolders, path as nsPath, File } from '@nativescript/core';
|
|
2
2
|
import { SHELL_CONFIG } from './config';
|
|
3
3
|
import { mimeFor } from './mime';
|
|
4
|
-
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, serviceWorkerGuardJs } from './web-quirks';
|
|
4
|
+
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
|
|
5
5
|
import { envGlobalsJs } from './env';
|
|
6
6
|
import { requestPermissions } from './android-helpers';
|
|
7
7
|
|
|
@@ -27,7 +27,7 @@ const PROMPT_PREFIX = '__appwrap__:';
|
|
|
27
27
|
* native-feel. Globals first so the page can read __APPWRAP__ / __APPWRAP_BACKEND_ORIGIN__ before its
|
|
28
28
|
* own scripts run. Built lazily (not a const) so envGlobalsJs() detects against a live activity context. */
|
|
29
29
|
function buildBootstrapJs(): string {
|
|
30
|
-
return `${envGlobalsJs()}\n${APPWRAP_GLOBALS_JS}\n${TRANSPORT_SHIM}\n${serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker)}\n${NATIVE_FEEL_JS}`;
|
|
30
|
+
return `${envGlobalsJs()}\n${APPWRAP_GLOBALS_JS}\n${TRANSPORT_SHIM}\n${serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker)}\n${externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser)}\n${NATIVE_FEEL_JS}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WebView, knownFolders, path as nsPath, File } from '@nativescript/core';
|
|
2
2
|
import { SHELL_CONFIG } from './config';
|
|
3
3
|
import { mimeFor } from './mime';
|
|
4
|
-
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, mediaCaptureGuardJs, serviceWorkerGuardJs } from './web-quirks';
|
|
4
|
+
import { APPWRAP_GLOBALS_JS, NATIVE_FEEL_JS, mediaCaptureGuardJs, serviceWorkerGuardJs, externalNavGuardJs } from './web-quirks';
|
|
5
5
|
import { envGlobalsJs } from './env';
|
|
6
6
|
import { createUiDelegate } from './ios-ui-delegate';
|
|
7
7
|
import { appwrapNativeLog } from './native-log';
|
|
@@ -56,7 +56,8 @@ export class CustomWebView extends WebView {
|
|
|
56
56
|
const hasPlistKey = (k: string) => !!NSBundle.mainBundle.objectForInfoDictionaryKey(k);
|
|
57
57
|
const mediaGuard = mediaCaptureGuardJs(hasPlistKey('NSCameraUsageDescription'), hasPlistKey('NSMicrophoneUsageDescription'));
|
|
58
58
|
const swGuard = serviceWorkerGuardJs(SHELL_CONFIG.neutralizeServiceWorker);
|
|
59
|
-
|
|
59
|
+
const extNavGuard = externalNavGuardJs(SHELL_CONFIG.openNewWindowsInBrowser);
|
|
60
|
+
for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, mediaGuard, swGuard, extNavGuard, NATIVE_FEEL_JS]) {
|
|
60
61
|
const script = WKUserScript.alloc().initWithSourceInjectionTimeForMainFrameOnly(
|
|
61
62
|
src,
|
|
62
63
|
WKUserScriptInjectionTime.AtDocumentStart,
|
|
@@ -10,8 +10,19 @@ let pendingPushTap: { data: Record<string, string> } | null = null;
|
|
|
10
10
|
// (the cold-start-from-notification bug). So we gate delivery on the handshake.
|
|
11
11
|
let pwaReady = false;
|
|
12
12
|
|
|
13
|
+
/** A pending native-side consumer of an inbound deep link (e.g. the Android OAuth flow waiting for
|
|
14
|
+
* the provider's `callbackScheme://…` redirect). Returns true if it CONSUMED the URL — then it's an
|
|
15
|
+
* internal callback, not an app deep link, and must NOT reach the PWA. Set by handlers-oauth. */
|
|
16
|
+
let deepLinkInterceptor: ((url: string) => boolean) | null = null;
|
|
17
|
+
export function setDeepLinkInterceptor(fn: ((url: string) => boolean) | null): void {
|
|
18
|
+
deepLinkInterceptor = fn;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
/** Called by the iOS delegate (cold start or while running) and Android intents. */
|
|
14
22
|
export function onDeepLink(url: string): void {
|
|
23
|
+
// Give a native consumer (OAuth callback) first refusal — a matched OAuth redirect is internal
|
|
24
|
+
// plumbing, not an app deep link, so it's swallowed here and never forwarded to the PWA.
|
|
25
|
+
if (deepLinkInterceptor?.(url)) return;
|
|
15
26
|
if (pwaReady) bridge.emit('deeplink.open', { url });
|
|
16
27
|
else pendingDeepLink = url; // buffer until the PWA handshakes
|
|
17
28
|
}
|
|
@@ -320,6 +320,49 @@ export function registerAndroidHandlers(): void {
|
|
|
320
320
|
};
|
|
321
321
|
});
|
|
322
322
|
|
|
323
|
+
// ── contacts bulk read (ContactsContract query over all contacts) ──
|
|
324
|
+
bridge.register('contacts.getAll', async () => {
|
|
325
|
+
if (!(await requestPermissions(['android.permission.READ_CONTACTS']))) {
|
|
326
|
+
throw err('DENIED', 'contacts permission denied');
|
|
327
|
+
}
|
|
328
|
+
const CC = android.provider.ContactsContract;
|
|
329
|
+
const cr = context().getContentResolver();
|
|
330
|
+
const column = (uri: any, col: string, sel: string | null, args: string[] | null): string[] => {
|
|
331
|
+
const out: string[] = [];
|
|
332
|
+
const cursor = cr.query(uri, [col], sel, args, null);
|
|
333
|
+
if (cursor) {
|
|
334
|
+
const idx = cursor.getColumnIndex(col);
|
|
335
|
+
while (cursor.moveToNext()) out.push(String(cursor.getString(idx)));
|
|
336
|
+
cursor.close();
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const contacts: Array<{ name: string; phones: string[]; emails: string[] }> = [];
|
|
342
|
+
const cursor = cr.query(
|
|
343
|
+
CC.Contacts.CONTENT_URI,
|
|
344
|
+
[CC.Contacts._ID, CC.Contacts.DISPLAY_NAME],
|
|
345
|
+
null, null, null
|
|
346
|
+
);
|
|
347
|
+
if (cursor) {
|
|
348
|
+
const idIdx = cursor.getColumnIndex(CC.Contacts._ID);
|
|
349
|
+
const nameIdx = cursor.getColumnIndex(CC.Contacts.DISPLAY_NAME);
|
|
350
|
+
while (cursor.moveToNext()) {
|
|
351
|
+
const contactId = String(cursor.getString(idIdx));
|
|
352
|
+
const name = cursor.getString(nameIdx) ?? '';
|
|
353
|
+
contacts.push({
|
|
354
|
+
name,
|
|
355
|
+
phones: column(CC.CommonDataKinds.Phone.CONTENT_URI, CC.CommonDataKinds.Phone.NUMBER,
|
|
356
|
+
CC.CommonDataKinds.Phone.CONTACT_ID + ' = ?', [contactId]),
|
|
357
|
+
emails: column(CC.CommonDataKinds.Email.CONTENT_URI, CC.CommonDataKinds.Email.ADDRESS,
|
|
358
|
+
CC.CommonDataKinds.Email.CONTACT_ID + ' = ?', [contactId]),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
cursor.close();
|
|
362
|
+
}
|
|
363
|
+
return { contacts };
|
|
364
|
+
});
|
|
365
|
+
|
|
323
366
|
// ── calendar (CalendarContract direct insert) ──────────────────────
|
|
324
367
|
bridge.register('calendar.createEvent', async ({ title, start, durationMin, notes }: any) => {
|
|
325
368
|
if (!(await requestPermissions(['android.permission.WRITE_CALENDAR', 'android.permission.READ_CALENDAR']))) {
|
|
@@ -127,7 +127,10 @@ export function registerExtendedHandlers(): void {
|
|
|
127
127
|
Utils.dispatchToMainThread(() => {
|
|
128
128
|
UNUserNotificationCenter.currentNotificationCenter().setBadgeCountWithCompletionHandler?.(
|
|
129
129
|
count,
|
|
130
|
-
()
|
|
130
|
+
// iOS silently ignores the badge unless notification authorization (incl. .badge) was granted —
|
|
131
|
+
// the completion's error is the ONLY signal, so DON'T swallow it (else the badge just never
|
|
132
|
+
// appears with no explanation). Resolve regardless (the JS call itself didn't fail).
|
|
133
|
+
(error: any) => { if (error) console.warn('[appwrap] setBadge ignored — notifications not authorized?', String(error)); }
|
|
131
134
|
);
|
|
132
135
|
resolve();
|
|
133
136
|
});
|