@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.
- package/package.json +1 -1
- package/runtime/app/shell/capabilities.manifest.ts +36 -16
- package/runtime/app/shell/custom-webview.ios.ts +53 -0
- package/runtime/app/shell/handlers-share-target.ts +67 -10
- 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/Android/src/main/java/cc/appwrap/share/AppwrapShareActivity.kt +307 -0
- 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 +22 -13
- package/src/config.ts +63 -1
|
@@ -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). */
|
|
@@ -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
|
|
688
|
-
*
|
|
689
|
-
* re-copied verbatim each sync, then re-substituted).
|
|
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 (
|
|
709
|
+
if (e.isDirectory()) walk(p, exts);
|
|
710
|
+
else if (exts.test(e.name)) subst(p);
|
|
705
711
|
}
|
|
706
712
|
};
|
|
707
|
-
walk(
|
|
708
|
-
|
|
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
|
]);
|