@livx.cc/appwrap 0.23.2 → 0.24.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.24.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
  {
@@ -40,6 +40,11 @@ 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`). */
@@ -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,
@@ -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']))) {
@@ -0,0 +1,367 @@
1
+ import { Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { startActivityForResult } from './android-helpers';
4
+
5
+ declare const android: any, java: any;
6
+
7
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
8
+
9
+ type Dir = 'documents' | 'data' | 'cache';
10
+
11
+ /** Strip a leading slash and reject any `..` segment so a path can't escape its sandbox root. */
12
+ function safeRelPath(path: string): string {
13
+ const rel = String(path).replace(/^\/+/, '');
14
+ if (rel.split('/').some((seg) => seg === '..')) throw err('NATIVE_ERROR', `Path traversal rejected: ${path}`);
15
+ return rel;
16
+ }
17
+
18
+ /**
19
+ * `kit.fs` shell handlers — app-sandbox file I/O + a system document picker, ONE API across iOS
20
+ * and Android. No runtime permission: every root is inside the app sandbox and the picker returns
21
+ * user-chosen security-scoped (iOS) / SAF (Android) URIs.
22
+ *
23
+ * iOS uses `FileManager` + `NSData`/`NSString`; dirs resolve via `NSSearchPathForDirectoriesInDomains`.
24
+ * Android uses `java.io.File` under `getFilesDir()`/`getCacheDir()`/`getExternalFilesDir()`, the
25
+ * existing share FileProvider for `getUri` content:// URIs, and `ACTION_OPEN_DOCUMENT` for the picker.
26
+ *
27
+ * DEVICE-GATED / UNVERIFIED this session: the iOS `UIDocumentPickerViewController` delegate and the
28
+ * Android `ACTION_OPEN_DOCUMENT` activity-result plumbing (both async, new to the repo).
29
+ */
30
+ export function registerFsHandlers(): void {
31
+ if (isIOS) registerIosFs();
32
+ else if (isAndroid) registerAndroidFs();
33
+ }
34
+
35
+ // ─────────────────────────────── iOS ───────────────────────────────
36
+
37
+ /** NSSearchPath domain index per dir; `documents`/`cache` are first-class, `data` → Application Support. */
38
+ function iosBaseDir(dir: Dir): string {
39
+ // NSDocumentDirectory=9, NSCachesDirectory=13, NSApplicationSupportDirectory=14; NSUserDomainMask=1.
40
+ const which = dir === 'documents' ? 9 : dir === 'cache' ? 13 : 14;
41
+ const paths = NSSearchPathForDirectoriesInDomains(which as any, 1 as any, true);
42
+ const base = paths.objectAtIndex(0) as string;
43
+ // Application Support isn't auto-created; ensure it exists so `data` writes don't fail.
44
+ if (which === 14) NSFileManager.defaultManager.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(base, true, null, null);
45
+ return base;
46
+ }
47
+
48
+ function iosResolve(path: string, dir: Dir): string {
49
+ return iosBaseDir(dir) + '/' + safeRelPath(path);
50
+ }
51
+
52
+ function registerIosFs(): void {
53
+ const fm = () => NSFileManager.defaultManager;
54
+
55
+ bridge.register('fs.read', ({ path, dir = 'data', encoding = 'utf8' }: any) => {
56
+ const full = iosResolve(path, dir);
57
+ if (!fm().fileExistsAtPath(full)) throw err('NATIVE_ERROR', `No such file: ${path}`);
58
+ const data = NSData.dataWithContentsOfFile(full);
59
+ if (!data) throw err('NATIVE_ERROR', `Read failed: ${path}`);
60
+ if (encoding === 'base64') return data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions);
61
+ return NSString.alloc().initWithDataEncoding(data, 4 /* NSUTF8StringEncoding */) as unknown as string;
62
+ });
63
+
64
+ bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8', recursive }: any) => {
65
+ const full = iosResolve(path, dir);
66
+ if (recursive) ensureParentIos(full);
67
+ const blob = encoding === 'base64'
68
+ ? NSData.alloc().initWithBase64EncodedStringOptions(data, 0 as unknown as NSDataBase64DecodingOptions)
69
+ : (NSString.stringWithString(String(data ?? '')) as any).dataUsingEncoding(4);
70
+ if (!blob) throw err('NATIVE_ERROR', `Encode failed: ${path}`);
71
+ if (!blob.writeToFileAtomically(full, true)) throw err('NATIVE_ERROR', `Write failed: ${path}`);
72
+ return { uri: NSURL.fileURLWithPath(full).absoluteString };
73
+ });
74
+
75
+ bridge.register('fs.append', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
76
+ const full = iosResolve(path, dir);
77
+ ensureParentIos(full);
78
+ const blob = encoding === 'base64'
79
+ ? NSData.alloc().initWithBase64EncodedStringOptions(data, 0 as unknown as NSDataBase64DecodingOptions)
80
+ : (NSString.stringWithString(String(data ?? '')) as any).dataUsingEncoding(4);
81
+ if (!blob) throw err('NATIVE_ERROR', `Encode failed: ${path}`);
82
+ if (!fm().fileExistsAtPath(full)) {
83
+ blob.writeToFileAtomically(full, true);
84
+ return;
85
+ }
86
+ const handle = NSFileHandle.fileHandleForWritingAtPath(full);
87
+ handle.seekToEndOfFile();
88
+ handle.writeData(blob);
89
+ handle.closeFile();
90
+ });
91
+
92
+ bridge.register('fs.delete', ({ path, dir = 'data' }: any) => {
93
+ fm().removeItemAtPathError(iosResolve(path, dir), null); // no-throw if absent
94
+ });
95
+
96
+ bridge.register('fs.list', ({ path, dir = 'data' }: any) => {
97
+ const full = iosResolve(path, dir);
98
+ const names = fm().contentsOfDirectoryAtPathError(full, null);
99
+ if (!names) throw err('NATIVE_ERROR', `No such dir: ${path}`);
100
+ const out: any[] = [];
101
+ for (let i = 0; i < names.count; i++) {
102
+ const name = names.objectAtIndex(i) as string;
103
+ out.push(iosStat(full + '/' + name, name));
104
+ }
105
+ return out;
106
+ });
107
+
108
+ bridge.register('fs.mkdir', ({ path, dir = 'data', recursive = true }: any) => {
109
+ const ok = fm().createDirectoryAtPathWithIntermediateDirectoriesAttributesError(
110
+ iosResolve(path, dir), recursive !== false, null, null
111
+ );
112
+ if (!ok) throw err('NATIVE_ERROR', `mkdir failed: ${path}`);
113
+ });
114
+
115
+ bridge.register('fs.stat', ({ path, dir = 'data' }: any) => {
116
+ const full = iosResolve(path, dir);
117
+ if (!fm().fileExistsAtPath(full)) throw err('NATIVE_ERROR', `No such path: ${path}`);
118
+ return iosStat(full, path);
119
+ });
120
+
121
+ bridge.register('fs.getUri', ({ path, dir = 'data' }: any) =>
122
+ NSURL.fileURLWithPath(iosResolve(path, dir)).absoluteString
123
+ );
124
+
125
+ bridge.register('fs.pickFile', ({ types, multiple }: any) => pickFileIos(types, !!multiple));
126
+ }
127
+
128
+ function ensureParentIos(full: string): void {
129
+ const parent = (NSString.stringWithString(full) as any).stringByDeletingLastPathComponent;
130
+ NSFileManager.defaultManager.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(parent, true, null, null);
131
+ }
132
+
133
+ function iosStat(full: string, name: string): any {
134
+ const fm = NSFileManager.defaultManager;
135
+ const isDirRef = new interop.Reference<boolean>(interop.types.bool, false);
136
+ const exists = fm.fileExistsAtPathIsDirectory(full, isDirRef as any);
137
+ const isDir = !!isDirRef.value;
138
+ const attrs = exists ? fm.attributesOfItemAtPathError(full, null) : null;
139
+ const size = attrs && !isDir ? Number(attrs.objectForKey(NSFileSize) ?? 0) : undefined;
140
+ const modDate = attrs ? attrs.objectForKey(NSFileModificationDate) : null;
141
+ const mtime = modDate ? Math.round(modDate.timeIntervalSince1970 * 1000) : undefined;
142
+ return {
143
+ name,
144
+ type: isDir ? 'dir' : 'file',
145
+ size,
146
+ mtime,
147
+ uri: NSURL.fileURLWithPath(full).absoluteString,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * iOS document picker via UIDocumentPickerViewController. Reads each chosen URL's bytes (under a
153
+ * security-scoped access window) and returns them base64-inline. DEVICE-GATED — the delegate
154
+ * callbacks (didPickDocumentsAtURLs / wasCancelled) are not exercised in this session.
155
+ */
156
+ function pickFileIos(types: string[] | undefined, multiple: boolean): Promise<any[]> {
157
+ return new Promise((resolve, reject) => {
158
+ Utils.dispatchToMainThread(() => {
159
+ try {
160
+ const utis = NSMutableArray.new();
161
+ // Map common MIME/extension hints to UTTypes; default to "any item".
162
+ (types ?? []).forEach((t) => {
163
+ const ut = (UTType as any).typeWithMIMEType?.(t) ?? (UTType as any).typeWithFilenameExtension?.(t.replace(/^\./, ''));
164
+ if (ut) utis.addObject(ut);
165
+ });
166
+ if (utis.count === 0) utis.addObject(UTTypeItem);
167
+
168
+ const picker = UIDocumentPickerViewController.alloc().initForOpeningContentTypes(utis as any);
169
+ picker.allowsMultipleSelection = multiple;
170
+
171
+ const DelegateClass = (NSObject as any).extend(
172
+ {
173
+ documentPickerDidPickDocumentsAtURLs(_p: any, urls: any) {
174
+ const out: any[] = [];
175
+ for (let i = 0; i < urls.count; i++) {
176
+ const url = urls.objectAtIndex(i);
177
+ const scoped = url.startAccessingSecurityScopedResource?.();
178
+ try {
179
+ const data = NSData.dataWithContentsOfURL(url);
180
+ if (data) {
181
+ out.push({
182
+ name: url.lastPathComponent,
183
+ mimeType: mimeForUrlIos(url),
184
+ size: data.length,
185
+ base64: data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions),
186
+ });
187
+ }
188
+ } finally {
189
+ if (scoped) url.stopAccessingSecurityScopedResource?.();
190
+ }
191
+ }
192
+ resolve(out);
193
+ },
194
+ documentPickerWasCancelled() {
195
+ resolve([]);
196
+ },
197
+ },
198
+ { protocols: [UIDocumentPickerDelegate] }
199
+ );
200
+ const delegate = DelegateClass.new();
201
+ (picker as any)._appwrapDelegate = delegate; // retain past the present call
202
+ picker.delegate = delegate;
203
+ Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(picker, true, null);
204
+ } catch (e: any) {
205
+ reject(err('NATIVE_ERROR', e?.message ?? 'document picker failed'));
206
+ }
207
+ });
208
+ });
209
+ }
210
+
211
+ function mimeForUrlIos(url: any): string {
212
+ const ut = (UTType as any).typeWithFilenameExtension?.(url.pathExtension);
213
+ return ut?.preferredMIMEType ?? 'application/octet-stream';
214
+ }
215
+
216
+ // ───────────────────────────── Android ─────────────────────────────
217
+
218
+ function androidBaseDir(dir: Dir): any {
219
+ const ctx = Utils.android.getApplicationContext();
220
+ if (dir === 'cache') return ctx.getCacheDir();
221
+ if (dir === 'documents') return ctx.getExternalFilesDir(null) ?? ctx.getFilesDir();
222
+ return ctx.getFilesDir(); // 'data'
223
+ }
224
+
225
+ function androidFile(path: string, dir: Dir): any {
226
+ return new java.io.File(androidBaseDir(dir), safeRelPath(path));
227
+ }
228
+
229
+ function readBytesAndroid(file: any): any {
230
+ const fis = new java.io.FileInputStream(file);
231
+ const baos = new java.io.ByteArrayOutputStream();
232
+ const buf = Array.create('byte', 8192);
233
+ let n: number;
234
+ while ((n = fis.read(buf)) !== -1) baos.write(buf, 0, n);
235
+ fis.close();
236
+ return baos.toByteArray();
237
+ }
238
+
239
+ function registerAndroidFs(): void {
240
+ bridge.register('fs.read', ({ path, dir = 'data', encoding = 'utf8' }: any) => {
241
+ const file = androidFile(path, dir);
242
+ if (!file.exists()) throw err('NATIVE_ERROR', `No such file: ${path}`);
243
+ const bytes = readBytesAndroid(file);
244
+ if (encoding === 'base64') return android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP);
245
+ return new java.lang.String(bytes, 'UTF-8').toString();
246
+ });
247
+
248
+ bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8', recursive }: any) => {
249
+ const file = androidFile(path, dir);
250
+ if (recursive) file.getParentFile()?.mkdirs();
251
+ writeAndroid(file, data, encoding, false);
252
+ return { uri: android.net.Uri.fromFile(file).toString() };
253
+ });
254
+
255
+ bridge.register('fs.append', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
256
+ const file = androidFile(path, dir);
257
+ file.getParentFile()?.mkdirs();
258
+ writeAndroid(file, data, encoding, true);
259
+ });
260
+
261
+ bridge.register('fs.delete', ({ path, dir = 'data' }: any) => {
262
+ androidFile(path, dir).delete(); // no-throw if absent
263
+ });
264
+
265
+ bridge.register('fs.list', ({ path, dir = 'data' }: any) => {
266
+ const file = androidFile(path, dir);
267
+ const children = file.listFiles();
268
+ if (!children) throw err('NATIVE_ERROR', `No such dir: ${path}`);
269
+ const out: any[] = [];
270
+ for (let i = 0; i < children.length; i++) out.push(androidStat(children[i], children[i].getName()));
271
+ return out;
272
+ });
273
+
274
+ bridge.register('fs.mkdir', ({ path, dir = 'data', recursive = true }: any) => {
275
+ const file = androidFile(path, dir);
276
+ const ok = recursive !== false ? file.mkdirs() : file.mkdir();
277
+ if (!ok && !file.isDirectory()) throw err('NATIVE_ERROR', `mkdir failed: ${path}`);
278
+ });
279
+
280
+ bridge.register('fs.stat', ({ path, dir = 'data' }: any) => {
281
+ const file = androidFile(path, dir);
282
+ if (!file.exists()) throw err('NATIVE_ERROR', `No such path: ${path}`);
283
+ return androidStat(file, path);
284
+ });
285
+
286
+ bridge.register('fs.getUri', ({ path, dir = 'data' }: any) => {
287
+ // Hand out a content:// via the existing share FileProvider so other apps (kit.share) can read it.
288
+ const ctx = Utils.android.getApplicationContext();
289
+ const authority = ctx.getPackageName() + '.fileprovider';
290
+ return androidx.core.content.FileProvider.getUriForFile(ctx, authority, androidFile(path, dir)).toString();
291
+ });
292
+
293
+ bridge.register('fs.pickFile', ({ types, multiple }: any) => pickFileAndroid(types, !!multiple));
294
+ }
295
+
296
+ function writeAndroid(file: any, data: string, encoding: string, append: boolean): void {
297
+ const bytes = encoding === 'base64'
298
+ ? android.util.Base64.decode(String(data ?? ''), android.util.Base64.DEFAULT)
299
+ : new java.lang.String(String(data ?? '')).getBytes('UTF-8');
300
+ const fos = new java.io.FileOutputStream(file, append);
301
+ fos.write(bytes);
302
+ fos.close();
303
+ }
304
+
305
+ function androidStat(file: any, name: string): any {
306
+ const isDir = file.isDirectory();
307
+ return {
308
+ name,
309
+ type: isDir ? 'dir' : 'file',
310
+ size: isDir ? undefined : file.length(),
311
+ mtime: file.lastModified(),
312
+ uri: android.net.Uri.fromFile(file).toString(),
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Android document picker via ACTION_OPEN_DOCUMENT (reuses the shared startActivityForResult).
318
+ * Reads each chosen content:// URI's bytes via the ContentResolver and returns them base64-inline.
319
+ * DEVICE-GATED — the activity-result plumbing is not exercised in this session.
320
+ */
321
+ async function pickFileAndroid(types: string[] | undefined, multiple: boolean): Promise<any[]> {
322
+ const intent = new android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT);
323
+ intent.addCategory(android.content.Intent.CATEGORY_OPENABLE);
324
+ intent.setType((types && types.length === 1) ? types[0] : '*/*');
325
+ if (types && types.length > 1) {
326
+ // EXTRA_MIME_TYPES needs a Java String[]; NativeScript won't auto-marshal a JS array here.
327
+ const arr = Array.create('java.lang.String', types.length);
328
+ types.forEach((t, i) => { arr[i] = t; });
329
+ intent.putExtra(android.content.Intent.EXTRA_MIME_TYPES, arr);
330
+ }
331
+ if (multiple) intent.putExtra(android.content.Intent.EXTRA_ALLOW_MULTIPLE, true);
332
+
333
+ const { resultCode, intent: result } = await startActivityForResult(intent);
334
+ if (resultCode !== android.app.Activity.RESULT_OK || !result) return [];
335
+
336
+ const uris: any[] = [];
337
+ const clip = result.getClipData?.();
338
+ if (clip) for (let i = 0; i < clip.getItemCount(); i++) uris.push(clip.getItemAt(i).getUri());
339
+ else if (result.getData()) uris.push(result.getData());
340
+
341
+ const cr = Utils.android.getApplicationContext().getContentResolver();
342
+ return uris.map((uri) => {
343
+ const stream = cr.openInputStream(uri);
344
+ const baos = new java.io.ByteArrayOutputStream();
345
+ const buf = Array.create('byte', 8192);
346
+ let n: number;
347
+ while ((n = stream.read(buf)) !== -1) baos.write(buf, 0, n);
348
+ stream.close();
349
+ const bytes = baos.toByteArray();
350
+ return {
351
+ name: queryDisplayName(cr, uri),
352
+ mimeType: cr.getType(uri) ?? 'application/octet-stream',
353
+ size: bytes.length,
354
+ base64: android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP),
355
+ };
356
+ });
357
+ }
358
+
359
+ function queryDisplayName(cr: any, uri: any): string {
360
+ const cursor = cr.query(uri, [android.provider.OpenableColumns.DISPLAY_NAME], null, null, null);
361
+ let name = 'file';
362
+ if (cursor) {
363
+ if (cursor.moveToFirst()) name = String(cursor.getString(0) ?? 'file');
364
+ cursor.close();
365
+ }
366
+ return name;
367
+ }
@@ -184,6 +184,44 @@ export function registerParityHandlers(): void {
184
184
  });
185
185
  });
186
186
 
187
+ // ── contacts bulk read (CNContactStore — requires permission) ──────
188
+ bridge.register('contacts.getAll', () => {
189
+ if (!isIOS) throw iosOnly();
190
+ const store = CNContactStore.new();
191
+ return new Promise((resolve, reject) => {
192
+ store.requestAccessForEntityTypeCompletionHandler(CNEntityType.Contacts, (granted: boolean, error: NSError | null) => {
193
+ if (!granted) {
194
+ return reject(err('DENIED', error?.localizedDescription ?? 'contacts access denied'));
195
+ }
196
+ try {
197
+ const collect = (labeled: NSArray<any>, map: (v: any) => string) => {
198
+ const out: string[] = [];
199
+ for (let i = 0; i < labeled.count; i++) out.push(map(labeled.objectAtIndex(i).value));
200
+ return out;
201
+ };
202
+ const keys = NSArray.arrayWithArray([
203
+ CNContactGivenNameKey,
204
+ CNContactFamilyNameKey,
205
+ CNContactPhoneNumbersKey,
206
+ CNContactEmailAddressesKey,
207
+ ] as any);
208
+ const request = CNContactFetchRequest.alloc().initWithKeysToFetch(keys);
209
+ const contacts: Array<{ name: string; phones: string[]; emails: string[] }> = [];
210
+ store.enumerateContactsWithFetchRequestErrorUsingBlock(request, null as any, (contact: any) => {
211
+ contacts.push({
212
+ name: `${contact.givenName ?? ''} ${contact.familyName ?? ''}`.trim(),
213
+ phones: collect(contact.phoneNumbers, (v) => String(v.stringValue)),
214
+ emails: collect(contact.emailAddresses, (v) => String(v)),
215
+ });
216
+ });
217
+ resolve({ contacts });
218
+ } catch (e: any) {
219
+ reject(err('NATIVE_ERROR', e.message));
220
+ }
221
+ });
222
+ });
223
+ });
224
+
187
225
  // ── calendar (EventKit) ────────────────────────────────────────────
188
226
  bridge.register('calendar.createEvent', ({ title, start, durationMin, notes }: any) => {
189
227
  if (!isIOS) throw iosOnly();
@@ -30,6 +30,14 @@ declare const HKStatisticsOptions: any;
30
30
  declare const HKUnit: any;
31
31
  declare const CNContactPickerViewController: any;
32
32
  declare const CNContactPickerDelegate: any;
33
+ // Contacts framework (contacts.getAll bulk read — CNContactStore, vs the ContactsUI picker above)
34
+ declare const CNContactStore: any;
35
+ declare const CNEntityType: any;
36
+ declare const CNContactFetchRequest: any;
37
+ declare const CNContactGivenNameKey: any;
38
+ declare const CNContactFamilyNameKey: any;
39
+ declare const CNContactPhoneNumbersKey: any;
40
+ declare const CNContactEmailAddressesKey: any;
33
41
  declare const EKEventStore: any;
34
42
  declare const EKEvent: any;
35
43
  declare const EKSpan: any;
@@ -0,0 +1,85 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+
4
+ declare const android: any;
5
+ // iOS keyboard-notification globals (marshalled from UIKit at runtime).
6
+ declare const NSNotificationCenter: any;
7
+ declare const UIKeyboardWillShowNotification: string;
8
+ declare const UIKeyboardWillHideNotification: string;
9
+ declare const UIKeyboardFrameEndUserInfoKey: string;
10
+
11
+ let observersArmed = false;
12
+
13
+ /**
14
+ * `kit.keyboard` shell handlers: dismiss the software keyboard (`keyboard.hide`)
15
+ * and forward show/hide so the page can lift content above it.
16
+ *
17
+ * Heights are emitted in CSS px — iOS keyboard frames are in points (≈ CSS px in
18
+ * a WKWebView), Android raw pixels are divided by display density. iOS observes
19
+ * `UIKeyboardWillShow/Hide`; Android watches the decor view's visible frame.
20
+ */
21
+ export function registerKeyboardHandlers(): void {
22
+ bridge.register('keyboard.hide', () => {
23
+ Utils.dispatchToMainThread(() => {
24
+ if (isIOS) {
25
+ // WKWebView is a UIView → endEditing resigns the active field's first responder.
26
+ (bridge.getWebView()?.ios as any)?.endEditing?.(true);
27
+ } else if (isAndroid) {
28
+ hideAndroidKeyboard();
29
+ }
30
+ });
31
+ });
32
+
33
+ if (observersArmed) return;
34
+ observersArmed = true;
35
+ if (isIOS) armIosKeyboardObservers();
36
+ else if (isAndroid) armAndroidKeyboardObserver();
37
+ }
38
+
39
+ /** iOS: NSNotificationCenter observers → emit keyboard.show {height} / keyboard.hide. */
40
+ function armIosKeyboardObservers(): void {
41
+ const center = NSNotificationCenter.defaultCenter;
42
+ center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillShowNotification, null, null, (note: any) => {
43
+ const value = note?.userInfo?.objectForKey?.(UIKeyboardFrameEndUserInfoKey);
44
+ const height = value ? value.CGRectValue.size.height : 0; // points ≈ CSS px in WKWebView
45
+ bridge.emit('keyboard.show', { height: Math.round(height) });
46
+ });
47
+ center.addObserverForNameObjectQueueUsingBlock(UIKeyboardWillHideNotification, null, null, () => {
48
+ bridge.emit('keyboard.hide');
49
+ });
50
+ }
51
+
52
+ /** Android: a global-layout listener compares the decor view's visible frame to its full height. */
53
+ function armAndroidKeyboardObserver(): void {
54
+ const activity = androidActivity();
55
+ if (!activity) return;
56
+ const rootView = activity.getWindow().getDecorView();
57
+ const density = Utils.android.getApplicationContext().getResources().getDisplayMetrics().density || 1;
58
+ let lastShown = false;
59
+ const listener = new android.view.ViewTreeObserver.OnGlobalLayoutListener({
60
+ onGlobalLayout() {
61
+ const rect = new android.graphics.Rect();
62
+ rootView.getWindowVisibleDisplayFrame(rect);
63
+ const screenHeight = rootView.getHeight();
64
+ const hiddenPx = screenHeight - rect.bottom; // band covered at the bottom
65
+ const shown = hiddenPx > screenHeight * 0.15; // >15% ≈ keyboard, not a nav/status bar
66
+ if (shown === lastShown) return;
67
+ lastShown = shown;
68
+ if (shown) bridge.emit('keyboard.show', { height: Math.round(hiddenPx / density) });
69
+ else bridge.emit('keyboard.hide');
70
+ },
71
+ });
72
+ rootView.getViewTreeObserver().addOnGlobalLayoutListener(listener);
73
+ }
74
+
75
+ function hideAndroidKeyboard(): void {
76
+ const activity = androidActivity();
77
+ if (!activity) return;
78
+ const imm = activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE);
79
+ const view = activity.getCurrentFocus() ?? activity.getWindow().getDecorView();
80
+ imm?.hideSoftInputFromWindow(view.getWindowToken(), 0);
81
+ }
82
+
83
+ function androidActivity(): any {
84
+ return Application.android.foregroundActivity ?? Application.android.startActivity;
85
+ }
@@ -119,6 +119,64 @@ export function serviceWorkerGuardJs(enabled: boolean): string {
119
119
  })();`;
120
120
  }
121
121
 
122
+ /**
123
+ * Make the shell behave like a regular native app when the web content tries to leave the app's own
124
+ * origin: hand external-origin navigations to the OS default browser (Safari / Chrome — via the core
125
+ * `app.openUrl` handler, which leaves the app) instead of replacing the shell WebView. Intercepts, at
126
+ * document-start (BEFORE the page wires its own handlers), two intents:
127
+ * - a click on an `<a href>` resolving to a DIFFERENT origin (incl. `target="_blank"`), and
128
+ * - `window.open(url, ...)` to a different origin.
129
+ * Same-origin links / SPA route changes pass straight through (the app keeps owning its own
130
+ * navigation), so this never ejects the user mid-flow inside their own app. Only runs in the top
131
+ * frame — embedded iframes (maps, video, ad frames) are left alone. Non-http(s) schemes
132
+ * (`tel:`/`mailto:`/`app:`) are ignored here and handled by the OS/WebView as usual.
133
+ *
134
+ * The request is dispatched fire-and-forget over the same raw transport the bridge listens on (iOS
135
+ * `webkit.messageHandlers.appwrap`, Android `appwrapNative.postMessage`) — independent of the
136
+ * consumer's native-kit, so it works even on an unmodified remotely-loaded (loader:'server') webapp.
137
+ *
138
+ * `enabled` = SHELL_CONFIG.openNewWindowsInBrowser (default false). Idempotent via a window guard.
139
+ */
140
+ export function externalNavGuardJs(enabled: boolean): string {
141
+ if (!enabled) return '';
142
+ return `(function(){
143
+ if (window.top !== window.self || window.__appwrapExtNavGuard) return;
144
+ window.__appwrapExtNavGuard = true;
145
+ function send(url){
146
+ try {
147
+ var json = JSON.stringify({ v:1, id:'extnav-'+Date.now()+'-'+Math.random().toString(36).slice(2),
148
+ kind:'request', method:'app.openUrl', params:{ url:url } });
149
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.appwrap) {
150
+ window.webkit.messageHandlers.appwrap.postMessage(json); return true; }
151
+ if (window.appwrapNative && window.appwrapNative.postMessage) {
152
+ window.appwrapNative.postMessage(json); return true; }
153
+ } catch (e) {}
154
+ return false;
155
+ }
156
+ // Resolve to an absolute http(s) URL that is on a DIFFERENT origin than the app shell; else null.
157
+ function external(href){
158
+ try {
159
+ var u = new URL(href, location.href);
160
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; // tel:/mailto:/app:/blob: → OS/WebView
161
+ return u.origin !== location.origin ? u.href : null;
162
+ } catch (e) { return null; }
163
+ }
164
+ document.addEventListener('click', function(e){
165
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
166
+ var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
167
+ if (!a) return;
168
+ var url = external(a.getAttribute('href'));
169
+ if (url && send(url)) { e.preventDefault(); e.stopPropagation(); }
170
+ }, true);
171
+ var open = window.open;
172
+ window.open = function(url){
173
+ var ext = url != null ? external(String(url)) : null;
174
+ if (ext && send(ext)) return null;
175
+ return open ? open.apply(window, arguments) : null;
176
+ };
177
+ })();`;
178
+ }
179
+
122
180
  /**
123
181
  * Native-feel injection shared by both CustomWebViews. Suppresses the "it's a
124
182
  * web page" tells: pinch / double-tap zoom, long-press callout & selection,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrap-shell",
3
3
  "main": "app/app.ts",
4
- "version": "0.18.0",
4
+ "version": "0.21.0",
5
5
  "private": true,
6
6
  "scripts": {
7
7
  "dev:ios": "ns run ios",
package/src/cli.ts CHANGED
@@ -363,6 +363,7 @@ export const SHELL_CONFIG = {
363
363
  debugLog: ${JSON.stringify(cfg.debugLog ?? '*')},
364
364
  devMenu: ${JSON.stringify(cfg.devMenu ?? true)},
365
365
  neutralizeServiceWorker: ${JSON.stringify(cfg.neutralizeServiceWorker ?? true)},
366
+ openNewWindowsInBrowser: ${JSON.stringify(cfg.openNewWindowsInBrowser ?? false)},
366
367
  pushIos: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.ios !== false)},
367
368
  pushAndroid: ${JSON.stringify(!!cfg.push?.enabled && cfg.push?.android !== false)},
368
369
  };
package/src/config.ts CHANGED
@@ -79,6 +79,13 @@ export interface AppwrapConfig {
79
79
  * for in-WebView web-push as a fallback. NOTE: keeping the SW is the only way to get web push; native
80
80
  * push is the separate `push` lane (APNs/FCM) and does NOT need a SW. */
81
81
  neutralizeServiceWorker?: boolean;
82
+ /** Hand navigations that LEAVE the app's own origin to the OS default browser (Safari / Chrome)
83
+ * instead of replacing the shell WebView — so external links and `window.open(...)`/`target="_blank"`
84
+ * behave like a regular native app (open in the system browser) rather than navigating away inside
85
+ * the shell. Covers external-origin `<a>` clicks (including `target="_blank"`) and `window.open()`;
86
+ * same-origin SPA navigation, subframes/iframes and `tel:`/`mailto:` are left untouched. Default
87
+ * `false` (unchanged in-WebView navigation). See `externalNavGuardJs`. */
88
+ openNewWindowsInBrowser?: boolean;
82
89
  /** Apple Development Team ID for device builds (Xcode → Settings → Accounts). */
83
90
  teamId?: string;
84
91
  /** Path (relative to the PWA project) to a StoreKit configuration file for LOCAL IAP