2026-07-23 22:58:08 +05:30
|
|
|
|
import UIKit
|
|
|
|
|
|
import UniformTypeIdentifiers
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
// 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.
|
2026-07-23 22:58:08 +05:30
|
|
|
|
//
|
2026-07-24 16:33:23 +05:30
|
|
|
|
// 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 {
|
2026-07-23 22:58:08 +05:30
|
|
|
|
|
|
|
|
|
|
private let appGroup = "group.com.bizgaze.connect"
|
|
|
|
|
|
private let maxItems = 20
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
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
|
2026-07-24 16:24:15 +05:30
|
|
|
|
|
|
|
|
|
|
override func viewDidLoad() {
|
|
|
|
|
|
super.viewDidLoad()
|
2026-07-24 16:33:23 +05:30
|
|
|
|
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)
|
|
|
|
|
|
])
|
2026-07-24 16:24:15 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
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() }
|
|
|
|
|
|
}
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
// MARK: - Ingest shared items
|
2026-07-23 22:58:08 +05:30
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
private func ingest() {
|
|
|
|
|
|
setStatus("Preparing…", busy: true)
|
2026-07-23 22:58:08 +05:30
|
|
|
|
let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
|
|
|
|
|
|
.flatMap { $0.attachments ?? [] }
|
|
|
|
|
|
.prefix(maxItems)
|
2026-07-24 16:33:23 +05:30
|
|
|
|
guard !providers.isEmpty else { return finishFail("Nothing to share.") }
|
2026-07-23 22:58:08 +05:30
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
var collected: [ShareItem] = []
|
2026-07-23 22:58:08 +05:30
|
|
|
|
let lock = NSLock()
|
|
|
|
|
|
let group = DispatchGroup()
|
|
|
|
|
|
for provider in providers {
|
|
|
|
|
|
group.enter()
|
2026-07-24 16:33:23 +05:30
|
|
|
|
load(provider) { item in
|
|
|
|
|
|
if let item = item { lock.lock(); collected.append(item); lock.unlock() }
|
2026-07-23 22:58:08 +05:30
|
|
|
|
group.leave()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
group.notify(queue: .main) { [weak self] in
|
|
|
|
|
|
guard let self = self else { return }
|
2026-07-24 16:33:23 +05:30
|
|
|
|
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()
|
2026-07-24 16:24:15 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
private func load(_ provider: NSItemProvider, completion: @escaping (ShareItem?) -> Void) {
|
2026-07-23 22:58:08 +05:30
|
|
|
|
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
|
2026-07-24 16:33:23 +05:30
|
|
|
|
if let u = item as? URL { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: u.absoluteString)) }
|
|
|
|
|
|
else { completion(nil) }
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
|
|
|
|
|
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in
|
2026-07-24 16:33:23 +05:30
|
|
|
|
if let s = item as? String { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: s)) }
|
|
|
|
|
|
else { completion(nil) }
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
completion(nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
private func stage(_ src: URL) -> ShareItem? {
|
|
|
|
|
|
guard let dir = sharedDir() else { return nil }
|
2026-07-23 22:58:08 +05:30
|
|
|
|
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 }
|
2026-07-24 16:33:23 +05:30
|
|
|
|
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()
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - App Group storage
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
private func sharedDir() -> URL? {
|
2026-07-23 22:58:08 +05:30
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
// 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]
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
2026-07-24 16:33:23 +05:30
|
|
|
|
if let data = try? JSONSerialization.data(withJSONObject: records) {
|
|
|
|
|
|
try? data.write(to: dir.appendingPathComponent("manifest.json"), options: .atomic)
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
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) }
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 22:58:08 +05:30
|
|
|
|
private func finish() {
|
|
|
|
|
|
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:33:23 +05:30
|
|
|
|
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()
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|