@livx.cc/appwrap 0.57.0 → 0.58.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.57.0",
3
+ "version": "0.58.1",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -58,6 +58,8 @@
58
58
  <!-- App Links (https) — autoVerify intent-filters stamped per host from config.androidAppLinks
59
59
  (or derived from iOS associated-domains applinks:). Needs assetlinks.json on the host. -->
60
60
  <!-- appwrap:app-links --><!-- /appwrap:app-links -->
61
+ <!-- Main-activity XML from active modules (e.g. shareTarget's ACTION_SEND filter) -->
62
+ <!-- appwrap:activity --><!-- /appwrap:activity -->
61
63
  </activity>
62
64
  <activity android:name="com.tns.ErrorReportActivity"/>
63
65
 
@@ -56,6 +56,9 @@ export interface ModuleManifest {
56
56
  /** Raw XML injected inside the AndroidManifest `<application>` (activities, providers,
57
57
  * intent-filters a capability needs — e.g. Health Connect's permission-rationale activity). */
58
58
  manifestApplication?: string;
59
+ /** Raw XML injected inside the MAIN `<activity>` (intent-filters a capability needs on the
60
+ * launcher activity — e.g. shareTarget's ACTION_SEND filter). */
61
+ manifestActivity?: string;
59
62
  };
60
63
  /** Module-owned native source: a dir under `runtime/modules-native/<nativeSrc>/` mirroring the
61
64
  * App_Resources layout, copied into `native/` ONLY when the module is active (stays strippable).
@@ -272,13 +275,50 @@ export const MODULES: ModuleManifest[] = [
272
275
  android: { gradleDeps: ['androidx.work:work-runtime:2.9.1', 'com.google.guava:guava:33.3.1-android'] },
273
276
  },
274
277
 
278
+ // ── shareTarget — INBOUND OS share sheet (receive shared text/images) — opt-in, STRIPPABLE ──
279
+ // Android: an ACTION_SEND(_MULTIPLE) intent-filter on the main activity puts the app in the share
280
+ // sheet; the strippable handler reads the SEND intent (cold launch + warm onNewIntent) and
281
+ // re-delivers it through the EXISTING deep-link path as `<urlScheme>://share?text=…` — riding the
282
+ // proven cold-start buffering (handshake `deepLink`) + warm `deeplink.open` event with zero new
283
+ // bridge surface. iOS: a generated share-extension TARGET (`AppwrapShare`, from this module's
284
+ // nativeSrc via NS's App_Resources/iOS/extensions seam — same machinery as the widget pack) that
285
+ // PERSISTS the payload as the same `<urlScheme>://share?…` URL into an App-Group UserDefaults
286
+ // MAILBOX (modern iOS blocks share extensions from launching their host via openURL:, so there is
287
+ // no live hand-off — the extension shows a brief "Added to <App>" confirmation instead); the host
288
+ // drains the mailbox on cold launch + every resume (read-once) into the same deep-link path. Images
289
+ // cross via the App Group container (entitlement below, stamped on both targets) and the host shell
290
+ // relocates them into the app cache so the JS contract is platform-identical. `urlScheme` REQUIRED. Consume via `kit.shareTarget.onReceive` (or
291
+ // raw `kit.lifecycle.onDeepLink`). OPTIONAL direct sync: config `shareTarget.directSync` lets the
292
+ // iOS extension complete the share ITSELF (one templated HTTP call, "Syncing… → Synced" drawer
293
+ // status) using the app-published context KV (`kit.shareTarget.setContext`) — mailbox stays the
294
+ // fallback on any failure. See ShareDirectSyncConfig in appwrap-cli config.ts.
295
+ {
296
+ name: 'shareTarget', group: 'shareTarget', nativeSrc: 'shareTarget',
297
+ capabilities: { shareTarget: 'native' },
298
+ ios: { entitlements: { 'com.apple.security.application-groups': ['__APP_GROUP__'] } },
299
+ android: {
300
+ manifestActivity:
301
+ `<intent-filter>\n` +
302
+ `\t\t\t<action android:name="android.intent.action.SEND" />\n` +
303
+ `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
304
+ `\t\t\t<data android:mimeType="text/plain" />\n` +
305
+ `\t\t\t<data android:mimeType="image/*" />\n` +
306
+ `\t\t</intent-filter>\n` +
307
+ `\t\t<intent-filter>\n` +
308
+ `\t\t\t<action android:name="android.intent.action.SEND_MULTIPLE" />\n` +
309
+ `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
310
+ `\t\t\t<data android:mimeType="image/*" />\n` +
311
+ `\t\t</intent-filter>`,
312
+ },
313
+ },
314
+
275
315
  // NOTE: billing, health, and widget are NOT part of CE — they live in host-provided module packs
276
316
  // (a consumer opts in via `modulePacks`).
277
317
  ];
278
318
 
279
319
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
280
320
  * groups (core/extended/parity/system/media) are always bundled; only these are CLI-gated. */
281
- export const OPTIONAL_GROUPS = ['oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask'] as const;
321
+ export const OPTIONAL_GROUPS = ['oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask', 'shareTarget'] as const;
282
322
 
283
323
  /** CLI-gated groups (strippable in explicit mode) that legacy mode (no `modules` key) STILL auto-bundles
284
324
  * for back-compat. Empty now that billing (its only member) moved to a host-provided pack — kept as the
@@ -161,6 +161,23 @@ class DevCertNavDelegate extends NSObject implements WKNavigationDelegate {
161
161
  }
162
162
 
163
163
  export class CustomWebView extends WebView {
164
+ constructor() {
165
+ super();
166
+ // The WKWebView must OWN the safe areas for CSS env(safe-area-inset-*) to be non-zero: WebKit
167
+ // (WKWebViewIOS.mm _computedUnobscuredSafeAreaInset) feeds env() from the WKWebView's OWN UIKit
168
+ // safeAreaInsets when contentInsetAdjustmentBehavior is .never — which requires the view's frame
169
+ // to genuinely extend under the status bar / home indicator. NS core's WebView defaults
170
+ // `iosOverflowSafeArea` to FALSE (only layout containers default true), which routes every layout
171
+ // pass through IOSHelper.shrinkToSafeArea: whenever the pass runs with the view attached to a
172
+ // window, the frame is boxed inside the safe area → safeAreaInsets become 0 → env() = 0, and the
173
+ // wrapped page's own safe-area padding collapses (content behind the notch). Whether the shrink
174
+ // actually lands is TIMING-dependent (NS's _cachedFrame skips re-adjustment when the frame value
175
+ // is unchanged), so devices differ. `true` flips NS to its expandBeyondSafeArea path —
176
+ // deterministic full-bleed frame, real safeAreaInsets, env() populated. Page-level padding then
177
+ // works with zero per-app configuration. (iOS-only NS property; inert on Android.)
178
+ this.iosOverflowSafeArea = true;
179
+ }
180
+
164
181
  /** Set by the bridge before load; receives raw envelope JSON. */
165
182
  onAppwrapMessage: ((json: string) => void) | null = null;
166
183
  private _scriptHandler!: AppwrapScriptHandler; // retained — WKUserContentController holds it weakly
@@ -363,8 +380,44 @@ export class CustomWebView extends WebView {
363
380
  return webView;
364
381
  }
365
382
 
383
+ /** Debug diagnostic: snapshot the native safe-area geometry (frame, safeAreaInsets, scroll-view
384
+ * inset behavior, superview chain) into the page as `window.__appwrapSafeAreaDiag` + the file log.
385
+ * Support surface for "env(safe-area-inset-*) is 0" reports — separates the UIKit side (view
386
+ * geometry, this snapshot) from the WebKit side (viewport-fit meta, see NATIVE_FEEL_JS): the field
387
+ * bug this was built for showed PERFECT native insets while a late page viewport meta without
388
+ * viewport-fit reverted cover and zeroed env(). Debug builds only. */
389
+ private snapshotSafeAreaDiag(tag: string): void {
390
+ const wk = this.nativeViewProtected as WKWebView;
391
+ if (!wk) return;
392
+ const r = (x: CGRect) => `${Math.round(x.origin.x)},${Math.round(x.origin.y)},${Math.round(x.size.width)}x${Math.round(x.size.height)}`;
393
+ const ins = (i: UIEdgeInsets) => `t${i.top} l${i.left} b${i.bottom} r${i.right}`;
394
+ const chain: string[] = [];
395
+ let v: UIView | null = wk;
396
+ while (v) { chain.push(`${NSStringFromClass(v.class())}[${r(v.frame)}]sa(${ins(v.safeAreaInsets)})`); v = v.superview; }
397
+ const win = wk.window;
398
+ const diag = {
399
+ tag,
400
+ frame: r(wk.frame), bounds: r(wk.bounds),
401
+ safeAreaInsets: ins(wk.safeAreaInsets),
402
+ behavior: wk.scrollView.contentInsetAdjustmentBehavior,
403
+ adjusted: ins(wk.scrollView.adjustedContentInset),
404
+ contentInset: ins(wk.scrollView.contentInset),
405
+ inWindow: !!win,
406
+ windowSA: win ? ins(win.safeAreaInsets) : 'nil',
407
+ windowFrame: win ? r(win.frame) : 'nil',
408
+ chain: chain.join(' < '),
409
+ };
410
+ const js = `window.__appwrapSafeAreaDiag=${JSON.stringify(JSON.stringify(diag))};`;
411
+ wk.evaluateJavaScriptCompletionHandler(js, () => { /* fire-and-forget */ });
412
+ appendWebLog(`[native:sa-diag] ${JSON.stringify(diag)}`);
413
+ }
414
+
366
415
  initNativeView(): void {
367
416
  super.initNativeView();
417
+ // Debug builds: delayed safe-area snapshots after the view settles in-window (see method doc).
418
+ if (SHELL_CONFIG.debug) {
419
+ for (const ms of [1500, 4000, 8000]) setTimeout(() => this.snapshotSafeAreaDiag(`t+${ms}`), ms);
420
+ }
368
421
  // NativeScript's WebView assigns its own wkWebView.UIDelegate during init,
369
422
  // which clobbers the media-capture grant set in createNativeView — so WebKit
370
423
  // falls back to prompting on every getUserMedia call. Reassert ours last so
@@ -25,8 +25,20 @@ export function setDeepLinkInterceptor(fn: ((url: string) => boolean) | null): v
25
25
  deepLinkInterceptor = fn;
26
26
  }
27
27
 
28
+ /** A pending native-side REWRITER of an inbound deep link (e.g. shareTarget on iOS relocating shared
29
+ * files from the App Group container into the app cache and rewriting the link to point at them).
30
+ * Runs before the interceptor; returns the URL to deliver (usually unchanged). Set by
31
+ * handlers-share-target. Distinct from the interceptor: a transform forwards, an interceptor consumes. */
32
+ let deepLinkTransformer: ((url: string) => string) | null = null;
33
+ export function setDeepLinkTransformer(fn: ((url: string) => string) | null): void {
34
+ deepLinkTransformer = fn;
35
+ }
36
+
28
37
  /** Called by the iOS delegate (cold start or while running) and Android intents. */
29
38
  export function onDeepLink(url: string): void {
39
+ if (deepLinkTransformer) {
40
+ try { url = deepLinkTransformer(url); } catch (e) { console.warn('AppWrap: deep-link transform failed', e); }
41
+ }
30
42
  // Give a native consumer (OAuth callback) first refusal — a matched OAuth redirect is internal
31
43
  // plumbing, not an app deep link, so it's swallowed here and never forwarded to the PWA.
32
44
  if (deepLinkInterceptor?.(url)) return;
@@ -48,8 +60,15 @@ export function onDeepLink(url: string): void {
48
60
  * Returns null when the launch wasn't from a link (or it was already consumed / delivered warm).
49
61
  */
50
62
  export function consumePendingDeepLink(): string | null {
51
- const url = pendingDeepLink;
63
+ let url = pendingDeepLink;
52
64
  pendingDeepLink = null;
65
+ // Re-apply the transformer: an iOS cold-launch link is ingested in didFinishLaunching, BEFORE
66
+ // main-page init registers transformers (e.g. shareTarget's gfile=→file= relocation) — so the
67
+ // buffered URL may still be raw. Transformers are idempotent (guard on their own markers), so a
68
+ // link already transformed at ingress passes through unchanged.
69
+ if (url && deepLinkTransformer) {
70
+ try { url = deepLinkTransformer(url); } catch (e) { console.warn('AppWrap: deep-link transform failed', e); }
71
+ }
53
72
  return url;
54
73
  }
55
74
 
@@ -0,0 +1,199 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import type { AndroidActivityNewIntentEventData } from '@nativescript/core';
3
+ import { SHELL_CONFIG } from './config';
4
+ import { bridge } from './bridge';
5
+ import { onDeepLink, setDeepLinkTransformer } from './events';
6
+ import { MAILBOX_KEY, drainShareMailbox, type ShareMailboxStore } from './share-mailbox';
7
+
8
+ /** App Group UserDefaults key holding the app-published SHARE CONTEXT (JSON string KV). Written by
9
+ * `shareTarget.setContext`, read by the AppwrapShare extension to resolve direct-sync `{key}`
10
+ * template placeholders. Must match ShareViewController.swift. */
11
+ const CONTEXT_KEY = 'appwrap-share-context';
12
+
13
+ /**
14
+ * shareTarget (INBOUND share sheet) — Android. The manifest's ACTION_SEND(_MULTIPLE) intent-filter
15
+ * (stamped by the CLI when the module is active) lands the shared payload on the main activity as a
16
+ * SEND intent; this handler reads it (cold launch + warm onNewIntent) and re-delivers through the
17
+ * EXISTING deep-link path as a synthetic URL — so it inherits the proven cold-start buffering
18
+ * (delivered in the handshake `deepLink`) and warm `deeplink.open` event with no new bridge surface.
19
+ *
20
+ * URL contract (parsed by kit.shareTarget, also directly consumable via kit.lifecycle.onDeepLink):
21
+ * <urlScheme>://share?text=<enc>&title=<enc>&file=<enc>&file=<enc>…
22
+ * - `text` — EXTRA_TEXT (shared text / URL), when present.
23
+ * - `title` — EXTRA_SUBJECT, when present.
24
+ * - `file` — repeated; each is a path RELATIVE TO THE APP CACHE DIR (`appwrap-share/…`) where the
25
+ * shared stream (image) was copied — read it via `kit.fs.read(p, { dir: 'cache', encoding: 'base64' })`.
26
+ *
27
+ * iOS: the generated `AppwrapShare` extension (this module's nativeSrc) PERSISTS the payload as the
28
+ * SAME URL into an App-Group "mailbox" (UserDefaults key `appwrap-share-mailbox`, a string array) —
29
+ * modern iOS silently blocks a share extension from launching its host via `openURL:`, so there is
30
+ * no live hand-off; the host drains the mailbox on cold launch and on every foreground/resume
31
+ * (read-once) and feeds each URL through the normal deep-link path (cold: buffered into the
32
+ * handshake `deepLink`; warm: `deeplink.open` event). Text rides in the URL directly; images land in
33
+ * the App Group container as `gfile=<name>` params, which the transformer below relocates into the
34
+ * app cache and rewrites to `file=` (cache-relative) so the contract the web app sees is
35
+ * platform-identical.
36
+ */
37
+ export function registerShareTargetHandlers(): void {
38
+ // Share-context KV (kit.shareTarget.setContext) — generic, app-opaque. Only iOS persists it (the
39
+ // AppwrapShare extension is the sole consumer); Android accepts + ignores so a cross-platform web
40
+ // app can call it unconditionally without an UNSUPPORTED rejection.
41
+ bridge.register('shareTarget.setContext', ({ context }: { context?: Record<string, string | number> | null }) => {
42
+ if (!isIOS) return;
43
+ const d = NSUserDefaults.alloc().initWithSuiteName(`group.${SHELL_CONFIG.appId}`);
44
+ if (!d) return;
45
+ if (context && typeof context === 'object') d.setObjectForKey(JSON.stringify(context), CONTEXT_KEY);
46
+ else d.removeObjectForKey(CONTEXT_KEY);
47
+ });
48
+ if (isIOS) {
49
+ // Rewrite share links carrying App-Group files (gfile=) → cache files (file=). Text-only share
50
+ // links (no gfile) pass through untouched.
51
+ setDeepLinkTransformer((url) => (url.includes('://share?') && url.includes('gfile=') ? iosRelocateGroupFiles(url) : url));
52
+ const drain = () => {
53
+ try { drainShareMailbox(iosMailboxStore(), onDeepLink); }
54
+ catch (e) { console.warn('AppWrap: share mailbox drain failed', e); }
55
+ };
56
+ drain(); // cold launch — a share made while the app was killed is waiting in the mailbox
57
+ Application.on(Application.resumeEvent, drain); // warm — user shared, then switched back to the app
58
+ return;
59
+ }
60
+ if (!isAndroid) return;
61
+ Application.android.on(Application.android.activityNewIntentEvent, (args: AndroidActivityNewIntentEventData) =>
62
+ deliverShareIntent(args.intent)
63
+ );
64
+ // Cold start: the SEND intent IS the launch intent (events.ts only reads getData(), null for SEND).
65
+ deliverShareIntent(Application.android.startActivity?.getIntent?.());
66
+ }
67
+
68
+ // ── iOS App-Group mailbox (written by the AppwrapShare extension, drained by the host) ──
69
+ // Drain semantics (read-once, crash-safe) are PURE and live in share-mailbox.ts (bun-tested).
70
+
71
+ /** The real iOS store: `group.<appId>` suite UserDefaults, string-array under MAILBOX_KEY. */
72
+ function iosMailboxStore(): ShareMailboxStore {
73
+ const defaults = NSUserDefaults.alloc().initWithSuiteName(`group.${SHELL_CONFIG.appId}`);
74
+ return {
75
+ read: () => {
76
+ const arr = defaults?.arrayForKey(MAILBOX_KEY);
77
+ const out: string[] = [];
78
+ for (let i = 0; arr && i < arr.count; i++) out.push(String(arr.objectAtIndex(i)));
79
+ return out;
80
+ },
81
+ clear: () => { defaults?.removeObjectForKey(MAILBOX_KEY); },
82
+ };
83
+ }
84
+
85
+ /** iOS: move each `gfile=<name>` from `<AppGroup>/appwrap-share/` into `<Caches>/appwrap-share/` and
86
+ * rewrite the param to `file=appwrap-share/<name>` (the fs `cache` root), matching Android's shape.
87
+ * Any file that can't be moved (missing group container, purged file) is dropped from the link;
88
+ * text/title params always survive. */
89
+ function iosRelocateGroupFiles(url: string): string {
90
+ try {
91
+ const [head, query = ''] = url.split('?');
92
+ const fm = NSFileManager.defaultManager;
93
+ const groupRoot = fm.containerURLForSecurityApplicationGroupIdentifier(`group.${SHELL_CONFIG.appId}`);
94
+ const cacheBase = NSSearchPathForDirectoriesInDomains(13 /* Caches */, 1, true).objectAtIndex(0) as string;
95
+ const destDir = `${cacheBase}/appwrap-share`;
96
+ fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(destDir, true, null, null);
97
+ const params: string[] = [];
98
+ for (const pair of query.split('&')) {
99
+ if (!pair.startsWith('gfile=')) { if (pair) params.push(pair); continue; }
100
+ if (!groupRoot) continue; // no group entitlement in this build — drop the file, keep the text
101
+ const name = decodeURIComponent(pair.slice('gfile='.length));
102
+ if (!name || name.includes('/') || name.includes('..')) continue; // extension controls names — don't let one escape the dir
103
+ const src = `${String(groupRoot.path)}/appwrap-share/${name}`;
104
+ const dest = `${destDir}/${name}`;
105
+ fm.removeItemAtPathError(dest, null); // overwrite-safe (moveItem fails on an existing dest)
106
+ if (fm.moveItemAtPathToPathError(src, dest, null)) params.push(`file=${encodeURIComponent(`appwrap-share/${name}`)}`);
107
+ }
108
+ return `${head}?${params.join('&')}`;
109
+ } catch (e) {
110
+ console.warn('AppWrap: share group-file relocation failed', e);
111
+ return url;
112
+ }
113
+ }
114
+
115
+ const CONSUMED = 'appwrap_share_consumed';
116
+
117
+ function deliverShareIntent(intent: android.content.Intent | null | undefined): void {
118
+ try {
119
+ const action = intent?.getAction?.();
120
+ if (action !== 'android.intent.action.SEND' && action !== 'android.intent.action.SEND_MULTIPLE') return;
121
+ if (intent!.getBooleanExtra?.(CONSUMED, false)) return; // relayout re-read of the same launch intent
122
+ intent!.putExtra?.(CONSUMED, true);
123
+
124
+ const params: string[] = [];
125
+ const text = intent!.getStringExtra?.(android.content.Intent.EXTRA_TEXT);
126
+ if (text) params.push(`text=${encodeURIComponent(String(text))}`);
127
+ const subject = intent!.getStringExtra?.(android.content.Intent.EXTRA_SUBJECT);
128
+ if (subject) params.push(`title=${encodeURIComponent(String(subject))}`);
129
+ for (const rel of copySharedStreams(intent!, action)) params.push(`file=${encodeURIComponent(rel)}`);
130
+ if (!params.length) return;
131
+
132
+ // Scheme is cosmetic here — the URL goes straight into onDeepLink, never through the OS.
133
+ const scheme = SHELL_CONFIG.urlScheme || 'app';
134
+ onDeepLink(`${scheme}://share?${params.join('&')}`);
135
+ } catch (e) {
136
+ console.warn('AppWrap: share intent read failed', e);
137
+ }
138
+ }
139
+
140
+ /** Copy each EXTRA_STREAM content:// URI into `<cacheDir>/appwrap-share/`; return cache-relative paths. */
141
+ function copySharedStreams(intent: android.content.Intent, action: string): string[] {
142
+ const uris: android.net.Uri[] = [];
143
+ try {
144
+ if (action === 'android.intent.action.SEND_MULTIPLE') {
145
+ const list = intent.getParcelableArrayListExtra?.(android.content.Intent.EXTRA_STREAM);
146
+ for (let i = 0; list && i < list.size(); i++) uris.push(list.get(i) as android.net.Uri);
147
+ } else {
148
+ const one = intent.getParcelableExtra?.(android.content.Intent.EXTRA_STREAM) as android.net.Uri | null;
149
+ if (one) uris.push(one);
150
+ }
151
+ } catch (e) {
152
+ console.warn('AppWrap: share stream extras read failed', e);
153
+ }
154
+
155
+ const out: string[] = [];
156
+ if (!uris.length) return out;
157
+ const ctx = Utils.android.getApplicationContext() as android.content.Context;
158
+ const dir = new java.io.File(ctx.getCacheDir(), 'appwrap-share');
159
+ dir.mkdirs();
160
+ const resolver = ctx.getContentResolver();
161
+ uris.forEach((uri, i) => {
162
+ try {
163
+ const name = fileNameFor(resolver, uri, i);
164
+ const dest = new java.io.File(dir, name);
165
+ const input = resolver.openInputStream(uri);
166
+ if (!input) return;
167
+ const output = new java.io.FileOutputStream(dest);
168
+ const buf = Array.create('byte', 64 * 1024);
169
+ let n: number;
170
+ while ((n = input.read(buf)) > 0) output.write(buf, 0, n);
171
+ output.close();
172
+ input.close();
173
+ out.push(`appwrap-share/${name}`);
174
+ } catch (e) {
175
+ console.warn('AppWrap: shared stream copy failed', e);
176
+ }
177
+ });
178
+ return out;
179
+ }
180
+
181
+ /** A safe, unique-enough filename: display name when the provider offers one, else mime-derived. */
182
+ function fileNameFor(resolver: android.content.ContentResolver, uri: android.net.Uri, i: number): string {
183
+ let name = '';
184
+ try {
185
+ const c = resolver.query(uri, null, null, null, null);
186
+ if (c) {
187
+ const idx = c.getColumnIndex('_display_name');
188
+ if (idx >= 0 && c.moveToFirst()) name = String(c.getString(idx) ?? '');
189
+ c.close();
190
+ }
191
+ } catch { /* fall through to mime-derived name */ }
192
+ if (!name) {
193
+ const mime = String(resolver.getType(uri) ?? '');
194
+ const ext = mime.startsWith('image/') ? mime.slice(6).replace(/[^a-z0-9]/gi, '') || 'img' : 'bin';
195
+ name = `shared-${i}.${ext}`;
196
+ }
197
+ // Sanitize (providers control display names) + prefix a timestamp so repeated shares don't collide.
198
+ return `${Date.now()}-${i}-${name.replace(/[^\w.-]/g, '_')}`;
199
+ }
@@ -10,7 +10,7 @@ import { ACTIVE_MODULE_NAMES, PACK_MODULES } from './active-modules.generated';
10
10
  import { consumePendingBackgroundTaskId } from './background-context';
11
11
 
12
12
  /** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
13
- export const SHELL_BUILD = 'apple-signin-1';
13
+ export const SHELL_BUILD = 'share-target-1';
14
14
 
15
15
  /** Version status the web side (native-kit `kit.updates`) reports via `app.reportWebVersion`. */
16
16
  export interface WebVersionInfo { current?: string; latest?: string; build?: string | number; updateAvailable?: boolean; }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * PURE iOS shareTarget mailbox-drain logic (no NativeScript imports; bun-tested — mirrors
3
+ * geo-auth.ts/notif-identity.ts: the semantics live here, handlers-share-target.ts only does the
4
+ * NSUserDefaults plumbing).
5
+ *
6
+ * WHY a mailbox: modern iOS silently blocks a share extension's responder-chain `openURL:` — a share
7
+ * extension may NOT launch its host app. So the generated `AppwrapShare` extension durably appends
8
+ * the share URL (`<scheme>://share?…`) to an App-Group UserDefaults string array under MAILBOX_KEY,
9
+ * and the HOST drains it on cold launch and on every foreground/resume. Read-once: the drain clears
10
+ * the mailbox BEFORE delivering, so overlapping drains can never double-deliver; crash-safety: the
11
+ * extension's write persists until a drain actually runs, and one runs on every foreground.
12
+ */
13
+
14
+ /** App-Group UserDefaults key the extension appends to (see ShareViewController.swift). */
15
+ export const MAILBOX_KEY = 'appwrap-share-mailbox';
16
+
17
+ /** Minimal seam over the App-Group UserDefaults mailbox — injectable for tests. */
18
+ export interface ShareMailboxStore {
19
+ read(): string[];
20
+ clear(): void;
21
+ }
22
+
23
+ /**
24
+ * Drain the share mailbox: read all pending share URLs, CLEAR first (exactly-once), then deliver
25
+ * each through `deliver` (the normal deep-link ingress: cold launches buffer into the handshake
26
+ * `deepLink`, warm apps get `deeplink.open`). Non-share/garbage entries are dropped. Returns the
27
+ * number of URLs delivered.
28
+ */
29
+ export function drainShareMailbox(store: ShareMailboxStore, deliver: (url: string) => void): number {
30
+ const urls = store.read();
31
+ if (!urls.length) return 0;
32
+ store.clear(); // read-once: clear BEFORE delivering so re-entry during delivery can't duplicate
33
+ let delivered = 0;
34
+ for (const u of urls) {
35
+ if (typeof u !== 'string' || !u.includes('://share?')) continue;
36
+ delivered++;
37
+ deliver(u);
38
+ }
39
+ return delivered;
40
+ }
@@ -296,14 +296,21 @@ export const NATIVE_FEEL_JS = `(function(){
296
296
  window.__appwrapNativeFeel = true;
297
297
  function apply(){
298
298
  var head = document.head || document.documentElement;
299
- var vp = document.querySelector('meta[name=viewport]');
299
+ // Normalize the LAST viewport meta — WebKit honors the last one parsed, so patching the first
300
+ // (or an injected one) is silently overridden when the page's own meta appears later in <head>.
301
+ // Device-verified failure: injected meta had viewport-fit=cover, the site's own plain
302
+ // "width=device-width, initial-scale=1.0" meta parsed after it → viewport-fit reverted to auto
303
+ // → env(safe-area-inset-*) = 0 despite correct native safeAreaInsets (t47/b34).
304
+ var metas = document.querySelectorAll('meta[name=viewport]');
305
+ var vp = metas.length ? metas[metas.length - 1] : null;
300
306
  if (!vp){ vp = document.createElement('meta'); vp.setAttribute('name','viewport'); head.appendChild(vp); }
301
307
  var c = vp.getAttribute('content') || 'width=device-width, initial-scale=1';
302
308
  if (!/user-scalable\\s*=\\s*no/.test(c)) c += ', maximum-scale=1, user-scalable=no';
303
309
  // viewport-fit=cover makes env(safe-area-inset-*) resolve to real notch /
304
310
  // home-indicator insets — the WebView owns the safe areas (contentInset .never).
311
+ // A page's own explicit viewport-fit is respected (only appended when absent).
305
312
  if (!/viewport-fit/.test(c)) c += ', viewport-fit=cover';
306
- vp.setAttribute('content', c);
313
+ if (vp.getAttribute('content') !== c) vp.setAttribute('content', c);
307
314
  if (!document.getElementById('__appwrap_feel')){
308
315
  var s = document.createElement('style'); s.id = '__appwrap_feel';
309
316
  s.textContent = ${JSON.stringify(NATIVE_FEEL_CSS)};
@@ -322,6 +329,22 @@ export const NATIVE_FEEL_JS = `(function(){
322
329
  }
323
330
  apply();
324
331
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply);
332
+ // SPA head managers (React Helmet, Next, vue-meta) add/rewrite the viewport meta AFTER load —
333
+ // re-normalize whenever one changes. apply() is idempotent (only writes on an actual content
334
+ // change), so the observer can't loop on its own writes.
335
+ try {
336
+ new MutationObserver(function(muts){
337
+ for (var i = 0; i < muts.length; i++){
338
+ var m = muts[i];
339
+ var t = m.target;
340
+ if (t && t.nodeName === 'META' && t.getAttribute('name') === 'viewport') return apply();
341
+ for (var j = 0; j < (m.addedNodes ? m.addedNodes.length : 0); j++){
342
+ var n = m.addedNodes[j];
343
+ if (n.nodeName === 'META' && n.getAttribute && n.getAttribute('name') === 'viewport') return apply();
344
+ }
345
+ }
346
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['content', 'name'] });
347
+ } catch(e) { /* observer is a hardening layer; core apply() already ran */ }
325
348
  // Pinch-zoom on iOS fires gesture* even with user-scalable=no in some builds.
326
349
  document.addEventListener('gesturestart', function(e){ e.preventDefault(); }, { passive: false });
327
350
  })();`;
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.application-groups</key>
6
+ <array>
7
+ <string>__APP_GROUP__</string>
8
+ </array>
9
+ </dict>
10
+ </plist>
@@ -0,0 +1,43 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>en</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>__APP_NAME__</string>
9
+ <key>CFBundleName</key>
10
+ <string>$(PRODUCT_NAME)</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13
+ <key>CFBundleExecutable</key>
14
+ <string>$(EXECUTABLE_NAME)</string>
15
+ <key>CFBundleInfoDictionaryVersion</key>
16
+ <string>6.0</string>
17
+ <key>CFBundlePackageType</key>
18
+ <string>XPC!</string>
19
+ <key>CFBundleShortVersionString</key>
20
+ <string>1.0.0</string>
21
+ <key>CFBundleVersion</key>
22
+ <string>1</string>
23
+ <key>NSExtension</key>
24
+ <dict>
25
+ <key>NSExtensionPointIdentifier</key>
26
+ <string>com.apple.share-services</string>
27
+ <key>NSExtensionPrincipalClass</key>
28
+ <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
29
+ <key>NSExtensionAttributes</key>
30
+ <dict>
31
+ <key>NSExtensionActivationRule</key>
32
+ <dict>
33
+ <key>NSExtensionActivationSupportsText</key>
34
+ <true/>
35
+ <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
36
+ <integer>1</integer>
37
+ <key>NSExtensionActivationSupportsImageWithMaxCount</key>
38
+ <integer>10</integer>
39
+ </dict>
40
+ </dict>
41
+ </dict>
42
+ </dict>
43
+ </plist>
@@ -0,0 +1,338 @@
1
+ import UIKit
2
+ import MobileCoreServices
3
+
4
+ /// appwrap shareTarget — iOS share extension (`AppwrapShare` target, generated per-app by the CLI).
5
+ ///
6
+ /// Collects the shared attachments (text / web URL / images), then delivers them one of two ways:
7
+ ///
8
+ /// 1. DIRECT SYNC (opt-in via config `shareTarget.directSync`, stamped as `__SHARE_SYNC_B64__` —
9
+ /// base64 JSON, empty = off): the extension completes the share ITSELF with one HTTP call to the
10
+ /// app's backend, showing an honest "Syncing… → Synced" status in the drawer. `{key}` placeholders
11
+ /// in the URL / success-message templates resolve from the SHARE CONTEXT — a small JSON KV the
12
+ /// web app published via `kit.shareTarget.setContext(...)` (App Group UserDefaults key
13
+ /// `appwrap-share-context`). `merge:"append"` GETs the resource first, appends the shared text to
14
+ /// the existing text field (newline-joined) and preserves the existing image unless a new one is
15
+ /// shared. Any failure — offline, non-2xx, missing/unresolvable context, >1 image, oversized
16
+ /// image — falls back to (2). Apple forbids a share extension launching its host app; direct sync
17
+ /// makes launching unnecessary.
18
+ ///
19
+ /// 2. MAILBOX (always available, the pre-direct-sync behavior): PERSIST the payload as a mailbox
20
+ /// entry in the shared App Group UserDefaults — the SAME deep-link contract Android uses:
21
+ /// __URL_SCHEME__://share?text=<enc>&title=<enc>&gfile=<enc>&gfile=<enc>…
22
+ /// - text / web URLs ride entirely in the URL (no files involved).
23
+ /// - images are written into the shared App Group container under `appwrap-share/`; each `gfile`
24
+ /// param is the file NAME there. The host shell relocates them into the app cache and rewrites
25
+ /// `gfile=` → `file=` (cache-relative) before the link reaches the web app — so the JS contract
26
+ /// is identical on both platforms (see runtime/app/shell/handlers-share-target.ts).
27
+ /// The host drains the mailbox (App Group key `appwrap-share-mailbox`, an appended string array)
28
+ /// on cold launch AND on every foreground/resume (read-once).
29
+ ///
30
+ /// Build-time tokens (stamped by `appwrap init`/`sync`): __URL_SCHEME__ (config `urlScheme`),
31
+ /// __APP_GROUP__ (`group.<appId>`), __APP_NAME__ (Info.plist display name), __SHARE_SYNC_B64__
32
+ /// (config `shareTarget.directSync`, defaults applied — see appwrap-cli config.ts).
33
+ class ShareViewController: UIViewController {
34
+ private var processed = false
35
+ private var statusPill: UILabel?
36
+
37
+ override func viewDidAppear(_ animated: Bool) {
38
+ super.viewDidAppear(animated)
39
+ guard !processed else { return }
40
+ processed = true
41
+ collectPayload { [weak self] texts, files in
42
+ guard let self = self else { return }
43
+ if texts.isEmpty && files.isEmpty {
44
+ self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
45
+ return
46
+ }
47
+ if let sync = self.directSyncPlan(texts: texts, files: files) {
48
+ self.showStatus("Syncing…")
49
+ self.runDirectSync(sync, texts: texts, files: files)
50
+ } else {
51
+ self.finishViaMailbox(texts: texts, files: files)
52
+ }
53
+ }
54
+ }
55
+
56
+ // MARK: payload collection
57
+
58
+ /// Collect shared text/URLs (`texts`) and image file NAMES (`files`, already stashed into the
59
+ /// App Group `appwrap-share/` dir — both delivery paths read them from there).
60
+ private func collectPayload(_ done: @escaping ([String], [String]) -> Void) {
61
+ var texts: [String] = []
62
+ var files: [String] = []
63
+ let group = DispatchGroup()
64
+ // loadItem completions fire on provider-internal queues — serialize array mutation
65
+ // (concurrent appends on a Swift Array are UB with multi-attachment shares).
66
+ let collectQ = DispatchQueue(label: "appwrap.share.collect")
67
+
68
+ let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
69
+ for item in items {
70
+ for provider in item.attachments ?? [] {
71
+ if provider.hasItemConformingToTypeIdentifier("public.url") && !provider.hasItemConformingToTypeIdentifier("public.file-url") {
72
+ group.enter()
73
+ provider.loadItem(forTypeIdentifier: "public.url", options: nil) { data, _ in
74
+ collectQ.async {
75
+ if let u = data as? URL { texts.append(u.absoluteString) }
76
+ group.leave()
77
+ }
78
+ }
79
+ } else if provider.hasItemConformingToTypeIdentifier("public.plain-text") {
80
+ group.enter()
81
+ provider.loadItem(forTypeIdentifier: "public.plain-text", options: nil) { data, _ in
82
+ collectQ.async {
83
+ if let s = data as? String, !s.isEmpty { texts.append(s) }
84
+ else if let d = data as? Data, let s = String(data: d, encoding: .utf8), !s.isEmpty { texts.append(s) }
85
+ group.leave()
86
+ }
87
+ }
88
+ } else if provider.hasItemConformingToTypeIdentifier("public.image") {
89
+ group.enter()
90
+ provider.loadItem(forTypeIdentifier: "public.image", options: nil) { data, _ in
91
+ let name = self.stashImage(data) // heavy I/O stays off the serial queue
92
+ collectQ.async {
93
+ if let name { files.append(name) }
94
+ group.leave()
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ group.notify(queue: .main) { done(texts, files) }
102
+ }
103
+
104
+ /// Write a shared image (URL / Data / UIImage, whatever the provider hands over) into the App
105
+ /// Group container `appwrap-share/` dir; returns the file name there, or nil on any failure
106
+ /// (missing group entitlement included — text still goes through).
107
+ private func stashImage(_ data: Any?) -> String? {
108
+ guard let root = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "__APP_GROUP__") else { return nil }
109
+ let dir = root.appendingPathComponent("appwrap-share", isDirectory: true)
110
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
111
+ let stamp = Int(Date().timeIntervalSince1970 * 1000)
112
+ do {
113
+ if let src = data as? URL {
114
+ let name = "\(stamp)-\(src.lastPathComponent.replacingOccurrences(of: "[^\\w.-]", with: "_", options: .regularExpression))"
115
+ try FileManager.default.copyItem(at: src, to: dir.appendingPathComponent(name))
116
+ return name
117
+ }
118
+ if let d = data as? Data {
119
+ let name = "\(stamp)-shared.img"
120
+ try d.write(to: dir.appendingPathComponent(name))
121
+ return name
122
+ }
123
+ if let img = data as? UIImage, let d = img.pngData() {
124
+ let name = "\(stamp)-shared.png"
125
+ try d.write(to: dir.appendingPathComponent(name))
126
+ return name
127
+ }
128
+ } catch { return nil }
129
+ return nil
130
+ }
131
+
132
+ private func groupFileURL(_ name: String) -> URL? {
133
+ FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "__APP_GROUP__")?
134
+ .appendingPathComponent("appwrap-share", isDirectory: true).appendingPathComponent(name)
135
+ }
136
+
137
+ // MARK: direct sync (config `shareTarget.directSync` — stamped, empty token = off)
138
+
139
+ private struct SyncPlan {
140
+ let url: URL
141
+ let method: String
142
+ let textField: String
143
+ let imageField: String
144
+ let append: Bool
145
+ let imageDataURL: String? // nil = no image shared
146
+ let imageFile: String? // stashed name, deleted after a successful sync
147
+ let successText: String
148
+ }
149
+
150
+ /// Decide whether THIS share can direct-sync: config stamped, context published, url template
151
+ /// fully resolved, ≤1 image, image under the size cap. nil → mailbox path.
152
+ private func directSyncPlan(texts: [String], files: [String]) -> SyncPlan? {
153
+ let b64 = "__SHARE_SYNC_B64__"
154
+ guard !b64.isEmpty,
155
+ let cfgData = Data(base64Encoded: b64),
156
+ let cfg = (try? JSONSerialization.jsonObject(with: cfgData)) as? [String: Any],
157
+ let urlTemplate = cfg["urlTemplate"] as? String,
158
+ let ctx = shareContext(),
159
+ let urlStr = resolveTemplate(urlTemplate, ctx), let url = URL(string: urlStr)
160
+ else { return nil }
161
+ guard files.count <= 1 else { return nil } // multi-image shares keep full fidelity via the mailbox
162
+
163
+ let fields = cfg["fields"] as? [String: Any]
164
+ let maxImageBytes = (cfg["maxImageBytes"] as? Int) ?? 4_000_000
165
+ var imageDataURL: String? = nil
166
+ if let name = files.first {
167
+ guard let fileURL = groupFileURL(name), let data = try? Data(contentsOf: fileURL) else { return nil }
168
+ var encoded = "data:\(sniffMime(data));base64,\(data.base64EncodedString())"
169
+ if encoded.utf8.count > maxImageBytes {
170
+ // Typical camera photos exceed the cap — mirror the web app's pipeline: downscale
171
+ // (longest edge → maxImageEdge, JPEG jpegQuality) before giving up. Only a still-over
172
+ // (or undecodable) image sends the share down the mailbox, which keeps the ORIGINAL
173
+ // bytes (the app downscales on drain, unchanged).
174
+ let edge = CGFloat((cfg["maxImageEdge"] as? Double) ?? 2000)
175
+ let quality = CGFloat((cfg["jpegQuality"] as? Double) ?? 0.85)
176
+ guard let small = downscaled(data, maxEdge: edge, quality: quality) else { return nil }
177
+ encoded = "data:image/jpeg;base64,\(small.base64EncodedString())"
178
+ guard encoded.utf8.count <= maxImageBytes else { return nil } // still oversized → mailbox
179
+ }
180
+ imageDataURL = encoded
181
+ }
182
+ let successTemplate = (cfg["successMessage"] as? String) ?? "Synced"
183
+ return SyncPlan(
184
+ url: url,
185
+ method: (cfg["method"] as? String) ?? "PUT",
186
+ textField: (fields?["text"] as? String) ?? "content",
187
+ imageField: (fields?["image"] as? String) ?? "image",
188
+ append: (cfg["merge"] as? String) == "append",
189
+ imageDataURL: imageDataURL,
190
+ imageFile: files.first,
191
+ successText: resolveTemplate(successTemplate, ctx) ?? "Synced"
192
+ )
193
+ }
194
+
195
+ /// The app-published share context (App Group key `appwrap-share-context`, JSON KV). Numbers are
196
+ /// stringified so templates can interpolate them.
197
+ private func shareContext() -> [String: String]? {
198
+ guard let d = UserDefaults(suiteName: "__APP_GROUP__"),
199
+ let raw = d.string(forKey: "appwrap-share-context"),
200
+ let obj = (try? JSONSerialization.jsonObject(with: Data(raw.utf8))) as? [String: Any]
201
+ else { return nil }
202
+ var out: [String: String] = [:]
203
+ for (k, v) in obj { out[k] = "\(v)" }
204
+ return out
205
+ }
206
+
207
+ /// Replace `{key}` placeholders from the context. nil when any placeholder stays unresolved —
208
+ /// a partially-resolved URL must never be called.
209
+ private func resolveTemplate(_ template: String, _ ctx: [String: String]) -> String? {
210
+ var s = template
211
+ for (k, v) in ctx { s = s.replacingOccurrences(of: "{\(k)}", with: v) }
212
+ return s.range(of: #"\{[^}]+\}"#, options: .regularExpression) == nil ? s : nil
213
+ }
214
+
215
+ /// Re-render an oversized image to `maxEdge` px longest edge, JPEG at `quality`. nil on decode
216
+ /// failure (e.g. non-image bytes) — the caller falls back to the mailbox.
217
+ private func downscaled(_ data: Data, maxEdge: CGFloat, quality: CGFloat) -> Data? {
218
+ guard let img = UIImage(data: data), img.size.width > 0, img.size.height > 0 else { return nil }
219
+ let w = img.size.width * img.scale, h = img.size.height * img.scale
220
+ let scale = min(1, maxEdge / max(w, h))
221
+ let size = CGSize(width: max(1, floor(w * scale)), height: max(1, floor(h * scale)))
222
+ let fmt = UIGraphicsImageRendererFormat.default()
223
+ fmt.scale = 1
224
+ let out = UIGraphicsImageRenderer(size: size, format: fmt).image { _ in
225
+ img.draw(in: CGRect(origin: .zero, size: size))
226
+ }
227
+ return out.jpegData(compressionQuality: quality)
228
+ }
229
+
230
+ private func sniffMime(_ d: Data) -> String {
231
+ if d.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
232
+ if d.starts(with: [0xFF, 0xD8]) { return "image/jpeg" }
233
+ if d.starts(with: [0x47, 0x49, 0x46]) { return "image/gif" }
234
+ if d.count > 11, d[8...11].elementsEqual([0x57, 0x45, 0x42, 0x50]) { return "image/webp" }
235
+ return "image/jpeg" // best-effort default (e.g. HEIC handed over re-encoded)
236
+ }
237
+
238
+ /// Execute the sync: (append mode) GET current → merge → write; else write as-is. Any failure
239
+ /// falls back to the mailbox — the share is never lost.
240
+ private func runDirectSync(_ plan: SyncPlan, texts: [String], files: [String]) {
241
+ let fallback: () -> Void = { [weak self] in self?.finishViaMailbox(texts: texts, files: files) }
242
+ let newText = texts.joined(separator: "\n")
243
+
244
+ let write: ([String: Any]) -> Void = { [weak self] existing in
245
+ guard let self = self else { return }
246
+ let oldText = (existing[plan.textField] as? String) ?? ""
247
+ let merged = plan.append && !oldText.isEmpty && !newText.isEmpty ? "\(oldText)\n\(newText)"
248
+ : (newText.isEmpty ? oldText : newText)
249
+ var body: [String: Any] = [plan.textField: merged]
250
+ // New image replaces; append mode preserves the existing one (the endpoint is a full PUT).
251
+ if let img = plan.imageDataURL { body[plan.imageField] = img }
252
+ else if plan.append, let old = existing[plan.imageField] { body[plan.imageField] = old }
253
+ else { body[plan.imageField] = NSNull() }
254
+ var req = URLRequest(url: plan.url, timeoutInterval: 15)
255
+ req.httpMethod = plan.method
256
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
257
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
258
+ URLSession.shared.dataTask(with: req) { _, resp, _ in
259
+ DispatchQueue.main.async {
260
+ guard let code = (resp as? HTTPURLResponse)?.statusCode, (200..<300).contains(code) else { fallback(); return }
261
+ if let name = plan.imageFile, let u = self.groupFileURL(name) { try? FileManager.default.removeItem(at: u) }
262
+ self.finish(status: "✓ \(plan.successText)")
263
+ }
264
+ }.resume()
265
+ }
266
+
267
+ if plan.append {
268
+ var req = URLRequest(url: plan.url, timeoutInterval: 15)
269
+ req.httpMethod = "GET"
270
+ URLSession.shared.dataTask(with: req) { data, resp, _ in
271
+ DispatchQueue.main.async {
272
+ guard let code = (resp as? HTTPURLResponse)?.statusCode else { fallback(); return } // offline
273
+ if code == 404 { write([:]); return } // nothing there yet — first write
274
+ guard (200..<300).contains(code) else { fallback(); return }
275
+ let existing = data.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] } ?? [:]
276
+ write(existing)
277
+ }
278
+ }.resume()
279
+ } else {
280
+ write([:])
281
+ }
282
+ }
283
+
284
+ // MARK: host-app hand-off (App-Group mailbox)
285
+
286
+ private func finishViaMailbox(texts: [String], files: [String]) {
287
+ var params: [String] = []
288
+ let enc = { (s: String) in s.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "" }
289
+ if !texts.isEmpty { params.append("text=" + enc(texts.joined(separator: "\n"))) }
290
+ for f in files { params.append("gfile=" + enc(f)) }
291
+ enqueueMailbox("__URL_SCHEME__://share?" + params.joined(separator: "&"))
292
+ finish(status: "Added to __APP_NAME__")
293
+ }
294
+
295
+ /// Durably append the share URL to the App Group mailbox. The host app drains this key
296
+ /// (read-once) on cold launch and on every foreground — see handlers-share-target.ts.
297
+ private func enqueueMailbox(_ url: String) {
298
+ guard let d = UserDefaults(suiteName: "__APP_GROUP__") else { return }
299
+ var box = d.stringArray(forKey: "appwrap-share-mailbox") ?? []
300
+ box.append(url)
301
+ d.set(box, forKey: "appwrap-share-mailbox")
302
+ d.synchronize() // the extension process dies moments later — force the write to disk
303
+ }
304
+
305
+ /// Show the final status, let it register visually, then complete the request.
306
+ private func finish(status: String) {
307
+ showStatus(status)
308
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
309
+ self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
310
+ }
311
+ }
312
+
313
+ // MARK: drawer status pill
314
+
315
+ /// Centered status pill ("Syncing…" / "✓ Synced…" / "Added to <AppName>") so the share never
316
+ /// feels like a silent dismiss — the host app is NOT launched (iOS forbids that from a share
317
+ /// extension). Reused across status changes.
318
+ private func showStatus(_ text: String) {
319
+ if let pill = statusPill { pill.text = " \(text) "; return }
320
+ let pill = UILabel()
321
+ pill.text = " \(text) "
322
+ pill.font = UIFont.preferredFont(forTextStyle: .subheadline)
323
+ pill.textColor = .white
324
+ pill.backgroundColor = UIColor.black.withAlphaComponent(0.8)
325
+ pill.layer.cornerRadius = 18
326
+ pill.clipsToBounds = true
327
+ pill.translatesAutoresizingMaskIntoConstraints = false
328
+ view.addSubview(pill)
329
+ NSLayoutConstraint.activate([
330
+ pill.centerXAnchor.constraint(equalTo: view.centerXAnchor),
331
+ pill.centerYAnchor.constraint(equalTo: view.centerYAnchor),
332
+ pill.heightAnchor.constraint(equalToConstant: 36),
333
+ ])
334
+ pill.alpha = 0
335
+ UIView.animate(withDuration: 0.15) { pill.alpha = 1 }
336
+ statusPill = pill
337
+ }
338
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "frameworks": ["UIKit.framework"]
3
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ /**
6
+ * Static invariants for iOS safe-area / env(safe-area-inset-*) support.
7
+ *
8
+ * The iOS shell source can't be imported here (it references WKWebView/UIKit globals at module
9
+ * scope), so these are SOURCE-LEVEL assertions of the three conditions WebKit requires for a
10
+ * wrapped page's `env(safe-area-inset-*)` to be non-zero:
11
+ * 1. the WKWebView frame extends under the bars (NS `iosOverflowSafeArea = true` — default is
12
+ * false, which routes layout through shrinkToSafeArea and zeroes the view's safeAreaInsets),
13
+ * 2. `scrollView.contentInsetAdjustmentBehavior = .never` (2) — otherwise WebKit consumes the
14
+ * insets as contentInset instead of exposing them to CSS,
15
+ * 3. the injected viewport meta keeps/adds `viewport-fit=cover` (never strips the page's own).
16
+ * Field bug this pins: loader:'server' app on a notch device rendered under the status bar with
17
+ * env() = 0, so the site's own (correct) safe-area padding collapsed.
18
+ */
19
+ const shellDir = join(import.meta.dir, '..', 'app', 'shell');
20
+ const iosSrc = readFileSync(join(shellDir, 'custom-webview.ios.ts'), 'utf8');
21
+
22
+ describe('iOS WKWebView exposes real safe-area insets to page CSS', () => {
23
+ test('CustomWebView opts into NS full-bleed layout (iosOverflowSafeArea)', () => {
24
+ expect(iosSrc).toMatch(/this\.iosOverflowSafeArea\s*=\s*true/);
25
+ });
26
+
27
+ test('scrollView contentInsetAdjustmentBehavior is .never', () => {
28
+ expect(iosSrc).toMatch(/scrollView\.contentInsetAdjustmentBehavior\s*=\s*2/);
29
+ });
30
+
31
+ test('injected viewport meta preserves/adds viewport-fit=cover', async () => {
32
+ const { NATIVE_FEEL_JS } = await import('../app/shell/web-quirks');
33
+ // Adds cover only when the page's meta doesn't already declare a viewport-fit (never overrides).
34
+ expect(NATIVE_FEEL_JS).toContain("if (!/viewport-fit/.test(c)) c += ', viewport-fit=cover'");
35
+ // WebKit honors the LAST viewport meta — normalizing the first (or an injected one) is silently
36
+ // reverted by a page's own later meta (device-verified: env() stayed 0 with perfect native insets).
37
+ expect(NATIVE_FEEL_JS).toContain('metas[metas.length - 1]');
38
+ // SPA head managers rewrite the meta after load — the observer re-normalizes.
39
+ expect(NATIVE_FEEL_JS).toContain('MutationObserver');
40
+ });
41
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * iOS shareTarget App-Group mailbox — the durable extension→host hand-off (modern iOS blocks a share
3
+ * extension from launching its host via `openURL:`, so payloads are persisted and DRAINED by the host
4
+ * on cold launch + every resume). Contract under test: exactly-once delivery (clear-before-deliver),
5
+ * crash-safety (nothing lost while no drain ran), cold→resume sequencing, and garbage filtering.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { drainShareMailbox } from '../app/shell/share-mailbox';
9
+
10
+ /** In-memory stand-in for the App-Group UserDefaults mailbox the extension appends to. */
11
+ function fakeStore(initial: string[] = []) {
12
+ let box = [...initial];
13
+ return {
14
+ box: () => box,
15
+ push: (u: string) => box.push(u), // what ShareViewController.enqueueMailbox does
16
+ store: {
17
+ read: () => [...box],
18
+ clear: () => { box = []; },
19
+ },
20
+ };
21
+ }
22
+
23
+ describe('drainShareMailbox — exactly-once, crash-safe delivery', () => {
24
+ test('cold launch: delivers every pending share URL in order, then the mailbox is empty (read-once)', () => {
25
+ const { store, box } = fakeStore(['app://share?text=a', 'app://share?text=b']);
26
+ const got: string[] = [];
27
+ expect(drainShareMailbox(store, (u) => got.push(u))).toBe(2);
28
+ expect(got).toEqual(['app://share?text=a', 'app://share?text=b']);
29
+ expect(box()).toEqual([]);
30
+ // A second drain right after (e.g. resumeEvent firing just after the cold-launch drain) delivers NOTHING.
31
+ expect(drainShareMailbox(store, (u) => got.push(u))).toBe(0);
32
+ expect(got).toHaveLength(2);
33
+ });
34
+
35
+ test('resume: a share enqueued while backgrounded is picked up by the next foreground drain, once', () => {
36
+ const { store, push } = fakeStore();
37
+ const got: string[] = [];
38
+ expect(drainShareMailbox(store, (u) => got.push(u))).toBe(0); // cold launch, nothing shared yet
39
+ push('app://share?text=warm&gfile=pic.png'); // extension writes while app is backgrounded
40
+ expect(drainShareMailbox(store, (u) => got.push(u))).toBe(1); // foreground → resume drain
41
+ expect(drainShareMailbox(store, (u) => got.push(u))).toBe(0); // next resume: already consumed
42
+ expect(got).toEqual(['app://share?text=warm&gfile=pic.png']);
43
+ });
44
+
45
+ test('clears BEFORE delivering — a re-entrant drain during delivery cannot double-deliver', () => {
46
+ const { store } = fakeStore(['app://share?text=x']);
47
+ const got: string[] = [];
48
+ drainShareMailbox(store, (u) => {
49
+ got.push(u);
50
+ drainShareMailbox(store, (u2) => got.push(u2)); // overlapping drain mid-delivery
51
+ });
52
+ expect(got).toEqual(['app://share?text=x']);
53
+ });
54
+
55
+ test('crash-safety: an entry written by the extension persists until a drain actually runs', () => {
56
+ const { store, box } = fakeStore(['app://share?text=kept']);
57
+ // Host crashed (or was never opened) after the extension wrote — nothing consumed the store.
58
+ expect(box()).toEqual(['app://share?text=kept']); // still there on the next launch/foreground
59
+ const got: string[] = [];
60
+ drainShareMailbox(store, (u) => got.push(u));
61
+ expect(got).toEqual(['app://share?text=kept']);
62
+ });
63
+
64
+ test('non-share / garbage entries are dropped but still cleared', () => {
65
+ const { store, box } = fakeStore(['not a url', 'app://other?x=1', 'app://share?text=ok']);
66
+ const got: string[] = [];
67
+ expect(drainShareMailbox(store, (u) => got.push(u))).toBe(1);
68
+ expect(got).toEqual(['app://share?text=ok']);
69
+ expect(box()).toEqual([]);
70
+ });
71
+
72
+ test('empty mailbox: no clear, no delivery', () => {
73
+ let cleared = false;
74
+ const n = drainShareMailbox({ read: () => [], clear: () => { cleared = true; } }, () => { throw new Error('must not deliver'); });
75
+ expect(n).toBe(0);
76
+ expect(cleared).toBe(false);
77
+ });
78
+ });
package/src/cli.ts CHANGED
@@ -22,7 +22,7 @@ import type * as CapManifest from '../../../runtime/app/shell/capabilities.manif
22
22
  // Config shape lives in its own import-safe module so a `appwrap.config.ts` file can import the
23
23
  // type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
24
24
  import type { AppwrapConfig } from './config';
25
- import { unknownConfigKeys } from './config';
25
+ import { encodeShareDirectSync, unknownConfigKeys } from './config';
26
26
  import { resolveModulePacks, type ResolvedModule, type SyncContext } from './packs';
27
27
  import { createHash } from 'crypto';
28
28
  // Icon helpers re-exported so the EE desktop lane (which owns the Tauri chassis) can reuse them via
@@ -240,6 +240,7 @@ interface NativeReqs {
240
240
  androidGradleDeps: string[];
241
241
  androidKotlin: boolean; // any active module ships Kotlin native source
242
242
  androidManifestApp: string[]; // raw XML injected inside AndroidManifest <application>
243
+ androidManifestActivity: string[]; // raw XML injected inside the main <activity> (intent-filters)
243
244
  nativeSrc: string[]; // active BUILT-IN modules' nativeSrc dir names (under runtime/modules-native/)
244
245
  packNativeSrc: Array<{ name: string; srcDir: string }>; // active PACK modules' native source (absolute src dirs)
245
246
  packHandlers: Array<{ name: string; source: string; packDir: string; file: string; fn: string }>; // active pack modules with a register handler (for the generated barrel + staging)
@@ -270,6 +271,7 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
270
271
  const packHandlers: NativeReqs['packHandlers'] = [];
271
272
  const packModules: NativeReqs['packModules'] = [];
272
273
  const androidManifestApp: string[] = [];
274
+ const androidManifestActivity: string[] = [];
273
275
  let androidKotlin = false;
274
276
 
275
277
  if (cfg.modules) {
@@ -285,6 +287,7 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
285
287
  Object.assign(iosEntitlements, m.ios?.entitlements ?? {});
286
288
  if (m.android?.kotlin) androidKotlin = true;
287
289
  if (m.android?.manifestApplication) androidManifestApp.push(m.android.manifestApplication);
290
+ if (m.android?.manifestActivity) androidManifestActivity.push(m.android.manifestActivity);
288
291
  // built-in native source resolves under runtime/modules-native/; pack native source (below)
289
292
  // resolves under the pack's own dir, so it must not be conflated with the built-in dir names.
290
293
  if (m.nativeSrc && !packInfo(m.name)) nativeSrc.push(m.nativeSrc);
@@ -321,6 +324,7 @@ function nativeReqs(cfg: AppwrapConfig): NativeReqs {
321
324
  androidGradleDeps: [...gradle],
322
325
  androidKotlin,
323
326
  androidManifestApp,
327
+ androidManifestActivity,
324
328
  nativeSrc,
325
329
  packNativeSrc,
326
330
  packHandlers,
@@ -337,6 +341,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
337
341
  tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
338
342
  appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
339
343
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
344
+ shareTarget: { file: './handlers-share-target', fn: 'registerShareTargetHandlers' },
340
345
  // billing/health/widget live in host-provided module packs (not part of CE) — a consumer opts in via modulePacks.
341
346
  };
342
347
 
@@ -664,10 +669,18 @@ function copyModuleNativeSrc(outDir: string, req: NativeReqs): Set<string> {
664
669
 
665
670
  /** Build-time tokens a module's native source / entitlements may reference — resolved from the app
666
671
  * config so a module ships app-agnostic and the CLI stamps the app-specific value in. Not module-
667
- * specific: any module (built-in or pack) can use these tokens. Currently `__APP_GROUP__` → the App
668
- * Group id shared by the app + an extension (e.g. widget); extend here as new app-derived needs arise. */
672
+ * specific: any module (built-in or pack) can use these tokens. `__APP_GROUP__` → the App Group id
673
+ * shared by the app + an extension (e.g. widget, shareTarget); `__URL_SCHEME__` → config `urlScheme`
674
+ * (the shareTarget extension forwards to the host app through it); `__APP_NAME__` → display name;
675
+ * `__SHARE_SYNC_B64__` → base64(JSON) of `shareTarget.directSync` with defaults applied (empty =
676
+ * direct sync off — base64 so arbitrary config JSON is safe inside a Swift string literal). */
669
677
  function buildTokens(cfg: AppwrapConfig): Record<string, string> {
670
- return { __APP_GROUP__: `group.${cfg.id}` };
678
+ return {
679
+ __APP_GROUP__: `group.${cfg.id}`,
680
+ __URL_SCHEME__: cfg.urlScheme ?? '',
681
+ __APP_NAME__: cfg.name,
682
+ __SHARE_SYNC_B64__: encodeShareDirectSync(cfg.shareTarget?.directSync),
683
+ };
671
684
  }
672
685
 
673
686
  /** Substitute any {@link buildTokens} in a stamped string (entitlement value or native-source file). */
@@ -1598,6 +1611,11 @@ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
1598
1611
  /<!-- appwrap:application -->[\s\S]*?<!-- \/appwrap:application -->/,
1599
1612
  `<!-- appwrap:application -->\n\t\t${req.androidManifestApp.join('\n\t\t')}\n\t\t<!-- /appwrap:application -->`
1600
1613
  );
1614
+ // Main-<activity> XML from active modules (e.g. shareTarget's ACTION_SEND filter) — idempotent marker.
1615
+ src = src.replace(
1616
+ /<!-- appwrap:activity -->[\s\S]*?<!-- \/appwrap:activity -->/,
1617
+ `<!-- appwrap:activity -->\n\t\t${req.androidManifestActivity.join('\n\t\t')}\n\t\t<!-- /appwrap:activity -->`
1618
+ );
1601
1619
  // <queries> for kit.app.canOpenUrl() visibility probes (API 30+) — idempotent marker. queryPackages
1602
1620
  // → explicit <package>; queryUrlSchemes → a VIEW <intent> per scheme (symmetric with iOS's
1603
1621
  // LSApplicationQueriesSchemes). See stampAndroidQueries.
@@ -2114,6 +2132,11 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
2114
2132
  if (opts.firstRun && !req.explicit) {
2115
2133
  console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
2116
2134
  }
2135
+ // shareTarget forwards the shared payload to the host app over the custom URL scheme — without one
2136
+ // the iOS share extension has no way back into the app (Android delivery still works via intents).
2137
+ if (req.activeOptIn.includes('shareTarget') && !cfg.urlScheme) {
2138
+ console.warn(' ⚠ `shareTarget` is active but `urlScheme` is unset — the iOS share extension cannot forward to the app. Set `urlScheme` in the appwrap config.');
2139
+ }
2117
2140
  // Copy each template root in order — later roots OVERLAY earlier (file-level last-wins), so a
2118
2141
  // consumer template can add or replace shell files without forking CE. Single root (default) is
2119
2142
  // exactly the pre-overlay copy.
package/src/config.ts CHANGED
@@ -40,6 +40,64 @@ export interface EnvSwitcherConfig {
40
40
  deeplink?: boolean;
41
41
  }
42
42
 
43
+ /** iOS share-extension direct sync (`shareTarget.directSync`). When configured, the generated
44
+ * `AppwrapShare` extension tries to complete the share ITSELF — one HTTP call to the app's backend,
45
+ * with an honest "Syncing… → Synced" drawer status — instead of only parking the payload in the
46
+ * App-Group mailbox for the next app open (Apple forbids a share extension launching its host, so
47
+ * this makes launching unnecessary). Any failure (offline, non-2xx, missing/incomplete context,
48
+ * oversized image) falls back to the unchanged mailbox behavior.
49
+ *
50
+ * `{key}` placeholders in the templates resolve from the SHARE CONTEXT — a small KV the web app
51
+ * publishes via `kit.shareTarget.setContext({...})` (persisted as JSON in the App Group under
52
+ * `appwrap-share-context`). The framework treats both the context and the templates as opaque —
53
+ * nothing app-specific is baked in. */
54
+ export interface ShareDirectSyncConfig {
55
+ /** Endpoint template, e.g. `https://api.example.com/bin/{binId}`. Every `{key}` must resolve from
56
+ * the published context or the sync is skipped (mailbox fallback). */
57
+ urlTemplate: string;
58
+ /** HTTP method for the write (default `PUT`). Body is JSON. */
59
+ method?: 'PUT' | 'POST';
60
+ /** JSON body field names: shared text goes under `text` (default `content`), the shared image —
61
+ * as a base64 data URL — under `image` (default `image`). */
62
+ fields?: { text?: string; image?: string };
63
+ /** `append` (default `replace`): GET the same URL first, append the shared text to the existing
64
+ * `fields.text` value (newline-joined) and PRESERVE the existing image unless a new one is shared.
65
+ * `replace` writes the payload as-is (no GET). */
66
+ merge?: 'append' | 'replace';
67
+ /** Max encoded image size (bytes, default 4_000_000). A larger shared image is first DOWNSCALED
68
+ * (longest edge → `maxImageEdge`, JPEG `jpegQuality`) — mirroring the typical web-side pipeline;
69
+ * only if it still exceeds the cap (or fails to decode) does the whole share fall back to the
70
+ * mailbox (which keeps the ORIGINAL bytes for the app to ingest). */
71
+ maxImageBytes?: number;
72
+ /** Downscale target when over `maxImageBytes`: longest edge in px (default 2000). */
73
+ maxImageEdge?: number;
74
+ /** JPEG re-encode quality for the downscale (0–1, default 0.85). */
75
+ jpegQuality?: number;
76
+ /** Drawer success text template (default `Synced`), `{key}` from context — e.g. `Synced to {binId}`. */
77
+ successMessage?: string;
78
+ }
79
+
80
+ /** {@link ShareDirectSyncConfig} with defaults applied — the exact shape stamped into the extension. */
81
+ export function resolveShareDirectSync(ds: ShareDirectSyncConfig): Required<Omit<ShareDirectSyncConfig, 'fields'>> & { fields: { text: string; image: string } } {
82
+ return {
83
+ urlTemplate: ds.urlTemplate,
84
+ method: ds.method ?? 'PUT',
85
+ fields: { text: ds.fields?.text ?? 'content', image: ds.fields?.image ?? 'image' },
86
+ merge: ds.merge ?? 'replace',
87
+ maxImageBytes: ds.maxImageBytes ?? 4_000_000,
88
+ maxImageEdge: ds.maxImageEdge ?? 2000,
89
+ jpegQuality: ds.jpegQuality ?? 0.85,
90
+ successMessage: ds.successMessage ?? 'Synced',
91
+ };
92
+ }
93
+
94
+ /** Base64(JSON) encoding of the resolved direct-sync config — safe to stamp inside a Swift string
95
+ * literal (base64 needs no escaping). Empty string when absent/invalid → the feature is inert. */
96
+ export function encodeShareDirectSync(ds: ShareDirectSyncConfig | undefined): string {
97
+ if (!ds?.urlTemplate) return '';
98
+ return Buffer.from(JSON.stringify(resolveShareDirectSync(ds)), 'utf8').toString('base64');
99
+ }
100
+
43
101
  export interface AppwrapConfig {
44
102
  id: string;
45
103
  name: string;
@@ -308,6 +366,10 @@ export interface AppwrapConfig {
308
366
  * (the per-app `permissions{}` map only OVERRIDES the default usage copy). When ABSENT, every
309
367
  * capability is active and permissions come solely from `permissions{}` (pre-modules behavior). */
310
368
  modules?: string[];
369
+ /** `shareTarget` module options (module must be listed in `modules`). Currently just the iOS
370
+ * share-extension direct-sync lane — see {@link ShareDirectSyncConfig}. Absent → mailbox-only
371
+ * behavior, unchanged. */
372
+ shareTarget?: { directSync?: ShareDirectSyncConfig };
311
373
  /** Extra module packs layered on top of the built-in capabilities (see packs.ts). Each entry is a
312
374
  * local directory OR an npm package name; a pack contributes `ModuleManifest[]` (+ handler files,
313
375
  * native source, an optional kit client). Packs apply in order, LAST-WINS by module name, so a pack
@@ -375,7 +437,7 @@ export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
375
437
  'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
376
438
  'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'modulePacks', 'name',
377
439
  'envSwitcher', 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
378
- 'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'signing', 'signingProfiles', 'statusBarStyle',
440
+ 'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle',
379
441
  'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
380
442
  'usesNonExemptEncryption', 'vendorPaths', 'version',
381
443
  ]);