@livx.cc/appwrap 0.26.0 → 0.28.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/AndroidManifest.xml +3 -0
- package/runtime/app/app.ts +18 -1
- package/runtime/app/shell/background-bootstrap.generated.ts +8 -0
- package/runtime/app/shell/background-context.ts +20 -0
- package/runtime/app/shell/capabilities.manifest.ts +22 -3
- package/runtime/app/shell/events.ts +20 -0
- package/runtime/app/shell/fcm-firebase.d.ts +2 -1
- package/runtime/app/shell/handlers-background.ts +279 -0
- package/runtime/app/shell/handlers-push.ts +0 -12
- package/runtime/app/shell/handlers-reviews.ts +2 -1
- package/runtime/app/shell/handlers-system.ts +121 -0
- package/runtime/app/shell/handlers.ts +6 -0
- package/runtime/references.d.ts +15 -0
- package/src/cli.ts +31 -0
- package/src/config.ts +20 -0
- package/src/derive.ts +67 -0
- package/runtime/app/shell/ios-frameworks.d.ts +0 -44
package/package.json
CHANGED
package/runtime/app/app.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Application, isIOS } from '@nativescript/core';
|
|
2
|
-
import { onDeepLink } from './shell/events';
|
|
2
|
+
import { onDeepLink, onShortcut } from './shell/events';
|
|
3
3
|
import { iosOrientationMask } from './shell/orientation';
|
|
4
4
|
import { installForegroundNotificationDelegate } from './shell/handlers-extended';
|
|
5
5
|
import { onApnsToken, onApnsError, onRemoteMessage } from './shell/handlers-push';
|
|
6
|
+
import { registerBackgroundLaunchHandlers } from './shell/background-bootstrap.generated';
|
|
6
7
|
|
|
7
8
|
/** APNs device token (NSData) → lowercase hex string the push backend expects. */
|
|
8
9
|
function apnsTokenToHex(deviceToken: NSData): string {
|
|
@@ -20,10 +21,26 @@ if (isIOS) {
|
|
|
20
21
|
applicationDidFinishLaunchingWithOptions(_app: UIApplication, launchOptions: NSDictionary<string, any>) {
|
|
21
22
|
// Set the notification delegate NOW so a cold-launch tap's deep link is delivered.
|
|
22
23
|
installForegroundNotificationDelegate();
|
|
24
|
+
// BGTaskScheduler launch handlers MUST be registered here (Apple's rule) — no-op unless the
|
|
25
|
+
// backgroundTask module is active (the CLI wires the generated bootstrap to the real impl).
|
|
26
|
+
registerBackgroundLaunchHandlers();
|
|
23
27
|
const url = launchOptions?.objectForKey(UIApplicationLaunchOptionsURLKey);
|
|
24
28
|
if (url) onDeepLink(url.absoluteString ?? String(url));
|
|
29
|
+
// Cold-launch from a home-screen quick action — Apple delivers it ONLY here in launchOptions
|
|
30
|
+
// (performActionForShortcutItem is NOT called for the launch action). Buffered until handshake.
|
|
31
|
+
const shortcut = launchOptions?.objectForKey(UIApplicationLaunchOptionsShortcutItemKey);
|
|
32
|
+
if (shortcut) onShortcut(String(shortcut.type));
|
|
25
33
|
return true;
|
|
26
34
|
},
|
|
35
|
+
// Warm activation of a quick action (app already running/backgrounded).
|
|
36
|
+
applicationPerformActionForShortcutItemCompletionHandler(
|
|
37
|
+
_app: UIApplication,
|
|
38
|
+
shortcutItem: UIApplicationShortcutItem,
|
|
39
|
+
completionHandler: (done: boolean) => void
|
|
40
|
+
) {
|
|
41
|
+
onShortcut(String(shortcutItem.type));
|
|
42
|
+
completionHandler(true);
|
|
43
|
+
},
|
|
27
44
|
applicationOpenURLOptions(_app: UIApplication, url: NSURL, _options: NSDictionary<string, any>) {
|
|
28
45
|
console.log('AppWrap: openURL', url.absoluteString);
|
|
29
46
|
onDeepLink(url.absoluteString);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated by `appwrap init`/`sync`. iOS BGTaskScheduler launch handlers MUST be registered at
|
|
3
|
+
* `applicationDidFinishLaunchingWithOptions` (Apple's rule) — too early for the page-load handler
|
|
4
|
+
* barrel. The AppDelegate calls {@link registerBackgroundLaunchHandlers} from didFinishLaunching; the
|
|
5
|
+
* CLI rewrites this to import the real implementation ONLY when the `backgroundTask` module is active
|
|
6
|
+
* (so a build without it never references BGTaskScheduler). Default = no-op. Do not edit.
|
|
7
|
+
*/
|
|
8
|
+
export function registerBackgroundLaunchHandlers(): void {}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-launch context seam — a tiny always-bundled module so the (always-on) `app.handshake`
|
|
3
|
+
* handler can report a wake id WITHOUT importing the strippable `handlers-background` file (which only
|
|
4
|
+
* lands when the `backgroundTask` module is active). The headless runner sets the id before loading
|
|
5
|
+
* the offscreen WebView; the handshake reads it ONCE (so a foreground handshake later in the same
|
|
6
|
+
* process never re-reports a stale wake). No NativeScript globals → also unit-testable.
|
|
7
|
+
*/
|
|
8
|
+
let pendingBackgroundTaskId: string | null = null;
|
|
9
|
+
|
|
10
|
+
/** Set by the headless background runner before it loads the offscreen WebView for `id`. */
|
|
11
|
+
export function setPendingBackgroundTaskId(id: string | null): void {
|
|
12
|
+
pendingBackgroundTaskId = id;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Read-and-clear the pending wake id (called by the handshake handler). */
|
|
16
|
+
export function consumePendingBackgroundTaskId(): string | null {
|
|
17
|
+
const id = pendingBackgroundTaskId;
|
|
18
|
+
pendingBackgroundTaskId = null;
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
@@ -76,10 +76,13 @@ export const MODULES: ModuleManifest[] = [
|
|
|
76
76
|
{ name: 'device', core: true, group: 'extended', capabilities: { device: 'native' } },
|
|
77
77
|
{ name: 'clipboard', core: true, group: 'extended', capabilities: { clipboard: 'native' } },
|
|
78
78
|
{ name: 'network', core: true, group: 'extended', capabilities: { network: 'native' } },
|
|
79
|
-
|
|
79
|
+
// privacyScreen: hide content in the app-switcher / block screenshots — permission-free, cheap → core.
|
|
80
|
+
{ name: 'screen', core: true, group: 'extended', capabilities: { screen: 'native', dialogs: 'native', orientation: 'native', keyboard: 'native', privacyScreen: { ios: true, android: true } } },
|
|
80
81
|
// badge: app-icon badge via the always-bundled notifications.setBadge handler — iOS sets the
|
|
81
82
|
// springboard badge; Android is an honest no-op (launchers own badges) → ios:true / android:false.
|
|
82
|
-
|
|
83
|
+
// app.canOpenUrl rides the always-native `app` cap (no new key). shortcuts: home-screen quick
|
|
84
|
+
// actions — iOS UIApplicationShortcutItem / Android dynamic shortcuts (API 25+).
|
|
85
|
+
{ name: 'app', core: true, group: 'system', capabilities: { app: 'native', browser: 'native', badge: { ios: true, android: false }, shortcuts: { ios: true, android: true } } },
|
|
83
86
|
|
|
84
87
|
// ── opt-in: permission / dependency / weight-bearing ───────────────────
|
|
85
88
|
// POST_NOTIFICATIONS / VIBRATE / USE_BIOMETRIC etc. are in the template's baseline manifest already.
|
|
@@ -246,11 +249,27 @@ export const MODULES: ModuleManifest[] = [
|
|
|
246
249
|
},
|
|
247
250
|
nativeSrc: 'health',
|
|
248
251
|
},
|
|
252
|
+
|
|
253
|
+
// ── backgroundTask — headless background execution (HEADLESS JS HANDLER) — opt-in, STRIPPABLE ──
|
|
254
|
+
// The OS wakes the app (possibly cold, no visible WebView) for a permitted task id; the shell builds
|
|
255
|
+
// an OFFSCREEN WebView, loads the app conveying the id (the handshake reports it), awaits the JS
|
|
256
|
+
// handler's `backgroundTask.finish`, then completes + reschedules the OS task. iOS: BGTaskScheduler
|
|
257
|
+
// (BGAppRefreshTaskRequest / BGProcessingTaskRequest) — NO gradle dep, but Info.plist MUST declare
|
|
258
|
+
// the permitted identifiers (`appwrap.json.backgroundTasks`, stamped by the CLI) + the fetch/processing
|
|
259
|
+
// background modes. Android: WorkManager periodic work (the work-runtime dep rides via this module),
|
|
260
|
+
// self-initialized by its androidx startup provider — nothing mandatory to stamp.
|
|
261
|
+
// DEVICE-UNVERIFIED: the native background-wake path (offscreen WebView under BGTask / WorkManager)
|
|
262
|
+
// compiles only; it has NOT been run on a device. See handlers-background.ts.
|
|
263
|
+
{
|
|
264
|
+
name: 'backgroundTask', group: 'backgroundTask',
|
|
265
|
+
capabilities: { backgroundTask: { ios: true, android: true } },
|
|
266
|
+
android: { gradleDeps: ['androidx.work:work-runtime:2.9.1'] },
|
|
267
|
+
},
|
|
249
268
|
];
|
|
250
269
|
|
|
251
270
|
/** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
|
|
252
271
|
* groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
|
|
253
|
-
export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech'] as const;
|
|
272
|
+
export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'backgroundTask'] as const;
|
|
254
273
|
|
|
255
274
|
/** Resolve the active capability map for the handshake from a set of active capability names. */
|
|
256
275
|
export function buildCapabilityMap(
|
|
@@ -4,6 +4,7 @@ import { connectivityStatus } from './handlers-extended';
|
|
|
4
4
|
|
|
5
5
|
let pendingDeepLink: string | null = null;
|
|
6
6
|
let pendingPushTap: { data: Record<string, string> } | null = null;
|
|
7
|
+
let pendingShortcut: string | null = null;
|
|
7
8
|
// True only once the PWA's JS has handshaked — i.e. the WebView is actually
|
|
8
9
|
// running our bundle and about to subscribe. Native page-load is too early:
|
|
9
10
|
// a deeplink.open emitted then lands in a WebView with no listener and is lost
|
|
@@ -27,6 +28,15 @@ export function onDeepLink(url: string): void {
|
|
|
27
28
|
else pendingDeepLink = url; // buffer until the PWA handshakes
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/** A home-screen shortcut was activated (iOS performActionForShortcutItem / cold-start launchOptions;
|
|
32
|
+
* Android the launch intent's `appwrap_shortcut` extra). Buffered until the handshake like deep links
|
|
33
|
+
* (cold-start: a shortcut that launched the app would otherwise land in a WebView with no listener). */
|
|
34
|
+
export function onShortcut(id: string): void {
|
|
35
|
+
if (!id) return;
|
|
36
|
+
if (pwaReady) bridge.emit('app.shortcut', { id });
|
|
37
|
+
else pendingShortcut = id;
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
/** Android: a tray notification (FCM) was tapped → re-launched the activity with the data payload as
|
|
31
41
|
* intent extras. Buffered until handshake like deep links (cold-start-from-notification). iOS routes
|
|
32
42
|
* taps via the AppDelegate (handlers-push onRemoteMessage). */
|
|
@@ -53,6 +63,11 @@ export function onPwaHandshake(): void {
|
|
|
53
63
|
pendingPushTap = null;
|
|
54
64
|
setTimeout(() => bridge.emit('push.tap', payload), 500);
|
|
55
65
|
}
|
|
66
|
+
if (pendingShortcut) {
|
|
67
|
+
const id = pendingShortcut;
|
|
68
|
+
pendingShortcut = null;
|
|
69
|
+
setTimeout(() => bridge.emit('app.shortcut', { id }), 500);
|
|
70
|
+
}
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
/** Wire lifecycle + connectivity event forwarding (the PWA subscribes to these). */
|
|
@@ -77,6 +92,11 @@ function wireAndroidDeepLinks(): void {
|
|
|
77
92
|
if (data) onDeepLink(String(data.toString()));
|
|
78
93
|
const tap = readFcmTapExtras(intent);
|
|
79
94
|
if (tap) onPushTap(tap);
|
|
95
|
+
const shortcutId = intent?.getStringExtra?.('appwrap_shortcut');
|
|
96
|
+
if (shortcutId) {
|
|
97
|
+
onShortcut(String(shortcutId));
|
|
98
|
+
intent.removeExtra?.('appwrap_shortcut'); // consume — don't re-fire on the next relayout read
|
|
99
|
+
}
|
|
80
100
|
} catch (e) {
|
|
81
101
|
console.warn('AppWrap: intent read failed', e);
|
|
82
102
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Minimal ambient declarations for the Firebase Messaging Android classes the FCM service extends.
|
|
3
3
|
* The full classes are provided at runtime by the `firebase-messaging` AAR (added by the CLI only
|
|
4
4
|
* when push is wired) — the NativeScript Android runtime marshals them. Declare-merges into the
|
|
5
|
-
* `com` namespace from @nativescript/types-android.
|
|
5
|
+
* `com` namespace from @nativescript/types-android. (iOS SDK frameworks are typed via the
|
|
6
|
+
* @nativescript/types-ios references in references.d.ts.)
|
|
6
7
|
*/
|
|
7
8
|
declare namespace com.google.firebase.messaging {
|
|
8
9
|
class RemoteMessage {
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
import { SHELL_CONFIG } from './config';
|
|
4
|
+
import { setPendingBackgroundTaskId } from './background-context';
|
|
5
|
+
import { CustomWebView } from './custom-webview';
|
|
6
|
+
|
|
7
|
+
// ── BackgroundTasks (iOS) — NS auto-links the framework via metadata when these are referenced.
|
|
8
|
+
// Not in the default @nativescript/types-ios set, so declared `any` (repo idiom; see handlers-speech).
|
|
9
|
+
declare const BGTaskScheduler: any;
|
|
10
|
+
declare const BGAppRefreshTaskRequest: any;
|
|
11
|
+
declare const BGProcessingTaskRequest: any;
|
|
12
|
+
declare const NSDate: any;
|
|
13
|
+
declare const android: any;
|
|
14
|
+
declare const androidx: any;
|
|
15
|
+
declare const java: any;
|
|
16
|
+
|
|
17
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Headless background execution with a HEADLESS JS HANDLER contract.
|
|
21
|
+
*
|
|
22
|
+
* Bridge methods (called from the app's foreground OR the offscreen background WebView):
|
|
23
|
+
* - `backgroundTask.schedule` → ask the OS to (re)schedule a wake for an id.
|
|
24
|
+
* - `backgroundTask.cancel` → drop a scheduled wake.
|
|
25
|
+
* - `backgroundTask.finish` → the JS handler is done → complete the OS task + reschedule.
|
|
26
|
+
*
|
|
27
|
+
* Headless runner (the OS-wake path): {@link registerBackgroundTaskLaunchHandlers} (iOS, called from
|
|
28
|
+
* the AppDelegate at didFinishLaunching) registers a BGTask launch handler per PERMITTED id. On fire
|
|
29
|
+
* it stamps the wake id ({@link setPendingBackgroundTaskId}), builds an OFFSCREEN WebView (reusing
|
|
30
|
+
* `CustomWebView` → same scheme handler + bridge wiring as the visible one), loads the app so the
|
|
31
|
+
* handshake reports the id, and awaits `backgroundTask.finish`. Android runs the same loop inside a
|
|
32
|
+
* `WorkManager` Worker (see {@link AppwrapBackgroundWorker}).
|
|
33
|
+
*
|
|
34
|
+
* ⚠ DEVICE-UNVERIFIED: the native background-wake path (offscreen WebView under BGTask / WorkManager)
|
|
35
|
+
* COMPILES ONLY — it has NOT been run on a device this session. The any-typed FFI hides selector
|
|
36
|
+
* typos and the cold-launch headless-WebView lifecycle is the genuinely device-gated unknown. The
|
|
37
|
+
* bridge contract (schedule/cancel/finish ⇄ kit) and the dispatch logic are unit-tested in the kit.
|
|
38
|
+
*/
|
|
39
|
+
export function registerBackgroundTaskHandlers(): void {
|
|
40
|
+
if (isIOS) registerIos();
|
|
41
|
+
else if (isAndroid) registerAndroid();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Permitted task ids (iOS BGTaskSchedulerPermittedIdentifiers / the app's declared set). Read from
|
|
45
|
+
* the stamped Info.plist on iOS; on Android any id WorkManager enqueues is valid. */
|
|
46
|
+
function permittedIosIds(): string[] {
|
|
47
|
+
try {
|
|
48
|
+
const arr = NSBundle.mainBundle.objectForInfoDictionaryKey('BGTaskSchedulerPermittedIdentifiers');
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
const n = arr?.count ?? 0;
|
|
51
|
+
for (let i = 0; i < n; i++) out.push(String(arr.objectAtIndex(i)));
|
|
52
|
+
return out;
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── shared headless-run plumbing ────────────────────────────────────────────
|
|
59
|
+
// One offscreen WebView per in-flight task id, kept alive (ARC/GC + the bridge attach) until the JS
|
|
60
|
+
// handler reports finish. The `finish` handler resolves the matching pending run.
|
|
61
|
+
interface PendingRun { resolve: (success: boolean) => void; webView: CustomWebView | null; }
|
|
62
|
+
const pendingRuns = new Map<string, PendingRun>();
|
|
63
|
+
|
|
64
|
+
/** Build an offscreen WebView, attach the bridge, and load the app so its handshake reports `id`. The
|
|
65
|
+
* returned promise resolves when the JS handler calls `backgroundTask.finish` (or `abort()` fires).
|
|
66
|
+
* REUSES `CustomWebView` (scheme handler + bridge injection) — no duplicated transport. */
|
|
67
|
+
function runHeadless(id: string): Promise<boolean> {
|
|
68
|
+
return new Promise<boolean>((resolve) => {
|
|
69
|
+
setPendingBackgroundTaskId(id); // the next handshake reports this wake id
|
|
70
|
+
const webView = new CustomWebView();
|
|
71
|
+
pendingRuns.set(id, { resolve, webView });
|
|
72
|
+
// Detach the bridge from whatever (visible) WebView it held, attach the offscreen one so this run's
|
|
73
|
+
// invokes (incl. backgroundTask.finish) route here. A cold background launch has no visible view.
|
|
74
|
+
bridge.attach(webView);
|
|
75
|
+
Utils.dispatchToMainThread(() => {
|
|
76
|
+
try {
|
|
77
|
+
loadAppInto(webView, id);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
finishRun(id, false); // load failed → report failure, let the OS reschedule
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// An offscreen WebView's native peer (`.ios`/`.android`) is created lazily; off the visual tree it may
|
|
86
|
+
// never materialize (the device-gated cold-launch risk). Bound the poll so we can't spin forever —
|
|
87
|
+
// ~2s (40 × 50ms), then give up and let the OS reschedule rather than leak a timer / drain battery.
|
|
88
|
+
const LOAD_PEER_MAX_ATTEMPTS = 40;
|
|
89
|
+
|
|
90
|
+
/** Point an (offscreen) WebView at the app entry — mirrors main-page.loadBundle's loader branches so
|
|
91
|
+
* the same www/app:// / server URL serves the background run. Retries until the native peer exists,
|
|
92
|
+
* bounded by {@link LOAD_PEER_MAX_ATTEMPTS} → finish(false) on exhaustion. */
|
|
93
|
+
function loadAppInto(webView: CustomWebView, id: string, attempt = 0): void {
|
|
94
|
+
const retry = (peerMissing: boolean): boolean => {
|
|
95
|
+
if (!peerMissing) return false;
|
|
96
|
+
if (attempt >= LOAD_PEER_MAX_ATTEMPTS) { finishRun(id, false); return true; }
|
|
97
|
+
setTimeout(() => loadAppInto(webView, id, attempt + 1), 50);
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
if (isIOS) {
|
|
101
|
+
const wk = webView.ios as WKWebView;
|
|
102
|
+
if (retry(!wk)) return;
|
|
103
|
+
if (SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl) {
|
|
104
|
+
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(SHELL_CONFIG.serverUrl)));
|
|
105
|
+
} else {
|
|
106
|
+
wk.loadRequest(NSURLRequest.requestWithURL(NSURL.URLWithString(`app://local/${SHELL_CONFIG.entry}`)));
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
if (retry(!webView.android)) return;
|
|
110
|
+
webView.src = SHELL_CONFIG.loader === 'server' && SHELL_CONFIG.serverUrl
|
|
111
|
+
? SHELL_CONFIG.serverUrl
|
|
112
|
+
: `https://appwrap.local/${SHELL_CONFIG.entry}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Resolve an in-flight headless run (called by `backgroundTask.finish`, the safety abort, or a load
|
|
117
|
+
* failure). Tears the offscreen WebView's bridge attachment down. Idempotent. */
|
|
118
|
+
function finishRun(id: string, success: boolean): void {
|
|
119
|
+
const run = pendingRuns.get(id);
|
|
120
|
+
if (!run) return;
|
|
121
|
+
pendingRuns.delete(id);
|
|
122
|
+
try { if (bridge.getWebView() === run.webView) bridge.detach(); } catch { /* noop */ }
|
|
123
|
+
run.resolve(success);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── iOS ─────────────────────────────────────────────────────────────────────
|
|
127
|
+
function registerIos(): void {
|
|
128
|
+
const scheduler = () => BGTaskScheduler.sharedScheduler;
|
|
129
|
+
|
|
130
|
+
// schedule: app-refresh by default; processing when it needs network/charging (iOS routes those via
|
|
131
|
+
// BGProcessingTaskRequest, which exposes requiresNetworkConnectivity / requiresExternalPower).
|
|
132
|
+
bridge.register('backgroundTask.schedule', (p: any) => {
|
|
133
|
+
const id = String(p?.id ?? '');
|
|
134
|
+
if (!id) throw err('NATIVE_ERROR', 'backgroundTask.schedule: missing id');
|
|
135
|
+
submitIosRequest(id, p);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
bridge.register('backgroundTask.cancel', (p: any) => {
|
|
139
|
+
const id = String(p?.id ?? '');
|
|
140
|
+
if (id) scheduler().cancelTaskRequestWithIdentifier(id);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// finish: the JS handler is done → resolve the headless run, which (in the launch handler) completes
|
|
144
|
+
// the BGTask + resubmits the next request. Also reschedule here so a finish from the FOREGROUND
|
|
145
|
+
// (manual handler run) keeps the OS wake alive.
|
|
146
|
+
bridge.register('backgroundTask.finish', (p: any) => {
|
|
147
|
+
const id = String(p?.id ?? '');
|
|
148
|
+
const success = p?.success !== false;
|
|
149
|
+
if (id) finishRun(id, success);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Submit a BGTaskScheduler request for `id`. Processing request when network/charging constrained,
|
|
154
|
+
* else a lighter app-refresh request. `minIntervalMs` → earliestBeginDate floor. */
|
|
155
|
+
function submitIosRequest(id: string, p: any): void {
|
|
156
|
+
const needsProcessing = !!p?.requiresNetwork || !!p?.requiresCharging;
|
|
157
|
+
const req = needsProcessing
|
|
158
|
+
? BGProcessingTaskRequest.alloc().initWithIdentifier(id)
|
|
159
|
+
: BGAppRefreshTaskRequest.alloc().initWithIdentifier(id);
|
|
160
|
+
if (needsProcessing) {
|
|
161
|
+
req.requiresNetworkConnectivity = !!p?.requiresNetwork;
|
|
162
|
+
req.requiresExternalPower = !!p?.requiresCharging;
|
|
163
|
+
}
|
|
164
|
+
if (p?.minIntervalMs) {
|
|
165
|
+
req.earliestBeginDate = NSDate.dateWithTimeIntervalSinceNow(Number(p.minIntervalMs) / 1000);
|
|
166
|
+
}
|
|
167
|
+
// submitTaskRequestError throws via the out-error; NS surfaces it as a thrown JS error.
|
|
168
|
+
BGTaskScheduler.sharedScheduler.submitTaskRequestError(req, null);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Register a BGTask LAUNCH handler per permitted id — MUST run at `applicationDidFinishLaunchingWith
|
|
173
|
+
* Options` (Apple's rule), so the AppDelegate calls this (see the generated background bootstrap). On
|
|
174
|
+
* fire: stamp the wake id, set the task's expirationHandler to abort the run, run the headless WebView
|
|
175
|
+
* loop, then `setTaskCompleted(success:)` + resubmit the next request. @internal (called from app.ts).
|
|
176
|
+
*/
|
|
177
|
+
export function registerBackgroundTaskLaunchHandlers(): void {
|
|
178
|
+
if (!isIOS) return;
|
|
179
|
+
for (const id of permittedIosIds()) {
|
|
180
|
+
// The launch handler runs on the queue we pass (null = a default background queue). It receives the
|
|
181
|
+
// BGTask; we drive the JS handler, then complete + reschedule.
|
|
182
|
+
BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifierUsingQueueLaunchHandler(
|
|
183
|
+
id,
|
|
184
|
+
null,
|
|
185
|
+
(task: any) => {
|
|
186
|
+
// The OS budget is nearly spent → abort the run (the kit also self-aborts at ~25s). Reporting
|
|
187
|
+
// failure lets the OS learn the task overran.
|
|
188
|
+
task.expirationHandler = () => finishRun(id, false);
|
|
189
|
+
runHeadless(id)
|
|
190
|
+
.then((success) => {
|
|
191
|
+
try { submitIosRequest(id, {}); } catch { /* a failed resubmit shouldn't crash the wake */ }
|
|
192
|
+
task.setTaskCompletedWithSuccess(success);
|
|
193
|
+
})
|
|
194
|
+
.catch(() => {
|
|
195
|
+
try { submitIosRequest(id, {}); } catch { /* noop */ }
|
|
196
|
+
task.setTaskCompletedWithSuccess(false);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Android (WorkManager) ────────────────────────────────────────────────────
|
|
204
|
+
function registerAndroid(): void {
|
|
205
|
+
const ctx = () => Utils.android.getApplicationContext();
|
|
206
|
+
const WM = () => androidx.work.WorkManager.getInstance(ctx());
|
|
207
|
+
|
|
208
|
+
bridge.register('backgroundTask.schedule', (p: any) => {
|
|
209
|
+
const id = String(p?.id ?? '');
|
|
210
|
+
if (!id) throw err('NATIVE_ERROR', 'backgroundTask.schedule: missing id');
|
|
211
|
+
// WorkManager periodic floor is 15 min; clamp a smaller hint up so enqueue doesn't reject it.
|
|
212
|
+
const ms = Math.max(15 * 60_000, Number(p?.minIntervalMs ?? 15 * 60_000));
|
|
213
|
+
const builder = new androidx.work.PeriodicWorkRequest.Builder(
|
|
214
|
+
AppwrapBackgroundWorker.class,
|
|
215
|
+
ms, java.util.concurrent.TimeUnit.MILLISECONDS
|
|
216
|
+
);
|
|
217
|
+
// The id rides as input data → the Worker reads it to drive the matching JS handler.
|
|
218
|
+
builder.setInputData(
|
|
219
|
+
new androidx.work.Data.Builder().putString('appwrap.taskId', id).build()
|
|
220
|
+
);
|
|
221
|
+
const constraints = new androidx.work.Constraints.Builder();
|
|
222
|
+
if (p?.requiresNetwork) constraints.setRequiredNetworkType(androidx.work.NetworkType.CONNECTED);
|
|
223
|
+
if (p?.requiresCharging) constraints.setRequiresCharging(true);
|
|
224
|
+
builder.setConstraints(constraints.build());
|
|
225
|
+
WM().enqueueUniquePeriodicWork(id, androidx.work.ExistingPeriodicWorkPolicy.UPDATE, builder.build());
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
bridge.register('backgroundTask.cancel', (p: any) => {
|
|
229
|
+
const id = String(p?.id ?? '');
|
|
230
|
+
if (id) WM().cancelUniqueWork(id);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// finish: resolve the run → the Worker's CountDownLatch releases → doWork returns success/failure.
|
|
234
|
+
// WorkManager reschedules periodic work itself (no resubmit needed, unlike iOS BGTask).
|
|
235
|
+
bridge.register('backgroundTask.finish', (p: any) => {
|
|
236
|
+
const id = String(p?.id ?? '');
|
|
237
|
+
finishRun(id, p?.success !== false);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* WorkManager headless Worker — created by WorkManager on a background launch. `doWork()` posts to the
|
|
243
|
+
* MAIN looper (the WebView must be built + driven on the UI thread), runs the headless WebView loop for
|
|
244
|
+
* the input task id, and blocks the worker thread on a `CountDownLatch` until `backgroundTask.finish`
|
|
245
|
+
* (or a timeout). Returns success/failure; WorkManager handles periodic rescheduling.
|
|
246
|
+
*
|
|
247
|
+
* ⚠ DEVICE-UNVERIFIED — compiles only (see the file header).
|
|
248
|
+
*/
|
|
249
|
+
@NativeClass()
|
|
250
|
+
@JavaProxy('cc.livx.appwrap.AppwrapBackgroundWorker')
|
|
251
|
+
export class AppwrapBackgroundWorker extends androidx.work.Worker {
|
|
252
|
+
constructor(context: any, params: any) {
|
|
253
|
+
super(context, params);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
doWork(): any {
|
|
257
|
+
const id = this.getInputData().getString('appwrap.taskId') ?? '';
|
|
258
|
+
if (!id) return androidx.work.ListenableWorker.Result.failure();
|
|
259
|
+
|
|
260
|
+
const latch = new java.util.concurrent.CountDownLatch(1);
|
|
261
|
+
const result = { success: false };
|
|
262
|
+
Utils.dispatchToMainThread(() => {
|
|
263
|
+
runHeadless(id)
|
|
264
|
+
.then((ok: boolean) => { result.success = ok; latch.countDown(); })
|
|
265
|
+
.catch(() => { latch.countDown(); });
|
|
266
|
+
});
|
|
267
|
+
// Block the worker thread (bounded — below the WorkManager 10-min ceiling) until the JS handler
|
|
268
|
+
// finishes. A timeout returns failure so WorkManager retries on its schedule.
|
|
269
|
+
const completed = latch.await(9, java.util.concurrent.TimeUnit.MINUTES);
|
|
270
|
+
if (!completed) finishRun(id, false);
|
|
271
|
+
return completed && result.success
|
|
272
|
+
? androidx.work.ListenableWorker.Result.success()
|
|
273
|
+
: androidx.work.ListenableWorker.Result.failure();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Reference the Worker class so the bundler/NS metadata retains the JavaProxy (mirrors how the FCM
|
|
278
|
+
// service is kept alive via its import side-effect). Without a reference the class can be tree-shaken.
|
|
279
|
+
void AppwrapBackgroundWorker;
|
|
@@ -166,18 +166,6 @@ export function registerPushHandlers(): void {
|
|
|
166
166
|
if (isAndroid) return androidDeleteToken();
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
-
// Dev/demo: ask the backend to push THIS device on demand. Goes native (NS Http) → no app:// CORS
|
|
170
|
-
// wall. Requires push.registrationUrl + a registered token; the backend honors `test:true`.
|
|
171
|
-
bridge.register('push.sendTest', () => {
|
|
172
|
-
const url = SHELL_CONFIG.pushRegistrationUrl;
|
|
173
|
-
if (!url) return Promise.reject(Object.assign(new Error('push.registrationUrl not configured'), { code: 'UNSUPPORTED' }));
|
|
174
|
-
if (!cachedToken) return Promise.reject(Object.assign(new Error('no device token yet — register first'), { code: 'NOT_READY' }));
|
|
175
|
-
return Http.request({
|
|
176
|
-
url, method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
177
|
-
content: JSON.stringify({ token: cachedToken, platform: isIOS ? 'ios' : 'android', test: true }),
|
|
178
|
-
}).then((res) => ({ status: res.statusCode }));
|
|
179
|
-
});
|
|
180
|
-
|
|
181
169
|
// Debug: auto-acquire + log the FCM token at boot so it's readable HEADLESSLY (adb logcat / appwrap
|
|
182
170
|
// logs) without a UI tap — the dev loop needs the token to send a test push. Android only (iOS push
|
|
183
171
|
// is off on personal teams); no-op when push isn't configured for this platform.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
|
|
4
|
-
// SKStoreReviewController
|
|
4
|
+
// SKStoreReviewController comes from the StoreKit typings referenced in references.d.ts. `com` is the
|
|
5
|
+
// Android namespace.
|
|
5
6
|
declare const com: any;
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -119,6 +119,70 @@ export function registerSystemHandlers(): void {
|
|
|
119
119
|
|
|
120
120
|
bridge.register('app.environment', () => appEnvironment());
|
|
121
121
|
|
|
122
|
+
// Probe whether the OS can open a URL. Custom schemes need declaring up-front (iOS
|
|
123
|
+
// LSApplicationQueriesSchemes via appwrap.json.queryUrlSchemes; Android <queries> via
|
|
124
|
+
// queryPackages) — common schemes (http/https/tel/mailto/sms) resolve without declaration.
|
|
125
|
+
bridge.register('app.canOpenUrl', ({ url }: { url: string }) => {
|
|
126
|
+
const target = String(url ?? '');
|
|
127
|
+
if (!target) return false;
|
|
128
|
+
if (isIOS) {
|
|
129
|
+
const nsUrl = NSURL.URLWithString(target);
|
|
130
|
+
return !!nsUrl && UIApplication.sharedApplication.canOpenURL(nsUrl);
|
|
131
|
+
}
|
|
132
|
+
if (isAndroid) {
|
|
133
|
+
const pm = Utils.android.getApplicationContext().getPackageManager();
|
|
134
|
+
const intent = new android.content.Intent(
|
|
135
|
+
android.content.Intent.ACTION_VIEW,
|
|
136
|
+
android.net.Uri.parse(target)
|
|
137
|
+
);
|
|
138
|
+
return pm.resolveActivity(intent, 0) != null;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Home-screen long-press quick actions. iOS: UIApplication.shortcutItems. Android: dynamic
|
|
144
|
+
// ShortcutManager shortcuts (API 25+; no-op below) whose launch intent carries appwrap_shortcut=<id>.
|
|
145
|
+
bridge.register('app.setShortcuts', ({ items }: { items: Array<{ id: string; title: string; subtitle?: string }> }) => {
|
|
146
|
+
const list = (items ?? []).filter((i) => i && i.id && i.title);
|
|
147
|
+
if (isIOS) {
|
|
148
|
+
Utils.dispatchToMainThread(() => {
|
|
149
|
+
const arr = NSMutableArray.alloc().init();
|
|
150
|
+
for (const i of list) {
|
|
151
|
+
const item = UIApplicationShortcutItem.alloc().initWithTypeLocalizedTitleLocalizedSubtitleIconUserInfo(
|
|
152
|
+
String(i.id), String(i.title), i.subtitle ? String(i.subtitle) : null, null, null
|
|
153
|
+
);
|
|
154
|
+
arr.addObject(item);
|
|
155
|
+
}
|
|
156
|
+
UIApplication.sharedApplication.shortcutItems = arr as any;
|
|
157
|
+
});
|
|
158
|
+
} else if (isAndroid) {
|
|
159
|
+
if (android.os.Build.VERSION.SDK_INT < 25) return; // ShortcutManager is API 25+
|
|
160
|
+
const ctx = Utils.android.getApplicationContext();
|
|
161
|
+
const sm = ctx.getSystemService(android.content.Context.SHORTCUT_SERVICE); // API 25+ string const
|
|
162
|
+
if (!sm) return;
|
|
163
|
+
const launchClass = (Application.android.foregroundActivity ?? Application.android.startActivity)?.getClass();
|
|
164
|
+
const shortcuts = new java.util.ArrayList();
|
|
165
|
+
for (const i of list) {
|
|
166
|
+
const intent = new android.content.Intent(android.content.Intent.ACTION_VIEW);
|
|
167
|
+
if (launchClass) intent.setClassName(ctx, launchClass.getName());
|
|
168
|
+
intent.putExtra('appwrap_shortcut', String(i.id));
|
|
169
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK | android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
|
170
|
+
const b = new android.content.pm.ShortcutInfo.Builder(ctx, String(i.id))
|
|
171
|
+
.setShortLabel(String(i.title))
|
|
172
|
+
.setLongLabel(String(i.subtitle ?? i.title))
|
|
173
|
+
.setIntent(intent);
|
|
174
|
+
shortcuts.add(b.build());
|
|
175
|
+
}
|
|
176
|
+
sm.setDynamicShortcuts(shortcuts);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Privacy screen — hide content in the app-switcher / block screenshots. iOS: cover the key window
|
|
181
|
+
// with a blur while inactive/backgrounded (wired via the app lifecycle below). Android: FLAG_SECURE.
|
|
182
|
+
bridge.register('screen.setPrivacy', ({ enabled }: { enabled: boolean }) => {
|
|
183
|
+
setPrivacyScreen(!!enabled);
|
|
184
|
+
});
|
|
185
|
+
|
|
122
186
|
bridge.register('browser.open', ({ url, toolbarColor }: { url: string; toolbarColor?: string }) => {
|
|
123
187
|
const target = String(url ?? '');
|
|
124
188
|
if (!target) throw err('NATIVE_ERROR', 'browser.open: empty url');
|
|
@@ -139,3 +203,60 @@ export function registerSystemHandlers(): void {
|
|
|
139
203
|
}
|
|
140
204
|
});
|
|
141
205
|
}
|
|
206
|
+
|
|
207
|
+
// ── privacy screen ────────────────────────────────────────────────────
|
|
208
|
+
// iOS keeps the enabled flag + lazily wires the lifecycle hooks once. The cover MUST go on at
|
|
209
|
+
// `willResignActive` — that fires BEFORE the app-switcher snapshot, whereas NS's suspendEvent
|
|
210
|
+
// (≈ didEnterBackground) fires AFTER it, so suspendEvent alone would leak the first snapshot. We
|
|
211
|
+
// observe the raw UIApplicationWillResignActiveNotification for the cover, removing it on
|
|
212
|
+
// becomeActive. Android flips FLAG_SECURE immediately (also blocks screenshots).
|
|
213
|
+
let privacyEnabled = false;
|
|
214
|
+
let privacyCover: UIView | null = null;
|
|
215
|
+
let privacyLifecycleWired = false;
|
|
216
|
+
|
|
217
|
+
function setPrivacyScreen(enabled: boolean): void {
|
|
218
|
+
privacyEnabled = enabled;
|
|
219
|
+
if (isIOS) {
|
|
220
|
+
wireIosPrivacyLifecycle();
|
|
221
|
+
if (!enabled) Utils.dispatchToMainThread(removeIosPrivacyCover); // disabling mid-foreground
|
|
222
|
+
} else if (isAndroid) {
|
|
223
|
+
Utils.dispatchToMainThread(() => {
|
|
224
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
225
|
+
const FLAG_SECURE = android.view.WindowManager.LayoutParams.FLAG_SECURE;
|
|
226
|
+
const window = activity?.getWindow();
|
|
227
|
+
if (!window) return;
|
|
228
|
+
if (enabled) window.setFlags(FLAG_SECURE, FLAG_SECURE);
|
|
229
|
+
else window.clearFlags(FLAG_SECURE);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function wireIosPrivacyLifecycle(): void {
|
|
235
|
+
if (privacyLifecycleWired) return;
|
|
236
|
+
privacyLifecycleWired = true;
|
|
237
|
+
const center = NSNotificationCenter.defaultCenter;
|
|
238
|
+
// willResignActive → cover BEFORE the OS captures the app-switcher snapshot (the critical timing).
|
|
239
|
+
center.addObserverForNameObjectQueueUsingBlock(
|
|
240
|
+
UIApplicationWillResignActiveNotification, null, null, () => { if (privacyEnabled) addIosPrivacyCover(); }
|
|
241
|
+
);
|
|
242
|
+
// didBecomeActive → reveal again once the user returns.
|
|
243
|
+
center.addObserverForNameObjectQueueUsingBlock(
|
|
244
|
+
UIApplicationDidBecomeActiveNotification, null, null, () => removeIosPrivacyCover()
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function addIosPrivacyCover(): void {
|
|
249
|
+
const window = UIApplication.sharedApplication.keyWindow;
|
|
250
|
+
if (!window || privacyCover) return;
|
|
251
|
+
const effect = UIBlurEffect.effectWithStyle(UIBlurEffectStyle.SystemMaterial);
|
|
252
|
+
const blur = UIVisualEffectView.alloc().initWithEffect(effect);
|
|
253
|
+
blur.frame = window.bounds;
|
|
254
|
+
blur.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
|
|
255
|
+
window.addSubview(blur);
|
|
256
|
+
privacyCover = blur;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function removeIosPrivacyCover(): void {
|
|
260
|
+
privacyCover?.removeFromSuperview();
|
|
261
|
+
privacyCover = null;
|
|
262
|
+
}
|
|
@@ -7,6 +7,7 @@ import { showBanner, dismissBanner } from './banner';
|
|
|
7
7
|
import { setStatusBarStyle } from './status-bar';
|
|
8
8
|
import { buildCapabilityMap } from './capabilities.manifest';
|
|
9
9
|
import { ACTIVE_MODULE_NAMES } from './active-modules.generated';
|
|
10
|
+
import { consumePendingBackgroundTaskId } from './background-context';
|
|
10
11
|
|
|
11
12
|
/** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
|
|
12
13
|
export const SHELL_BUILD = 'updates-devmenu-3';
|
|
@@ -27,12 +28,17 @@ export function registerHandlers(): void {
|
|
|
27
28
|
// kit degrades gracefully. Push is special — gated by per-platform build config, not the manifest.
|
|
28
29
|
const capabilities = buildCapabilityMap(new Set(ACTIVE_MODULE_NAMES), isIOS ? 'ios' : 'android') as Record<string, 'native' | 'none'>;
|
|
29
30
|
capabilities.push = (isIOS ? SHELL_CONFIG.pushIos : SHELL_CONFIG.pushAndroid) ? 'native' : 'none';
|
|
31
|
+
// Background launch: the headless runner (handlers-background) set the wake id before loading this
|
|
32
|
+
// (offscreen) WebView. Report it so `kit.backgroundTask` dispatches the registered handler. Consumed
|
|
33
|
+
// (read-once) so a later foreground handshake in the same process never re-reports a stale wake.
|
|
34
|
+
const backgroundTaskId = consumePendingBackgroundTaskId();
|
|
30
35
|
return {
|
|
31
36
|
protocol: 1,
|
|
32
37
|
platform: isIOS ? 'ios' : 'android',
|
|
33
38
|
app: { id: SHELL_CONFIG.appId, name: SHELL_CONFIG.name, version: SHELL_CONFIG.version, build: SHELL_BUILD, loader: SHELL_CONFIG.loader },
|
|
34
39
|
debug: { lastNotifTap: safeJson(ApplicationSettings.getString('kit:__notifTap', '')) },
|
|
35
40
|
capabilities,
|
|
41
|
+
...(backgroundTaskId ? { backgroundTaskId } : {}),
|
|
36
42
|
};
|
|
37
43
|
});
|
|
38
44
|
|
package/runtime/references.d.ts
CHANGED
|
@@ -1,2 +1,17 @@
|
|
|
1
1
|
/// <reference path="./node_modules/@nativescript/types-ios/index.d.ts" />
|
|
2
2
|
/// <reference path="./node_modules/@nativescript/types-android/lib/android-32.d.ts" />
|
|
3
|
+
|
|
4
|
+
// Opt-in iOS framework typings the shell's native handlers use. @nativescript/types-ios ships the FULL
|
|
5
|
+
// SDK but its default index references only a curated subset (for IDE/compile speed); reference the
|
|
6
|
+
// extras here so handlers get real types + autocomplete instead of `any`. (NativeScript-documented:
|
|
7
|
+
// blog.nativescript.org/where-did-my-types-go) Path arch ('objc-x86_64') is where they were generated;
|
|
8
|
+
// the API surface is arch-independent for type-checking.
|
|
9
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!Contacts.d.ts" />
|
|
10
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!ContactsUI.d.ts" />
|
|
11
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!HealthKit.d.ts" />
|
|
12
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!EventKit.d.ts" />
|
|
13
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!CoreMotion.d.ts" />
|
|
14
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!StoreKit.d.ts" />
|
|
15
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!LocalAuthentication.d.ts" />
|
|
16
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!PhotosUI.d.ts" />
|
|
17
|
+
/// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!CoreLocation.d.ts" />
|
package/src/cli.ts
CHANGED
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
iosOrientations,
|
|
26
26
|
mergeManifest,
|
|
27
27
|
stampAndroidOrientation,
|
|
28
|
+
stampAndroidQueries,
|
|
29
|
+
stampPlistBackgroundTasks,
|
|
28
30
|
stampPlistOrientations,
|
|
29
31
|
} from './derive';
|
|
30
32
|
|
|
@@ -175,6 +177,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
|
|
|
175
177
|
reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
|
|
176
178
|
scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
|
|
177
179
|
speech: { file: './handlers-speech', fn: 'registerSpeechHandlers' },
|
|
180
|
+
backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
|
|
178
181
|
};
|
|
179
182
|
|
|
180
183
|
/** Generate the two composition artifacts in the wrapper: the active capability list (drives the
|
|
@@ -195,6 +198,18 @@ function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
|
|
|
195
198
|
`/** Generated by \`appwrap\` — only the active strippable modules are imported. Do not edit. */\n` +
|
|
196
199
|
`${imports}${imports ? '\n' : ''}\nexport function registerOptionalHandlers(): void {\n${calls}\n}\n`
|
|
197
200
|
);
|
|
201
|
+
|
|
202
|
+
// iOS BGTaskScheduler launch handlers must register at didFinishLaunching (the AppDelegate calls
|
|
203
|
+
// registerBackgroundLaunchHandlers) — too early for the page-load barrel. Wire the real impl ONLY
|
|
204
|
+
// when backgroundTask is active, so a build without it never references BGTaskScheduler. No-op default.
|
|
205
|
+
const bgActive = req.activeOptionalGroups.includes('backgroundTask');
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(shell, 'background-bootstrap.generated.ts'),
|
|
208
|
+
`/** Generated by \`appwrap\` — wires the iOS BGTask launch handlers only when backgroundTask is active. Do not edit. */\n` +
|
|
209
|
+
(bgActive
|
|
210
|
+
? `export { registerBackgroundTaskLaunchHandlers as registerBackgroundLaunchHandlers } from './handlers-background';\n`
|
|
211
|
+
: `export function registerBackgroundLaunchHandlers(): void {}\n`)
|
|
212
|
+
);
|
|
198
213
|
}
|
|
199
214
|
|
|
200
215
|
/** Stamp the active modules' gradle dependencies into Android app.gradle. Idempotent marker block. */
|
|
@@ -398,6 +413,12 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
398
413
|
// arrays (iPhone + ~ipad). Skipped when unset → keep the template's free-rotation default.
|
|
399
414
|
if (cfg.orientation) src = stampPlistOrientations(src, iosOrientations(cfg.orientation));
|
|
400
415
|
|
|
416
|
+
// Headless background tasks (backgroundTask module): stamp BGTaskSchedulerPermittedIdentifiers +
|
|
417
|
+
// fetch/processing background modes from `backgroundTasks`. Idempotent both ways — passing []/undefined
|
|
418
|
+
// strips the block — so it no-ops (and cleans up) when the module is inactive or the field is absent.
|
|
419
|
+
const bgActive = req.activeOptionalGroups.includes('backgroundTask');
|
|
420
|
+
src = stampPlistBackgroundTasks(src, bgActive ? cfg.backgroundTasks : undefined);
|
|
421
|
+
|
|
401
422
|
// Permission usage strings + URL scheme + export-compliance — idempotent: strip stamped block, re-add
|
|
402
423
|
src = src.replace(/\s*<!-- appwrap:begin -->[\s\S]*?<!-- appwrap:end -->/g, '');
|
|
403
424
|
const extras: string[] = [];
|
|
@@ -412,6 +433,12 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
412
433
|
` <key>CFBundleURLTypes</key>\n <array>\n <dict>\n <key>CFBundleTypeRole</key>\n <string>Editor</string>\n <key>CFBundleURLName</key>\n <string>${cfg.id}</string>\n <key>CFBundleURLSchemes</key>\n <array>\n <string>${cfg.urlScheme}</string>\n </array>\n </dict>\n </array>`
|
|
413
434
|
);
|
|
414
435
|
}
|
|
436
|
+
// Schemes kit.app.canOpenUrl() may probe → LSApplicationQueriesSchemes (iOS 9+ requires declaration
|
|
437
|
+
// for custom schemes). No-op when absent.
|
|
438
|
+
if (cfg.queryUrlSchemes?.length) {
|
|
439
|
+
const items = cfg.queryUrlSchemes.map((s) => ` <string>${s}</string>`).join('\n');
|
|
440
|
+
extras.push(` <key>LSApplicationQueriesSchemes</key>\n <array>\n${items}\n </array>`);
|
|
441
|
+
}
|
|
415
442
|
if (extras.length) {
|
|
416
443
|
src = src.replace(
|
|
417
444
|
/<\/dict>\s*<\/plist>\s*$/,
|
|
@@ -644,6 +671,10 @@ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
644
671
|
/<!-- appwrap:application -->[\s\S]*?<!-- \/appwrap:application -->/,
|
|
645
672
|
`<!-- appwrap:application -->\n\t\t${req.androidManifestApp.join('\n\t\t')}\n\t\t<!-- /appwrap:application -->`
|
|
646
673
|
);
|
|
674
|
+
// <queries> for kit.app.canOpenUrl() visibility probes (API 30+) — idempotent marker. queryPackages
|
|
675
|
+
// → explicit <package>; queryUrlSchemes → a VIEW <intent> per scheme (symmetric with iOS's
|
|
676
|
+
// LSApplicationQueriesSchemes). See stampAndroidQueries.
|
|
677
|
+
src = stampAndroidQueries(src, cfg.queryPackages, cfg.queryUrlSchemes);
|
|
647
678
|
writeFileSync(manifest, src);
|
|
648
679
|
}
|
|
649
680
|
}
|
package/src/config.ts
CHANGED
|
@@ -43,6 +43,19 @@ export interface AppwrapConfig {
|
|
|
43
43
|
pwaDist: string;
|
|
44
44
|
/** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
|
|
45
45
|
urlScheme?: string;
|
|
46
|
+
/** Custom URL schemes `kit.app.canOpenUrl()` may probe (e.g. `['whatsapp', 'tg']`) — covers BOTH
|
|
47
|
+
* platforms with a single declaration. Since iOS 9 / Android API 30+ a probe of an undeclared custom
|
|
48
|
+
* scheme returns false for privacy. These stamp into iOS Info.plist `LSApplicationQueriesSchemes` AND
|
|
49
|
+
* Android `<queries>` as a VIEW `<intent>` per scheme — so scheme-based `canOpenUrl` is symmetric
|
|
50
|
+
* without hand-mapping each scheme to a package. Common schemes (http/https/tel/mailto/sms) need no
|
|
51
|
+
* declaration on either platform. No-op when absent. */
|
|
52
|
+
queryUrlSchemes?: string[];
|
|
53
|
+
/** Android-only explicit package visibility for `kit.app.canOpenUrl()` (e.g. `['com.whatsapp']`) —
|
|
54
|
+
* for probing a specific package directly. Under API 30+ package visibility, `resolveActivity`
|
|
55
|
+
* returns null for undeclared packages — these stamp into AndroidManifest `<queries>` as
|
|
56
|
+
* `<package android:name="..."/>`. For scheme probes prefer `queryUrlSchemes` (cross-platform).
|
|
57
|
+
* No-op when absent. */
|
|
58
|
+
queryPackages?: string[];
|
|
46
59
|
/** App icon source (≥512px square png). Defaults to the largest icon in the PWA manifest. */
|
|
47
60
|
icon?: string;
|
|
48
61
|
/** Loader: 'app' (default — app:// scheme, ES modules OK), 'file' (debug fallback), or 'server'
|
|
@@ -120,6 +133,13 @@ export interface AppwrapConfig {
|
|
|
120
133
|
* (the per-app `permissions{}` map only OVERRIDES the default usage copy). When ABSENT, every
|
|
121
134
|
* capability is active and permissions come solely from `permissions{}` (pre-modules behavior). */
|
|
122
135
|
modules?: string[];
|
|
136
|
+
/** Permitted headless background-task identifiers (for the `backgroundTask` module). iOS REQUIRES
|
|
137
|
+
* these declared at build time — they stamp into Info.plist `BGTaskSchedulerPermittedIdentifiers`
|
|
138
|
+
* (without them `BGTaskScheduler.register` throws) plus `fetch`+`processing` into UIBackgroundModes.
|
|
139
|
+
* Android self-initializes WorkManager via its androidx startup provider — nothing to stamp. The
|
|
140
|
+
* same ids are what `kit.backgroundTask.register(id, …)` / `.schedule({id})` use. No-op when absent
|
|
141
|
+
* or the module is inactive. */
|
|
142
|
+
backgroundTasks?: string[];
|
|
123
143
|
/** Remote push (APNs/FCM). Off unless set — gating matters: an `aps-environment` entitlement on a
|
|
124
144
|
* team that can't hold the Push capability (e.g. a personal team) BREAKS code signing, and the
|
|
125
145
|
* handshake should honestly report `push: 'none'` on an un-provisioned build. The kit returns a raw
|
package/src/derive.ts
CHANGED
|
@@ -97,3 +97,70 @@ export function stampAndroidOrientation(src: string, value: string): string {
|
|
|
97
97
|
return stripped.replace(/(android:name="[^"]*")/, `$1\n\t\t\tandroid:screenOrientation="${value}"`);
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rewrite the `<!-- appwrap:queries -->…<!-- /appwrap:queries -->` block in an AndroidManifest string
|
|
103
|
+
* for `kit.app.canOpenUrl()` visibility probes (API 30+). Both child kinds live in ONE `<queries>`:
|
|
104
|
+
* - each `queryPackages` entry → explicit `<package android:name="…"/>` (Android-only package probe);
|
|
105
|
+
* - each `queryUrlSchemes` entry → a VIEW `<intent>` on that scheme, so a custom-scheme probe is
|
|
106
|
+
* symmetric with iOS's `LSApplicationQueriesSchemes` (no hand-mapping scheme→package needed).
|
|
107
|
+
* Pure + idempotent: always rewrites the marker block, so a re-sync never duplicates. No-op (empty
|
|
108
|
+
* block) when both lists are absent.
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* Stamp (or strip) the headless background-task wiring in an Info.plist string for the `backgroundTask`
|
|
112
|
+
* module: `BGTaskSchedulerPermittedIdentifiers` (the permitted ids — iOS requires them declared at
|
|
113
|
+
* build time or `BGTaskScheduler.register` throws) AND the `fetch`+`processing` `UIBackgroundModes`
|
|
114
|
+
* (BGAppRefreshTask needs `fetch`, BGProcessingTask needs `processing`). Pure + idempotent: rewrites an
|
|
115
|
+
* `appwrap:bgtask` marker block (added before `</dict>`), and MERGES the two modes into any existing
|
|
116
|
+
* `UIBackgroundModes` array (a second `<key>` would be invalid plist — same hazard as push's
|
|
117
|
+
* `remote-notification`). `ids` empty/undefined → strips the block + removes the two modes it added.
|
|
118
|
+
*/
|
|
119
|
+
export function stampPlistBackgroundTasks(src: string, ids: string[] | undefined): string {
|
|
120
|
+
// 1) Always rewrite the marker block (permitted identifiers). Strip first → idempotent.
|
|
121
|
+
src = src.replace(/\s*<!-- appwrap:bgtask -->[\s\S]*?<!-- \/appwrap:bgtask -->/g, '');
|
|
122
|
+
const list = (ids ?? []).filter(Boolean);
|
|
123
|
+
if (list.length) {
|
|
124
|
+
const items = list.map((s) => ` <string>${s}</string>`).join('\n');
|
|
125
|
+
const block =
|
|
126
|
+
` <!-- appwrap:bgtask -->\n` +
|
|
127
|
+
` <key>BGTaskSchedulerPermittedIdentifiers</key>\n <array>\n${items}\n </array>\n` +
|
|
128
|
+
` <!-- /appwrap:bgtask -->`;
|
|
129
|
+
src = src.replace(/<\/dict>\s*<\/plist>\s*$/, `${block}\n</dict>\n</plist>\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2) Merge/remove the fetch + processing background modes (separate from the marker — they live in
|
|
133
|
+
// the shared UIBackgroundModes array, which may also hold audio/remote-notification).
|
|
134
|
+
const modes = ['fetch', 'processing'];
|
|
135
|
+
const bgArray = /(<key>UIBackgroundModes<\/key>\s*<array>)([\s\S]*?)(<\/array>)/;
|
|
136
|
+
if (list.length) {
|
|
137
|
+
const need = modes.filter((m) => !new RegExp(`<string>${m}</string>`).test(src));
|
|
138
|
+
if (need.length) {
|
|
139
|
+
const inject = need.map((m) => `\t<string>${m}</string>`).join('\n');
|
|
140
|
+
src = bgArray.test(src)
|
|
141
|
+
? src.replace(bgArray, (_m, open, inner, close) => `${open}${inner}${inject}\n\t${close}`)
|
|
142
|
+
: src.replace(
|
|
143
|
+
/<\/dict>\s*<\/plist>\s*$/,
|
|
144
|
+
` <key>UIBackgroundModes</key>\n <array>\n${need.map((m) => ` <string>${m}</string>`).join('\n')}\n </array>\n</dict>\n</plist>\n`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
for (const m of modes) src = src.replace(new RegExp(`\\s*<string>${m}</string>`), '');
|
|
149
|
+
}
|
|
150
|
+
return src;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function stampAndroidQueries(src: string, queryPackages?: string[], queryUrlSchemes?: string[]): string {
|
|
154
|
+
const children = [
|
|
155
|
+
...(queryPackages ?? []).map((p) => `\t\t<package android:name="${p}"/>`),
|
|
156
|
+
...(queryUrlSchemes ?? []).map(
|
|
157
|
+
(s) => `\t\t<intent><action android:name="android.intent.action.VIEW"/><data android:scheme="${s}"/></intent>`
|
|
158
|
+
),
|
|
159
|
+
];
|
|
160
|
+
return src.replace(
|
|
161
|
+
/<!-- appwrap:queries -->[\s\S]*?<!-- \/appwrap:queries -->/,
|
|
162
|
+
children.length
|
|
163
|
+
? `<!-- appwrap:queries -->\n\t<queries>\n${children.join('\n')}\n\t</queries>\n\t<!-- /appwrap:queries -->`
|
|
164
|
+
: `<!-- appwrap:queries -->\n\t<!-- /appwrap:queries -->`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ambient declarations for iOS frameworks not covered by the default
|
|
3
|
-
* @nativescript/types-ios reference set (LocalAuthentication, CoreLocation,
|
|
4
|
-
* PhotosUI). Marshalled at runtime by the NativeScript iOS runtime regardless.
|
|
5
|
-
*/
|
|
6
|
-
declare const LAContext: any;
|
|
7
|
-
declare const LAPolicy: any;
|
|
8
|
-
declare const LABiometryType: any;
|
|
9
|
-
declare const CLLocationManager: any;
|
|
10
|
-
declare const CLAuthorizationStatus: any;
|
|
11
|
-
declare const CLLocationManagerDelegate: any;
|
|
12
|
-
declare const kCLLocationAccuracyHundredMeters: number;
|
|
13
|
-
declare type CLLocation = any;
|
|
14
|
-
declare type CLLocationManager = any;
|
|
15
|
-
declare const PHPickerConfiguration: any;
|
|
16
|
-
declare const PHPickerViewController: any;
|
|
17
|
-
declare const PHPickerViewControllerDelegate: any;
|
|
18
|
-
declare type PHPickerResult = any;
|
|
19
|
-
declare type PHPickerViewController = any;
|
|
20
|
-
// StoreKit / CoreMotion / ContactsUI / EventKit (parity handlers)
|
|
21
|
-
declare const SKStoreReviewController: any;
|
|
22
|
-
declare const CMMotionManager: any;
|
|
23
|
-
declare const CMPedometer: any;
|
|
24
|
-
// HealthKit (health/steps module)
|
|
25
|
-
declare const HKHealthStore: any;
|
|
26
|
-
declare const HKObjectType: any;
|
|
27
|
-
declare const HKQuery: any;
|
|
28
|
-
declare const HKStatisticsQuery: any;
|
|
29
|
-
declare const HKStatisticsOptions: any;
|
|
30
|
-
declare const HKUnit: any;
|
|
31
|
-
declare const CNContactPickerViewController: any;
|
|
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;
|
|
41
|
-
declare const EKEventStore: any;
|
|
42
|
-
declare const EKEvent: any;
|
|
43
|
-
declare const EKSpan: any;
|
|
44
|
-
declare const EKEntityType: any;
|