@livx.cc/appwrap 0.58.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 +1 -1
- package/runtime/app/shell/capabilities.manifest.ts +10 -4
- package/runtime/app/shell/custom-webview.ios.ts +53 -0
- package/runtime/app/shell/handlers-share-target.ts +49 -5
- package/runtime/app/shell/share-mailbox.ts +40 -0
- package/runtime/app/shell/web-quirks.ts +25 -2
- package/runtime/modules-native/shareTarget/App_Resources/iOS/extensions/AppwrapShare/ShareViewController.swift +256 -42
- package/runtime/tests/ios-safe-area.test.ts +41 -0
- package/runtime/tests/share-mailbox.test.ts +78 -0
- package/src/cli.ts +10 -3
- package/src/config.ts +63 -1
package/package.json
CHANGED
|
@@ -282,10 +282,16 @@ export const MODULES: ModuleManifest[] = [
|
|
|
282
282
|
// proven cold-start buffering (handshake `deepLink`) + warm `deeplink.open` event with zero new
|
|
283
283
|
// bridge surface. iOS: a generated share-extension TARGET (`AppwrapShare`, from this module's
|
|
284
284
|
// nativeSrc via NS's App_Resources/iOS/extensions seam — same machinery as the widget pack) that
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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.
|
|
289
295
|
{
|
|
290
296
|
name: 'shareTarget', group: 'shareTarget', nativeSrc: 'shareTarget',
|
|
291
297
|
capabilities: { shareTarget: 'native' },
|
|
@@ -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
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import type { AndroidActivityNewIntentEventData } from '@nativescript/core';
|
|
3
3
|
import { SHELL_CONFIG } from './config';
|
|
4
|
+
import { bridge } from './bridge';
|
|
4
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';
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* shareTarget (INBOUND share sheet) — Android. The manifest's ACTION_SEND(_MULTIPLE) intent-filter
|
|
@@ -17,17 +24,37 @@ import { onDeepLink, setDeepLinkTransformer } from './events';
|
|
|
17
24
|
* - `file` — repeated; each is a path RELATIVE TO THE APP CACHE DIR (`appwrap-share/…`) where the
|
|
18
25
|
* shared stream (image) was copied — read it via `kit.fs.read(p, { dir: 'cache', encoding: 'base64' })`.
|
|
19
26
|
*
|
|
20
|
-
* iOS: the generated `AppwrapShare` extension (this module's nativeSrc)
|
|
21
|
-
* SAME URL
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
25
36
|
*/
|
|
26
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
|
+
});
|
|
27
48
|
if (isIOS) {
|
|
28
49
|
// Rewrite share links carrying App-Group files (gfile=) → cache files (file=). Text-only share
|
|
29
50
|
// links (no gfile) pass through untouched.
|
|
30
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
|
|
31
58
|
return;
|
|
32
59
|
}
|
|
33
60
|
if (!isAndroid) return;
|
|
@@ -38,6 +65,23 @@ export function registerShareTargetHandlers(): void {
|
|
|
38
65
|
deliverShareIntent(Application.android.startActivity?.getIntent?.());
|
|
39
66
|
}
|
|
40
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
|
+
|
|
41
85
|
/** iOS: move each `gfile=<name>` from `<AppGroup>/appwrap-share/` into `<Caches>/appwrap-share/` and
|
|
42
86
|
* rewrite the param to `file=appwrap-share/<name>` (the fs `cache` root), matching Android's shape.
|
|
43
87
|
* Any file that can't be moved (missing group container, purged file) is dropped from the link;
|
|
@@ -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
|
-
|
|
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
|
})();`;
|
|
@@ -3,45 +3,67 @@ import MobileCoreServices
|
|
|
3
3
|
|
|
4
4
|
/// appwrap shareTarget — iOS share extension (`AppwrapShare` target, generated per-app by the CLI).
|
|
5
5
|
///
|
|
6
|
-
/// Collects the shared attachments (text / web URL / images), then
|
|
7
|
-
///
|
|
8
|
-
///
|
|
9
|
-
///
|
|
10
|
-
///
|
|
11
|
-
///
|
|
12
|
-
///
|
|
13
|
-
///
|
|
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).
|
|
14
29
|
///
|
|
15
30
|
/// Build-time tokens (stamped by `appwrap init`/`sync`): __URL_SCHEME__ (config `urlScheme`),
|
|
16
|
-
/// __APP_GROUP__ (`group.<appId>`), __APP_NAME__ (Info.plist display name)
|
|
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).
|
|
17
33
|
class ShareViewController: UIViewController {
|
|
18
34
|
private var processed = false
|
|
35
|
+
private var statusPill: UILabel?
|
|
19
36
|
|
|
20
37
|
override func viewDidAppear(_ animated: Bool) {
|
|
21
38
|
super.viewDidAppear(animated)
|
|
22
39
|
guard !processed else { return }
|
|
23
40
|
processed = true
|
|
24
|
-
collectPayload { [weak self]
|
|
41
|
+
collectPayload { [weak self] texts, files in
|
|
25
42
|
guard let self = self else { return }
|
|
26
|
-
if
|
|
43
|
+
if texts.isEmpty && files.isEmpty {
|
|
27
44
|
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
|
28
45
|
return
|
|
29
46
|
}
|
|
30
|
-
let
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
self.
|
|
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)
|
|
35
52
|
}
|
|
36
53
|
}
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
// MARK: payload collection
|
|
40
57
|
|
|
41
|
-
|
|
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) {
|
|
42
61
|
var texts: [String] = []
|
|
43
62
|
var files: [String] = []
|
|
44
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")
|
|
45
67
|
|
|
46
68
|
let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? []
|
|
47
69
|
for item in items {
|
|
@@ -49,33 +71,34 @@ class ShareViewController: UIViewController {
|
|
|
49
71
|
if provider.hasItemConformingToTypeIdentifier("public.url") && !provider.hasItemConformingToTypeIdentifier("public.file-url") {
|
|
50
72
|
group.enter()
|
|
51
73
|
provider.loadItem(forTypeIdentifier: "public.url", options: nil) { data, _ in
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
collectQ.async {
|
|
75
|
+
if let u = data as? URL { texts.append(u.absoluteString) }
|
|
76
|
+
group.leave()
|
|
77
|
+
}
|
|
54
78
|
}
|
|
55
79
|
} else if provider.hasItemConformingToTypeIdentifier("public.plain-text") {
|
|
56
80
|
group.enter()
|
|
57
81
|
provider.loadItem(forTypeIdentifier: "public.plain-text", options: nil) { data, _ in
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|
|
61
87
|
}
|
|
62
88
|
} else if provider.hasItemConformingToTypeIdentifier("public.image") {
|
|
63
89
|
group.enter()
|
|
64
90
|
provider.loadItem(forTypeIdentifier: "public.image", options: nil) { data, _ in
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|
|
67
96
|
}
|
|
68
97
|
}
|
|
69
98
|
}
|
|
70
99
|
}
|
|
71
100
|
|
|
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
|
-
}
|
|
101
|
+
group.notify(queue: .main) { done(texts, files) }
|
|
79
102
|
}
|
|
80
103
|
|
|
81
104
|
/// Write a shared image (URL / Data / UIImage, whatever the provider hands over) into the App
|
|
@@ -106,19 +129,210 @@ class ShareViewController: UIViewController {
|
|
|
106
129
|
return nil
|
|
107
130
|
}
|
|
108
131
|
|
|
109
|
-
|
|
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)
|
|
110
138
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
let
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
120
179
|
}
|
|
121
|
-
|
|
180
|
+
imageDataURL = encoded
|
|
122
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
|
|
123
337
|
}
|
|
124
338
|
}
|
|
@@ -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
|
|
@@ -671,9 +671,16 @@ function copyModuleNativeSrc(outDir: string, req: NativeReqs): Set<string> {
|
|
|
671
671
|
* config so a module ships app-agnostic and the CLI stamps the app-specific value in. Not module-
|
|
672
672
|
* specific: any module (built-in or pack) can use these tokens. `__APP_GROUP__` → the App Group id
|
|
673
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
|
|
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). */
|
|
675
677
|
function buildTokens(cfg: AppwrapConfig): Record<string, string> {
|
|
676
|
-
return {
|
|
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
|
+
};
|
|
677
684
|
}
|
|
678
685
|
|
|
679
686
|
/** Substitute any {@link buildTokens} in a stamped string (entitlement value or native-source file). */
|
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
|
]);
|