diff --git a/mobile/ios-share/ShareViewController.swift b/mobile/ios-share/ShareViewController.swift index d7eb9bb..677b0d1 100644 --- a/mobile/ios-share/ShareViewController.swift +++ b/mobile/ios-share/ShareViewController.swift @@ -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() } } diff --git a/mobile/plugins/share-inbox/ios/Sources/ShareInboxPlugin/ShareInboxPlugin.swift b/mobile/plugins/share-inbox/ios/Sources/ShareInboxPlugin/ShareInboxPlugin.swift index 353e79b..b2bd90a 100644 --- a/mobile/plugins/share-inbox/ios/Sources/ShareInboxPlugin/ShareInboxPlugin.swift +++ b/mobile/plugins/share-inbox/ios/Sources/ShareInboxPlugin/ShareInboxPlugin.swift @@ -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") diff --git a/server/public/home.html b/server/public/home.html index 0861912..3c64ee8 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -3528,6 +3528,17 @@ async function sendMessage(){ // build) or there's nothing pending, this is a no-op. let _shareBusy=false; function bzShareInbox(){ const P=window.Capacitor&&window.Capacitor.Plugins; return P&&P.ShareInbox||null; } +// Hand the Share Extension a bearer token + API base so it can list chats, upload and send on its own — +// the Teams-style in-sheet picker, no app-open needed. The extension can't read our HttpOnly cookie, so +// the logged-in web app mints a token (/api/share/token) and writes it into the App Group each launch. +async function bzPushShareAuth(){ + const SI=bzShareInbox(); if(!SI||!SI.setAuth||!ME||!ME.id) return; + try{ + const r=await fetch('/api/share/token'); if(!r.ok) return; + const d=await r.json(); if(!d||!d.token) return; + await SI.setAuth({ token:d.token, base:location.origin }); + }catch(_){} +} async function bzCheckSharedInbox(){ const SI=bzShareInbox(); if(!SI||_shareBusy) return; if(!ME||!ME.id) return; // must be signed in to choose a conversation @@ -5663,6 +5674,8 @@ window.addEventListener('message',(e)=>{ // Cold launch FROM the share sheet: the extension staged files before the app was even running, so // the appUrlOpen listener may have missed it. Sweep once now that ME + the chat list are ready. setTimeout(bzCheckSharedInbox, 600); + // Refresh the token the Share Extension uses to send on its own (Teams-style in-sheet picker). + bzPushShareAuth(); })(); // GUEST meeting: a lightweight pre-join (name) then join the call with a throwaway guest identity — diff --git a/server/routes.js b/server/routes.js index 75de229..fb40a63 100644 --- a/server/routes.js +++ b/server/routes.js @@ -304,6 +304,19 @@ route('POST', '/api/auth/refresh', async (req, res) => { json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL }); }); +// Mint a bearer token for the iOS Share Extension. The extension is a separate process that can't see the +// web app's HttpOnly `sid` cookie, so the logged-in web app calls this on boot and hands the token to the +// extension via the App Group. The extension then talks to the API directly (list chats, upload, send) — +// exactly like the native client, so no app-open is needed to share. Short-ish TTL, refreshed each boot. +route('GET', '/api/share/token', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const tok = A.token(); + const ttl = 1000 * 60 * 60 * 24 * 30; // 30 days; the app re-mints on every launch anyway + R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl }); + json(res, 200, { token: tok, expiresAt: now() + ttl }); +}); + // Login step 2: TOTP code -> marks session mfa_passed route('POST', '/api/login/mfa', async (req, res) => { const { code } = await readBody(req);