d664dde798
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>
423 lines
18 KiB
Swift
423 lines
18 KiB
Swift
import UIKit
|
||
import UniformTypeIdentifiers
|
||
|
||
// 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.
|
||
//
|
||
// 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 maxItems = 20
|
||
|
||
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()
|
||
readAuth()
|
||
buildUI()
|
||
ingest() // stage the shared items, then load the chat list
|
||
}
|
||
|
||
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: - UI
|
||
|
||
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 finishFail("Nothing to share.") }
|
||
|
||
var collected: [ShareItem] = []
|
||
let lock = NSLock()
|
||
let group = DispatchGroup()
|
||
for provider in providers {
|
||
group.enter()
|
||
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 }
|
||
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()
|
||
}
|
||
}
|
||
|
||
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) }) {
|
||
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
|
||
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
|
||
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) -> 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 }
|
||
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 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)
|
||
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
|
||
}
|
||
|
||
// 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]
|
||
}
|
||
if let data = try? JSONSerialization.data(withJSONObject: records) {
|
||
try? data.write(to: dir.appendingPathComponent("manifest.json"), options: .atomic)
|
||
}
|
||
}
|
||
|
||
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 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()
|
||
}
|
||
}
|