@livx.cc/appwrap 0.56.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 +1 -1
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +2 -0
- package/runtime/app/shell/capabilities.manifest.ts +35 -1
- package/runtime/app/shell/events.ts +20 -1
- package/runtime/app/shell/handlers-share-target.ts +155 -0
- package/runtime/app/shell/handlers.ts +1 -1
- package/runtime/modules-native/shareTarget/App_Resources/iOS/extensions/AppwrapShare/AppwrapShare.entitlements +10 -0
- package/runtime/modules-native/shareTarget/App_Resources/iOS/extensions/AppwrapShare/Info.plist +43 -0
- package/runtime/modules-native/shareTarget/App_Resources/iOS/extensions/AppwrapShare/ShareViewController.swift +124 -0
- package/runtime/modules-native/shareTarget/App_Resources/iOS/extensions/AppwrapShare/extension.json +3 -0
- package/src/cli.ts +19 -3
- package/templates/ci/fastlane/Fastfile +47 -6
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 = '
|
|
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>
|
package/runtime/modules-native/shareTarget/App_Resources/iOS/extensions/AppwrapShare/Info.plist
ADDED
|
@@ -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
|
+
}
|
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.
|
|
668
|
-
*
|
|
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.
|
|
@@ -6,6 +6,21 @@ default_platform(:ios)
|
|
|
6
6
|
APP_ID = '__APP_ID__'.freeze
|
|
7
7
|
TEAM_ID = '__TEAM_ID__'.freeze
|
|
8
8
|
|
|
9
|
+
# iOS app-extension targets discovered from the generated wrapper. Each dir under
|
|
10
|
+
# App_Resources/iOS/extensions/ carrying an extension.json is a SEPARATE Xcode target (name = dir
|
|
11
|
+
# name, e.g. AppwrapWidget / NotificationService). Its bundle id MUST be a child `<APP_ID>.<name>`
|
|
12
|
+
# (Apple rejects an extension whose id equals the app's or has >1 extra period), and it needs its
|
|
13
|
+
# OWN App Store profile. NS already stamps the correct child PRODUCT_BUNDLE_IDENTIFIER at prepare —
|
|
14
|
+
# the per-target signing below must NOT clobber it (a single unscoped update_code_signing_settings
|
|
15
|
+
# rewrites EVERY target's bundle id to APP_ID → collision at upload).
|
|
16
|
+
def appwrap_ios_extensions
|
|
17
|
+
ext_root = File.expand_path('../App_Resources/iOS/extensions', __dir__)
|
|
18
|
+
return [] unless Dir.exist?(ext_root)
|
|
19
|
+
Dir.children(ext_root)
|
|
20
|
+
.select { |name| File.file?(File.join(ext_root, name, 'extension.json')) }
|
|
21
|
+
.sort
|
|
22
|
+
end
|
|
23
|
+
|
|
9
24
|
# Run `ns prepare` (which shells out to `pod install`) with a SANITIZED gem env.
|
|
10
25
|
#
|
|
11
26
|
# Why: fastlane runs under its own Ruby and exports GEM_HOME/GEM_PATH into every child process. On a
|
|
@@ -50,8 +65,12 @@ def appwrap_archive_and_sign_ios
|
|
|
50
65
|
is_key_content_base64: true
|
|
51
66
|
)
|
|
52
67
|
|
|
53
|
-
#
|
|
54
|
-
|
|
68
|
+
# The app AND each extension sign with their OWN App Store profile — pull them all here.
|
|
69
|
+
# readonly: certs/profiles must be seeded once via `fastlane match appstore` (see workflow header);
|
|
70
|
+
# regenerate them (incl. per-extension) via the `regen_profiles` lane if capabilities changed.
|
|
71
|
+
extensions = appwrap_ios_extensions
|
|
72
|
+
ext_ids = extensions.map { |name| "#{APP_ID}.#{name}" }
|
|
73
|
+
match(type: 'appstore', readonly: true, app_identifier: [APP_ID, *ext_ids],
|
|
55
74
|
git_url: ENV['MATCH_GIT_URL'], api_key: api_key)
|
|
56
75
|
|
|
57
76
|
# NativeScript prepares the Xcode project inside the wrapper dir (native/ = parent of fastlane/).
|
|
@@ -59,22 +78,44 @@ def appwrap_archive_and_sign_ios
|
|
|
59
78
|
appwrap_prepare_ios
|
|
60
79
|
|
|
61
80
|
# NS defaults the project to automatic signing → archive can't find a profile. Pin manual signing
|
|
62
|
-
# to the match-provided App Store profile (name exported by match into ENV).
|
|
63
|
-
|
|
81
|
+
# PER TARGET to the match-provided App Store profile (name exported by match into ENV). `targets:`
|
|
82
|
+
# is REQUIRED so each call scopes to ONE target — an unscoped call rewrites EVERY target's
|
|
83
|
+
# bundle_identifier to APP_ID, clobbering the extensions' child ids → CFBundleIdentifier collision.
|
|
84
|
+
provisioning = {}
|
|
85
|
+
resolve_profile = ->(bundle_id) { ENV["sigh_#{bundle_id}_appstore_profile-name"] || "match AppStore #{bundle_id}" }
|
|
86
|
+
|
|
87
|
+
app_profile = resolve_profile.call(APP_ID)
|
|
88
|
+
provisioning[APP_ID] = app_profile
|
|
64
89
|
update_code_signing_settings(
|
|
65
90
|
use_automatic_signing: false,
|
|
66
91
|
path: 'platforms/ios/native.xcodeproj',
|
|
92
|
+
targets: ['native'], # main app target only — leave NS's child ids on the extensions untouched
|
|
67
93
|
team_id: TEAM_ID,
|
|
68
94
|
bundle_identifier: APP_ID,
|
|
69
95
|
code_sign_identity: 'Apple Distribution',
|
|
70
|
-
profile_name:
|
|
96
|
+
profile_name: app_profile
|
|
71
97
|
)
|
|
72
98
|
|
|
99
|
+
extensions.each do |name|
|
|
100
|
+
ext_id = "#{APP_ID}.#{name}"
|
|
101
|
+
ext_profile = resolve_profile.call(ext_id)
|
|
102
|
+
provisioning[ext_id] = ext_profile
|
|
103
|
+
update_code_signing_settings(
|
|
104
|
+
use_automatic_signing: false,
|
|
105
|
+
path: 'platforms/ios/native.xcodeproj',
|
|
106
|
+
targets: [name],
|
|
107
|
+
team_id: TEAM_ID,
|
|
108
|
+
bundle_identifier: ext_id,
|
|
109
|
+
code_sign_identity: 'Apple Distribution',
|
|
110
|
+
profile_name: ext_profile
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
73
114
|
build_app(
|
|
74
115
|
workspace: 'platforms/ios/native.xcworkspace',
|
|
75
116
|
scheme: 'native',
|
|
76
117
|
export_method: 'app-store',
|
|
77
|
-
export_options: { provisioningProfiles:
|
|
118
|
+
export_options: { provisioningProfiles: provisioning }
|
|
78
119
|
)
|
|
79
120
|
|
|
80
121
|
api_key
|