From 0a2c7376b9a5c46edd132298e8e90de15fb4e4f0 Mon Sep 17 00:00:00 2001 From: sravan Date: Thu, 23 Jul 2026 22:58:08 +0530 Subject: [PATCH] =?UTF-8?q?feat(share):=20"Biz=20Connect"=20in=20the=20iOS?= =?UTF-8?q?=20share=20sheet=20=E2=80=94=20share=20a=20photo/file=20into=20?= =?UTF-8?q?a=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the reverse direction: share FROM Photos/Files/Safari INTO a Biz Connect conversation. An app can only appear in the iOS share sheet as an app-extension target, so this is real native work, not a web change. Pieces: - mobile/ios-share/ShareViewController.swift: a UI-less Share Extension. It stages the shared items into the App Group container and opens bizconnect://share. It deliberately does NOT reimplement the chat picker — that lives in the app, which already has the chat list, search and upload progress. Appends to the manifest (never overwrites), so sharing twice before opening the app loses nothing. - mobile/scripts/add-share-extension.rb: injects the extension target into the Capacitor-generated Xcode project on every CI build (Codemagic checks out fresh), using the xcodeproj gem that ships with CocoaPods. Embeds it, sets the bundle id .share, and MERGES the App Group into the app's entitlements rather than clobbering them (push's aps-environment must survive). Idempotent. - mobile/plugins/share-inbox: getPending()/clear() to read that manifest — the App Group container isn't one of Filesystem's known directories, so it needs a bridge. - home.html: on bizconnect://share (and every resume, and cold-launch), read the inbox and show a "Send to…" picker over the chat list; chosen files run the SAME upload + /api/messages send as an in-app attachment. Reuses convertFileSrc to read the staged bytes with no base64 marshalling. - ios-patch.sh registers the bizconnect URL scheme; codemagic.yaml fetches a profile for the .share bundle id too. One-time manual gate (CI cannot toggle App capabilities): the App Group group.com.bizgaze.connect must be created and enabled on both App IDs in the Apple portal — documented in mobile/IOS_SETUP.md. Without it the two processes can't see each other's files and sharing silently no-ops; everything else still works. Validated cross-file: pod-name/jsName/method wiring for all three plugins, App Group id identical in all 4 files, URL scheme consistent across extension/plist/web, entitlement-merge preserves push. Needs a new iOS build (new targets + plugins). Co-Authored-By: Claude Opus 4.8 --- codemagic.yaml | 18 ++ mobile/IOS_SETUP.md | 26 +++ mobile/ios-share/App.entitlements | 12 ++ mobile/ios-share/Info.plist | 50 ++++++ mobile/ios-share/ShareExtension.entitlements | 10 ++ mobile/ios-share/ShareViewController.swift | 163 ++++++++++++++++++ mobile/package.json | 1 + mobile/plugins/share-inbox/ShareInbox.podspec | 19 ++ .../ShareInboxPlugin/ShareInboxPlugin.swift | 68 ++++++++ mobile/plugins/share-inbox/package.json | 26 +++ mobile/scripts/add-share-extension.rb | 116 +++++++++++++ mobile/scripts/ios-patch.sh | 11 ++ server/public/home.html | 113 +++++++++++- 13 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 mobile/ios-share/App.entitlements create mode 100644 mobile/ios-share/Info.plist create mode 100644 mobile/ios-share/ShareExtension.entitlements create mode 100644 mobile/ios-share/ShareViewController.swift create mode 100644 mobile/plugins/share-inbox/ShareInbox.podspec create mode 100644 mobile/plugins/share-inbox/ios/Sources/ShareInboxPlugin/ShareInboxPlugin.swift create mode 100644 mobile/plugins/share-inbox/package.json create mode 100644 mobile/scripts/add-share-extension.rb diff --git a/codemagic.yaml b/codemagic.yaml index 4f3f162..6ffd1b0 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -53,6 +53,14 @@ workflows: script: | bash mobile/scripts/ios-patch.sh + - name: Add the Share Extension target + script: | + # Inject the second target (Biz Connect in the iOS share sheet) into the freshly-generated + # Xcode project. Uses the `xcodeproj` gem that ships with CocoaPods, so no extra install. + # Runs BEFORE pod install: the extension uses no pods, and this way the workspace that pods + # generates already contains the new target. + ruby mobile/scripts/add-share-extension.rb + - name: Set up code signing script: | # Create the distribution certificate + provisioning profile from the ASC API key and add the @@ -65,11 +73,21 @@ workflows: # cert it has no key for ("Cannot save Signing Certificates without certificate private key"). # By passing our own fixed private key (CERTIFICATE_PRIVATE_KEY, a secure var in the # `ios_signing` group), the cert is created once from that key and reused by every build. + # + # TWO bundle ids now need signing: the app AND the share extension (.share). Each gets its + # own App Store profile. The App Group capability (group.com.bizgaze.connect) must be enabled on + # BOTH App IDs in the Apple Developer portal — see mobile/IOS_SETUP.md. fetch-signing-files + # registers a missing bundle id and creates its profile, but does NOT toggle the App Group + # capability, so that stays a one-time manual step. keychain initialize app-store-connect fetch-signing-files "$BUNDLE_ID" \ --type IOS_APP_STORE \ --certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \ --create + app-store-connect fetch-signing-files "${BUNDLE_ID}.share" \ + --type IOS_APP_STORE \ + --certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \ + --create keychain add-certificates - name: Install CocoaPods diff --git a/mobile/IOS_SETUP.md b/mobile/IOS_SETUP.md index d7129ea..31789c3 100644 --- a/mobile/IOS_SETUP.md +++ b/mobile/IOS_SETUP.md @@ -89,3 +89,29 @@ listing yet — see the follow-up below (you can still *view* a screen someone e - Mobile **web** can't switch the audio output route (`setSinkId` is unimplemented on iOS/Android), so the in-meeting speaker/earpiece/Bluetooth control is web-only where it works and hidden where it doesn't. - True routing on iOS needs a small native plugin driving `AVAudioSession`. Phase-2 native task. + +--- + +## Share Extension ("Biz Connect" in the iOS share sheet) — one-time Apple portal setup + +The app now has a **Share Extension** target (`com.bizgaze.connect.share`) so users can share a photo / +video / file FROM the Photos or Files app INTO a Biz Connect conversation. The Codemagic build injects the +target and fetches a profile for it automatically, but two things can ONLY be done once, by hand, in the +Apple Developer portal — CI cannot toggle App capabilities: + +1. **Create the App Group** (developer.apple.com → Identifiers → App Groups → +): + identifier **`group.com.bizgaze.connect`**. +2. **Enable the App Groups capability on BOTH App IDs** and assign them to that group: + - `com.bizgaze.connect` (the app) + - `com.bizgaze.connect.share` (the extension — create this App ID if the first build hasn't yet; + `fetch-signing-files --create` will register it, then edit it to add App Groups) + After enabling the capability, the provisioning profiles must be regenerated — the next Codemagic build + does that via `fetch-signing-files`, so just re-run it once the capability is on. + +If the App Group isn't set up, the app and the extension can't see each other's files: sharing will appear +to do nothing (the extension stages the file, but the app finds an empty inbox). Everything else — download +to the Files folder, the Photos "Connect" album, Manage storage — works without it. + +Also enabled by this change (main app Info.plist, done automatically by `ios-patch.sh`): +- `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` → the **Biz Connect** folder in Files. +- `CFBundleURLTypes` scheme **`bizconnect`** → lets the extension bounce back into the app after staging. diff --git a/mobile/ios-share/App.entitlements b/mobile/ios-share/App.entitlements new file mode 100644 index 0000000..74ffac7 --- /dev/null +++ b/mobile/ios-share/App.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.application-groups + + group.com.bizgaze.connect + + aps-environment + production + + diff --git a/mobile/ios-share/Info.plist b/mobile/ios-share/Info.plist new file mode 100644 index 0000000..50b1fa6 --- /dev/null +++ b/mobile/ios-share/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Biz Connect + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.share-services + + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + NSExtensionAttributes + + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 20 + NSExtensionActivationSupportsMovieWithMaxCount + 10 + NSExtensionActivationSupportsFileWithMaxCount + 20 + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsText + + + + + + diff --git a/mobile/ios-share/ShareExtension.entitlements b/mobile/ios-share/ShareExtension.entitlements new file mode 100644 index 0000000..3c822a6 --- /dev/null +++ b/mobile/ios-share/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.bizgaze.connect + + + diff --git a/mobile/ios-share/ShareViewController.swift b/mobile/ios-share/ShareViewController.swift new file mode 100644 index 0000000..bed4b52 --- /dev/null +++ b/mobile/ios-share/ShareViewController.swift @@ -0,0 +1,163 @@ +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. +// +// It deliberately has NO UI. Picking the conversation happens in the app, where the chat list, search and +// upload progress already exist — re-implementing all of that natively here would be a second, divergent +// copy of the app. So this extension only does the part that must happen inside it: +// 1. copy the shared items into the App Group container (the only storage both processes can see), and +// 2. bounce the user into the app, which picks them up and shows "Send to…". +// If step 2 is blocked, the files still sit staged and are collected the next time the app is opened — +// nothing is lost, the hand-off is just deferred. +class ShareViewController: UIViewController { + + private let appGroup = "group.com.bizgaze.connect" + private let urlScheme = "bizconnect" + private let maxItems = 20 + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + handleShare() + } + + // MARK: - Ingest + + private func handleShare() { + let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? []) + .flatMap { $0.attachments ?? [] } + .prefix(maxItems) + guard !providers.isEmpty else { return finish() } + + var staged: [[String: Any]] = [] + let lock = NSLock() + let group = DispatchGroup() + + for provider in providers { + group.enter() + load(provider) { record in + if let record = record { lock.lock(); staged.append(record); lock.unlock() } + group.leave() + } + } + group.notify(queue: .main) { [weak self] in + guard let self = self else { return } + if !staged.isEmpty { self.appendToManifest(staged) } + self.openHostApp() + self.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. + 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)) + } + return + } + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in + completion((item as? URL).map { ["kind": "text", "text": $0.absoluteString] }) + } + return + } + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in + completion((item as? String).map { ["kind": "text", "text": $0] }) + } + return + } + completion(nil) + } + + private func stage(_ src: URL) -> [String: Any]? { + guard let dir = sharedDirectory() 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 + ] + } + + // MARK: - App Group storage + + private func sharedDirectory() -> 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 + } + + /// 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 + } + all.append(contentsOf: records) + if let data = try? JSONSerialization.data(withJSONObject: all) { + try? data.write(to: manifest, 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 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" + } +} diff --git a/mobile/package.json b/mobile/package.json index e9a5b67..6d17e27 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -12,6 +12,7 @@ "dependencies": { "audio-route": "file:plugins/audio-route", "media-library": "file:plugins/media-library", + "share-inbox": "file:plugins/share-inbox", "@capacitor-community/safe-area": "^7.0.0", "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", diff --git a/mobile/plugins/share-inbox/ShareInbox.podspec b/mobile/plugins/share-inbox/ShareInbox.podspec new file mode 100644 index 0000000..95acbcf --- /dev/null +++ b/mobile/plugins/share-inbox/ShareInbox.podspec @@ -0,0 +1,19 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + # Pod name MUST be 'ShareInbox' (PascalCase of 'share-inbox') — same rule as the other plugins, or + # pod install fails with "No podspec found for `ShareInbox`". + s.name = 'ShareInbox' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.homepage = 'https://bizgaze.com' + s.author = 'BizGaze' + s.source = { :git => 'https://bizgaze.com/share-inbox.git', :tag => s.version.to_s } + s.source_files = 'ios/Sources/**/*.{swift,h,m}' + s.ios.deployment_target = '14.0' + s.dependency 'Capacitor' + s.swift_version = '5.1' +end diff --git a/mobile/plugins/share-inbox/ios/Sources/ShareInboxPlugin/ShareInboxPlugin.swift b/mobile/plugins/share-inbox/ios/Sources/ShareInboxPlugin/ShareInboxPlugin.swift new file mode 100644 index 0000000..353e79b --- /dev/null +++ b/mobile/plugins/share-inbox/ios/Sources/ShareInboxPlugin/ShareInboxPlugin.swift @@ -0,0 +1,68 @@ +import Foundation +import Capacitor + +// Reads the files the Share Extension staged into the App Group container, so the web app can pick a +// conversation and send them. The extension and the app are separate processes; the App Group's shared +// container is the only place both can read/write, and it is NOT one of @capacitor/filesystem's known +// directories — hence this small bridge. +// +// getPending() → { items: [ {kind, name, path, uri, mime, size} | {kind:"text", text} ] } +// `uri` is a file:// URL the web layer turns into a fetchable source with Capacitor.convertFileSrc, +// so the existing upload path can read the bytes without base64 marshalling. +// clear() removes the manifest and every staged file, once the app has taken them. +@objc(ShareInboxPlugin) +public class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "ShareInboxPlugin" + public let jsName = "ShareInbox" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "getPending", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) + ] + + private let appGroup = "group.com.bizgaze.connect" + + @objc func getPending(_ call: CAPPluginCall) { + guard let dir = sharedDir() else { return call.resolve(["items": []]) } + let manifest = dir.appendingPathComponent("manifest.json") + guard let data = try? Data(contentsOf: manifest), + let records = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return call.resolve(["items": []]) + } + var items: [[String: Any]] = [] + for r in records { + let kind = r["kind"] as? String ?? "file" + if kind == "text" { + if let text = r["text"] as? String { items.append(["kind": "text", "text": text]) } + continue + } + // A file record is only usable if its staged copy is still on disk. + guard let path = r["path"] as? String, + FileManager.default.fileExists(atPath: path) else { continue } + let url = URL(fileURLWithPath: path) + items.append([ + "kind": "file", + "name": r["name"] as? String ?? url.lastPathComponent, + "path": path, + "uri": url.absoluteString, + "mime": r["mime"] as? String ?? "application/octet-stream", + "size": r["size"] as? Int64 ?? (r["size"] as? Int ?? 0) + ]) + } + call.resolve(["items": items]) + } + + @objc func clear(_ call: CAPPluginCall) { + if let dir = sharedDir() { + let fm = FileManager.default + if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) { + for e in entries { try? fm.removeItem(at: e) } + } + } + call.resolve() + } + + private func sharedDir() -> URL? { + guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { return nil } + return base.appendingPathComponent("Shared", isDirectory: true) + } +} diff --git a/mobile/plugins/share-inbox/package.json b/mobile/plugins/share-inbox/package.json new file mode 100644 index 0000000..a47ba41 --- /dev/null +++ b/mobile/plugins/share-inbox/package.json @@ -0,0 +1,26 @@ +{ + "name": "share-inbox", + "version": "1.0.0", + "description": "Read files handed to Biz Connect from the iOS share sheet (via the App Group)", + "main": "dist/plugin.cjs.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "author": "BizGaze", + "license": "MIT", + "files": [ + "dist/", + "ios/", + "ShareInbox.podspec" + ], + "capacitor": { + "ios": { + "src": "ios" + } + }, + "devDependencies": { + "@capacitor/core": "^7.0.0" + }, + "peerDependencies": { + "@capacitor/core": "^7.0.0" + } +} diff --git a/mobile/scripts/add-share-extension.rb b/mobile/scripts/add-share-extension.rb new file mode 100644 index 0000000..6f292e6 --- /dev/null +++ b/mobile/scripts/add-share-extension.rb @@ -0,0 +1,116 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Inject the Share Extension target into the Capacitor-generated Xcode project. +# +# WHY A SCRIPT: `npx cap add ios` scaffolds mobile/ios/App from a template that knows nothing about our +# extension, and Codemagic runs on a fresh checkout every time, so the target has to be (re)created on each +# build. This uses the `xcodeproj` gem, which ships with CocoaPods (already installed for `pod install`), +# so there is no extra dependency to add. +# +# WHAT IT WIRES: +# * a new app-extension target "ShareExtension" (bundle id .share) whose sources are our +# ShareViewController.swift + Info.plist, copied in from mobile/ios-share/ +# * the App Group entitlement on BOTH the App target and the extension (the only storage both processes +# can see), via the two .entitlements files +# * the extension embedded into the app ("Embed App Extensions" phase) and set as a build dependency +# +# Idempotent: if the target already exists it is removed and rebuilt, so re-runs never duplicate it. + +require 'xcodeproj' +require 'fileutils' + +ROOT = File.expand_path('../..', __dir__) # repo/mobile +PROJECT = File.join(ROOT, 'ios', 'App', 'App.xcodeproj') +SRC_DIR = File.join(ROOT, 'ios-share') # our checked-in extension sources +APP_DIR = File.join(ROOT, 'ios', 'App') +EXT_NAME = 'ShareExtension' +EXT_DIR = File.join(APP_DIR, EXT_NAME) +APP_TARGET = 'App' +APP_BUNDLE = ENV.fetch('BUNDLE_ID', 'com.bizgaze.connect') +EXT_BUNDLE = "#{APP_BUNDLE}.share" + +abort "xcodeproj not found at #{PROJECT}" unless File.directory?(PROJECT) + +project = Xcodeproj::Project.open(PROJECT) +app = project.targets.find { |t| t.name == APP_TARGET } +abort "App target not found" unless app + +# ── Clean any previous injection so this is idempotent ────────────────────────────────────────────── +project.targets.select { |t| t.name == EXT_NAME }.each do |t| + t.build_configuration_list&.build_configurations&.each { |c| c.remove_from_project } + t.remove_from_project +end +if (grp = project.main_group.children.find { |g| g.respond_to?(:display_name) && g.display_name == EXT_NAME }) + grp.remove_from_project +end + +# ── Copy our sources into the app project so paths inside the .xcodeproj are stable ────────────────── +FileUtils.mkdir_p(EXT_DIR) +%w[ShareViewController.swift Info.plist ShareExtension.entitlements].each do |f| + FileUtils.cp(File.join(SRC_DIR, f), File.join(EXT_DIR, f)) +end + +# The App Group entitlement for the MAIN app. MERGE, don't overwrite: the push-notifications plugin may +# already have written App/App.entitlements (aps-environment), and clobbering it would break push. We add +# the app-group array into whatever is there (or create the file if it's absent). +APP_GROUP = 'group.com.bizgaze.connect' +app_ent_path = File.join(APP_DIR, 'App', 'App.entitlements') +app_ent = File.exist?(app_ent_path) ? (Xcodeproj::Plist.read_from_path(app_ent_path) || {}) : {} +groups = app_ent['com.apple.security.application-groups'] || [] +groups << APP_GROUP unless groups.include?(APP_GROUP) +app_ent['com.apple.security.application-groups'] = groups +Xcodeproj::Plist.write_to_path(app_ent, app_ent_path) +puts "App entitlements: app-group ensured (kept #{(app_ent.keys - ['com.apple.security.application-groups']).join(', ')})" + +# ── Create the extension target ────────────────────────────────────────────────────────────────────── +# deployment_target can be nil when it's only set at the project level — fall back so we never create a +# target with an empty minimum-OS (which Xcode then flags). +deployment = app.deployment_target || project.build_configurations.first&.build_settings&.[]('IPHONEOS_DEPLOYMENT_TARGET') || '14.0' +ext = project.new_target( + :app_extension, EXT_NAME, :ios, + deployment, project.products_group, :swift +) + +# Source file + resources +group = project.main_group.new_group(EXT_NAME, "#{EXT_NAME}") +swift_ref = group.new_reference(File.join(EXT_DIR, 'ShareViewController.swift')) +ext.add_file_references([swift_ref]) + +# Build settings for every configuration (Debug/Release) +ext.build_configurations.each do |cfg| + s = cfg.build_settings + s['PRODUCT_BUNDLE_IDENTIFIER'] = EXT_BUNDLE + s['PRODUCT_NAME'] = '$(TARGET_NAME)' + s['INFOPLIST_FILE'] = "#{EXT_NAME}/Info.plist" + s['CODE_SIGN_ENTITLEMENTS'] = "#{EXT_NAME}/ShareExtension.entitlements" + s['IPHONEOS_DEPLOYMENT_TARGET'] = deployment + s['SWIFT_VERSION'] = '5.0' + s['TARGETED_DEVICE_FAMILY'] = '1,2' + s['GENERATE_INFOPLIST_FILE'] = 'NO' + s['SKIP_INSTALL'] = 'YES' + s['CODE_SIGN_STYLE'] = 'Manual' + s['MARKETING_VERSION'] = '1.0' + s['CURRENT_PROJECT_VERSION'] = ENV.fetch('BUILD_NUMBER', '1') + s['LD_RUNPATH_SEARCH_PATHS'] = '$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks' +end + +# ── App Group entitlement on the MAIN app target too ───────────────────────────────────────────────── +app.build_configurations.each do |cfg| + cfg.build_settings['CODE_SIGN_ENTITLEMENTS'] = 'App/App.entitlements' +end + +# ── Embed the extension into the app + depend on it ────────────────────────────────────────────────── +app.add_dependency(ext) +embed = app.build_phases.find { |p| p.respond_to?(:symbol_dst_subfolder_spec) && p.symbol_dst_subfolder_spec == :plug_ins } +embed ||= begin + phase = app.new_copy_files_build_phase('Embed App Extensions') + phase.symbol_dst_subfolder_spec = :plug_ins + phase +end +appex = ext.product_reference +build_file = embed.add_file_reference(appex) +build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] } + +project.save +puts "Share Extension injected: #{EXT_NAME} (#{EXT_BUNDLE}) embedded in #{APP_TARGET}" diff --git a/mobile/scripts/ios-patch.sh b/mobile/scripts/ios-patch.sh index 9f8dbd6..1dc5b01 100644 --- a/mobile/scripts/ios-patch.sh +++ b/mobile/scripts/ios-patch.sh @@ -40,6 +40,17 @@ set_bool() { "$PB" -c "Add :$1 bool $2" "$PLIST" 2>/dev/null || "$PB" -c "Set :$ set_bool UIFileSharingEnabled true set_bool LSSupportsOpeningDocumentsInPlace true +# ── Custom URL scheme so the Share Extension can bounce the user back into the app ────────────────── +# The extension stages the shared files into the App Group, then opens bizconnect://share; the app reads +# the staged files and shows "Send to…". Registering the scheme is what makes that openURL succeed. +if ! "$PB" -c "Print :CFBundleURLTypes" "$PLIST" >/dev/null 2>&1; then + "$PB" -c "Add :CFBundleURLTypes array" "$PLIST" + "$PB" -c "Add :CFBundleURLTypes:0 dict" "$PLIST" + "$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLName string com.bizgaze.connect" "$PLIST" + "$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST" + "$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string bizconnect" "$PLIST" +fi + # We use only standard encryption (HTTPS/TLS), which is exempt — declaring this up front stops App Store # Connect from asking the "export compliance" question on every single build/TestFlight upload. "$PB" -c "Add :ITSAppUsesNonExemptEncryption bool false" "$PLIST" 2>/dev/null \ diff --git a/server/public/home.html b/server/public/home.html index 7371318..dea6e68 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1197,7 +1197,7 @@ -