feat(share): send from inside the share sheet — the Teams-style in-extension picker
Replaces the wrong approach (stage + try to bounce into the app, which iOS blocks) with the one Teams/WhatsApp actually use: the picker and the send happen INSIDE the share extension, so there's no app-open at all. Tap Share → Biz Connect → pick a chat → it uploads and sends, right there in the sheet. How the extension can send without the app: it's a separate process that can't see the web app's HttpOnly cookie, so: - server: GET /api/share/token mints a bearer token for the logged-in user. - web: on every launch the app fetches that token and hands it to the extension via the App Group (ShareInbox.setAuth writes token+base to the shared UserDefaults). - extension: reads the token and calls the SAME API the native client uses — GET /api/messages/conversations to list chats, POST /api/messages/upload for each file, POST /api/messages to send. Native UITableView picker with search. Robustness: it still stages the files + writes a manifest first, so if there's no token yet (user never signed in) or the send fails, the file isn't lost — the app collects it on next open, exactly as before. On success the manifest is cleared so the app doesn't re-offer it. Server + web are live now; the token endpoint is harmless until a build ships the extension. NEEDS A NEW iOS BUILD for the picker itself. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,157 +1,172 @@
|
||||
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.
|
||||
// Share Extension — puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way
|
||||
// Teams/WhatsApp do: pick a chat in the sheet, it uploads and sends, no app-open needed.
|
||||
//
|
||||
// The picker itself lives in the APP, not here — the chat list, search and upload progress already exist
|
||||
// there, and re-implementing them natively would be a second, divergent copy (and the extension can't
|
||||
// reach the web session where contacts live). So this extension:
|
||||
// 1. copies the shared items into the App Group container (the only storage both processes can see),
|
||||
// 2. tries to open the app (bizconnect://share), and
|
||||
// 3. shows a small confirmation card — because a Share Extension opening its host app is unsupported on
|
||||
// modern iOS and often silently blocked, so the card gives the user a clear "Open Biz Connect" tap
|
||||
// and states the files are ready. Either way the app collects the staged files when next opened, so
|
||||
// nothing is ever lost.
|
||||
class ShareViewController: UIViewController {
|
||||
// How it can send without the app: the app writes a bearer token + API base into the App Group each launch
|
||||
// (via /api/share/token → ShareInbox.setAuth). This extension reads that token and calls the same server
|
||||
// API the native client uses — GET /api/messages/conversations, POST /api/messages/upload, POST
|
||||
// /api/messages. If there's no token (user never opened/signed in), we say so and still stage the files so
|
||||
// opening the app later picks them up.
|
||||
//
|
||||
// The App Group is the only storage the app and this separate process share.
|
||||
|
||||
struct ShareChat {
|
||||
let kind: String // "dm" | "group"
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct ShareItem {
|
||||
let name: String
|
||||
let url: URL // staged copy in the App Group container
|
||||
let mime: String
|
||||
let isText: Bool
|
||||
let text: String
|
||||
}
|
||||
|
||||
class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
private let appGroup = "group.com.bizgaze.connect"
|
||||
private let urlScheme = "bizconnect"
|
||||
private let maxItems = 20
|
||||
|
||||
// Minimal confirmation card (built in code — no storyboard).
|
||||
private let card = UIView()
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let openButton = UIButton(type: .system)
|
||||
private let doneButton = UIButton(type: .system)
|
||||
private var staged = false
|
||||
private var token: String = ""
|
||||
private var apiBase: String = "https://remote.bizgaze.com"
|
||||
private var items: [ShareItem] = []
|
||||
private var chats: [ShareChat] = []
|
||||
private var filtered: [ShareChat] = []
|
||||
private var sending = false
|
||||
|
||||
private let table = UITableView(frame: .zero, style: .plain)
|
||||
private let search = UISearchBar()
|
||||
private let statusLabel = UILabel()
|
||||
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||
private let subtitle = UILabel()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
buildCard()
|
||||
readAuth()
|
||||
buildUI()
|
||||
ingest() // stage the shared items, then load the chat list
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
handleShare()
|
||||
private func readAuth() {
|
||||
if let d = UserDefaults(suiteName: appGroup) {
|
||||
token = d.string(forKey: "bzc_token") ?? ""
|
||||
let b = d.string(forKey: "bzc_base") ?? ""
|
||||
if !b.isEmpty { apiBase = b }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ingest
|
||||
// MARK: - UI
|
||||
|
||||
private func handleShare() {
|
||||
private func buildUI() {
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
let nav = UINavigationBar()
|
||||
nav.translatesAutoresizingMaskIntoConstraints = false
|
||||
let navItem = UINavigationItem(title: "Send to…")
|
||||
navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
|
||||
nav.setItems([navItem], animated: false)
|
||||
view.addSubview(nav)
|
||||
|
||||
subtitle.font = .systemFont(ofSize: 12)
|
||||
subtitle.textColor = .secondaryLabel
|
||||
subtitle.textAlignment = .center
|
||||
subtitle.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(subtitle)
|
||||
|
||||
search.placeholder = "Search chats…"
|
||||
search.delegate = self
|
||||
search.searchBarStyle = .minimal
|
||||
search.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(search)
|
||||
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(UITableViewCell.self, forCellReuseIdentifier: "chat")
|
||||
table.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(table)
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 15)
|
||||
statusLabel.textColor = .secondaryLabel
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(statusLabel)
|
||||
|
||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
spinner.hidesWhenStopped = true
|
||||
view.addSubview(spinner)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
nav.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
nav.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
subtitle.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 2),
|
||||
subtitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
subtitle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
search.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
|
||||
search.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6),
|
||||
search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6),
|
||||
table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2),
|
||||
table.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
table.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
table.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
|
||||
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
|
||||
spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
spinner.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 14)
|
||||
])
|
||||
}
|
||||
|
||||
private func setStatus(_ text: String?, busy: Bool = false) {
|
||||
DispatchQueue.main.async {
|
||||
self.statusLabel.text = text
|
||||
self.statusLabel.isHidden = (text == nil)
|
||||
if busy { self.spinner.startAnimating() } else { self.spinner.stopAnimating() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ingest shared items
|
||||
|
||||
private func ingest() {
|
||||
setStatus("Preparing…", busy: true)
|
||||
let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
|
||||
.flatMap { $0.attachments ?? [] }
|
||||
.prefix(maxItems)
|
||||
guard !providers.isEmpty else { return finish() }
|
||||
guard !providers.isEmpty else { return finishFail("Nothing to share.") }
|
||||
|
||||
var collected: [[String: Any]] = []
|
||||
var collected: [ShareItem] = []
|
||||
let lock = NSLock()
|
||||
let group = DispatchGroup()
|
||||
|
||||
for provider in providers {
|
||||
group.enter()
|
||||
load(provider) { record in
|
||||
if let record = record { lock.lock(); collected.append(record); lock.unlock() }
|
||||
load(provider) { item in
|
||||
if let item = item { lock.lock(); collected.append(item); lock.unlock() }
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if !collected.isEmpty { self.appendToManifest(collected) }
|
||||
self.staged = !collected.isEmpty
|
||||
self.openHostApp() // best-effort; usually blocked, hence the card below
|
||||
self.showReady(count: collected.count)
|
||||
self.items = collected
|
||||
// Always ALSO leave a manifest, so if the send here fails the app can still collect them later.
|
||||
self.writeManifest(collected)
|
||||
if collected.isEmpty { return self.finishFail("Couldn’t read the shared file.") }
|
||||
let fileCount = collected.filter { !$0.isText }.count
|
||||
self.subtitle.text = fileCount > 0 ? "Sharing \(fileCount) file\(fileCount == 1 ? "" : "s")" : "Sharing a link"
|
||||
self.loadChats()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Confirmation UI
|
||||
|
||||
private func buildCard() {
|
||||
card.backgroundColor = .systemBackground
|
||||
card.layer.cornerRadius = 16
|
||||
card.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(card)
|
||||
|
||||
titleLabel.text = "Preparing…"
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.numberOfLines = 0
|
||||
|
||||
subtitleLabel.text = ""
|
||||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||
subtitleLabel.textColor = .secondaryLabel
|
||||
subtitleLabel.textAlignment = .center
|
||||
subtitleLabel.numberOfLines = 0
|
||||
|
||||
openButton.setTitle("Open Biz Connect", for: .normal)
|
||||
openButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
openButton.setTitleColor(.white, for: .normal)
|
||||
openButton.backgroundColor = UIColor(red: 0.12, green: 0.23, blue: 0.45, alpha: 1) // brand navy
|
||||
openButton.layer.cornerRadius = 10
|
||||
openButton.addTarget(self, action: #selector(openTapped), for: .touchUpInside)
|
||||
openButton.isHidden = true
|
||||
|
||||
doneButton.setTitle("Done", for: .normal)
|
||||
doneButton.titleLabel?.font = .systemFont(ofSize: 15)
|
||||
doneButton.addTarget(self, action: #selector(doneTapped), for: .touchUpInside)
|
||||
doneButton.isHidden = true
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, openButton, doneButton])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.alignment = .fill
|
||||
stack.setCustomSpacing(6, after: titleLabel)
|
||||
stack.setCustomSpacing(18, after: subtitleLabel)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
card.addSubview(stack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
card.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
card.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
card.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
|
||||
card.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
|
||||
card.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
|
||||
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 22),
|
||||
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -18),
|
||||
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 22),
|
||||
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -22),
|
||||
openButton.heightAnchor.constraint(equalToConstant: 46)
|
||||
])
|
||||
}
|
||||
|
||||
private func showReady(count: Int) {
|
||||
if !staged {
|
||||
titleLabel.text = "Couldn’t prepare the file"
|
||||
subtitleLabel.text = "Please try sharing again."
|
||||
} else {
|
||||
titleLabel.text = "✓ Ready to send"
|
||||
let noun = count == 1 ? "item" : "\(count) items"
|
||||
subtitleLabel.text = "Open Biz Connect to choose a chat and send your \(noun)."
|
||||
openButton.isHidden = false
|
||||
}
|
||||
doneButton.isHidden = false
|
||||
}
|
||||
|
||||
@objc private func openTapped() {
|
||||
openHostApp()
|
||||
finish()
|
||||
}
|
||||
|
||||
@objc private func doneTapped() {
|
||||
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.
|
||||
private func load(_ provider: NSItemProvider, completion: @escaping (ShareItem?) -> Void) {
|
||||
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))
|
||||
@@ -160,37 +175,177 @@ class ShareViewController: UIViewController {
|
||||
}
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
|
||||
completion((item as? URL).map { ["kind": "text", "text": $0.absoluteString] })
|
||||
if let u = item as? URL { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: u.absoluteString)) }
|
||||
else { completion(nil) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in
|
||||
completion((item as? String).map { ["kind": "text", "text": $0] })
|
||||
if let s = item as? String { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: s)) }
|
||||
else { completion(nil) }
|
||||
}
|
||||
return
|
||||
}
|
||||
completion(nil)
|
||||
}
|
||||
|
||||
private func stage(_ src: URL) -> [String: Any]? {
|
||||
guard let dir = sharedDirectory() else { return nil }
|
||||
private func stage(_ src: URL) -> ShareItem? {
|
||||
guard let dir = sharedDir() 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
|
||||
]
|
||||
return ShareItem(name: dest.lastPathComponent, url: dest, mime: Self.mimeType(for: dest), isText: false, text: "")
|
||||
}
|
||||
|
||||
// MARK: - Chat list
|
||||
|
||||
private func loadChats() {
|
||||
guard !token.isEmpty else {
|
||||
setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.")
|
||||
return
|
||||
}
|
||||
setStatus("Loading your chats…", busy: true)
|
||||
api("GET", "/api/messages/conversations", body: nil) { [weak self] data, _ in
|
||||
guard let self = self else { return }
|
||||
guard let data = data,
|
||||
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
|
||||
self.setStatus("Couldn’t load your chats.\n\nOpen Biz Connect once, then try sharing again.")
|
||||
return
|
||||
}
|
||||
let parsed: [ShareChat] = arr.compactMap { row in
|
||||
guard let kind = row["kind"] as? String, let id = idString(row["id"]), let name = row["name"] as? String else { return nil }
|
||||
return ShareChat(kind: kind, id: id, name: name)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.chats = parsed
|
||||
self.filtered = parsed
|
||||
self.setStatus(parsed.isEmpty ? "No chats yet." : nil)
|
||||
self.table.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filtered.count }
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath)
|
||||
let c = filtered[indexPath.row]
|
||||
cell.textLabel?.text = c.name
|
||||
cell.detailTextLabel?.text = nil
|
||||
cell.imageView?.image = nil
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard !sending else { return }
|
||||
sendAll(to: filtered[indexPath.row])
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
private func sendAll(to chat: ShareChat) {
|
||||
sending = true
|
||||
search.isHidden = true
|
||||
table.isHidden = true
|
||||
let files = items.filter { !$0.isText }
|
||||
let texts = items.filter { $0.isText }.map { $0.text }
|
||||
setStatus("Sending to \(chat.name)…", busy: true)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
var ok = true
|
||||
for (i, f) in files.enumerated() {
|
||||
if files.count > 1 { self.setStatus("Sending \(i + 1) of \(files.count) to \(chat.name)…", busy: true) }
|
||||
guard let data = try? Data(contentsOf: f.url), let attId = self.uploadSync(name: f.name, mime: f.mime, data: data) else { ok = false; break }
|
||||
let body = self.messageBody(chat: chat, attachmentId: attId, text: nil)
|
||||
if !self.sendMessageSync(body) { ok = false; break }
|
||||
}
|
||||
if ok && !texts.isEmpty {
|
||||
let body = self.messageBody(chat: chat, attachmentId: nil, text: texts.joined(separator: "\n"))
|
||||
if !self.sendMessageSync(body) { ok = false }
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if ok {
|
||||
self.clearManifest() // sent from here → don't let the app re-offer them
|
||||
self.setStatus("✓ Sent to \(chat.name)")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
|
||||
} else {
|
||||
self.sending = false
|
||||
self.setStatus("Couldn’t send. Your file is saved — open Biz Connect to send it.", busy: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.2) { self.finish() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func messageBody(chat: ShareChat, attachmentId: String?, text: String?) -> [String: Any] {
|
||||
var b: [String: Any] = [:]
|
||||
if chat.kind == "group" { b["group"] = chat.id } else { b["to"] = chat.id }
|
||||
if let a = attachmentId { b["attachmentId"] = a }
|
||||
if let t = text { b["body"] = t }
|
||||
return b
|
||||
}
|
||||
|
||||
// MARK: - Networking (synchronous helpers, always called off the main thread)
|
||||
|
||||
private func uploadSync(name: String, mime: String, data: Data) -> String? {
|
||||
guard let url = URL(string: apiBase + "/api/messages/upload") else { return nil }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
|
||||
req.setValue(mime.isEmpty ? "application/octet-stream" : mime, forHTTPHeaderField: "Content-Type")
|
||||
req.setValue(name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? name, forHTTPHeaderField: "X-Filename")
|
||||
req.httpBody = data
|
||||
req.timeoutInterval = 300
|
||||
var out: String? = nil
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
URLSession.shared.dataTask(with: req) { d, resp, _ in
|
||||
defer { sem.signal() }
|
||||
guard let d = d, let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
let obj = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] else { return }
|
||||
out = idString(obj["id"])
|
||||
}.resume()
|
||||
sem.wait()
|
||||
return out
|
||||
}
|
||||
|
||||
private func sendMessageSync(_ body: [String: Any]) -> Bool {
|
||||
guard let url = URL(string: apiBase + "/api/messages"),
|
||||
let json = try? JSONSerialization.data(withJSONObject: body) else { return false }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = json
|
||||
req.timeoutInterval = 60
|
||||
var ok = false
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
URLSession.shared.dataTask(with: req) { _, resp, _ in
|
||||
if let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) { ok = true }
|
||||
sem.signal()
|
||||
}.resume()
|
||||
sem.wait()
|
||||
return ok
|
||||
}
|
||||
|
||||
// GET/generic async helper for loading the chat list.
|
||||
private func api(_ method: String, _ path: String, body: Data?, completion: @escaping (Data?, HTTPURLResponse?) -> Void) {
|
||||
guard let url = URL(string: apiBase + path) else { return completion(nil, nil) }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
|
||||
if let body = body { req.httpBody = body; req.setValue("application/json", forHTTPHeaderField: "Content-Type") }
|
||||
req.timeoutInterval = 30
|
||||
URLSession.shared.dataTask(with: req) { d, resp, _ in completion(d, resp as? HTTPURLResponse) }.resume()
|
||||
}
|
||||
|
||||
// MARK: - App Group storage
|
||||
|
||||
private func sharedDirectory() -> URL? {
|
||||
private func sharedDir() -> 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)
|
||||
@@ -210,49 +365,58 @@ class ShareViewController: UIViewController {
|
||||
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
|
||||
// Fallback manifest so the app can still pick these up if sending from here fails.
|
||||
private func writeManifest(_ its: [ShareItem]) {
|
||||
guard let dir = sharedDir() else { return }
|
||||
let records: [[String: Any]] = its.map { it in
|
||||
it.isText ? ["kind": "text", "text": it.text]
|
||||
: ["kind": "file", "name": it.name, "path": it.url.path, "mime": it.mime]
|
||||
}
|
||||
all.append(contentsOf: records)
|
||||
if let data = try? JSONSerialization.data(withJSONObject: all) {
|
||||
try? data.write(to: manifest, options: .atomic)
|
||||
if let data = try? JSONSerialization.data(withJSONObject: records) {
|
||||
try? data.write(to: dir.appendingPathComponent("manifest.json"), 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 clearManifest() {
|
||||
guard let dir = sharedDir() else { return }
|
||||
let fm = FileManager.default
|
||||
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
|
||||
for e in entries { try? fm.removeItem(at: e) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func mimeType(for url: URL) -> String {
|
||||
if let t = UTType(filenameExtension: url.pathExtension.lowercased()), let m = t.preferredMIMEType { return m }
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// MARK: - Finish
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
extensionContext?.cancelRequest(withError: NSError(domain: "share", code: 0))
|
||||
}
|
||||
|
||||
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"
|
||||
private func finishFail(_ msg: String) {
|
||||
setStatus(msg)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { self.finish() }
|
||||
}
|
||||
}
|
||||
|
||||
// A JSON id may decode as String or NSNumber depending on the value — normalise to String.
|
||||
private func idString(_ v: Any?) -> String? {
|
||||
if let s = v as? String { return s }
|
||||
if let n = v as? NSNumber { return n.stringValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
extension ShareViewController: UISearchBarDelegate {
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
let q = searchText.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
filtered = q.isEmpty ? chats : chats.filter { $0.name.lowercased().contains(q) }
|
||||
table.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,25 @@ public class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let jsName = "ShareInbox"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "getPending", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
|
||||
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setAuth", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
private let appGroup = "group.com.bizgaze.connect"
|
||||
|
||||
// The web app hands the extension a bearer token + API base (via /api/share/token) so the extension can
|
||||
// list chats, upload and send on its own — no app-open needed. Stored in the App Group's shared
|
||||
// UserDefaults, which the extension reads directly. Passing an empty token clears it (e.g. on logout).
|
||||
@objc func setAuth(_ call: CAPPluginCall) {
|
||||
let token = call.getString("token") ?? ""
|
||||
let base = call.getString("base") ?? ""
|
||||
if let d = UserDefaults(suiteName: appGroup) {
|
||||
if token.isEmpty { d.removeObject(forKey: "bzc_token"); d.removeObject(forKey: "bzc_base") }
|
||||
else { d.set(token, forKey: "bzc_token"); d.set(base, forKey: "bzc_base") }
|
||||
}
|
||||
call.resolve(["ok": true])
|
||||
}
|
||||
|
||||
@objc func getPending(_ call: CAPPluginCall) {
|
||||
guard let dir = sharedDir() else { return call.resolve(["items": []]) }
|
||||
let manifest = dir.appendingPathComponent("manifest.json")
|
||||
|
||||
Reference in New Issue
Block a user