feat(share): "Biz Connect" in the iOS share sheet — share a photo/file into a chat
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 <app>.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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <app>.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}"
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user