@livx.cc/appwrap 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -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,44 @@ 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
+ // forwards to the host app over `urlScheme` (REQUIRED in config); images cross via the App Group
286
+ // container (entitlement below, stamped on both targets) and the host shell relocates them into the
287
+ // app cache so the JS contract is platform-identical. Consume via `kit.shareTarget.onReceive` (or
288
+ // raw `kit.lifecycle.onDeepLink`).
289
+ {
290
+ name: 'shareTarget', group: 'shareTarget', nativeSrc: 'shareTarget',
291
+ capabilities: { shareTarget: 'native' },
292
+ ios: { entitlements: { 'com.apple.security.application-groups': ['__APP_GROUP__'] } },
293
+ android: {
294
+ manifestActivity:
295
+ `<intent-filter>\n` +
296
+ `\t\t\t<action android:name="android.intent.action.SEND" />\n` +
297
+ `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
298
+ `\t\t\t<data android:mimeType="text/plain" />\n` +
299
+ `\t\t\t<data android:mimeType="image/*" />\n` +
300
+ `\t\t</intent-filter>\n` +
301
+ `\t\t<intent-filter>\n` +
302
+ `\t\t\t<action android:name="android.intent.action.SEND_MULTIPLE" />\n` +
303
+ `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
304
+ `\t\t\t<data android:mimeType="image/*" />\n` +
305
+ `\t\t</intent-filter>`,
306
+ },
307
+ },
308
+
275
309
  // NOTE: billing, health, and widget are NOT part of CE — they live in host-provided module packs
276
310
  // (a consumer opts in via `modulePacks`).
277
311
  ];
278
312
 
279
313
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
280
314
  * 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;
315
+ export const OPTIONAL_GROUPS = ['oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask', 'shareTarget'] as const;
282
316
 
283
317
  /** CLI-gated groups (strippable in explicit mode) that legacy mode (no `modules` key) STILL auto-bundles
284
318
  * for back-compat. Empty now that billing (its only member) moved to a host-provided pack — kept as the
@@ -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,155 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import type { AndroidActivityNewIntentEventData } from '@nativescript/core';
3
+ import { SHELL_CONFIG } from './config';
4
+ import { onDeepLink, setDeepLinkTransformer } from './events';
5
+
6
+ /**
7
+ * shareTarget (INBOUND share sheet) — Android. The manifest's ACTION_SEND(_MULTIPLE) intent-filter
8
+ * (stamped by the CLI when the module is active) lands the shared payload on the main activity as a
9
+ * SEND intent; this handler reads it (cold launch + warm onNewIntent) and re-delivers through the
10
+ * EXISTING deep-link path as a synthetic URL — so it inherits the proven cold-start buffering
11
+ * (delivered in the handshake `deepLink`) and warm `deeplink.open` event with no new bridge surface.
12
+ *
13
+ * URL contract (parsed by kit.shareTarget, also directly consumable via kit.lifecycle.onDeepLink):
14
+ * <urlScheme>://share?text=<enc>&title=<enc>&file=<enc>&file=<enc>…
15
+ * - `text` — EXTRA_TEXT (shared text / URL), when present.
16
+ * - `title` — EXTRA_SUBJECT, when present.
17
+ * - `file` — repeated; each is a path RELATIVE TO THE APP CACHE DIR (`appwrap-share/…`) where the
18
+ * shared stream (image) was copied — read it via `kit.fs.read(p, { dir: 'cache', encoding: 'base64' })`.
19
+ *
20
+ * iOS: the generated `AppwrapShare` extension (this module's nativeSrc) forwards the payload as the
21
+ * SAME URL over the app's urlScheme — that link enters through the normal openURL delegate path. Text
22
+ * rides in the URL directly; images land in the App Group container as `gfile=<name>` params, which
23
+ * the transformer below relocates into the app cache and rewrites to `file=` (cache-relative) so the
24
+ * contract the web app sees is platform-identical.
25
+ */
26
+ export function registerShareTargetHandlers(): void {
27
+ if (isIOS) {
28
+ // Rewrite share links carrying App-Group files (gfile=) → cache files (file=). Text-only share
29
+ // links (no gfile) pass through untouched.
30
+ setDeepLinkTransformer((url) => (url.includes('://share?') && url.includes('gfile=') ? iosRelocateGroupFiles(url) : url));
31
+ return;
32
+ }
33
+ if (!isAndroid) return;
34
+ Application.android.on(Application.android.activityNewIntentEvent, (args: AndroidActivityNewIntentEventData) =>
35
+ deliverShareIntent(args.intent)
36
+ );
37
+ // Cold start: the SEND intent IS the launch intent (events.ts only reads getData(), null for SEND).
38
+ deliverShareIntent(Application.android.startActivity?.getIntent?.());
39
+ }
40
+
41
+ /** iOS: move each `gfile=<name>` from `<AppGroup>/appwrap-share/` into `<Caches>/appwrap-share/` and
42
+ * rewrite the param to `file=appwrap-share/<name>` (the fs `cache` root), matching Android's shape.
43
+ * Any file that can't be moved (missing group container, purged file) is dropped from the link;
44
+ * text/title params always survive. */
45
+ function iosRelocateGroupFiles(url: string): string {
46
+ try {
47
+ const [head, query = ''] = url.split('?');
48
+ const fm = NSFileManager.defaultManager;
49
+ const groupRoot = fm.containerURLForSecurityApplicationGroupIdentifier(`group.${SHELL_CONFIG.appId}`);
50
+ const cacheBase = NSSearchPathForDirectoriesInDomains(13 /* Caches */, 1, true).objectAtIndex(0) as string;
51
+ const destDir = `${cacheBase}/appwrap-share`;
52
+ fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(destDir, true, null, null);
53
+ const params: string[] = [];
54
+ for (const pair of query.split('&')) {
55
+ if (!pair.startsWith('gfile=')) { if (pair) params.push(pair); continue; }
56
+ if (!groupRoot) continue; // no group entitlement in this build — drop the file, keep the text
57
+ const name = decodeURIComponent(pair.slice('gfile='.length));
58
+ if (!name || name.includes('/') || name.includes('..')) continue; // extension controls names — don't let one escape the dir
59
+ const src = `${String(groupRoot.path)}/appwrap-share/${name}`;
60
+ const dest = `${destDir}/${name}`;
61
+ fm.removeItemAtPathError(dest, null); // overwrite-safe (moveItem fails on an existing dest)
62
+ if (fm.moveItemAtPathToPathError(src, dest, null)) params.push(`file=${encodeURIComponent(`appwrap-share/${name}`)}`);
63
+ }
64
+ return `${head}?${params.join('&')}`;
65
+ } catch (e) {
66
+ console.warn('AppWrap: share group-file relocation failed', e);
67
+ return url;
68
+ }
69
+ }
70
+
71
+ const CONSUMED = 'appwrap_share_consumed';
72
+
73
+ function deliverShareIntent(intent: android.content.Intent | null | undefined): void {
74
+ try {
75
+ const action = intent?.getAction?.();
76
+ if (action !== 'android.intent.action.SEND' && action !== 'android.intent.action.SEND_MULTIPLE') return;
77
+ if (intent!.getBooleanExtra?.(CONSUMED, false)) return; // relayout re-read of the same launch intent
78
+ intent!.putExtra?.(CONSUMED, true);
79
+
80
+ const params: string[] = [];
81
+ const text = intent!.getStringExtra?.(android.content.Intent.EXTRA_TEXT);
82
+ if (text) params.push(`text=${encodeURIComponent(String(text))}`);
83
+ const subject = intent!.getStringExtra?.(android.content.Intent.EXTRA_SUBJECT);
84
+ if (subject) params.push(`title=${encodeURIComponent(String(subject))}`);
85
+ for (const rel of copySharedStreams(intent!, action)) params.push(`file=${encodeURIComponent(rel)}`);
86
+ if (!params.length) return;
87
+
88
+ // Scheme is cosmetic here — the URL goes straight into onDeepLink, never through the OS.
89
+ const scheme = SHELL_CONFIG.urlScheme || 'app';
90
+ onDeepLink(`${scheme}://share?${params.join('&')}`);
91
+ } catch (e) {
92
+ console.warn('AppWrap: share intent read failed', e);
93
+ }
94
+ }
95
+
96
+ /** Copy each EXTRA_STREAM content:// URI into `<cacheDir>/appwrap-share/`; return cache-relative paths. */
97
+ function copySharedStreams(intent: android.content.Intent, action: string): string[] {
98
+ const uris: android.net.Uri[] = [];
99
+ try {
100
+ if (action === 'android.intent.action.SEND_MULTIPLE') {
101
+ const list = intent.getParcelableArrayListExtra?.(android.content.Intent.EXTRA_STREAM);
102
+ for (let i = 0; list && i < list.size(); i++) uris.push(list.get(i) as android.net.Uri);
103
+ } else {
104
+ const one = intent.getParcelableExtra?.(android.content.Intent.EXTRA_STREAM) as android.net.Uri | null;
105
+ if (one) uris.push(one);
106
+ }
107
+ } catch (e) {
108
+ console.warn('AppWrap: share stream extras read failed', e);
109
+ }
110
+
111
+ const out: string[] = [];
112
+ if (!uris.length) return out;
113
+ const ctx = Utils.android.getApplicationContext() as android.content.Context;
114
+ const dir = new java.io.File(ctx.getCacheDir(), 'appwrap-share');
115
+ dir.mkdirs();
116
+ const resolver = ctx.getContentResolver();
117
+ uris.forEach((uri, i) => {
118
+ try {
119
+ const name = fileNameFor(resolver, uri, i);
120
+ const dest = new java.io.File(dir, name);
121
+ const input = resolver.openInputStream(uri);
122
+ if (!input) return;
123
+ const output = new java.io.FileOutputStream(dest);
124
+ const buf = Array.create('byte', 64 * 1024);
125
+ let n: number;
126
+ while ((n = input.read(buf)) > 0) output.write(buf, 0, n);
127
+ output.close();
128
+ input.close();
129
+ out.push(`appwrap-share/${name}`);
130
+ } catch (e) {
131
+ console.warn('AppWrap: shared stream copy failed', e);
132
+ }
133
+ });
134
+ return out;
135
+ }
136
+
137
+ /** A safe, unique-enough filename: display name when the provider offers one, else mime-derived. */
138
+ function fileNameFor(resolver: android.content.ContentResolver, uri: android.net.Uri, i: number): string {
139
+ let name = '';
140
+ try {
141
+ const c = resolver.query(uri, null, null, null, null);
142
+ if (c) {
143
+ const idx = c.getColumnIndex('_display_name');
144
+ if (idx >= 0 && c.moveToFirst()) name = String(c.getString(idx) ?? '');
145
+ c.close();
146
+ }
147
+ } catch { /* fall through to mime-derived name */ }
148
+ if (!name) {
149
+ const mime = String(resolver.getType(uri) ?? '');
150
+ const ext = mime.startsWith('image/') ? mime.slice(6).replace(/[^a-z0-9]/gi, '') || 'img' : 'bin';
151
+ name = `shared-${i}.${ext}`;
152
+ }
153
+ // Sanitize (providers control display names) + prefix a timestamp so repeated shares don't collide.
154
+ return `${Date.now()}-${i}-${name.replace(/[^\w.-]/g, '_')}`;
155
+ }
@@ -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,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,124 @@
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 forwards them to the HOST app as
7
+ /// the SAME deep-link contract Android uses:
8
+ /// __URL_SCHEME__://share?text=<enc>&title=<enc>&gfile=<enc>&gfile=<enc>…
9
+ /// - text / web URLs ride entirely in the URL (no files involved).
10
+ /// - images are written into the shared App Group container under `appwrap-share/`; each `gfile`
11
+ /// param is the file NAME there. The host shell relocates them into the app cache and rewrites
12
+ /// `gfile=` → `file=` (cache-relative) before the link reaches the web app — so the JS contract
13
+ /// is identical on both platforms (see runtime/app/shell/handlers-share-target.ts).
14
+ ///
15
+ /// Build-time tokens (stamped by `appwrap init`/`sync`): __URL_SCHEME__ (config `urlScheme`),
16
+ /// __APP_GROUP__ (`group.<appId>`), __APP_NAME__ (Info.plist display name).
17
+ class ShareViewController: UIViewController {
18
+ private var processed = false
19
+
20
+ override func viewDidAppear(_ animated: Bool) {
21
+ super.viewDidAppear(animated)
22
+ guard !processed else { return }
23
+ processed = true
24
+ collectPayload { [weak self] params in
25
+ guard let self = self else { return }
26
+ if params.isEmpty {
27
+ self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
28
+ return
29
+ }
30
+ let url = URL(string: "__URL_SCHEME__://share?" + params.joined(separator: "&"))
31
+ if let url = url { self.openHostApp(url) }
32
+ // Give the openURL hand-off a beat before the extension process is torn down.
33
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
34
+ self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
35
+ }
36
+ }
37
+ }
38
+
39
+ // MARK: payload collection
40
+
41
+ private func collectPayload(_ done: @escaping ([String]) -> Void) {
42
+ var texts: [String] = []
43
+ var files: [String] = []
44
+ let group = DispatchGroup()
45
+
46
+ let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
47
+ for item in items {
48
+ for provider in item.attachments ?? [] {
49
+ if provider.hasItemConformingToTypeIdentifier("public.url") && !provider.hasItemConformingToTypeIdentifier("public.file-url") {
50
+ group.enter()
51
+ provider.loadItem(forTypeIdentifier: "public.url", options: nil) { data, _ in
52
+ if let u = data as? URL { texts.append(u.absoluteString) }
53
+ group.leave()
54
+ }
55
+ } else if provider.hasItemConformingToTypeIdentifier("public.plain-text") {
56
+ group.enter()
57
+ provider.loadItem(forTypeIdentifier: "public.plain-text", options: nil) { data, _ in
58
+ if let s = data as? String, !s.isEmpty { texts.append(s) }
59
+ else if let d = data as? Data, let s = String(data: d, encoding: .utf8), !s.isEmpty { texts.append(s) }
60
+ group.leave()
61
+ }
62
+ } else if provider.hasItemConformingToTypeIdentifier("public.image") {
63
+ group.enter()
64
+ provider.loadItem(forTypeIdentifier: "public.image", options: nil) { data, _ in
65
+ if let name = self.stashImage(data) { files.append(name) }
66
+ group.leave()
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ group.notify(queue: .main) {
73
+ var params: [String] = []
74
+ let enc = { (s: String) in s.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "" }
75
+ if !texts.isEmpty { params.append("text=" + enc(texts.joined(separator: "\n"))) }
76
+ for f in files { params.append("gfile=" + enc(f)) }
77
+ done(params)
78
+ }
79
+ }
80
+
81
+ /// Write a shared image (URL / Data / UIImage, whatever the provider hands over) into the App
82
+ /// Group container `appwrap-share/` dir; returns the file name there, or nil on any failure
83
+ /// (missing group entitlement included — text still goes through).
84
+ private func stashImage(_ data: Any?) -> String? {
85
+ guard let root = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "__APP_GROUP__") else { return nil }
86
+ let dir = root.appendingPathComponent("appwrap-share", isDirectory: true)
87
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
88
+ let stamp = Int(Date().timeIntervalSince1970 * 1000)
89
+ do {
90
+ if let src = data as? URL {
91
+ let name = "\(stamp)-\(src.lastPathComponent.replacingOccurrences(of: "[^\\w.-]", with: "_", options: .regularExpression))"
92
+ try FileManager.default.copyItem(at: src, to: dir.appendingPathComponent(name))
93
+ return name
94
+ }
95
+ if let d = data as? Data {
96
+ let name = "\(stamp)-shared.img"
97
+ try d.write(to: dir.appendingPathComponent(name))
98
+ return name
99
+ }
100
+ if let img = data as? UIImage, let d = img.pngData() {
101
+ let name = "\(stamp)-shared.png"
102
+ try d.write(to: dir.appendingPathComponent(name))
103
+ return name
104
+ }
105
+ } catch { return nil }
106
+ return nil
107
+ }
108
+
109
+ // MARK: host-app hand-off
110
+
111
+ /// An app extension has no UIApplication.shared — the established path is walking the responder
112
+ /// chain to the hosting app's UIApplication and performing `openURL:` on it.
113
+ private func openHostApp(_ url: URL) {
114
+ var responder: UIResponder? = self as UIResponder
115
+ let selector = NSSelectorFromString("openURL:")
116
+ while let r = responder {
117
+ if r.responds(to: selector) && !(r is UIViewController) {
118
+ r.perform(selector, with: url)
119
+ return
120
+ }
121
+ responder = r.next
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "frameworks": ["UIKit.framework"]
3
+ }
package/src/cli.ts CHANGED
@@ -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,11 @@ 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. */
669
675
  function buildTokens(cfg: AppwrapConfig): Record<string, string> {
670
- return { __APP_GROUP__: `group.${cfg.id}` };
676
+ return { __APP_GROUP__: `group.${cfg.id}`, __URL_SCHEME__: cfg.urlScheme ?? '', __APP_NAME__: cfg.name };
671
677
  }
672
678
 
673
679
  /** Substitute any {@link buildTokens} in a stamped string (entitlement value or native-source file). */
@@ -1598,6 +1604,11 @@ function stampAndroidAppName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
1598
1604
  /<!-- appwrap:application -->[\s\S]*?<!-- \/appwrap:application -->/,
1599
1605
  `<!-- appwrap:application -->\n\t\t${req.androidManifestApp.join('\n\t\t')}\n\t\t<!-- /appwrap:application -->`
1600
1606
  );
1607
+ // Main-<activity> XML from active modules (e.g. shareTarget's ACTION_SEND filter) — idempotent marker.
1608
+ src = src.replace(
1609
+ /<!-- appwrap:activity -->[\s\S]*?<!-- \/appwrap:activity -->/,
1610
+ `<!-- appwrap:activity -->\n\t\t${req.androidManifestActivity.join('\n\t\t')}\n\t\t<!-- /appwrap:activity -->`
1611
+ );
1601
1612
  // <queries> for kit.app.canOpenUrl() visibility probes (API 30+) — idempotent marker. queryPackages
1602
1613
  // → explicit <package>; queryUrlSchemes → a VIEW <intent> per scheme (symmetric with iOS's
1603
1614
  // LSApplicationQueriesSchemes). See stampAndroidQueries.
@@ -2114,6 +2125,11 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
2114
2125
  if (opts.firstRun && !req.explicit) {
2115
2126
  console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
2116
2127
  }
2128
+ // shareTarget forwards the shared payload to the host app over the custom URL scheme — without one
2129
+ // the iOS share extension has no way back into the app (Android delivery still works via intents).
2130
+ if (req.activeOptIn.includes('shareTarget') && !cfg.urlScheme) {
2131
+ console.warn(' ⚠ `shareTarget` is active but `urlScheme` is unset — the iOS share extension cannot forward to the app. Set `urlScheme` in the appwrap config.');
2132
+ }
2117
2133
  // Copy each template root in order — later roots OVERLAY earlier (file-level last-wins), so a
2118
2134
  // consumer template can add or replace shell files without forking CE. Single root (default) is
2119
2135
  // exactly the pre-overlay copy.