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:
2026-07-24 16:33:23 +05:30
parent 2ec0a0c0cd
commit d664dde798
4 changed files with 371 additions and 167 deletions
+330 -166
View File
@@ -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("Couldnt 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 = "Couldnt 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("Couldnt 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("Couldnt 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")