@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.23.2",
3
+ "version": "0.25.0",
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",
@@ -1,5 +1,11 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
- <!-- Paths FileProvider may expose. kit.share.files writes into cacheDir/shared. -->
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>
@@ -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
- { name: 'app', core: true, group: 'system', capabilities: { app: 'native', browser: 'native' } },
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
- // ── oauth — system-browser OAuth (iOS ASWebAuthenticationSession) ──
142
- // Opt-in, strippable (own handler file). No permissions/entitlements — ASWebAuthenticationSession
143
- // needs none; it only relies on the app's urlScheme so the provider redirect returns to the app.
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: false } },
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
- for (const src of [envGlobalsJs(), APPWRAP_GLOBALS_JS, mediaGuard, swGuard, NATIVE_FEEL_JS]) {
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
  });