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() } }