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 @@ -