@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.58.0",
3
+ "version": "0.58.1",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -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
- // 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`).
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) 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.
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
- var vp = document.querySelector('meta[name=viewport]');
299
+ // Normalize the LAST viewport meta — WebKit honors the last one parsed, so patching the first
300
+ // (or an injected one) is silently overridden when the page's own meta appears later in <head>.
301
+ // Device-verified failure: injected meta had viewport-fit=cover, the site's own plain
302
+ // "width=device-width, initial-scale=1.0" meta parsed after it → viewport-fit reverted to auto
303
+ // → env(safe-area-inset-*) = 0 despite correct native safeAreaInsets (t47/b34).
304
+ var metas = document.querySelectorAll('meta[name=viewport]');
305
+ var vp = metas.length ? metas[metas.length - 1] : null;
300
306
  if (!vp){ vp = document.createElement('meta'); vp.setAttribute('name','viewport'); head.appendChild(vp); }
301
307
  var c = vp.getAttribute('content') || 'width=device-width, initial-scale=1';
302
308
  if (!/user-scalable\\s*=\\s*no/.test(c)) c += ', maximum-scale=1, user-scalable=no';
303
309
  // viewport-fit=cover makes env(safe-area-inset-*) resolve to real notch /
304
310
  // home-indicator insets — the WebView owns the safe areas (contentInset .never).
311
+ // A page's own explicit viewport-fit is respected (only appended when absent).
305
312
  if (!/viewport-fit/.test(c)) c += ', viewport-fit=cover';
306
- vp.setAttribute('content', c);
313
+ if (vp.getAttribute('content') !== c) vp.setAttribute('content', c);
307
314
  if (!document.getElementById('__appwrap_feel')){
308
315
  var s = document.createElement('style'); s.id = '__appwrap_feel';
309
316
  s.textContent = ${JSON.stringify(NATIVE_FEEL_CSS)};
@@ -322,6 +329,22 @@ export const NATIVE_FEEL_JS = `(function(){
322
329
  }
323
330
  apply();
324
331
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply);
332
+ // SPA head managers (React Helmet, Next, vue-meta) add/rewrite the viewport meta AFTER load —
333
+ // re-normalize whenever one changes. apply() is idempotent (only writes on an actual content
334
+ // change), so the observer can't loop on its own writes.
335
+ try {
336
+ new MutationObserver(function(muts){
337
+ for (var i = 0; i < muts.length; i++){
338
+ var m = muts[i];
339
+ var t = m.target;
340
+ if (t && t.nodeName === 'META' && t.getAttribute('name') === 'viewport') return apply();
341
+ for (var j = 0; j < (m.addedNodes ? m.addedNodes.length : 0); j++){
342
+ var n = m.addedNodes[j];
343
+ if (n.nodeName === 'META' && n.getAttribute && n.getAttribute('name') === 'viewport') return apply();
344
+ }
345
+ }
346
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['content', 'name'] });
347
+ } catch(e) { /* observer is a hardening layer; core apply() already ran */ }
325
348
  // Pinch-zoom on iOS fires gesture* even with user-scalable=no in some builds.
326
349
  document.addEventListener('gesturestart', function(e){ e.preventDefault(); }, { passive: false });
327
350
  })();`;
@@ -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 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).
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] params in
41
+ collectPayload { [weak self] texts, files in
25
42
  guard let self = self else { return }
26
- if params.isEmpty {
43
+ if texts.isEmpty && files.isEmpty {
27
44
  self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
28
45
  return
29
46
  }
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)
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
- private func collectPayload(_ done: @escaping ([String]) -> Void) {
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
- if let u = data as? URL { texts.append(u.absoluteString) }
53
- group.leave()
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
- 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()
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
- if let name = self.stashImage(data) { files.append(name) }
66
- group.leave()
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
- // MARK: host-app hand-off
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
- /// 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
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
- responder = r.next
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 { __APP_GROUP__: `group.${cfg.id}`, __URL_SCHEME__: cfg.urlScheme ?? '', __APP_NAME__: cfg.name };
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
  ]);