@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 +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 +8 -2
- package/runtime/app/shell/config.ts +5 -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/handlers-android.ts +43 -0
- package/runtime/app/shell/handlers-fs.ts +367 -0
- package/runtime/app/shell/handlers-parity.ts +38 -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 +1 -0
- package/src/config.ts +7 -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
|
{
|
|
@@ -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
|
-
|
|
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,
|
package/runtime/package.json
CHANGED
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
|