@livx.cc/appwrap 0.58.0 → 0.58.2

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.
@@ -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). */
@@ -684,12 +691,10 @@ function substituteBuildTokens(s: string, tokens: Record<string, string>): strin
684
691
  }
685
692
 
686
693
  /** Substitute build-time tokens in copied module native source (after copyModuleNativeSrc) — applied
687
- * generically to every iOS extension file, so a module's app-agnostic source (e.g. the widget
688
- * extension's Swift/entitlements/plist) gets the app-specific value stamped in. Idempotent (source is
689
- * re-copied verbatim each sync, then re-substituted). No-op when no extensions were copied. */
694
+ * generically to every iOS extension file AND every Android module source file (Kotlin/Java/XML —
695
+ * e.g. shareTarget's AppwrapShareActivity), so a module's app-agnostic source gets the app-specific
696
+ * value stamped in. Idempotent (source is re-copied verbatim each sync, then re-substituted). */
690
697
  function substituteModuleTokens(outDir: string, cfg: AppwrapConfig): void {
691
- const extRoot = join(outDir, 'App_Resources/iOS/extensions');
692
- if (!existsSync(extRoot)) return;
693
698
  const tokens = buildTokens(cfg);
694
699
  let touched = false;
695
700
  const subst = (file: string) => {
@@ -697,15 +702,19 @@ function substituteModuleTokens(outDir: string, cfg: AppwrapConfig): void {
697
702
  const next = substituteBuildTokens(s, tokens);
698
703
  if (next !== s) { writeFileSync(file, next); touched = true; }
699
704
  };
700
- const walk = (dir: string) => {
705
+ const walk = (dir: string, exts: RegExp) => {
706
+ if (!existsSync(dir)) return;
701
707
  for (const e of readdirSync(dir, { withFileTypes: true })) {
702
708
  const p = join(dir, e.name);
703
- if (e.isDirectory()) walk(p);
704
- else if (/\.(swift|entitlements|plist|json)$/.test(e.name)) subst(p);
709
+ if (e.isDirectory()) walk(p, exts);
710
+ else if (exts.test(e.name)) subst(p);
705
711
  }
706
712
  };
707
- walk(extRoot);
708
- if (touched) console.log(` tokn ← __APP_GROUP__ = ${tokens.__APP_GROUP__} (extension source)`);
713
+ walk(join(outDir, 'App_Resources/iOS/extensions'), /\.(swift|entitlements|plist|json)$/);
714
+ // Android module source: only java/ (module-owned code) — NOT the whole src/main (res/ + template
715
+ // manifest have their own stamping and must not see generic token substitution).
716
+ walk(join(outDir, 'App_Resources/Android/src/main/java'), /\.(kt|java|xml)$/);
717
+ if (touched) console.log(` tokn ← __APP_GROUP__ = ${tokens.__APP_GROUP__} (module native source)`);
709
718
  }
710
719
 
711
720
  /** Hosts to register as Android App Links (autoVerify https intent-filters). Explicit
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
  ]);