feat(share): "Biz Connect" in the iOS share sheet — share a photo/file into a chat
Adds the reverse direction: share FROM Photos/Files/Safari INTO a Biz Connect conversation. An app can only appear in the iOS share sheet as an app-extension target, so this is real native work, not a web change. Pieces: - mobile/ios-share/ShareViewController.swift: a UI-less Share Extension. It stages the shared items into the App Group container and opens bizconnect://share. It deliberately does NOT reimplement the chat picker — that lives in the app, which already has the chat list, search and upload progress. Appends to the manifest (never overwrites), so sharing twice before opening the app loses nothing. - mobile/scripts/add-share-extension.rb: injects the extension target into the Capacitor-generated Xcode project on every CI build (Codemagic checks out fresh), using the xcodeproj gem that ships with CocoaPods. Embeds it, sets the bundle id <app>.share, and MERGES the App Group into the app's entitlements rather than clobbering them (push's aps-environment must survive). Idempotent. - mobile/plugins/share-inbox: getPending()/clear() to read that manifest — the App Group container isn't one of Filesystem's known directories, so it needs a bridge. - home.html: on bizconnect://share (and every resume, and cold-launch), read the inbox and show a "Send to…" picker over the chat list; chosen files run the SAME upload + /api/messages send as an in-app attachment. Reuses convertFileSrc to read the staged bytes with no base64 marshalling. - ios-patch.sh registers the bizconnect URL scheme; codemagic.yaml fetches a profile for the .share bundle id too. One-time manual gate (CI cannot toggle App capabilities): the App Group group.com.bizgaze.connect must be created and enabled on both App IDs in the Apple portal — documented in mobile/IOS_SETUP.md. Without it the two processes can't see each other's files and sharing silently no-ops; everything else still works. Validated cross-file: pod-name/jsName/method wiring for all three plugins, App Group id identical in all 4 files, URL scheme consistent across extension/plist/web, entitlement-merge preserves push. Needs a new iOS build (new targets + plugins). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// Share Extension — this is what puts "Biz Connect" in the iOS share sheet (Photos, Files, Safari, …).
|
||||
// An app CANNOT appear there any other way: the share sheet only lists app extensions, so this has to be a
|
||||
// second target inside the app bundle, with its own bundle id and provisioning profile.
|
||||
//
|
||||
// It deliberately has NO UI. Picking the conversation happens in the app, where the chat list, search and
|
||||
// upload progress already exist — re-implementing all of that natively here would be a second, divergent
|
||||
// copy of the app. So this extension only does the part that must happen inside it:
|
||||
// 1. copy the shared items into the App Group container (the only storage both processes can see), and
|
||||
// 2. bounce the user into the app, which picks them up and shows "Send to…".
|
||||
// If step 2 is blocked, the files still sit staged and are collected the next time the app is opened —
|
||||
// nothing is lost, the hand-off is just deferred.
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private let appGroup = "group.com.bizgaze.connect"
|
||||
private let urlScheme = "bizconnect"
|
||||
private let maxItems = 20
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
handleShare()
|
||||
}
|
||||
|
||||
// MARK: - Ingest
|
||||
|
||||
private func handleShare() {
|
||||
let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
|
||||
.flatMap { $0.attachments ?? [] }
|
||||
.prefix(maxItems)
|
||||
guard !providers.isEmpty else { return finish() }
|
||||
|
||||
var staged: [[String: Any]] = []
|
||||
let lock = NSLock()
|
||||
let group = DispatchGroup()
|
||||
|
||||
for provider in providers {
|
||||
group.enter()
|
||||
load(provider) { record in
|
||||
if let record = record { lock.lock(); staged.append(record); lock.unlock() }
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if !staged.isEmpty { self.appendToManifest(staged) }
|
||||
self.openHostApp()
|
||||
self.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one attachment to a staged file (or a text/link record).
|
||||
private func load(_ provider: NSItemProvider, completion: @escaping ([String: Any]?) -> Void) {
|
||||
// Most specific first; .item is the catch-all for arbitrary documents.
|
||||
let fileTypes: [UTType] = [.movie, .image, .audio, .pdf, .item]
|
||||
if let type = fileTypes.first(where: { provider.hasItemConformingToTypeIdentifier($0.identifier) }) {
|
||||
// loadFileRepresentation hands back a URL that is deleted the moment this closure returns,
|
||||
// so the copy has to happen synchronously inside it.
|
||||
provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { [weak self] url, _ in
|
||||
guard let self = self, let url = url else { return completion(nil) }
|
||||
completion(self.stage(url))
|
||||
}
|
||||
return
|
||||
}
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
|
||||
completion((item as? URL).map { ["kind": "text", "text": $0.absoluteString] })
|
||||
}
|
||||
return
|
||||
}
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in
|
||||
completion((item as? String).map { ["kind": "text", "text": $0] })
|
||||
}
|
||||
return
|
||||
}
|
||||
completion(nil)
|
||||
}
|
||||
|
||||
private func stage(_ src: URL) -> [String: Any]? {
|
||||
guard let dir = sharedDirectory() else { return nil }
|
||||
let name = src.lastPathComponent.isEmpty ? "shared-file" : src.lastPathComponent
|
||||
let dest = uniqueURL(in: dir, preferred: name)
|
||||
do { try FileManager.default.copyItem(at: src, to: dest) } catch { return nil }
|
||||
let size = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? 0
|
||||
return [
|
||||
"kind": "file",
|
||||
"name": dest.lastPathComponent,
|
||||
"path": dest.path,
|
||||
"mime": Self.mimeType(for: dest),
|
||||
"size": size ?? 0
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - App Group storage
|
||||
|
||||
private func sharedDirectory() -> URL? {
|
||||
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { return nil }
|
||||
let dir = base.appendingPathComponent("Shared", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func uniqueURL(in dir: URL, preferred: String) -> URL {
|
||||
let ext = (preferred as NSString).pathExtension
|
||||
let stem = (preferred as NSString).deletingPathExtension
|
||||
var candidate = dir.appendingPathComponent(preferred)
|
||||
var i = 2
|
||||
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let next = ext.isEmpty ? "\(stem) (\(i))" : "\(stem) (\(i)).\(ext)"
|
||||
candidate = dir.appendingPathComponent(next)
|
||||
i += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// APPEND, never overwrite: the user can share twice before opening the app, and the second share
|
||||
/// must not discard the first.
|
||||
private func appendToManifest(_ records: [[String: Any]]) {
|
||||
guard let dir = sharedDirectory() else { return }
|
||||
let manifest = dir.appendingPathComponent("manifest.json")
|
||||
var all: [[String: Any]] = []
|
||||
if let data = try? Data(contentsOf: manifest),
|
||||
let existing = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
|
||||
all = existing
|
||||
}
|
||||
all.append(contentsOf: records)
|
||||
if let data = try? JSONSerialization.data(withJSONObject: all) {
|
||||
try? data.write(to: manifest, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hand-off
|
||||
|
||||
/// Share extensions have no public API to launch their container app, so walk the responder chain to
|
||||
/// UIApplication and use openURL:. If it fails the files stay staged and the app collects them on next
|
||||
/// launch, so this is an optimisation, never a requirement.
|
||||
private func openHostApp() {
|
||||
guard let url = URL(string: "\(urlScheme)://share") else { return }
|
||||
let selector = NSSelectorFromString("openURL:")
|
||||
var responder: UIResponder? = self
|
||||
while let current = responder {
|
||||
if current.responds(to: selector) && current is UIApplication {
|
||||
_ = current.perform(selector, with: url)
|
||||
return
|
||||
}
|
||||
responder = current.next
|
||||
}
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
|
||||
private static func mimeType(for url: URL) -> String {
|
||||
if let type = UTType(filenameExtension: url.pathExtension.lowercased()),
|
||||
let mime = type.preferredMIMEType {
|
||||
return mime
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user