From 8c4f7f86eacdcc68c83a88641b86636884eb6753 Mon Sep 17 00:00:00 2001 From: edwh Date: Sun, 14 Jun 2026 21:31:34 +0100 Subject: [PATCH] feat(app): share an image into Freegle to start an Offer (iOS) [dark] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS half of Share-into-Freegle (Android is in the parent commit). Adds an iOS share-sheet extension so a photo can be shared straight into a new OFFER with the image pre-attached. DARK / FLAG-GATED: everything is a no-op unless ENABLE_SHARE_EXTENSION=true. By default the Xcode project, the main app's entitlements and signing are byte-for-byte unchanged, so this cannot affect the weekly App Store release until deliberately enabled. Enabling first requires (human, Apple-portal): 1. Register App Group group.org.ilovefreegle.iphone and enable it on both App IDs (org.ilovefreegle.iphone and .ShareExtension). 2. Pre-create the .ShareExtension App ID + App Store distribution profile. 3. Regenerate the main app profile so it includes the App Group. 4. Set ENABLE_SHARE_EXTENSION=true in the deploy-apps CircleCI context. Then on-device verification. - ios/App/ShareExtension/ShareViewController.swift — no-UI share handler: copies shared image(s) into the App Group container and opens the host app with freegleshare://shared?p=... (responder-chain openURL). - ios/App/ShareExtension/Info.plist — share-services extension, image activation rule, programmatic principal class (no storyboard). - ios/App/ShareExtension/ShareExtension.entitlements — App Group. - ios/App/App/Info.plist — register the freegleshare:// URL scheme. - fastlane/add_share_extension_target.rb — gated; adds the target + (build-time only) the App Group entitlement to the main app. Mirrors add_nse_target.rb. - fastlane/Fastfile — gated wiring + signing + export map, composed so a disabled build is identical to today's app(+NSE) export. - stores/mobile: appUrlOpen handles freegleshare://shared, queuing the image(s) into the same pendingSharedImages give-flow path as Android. Co-Authored-By: Claude Opus 4.8 --- iznik-nuxt3/fastlane/Fastfile | 70 +++++++-- .../fastlane/add_share_extension_target.rb | 128 ++++++++++++++++ iznik-nuxt3/ios/App/App/Info.plist | 8 + iznik-nuxt3/ios/App/ShareExtension/Info.plist | 39 +++++ .../ShareExtension.entitlements | 10 ++ .../ShareExtension/ShareViewController.swift | 140 ++++++++++++++++++ iznik-nuxt3/stores/mobile.js | 30 ++++ 7 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 iznik-nuxt3/fastlane/add_share_extension_target.rb create mode 100644 iznik-nuxt3/ios/App/ShareExtension/Info.plist create mode 100644 iznik-nuxt3/ios/App/ShareExtension/ShareExtension.entitlements create mode 100644 iznik-nuxt3/ios/App/ShareExtension/ShareViewController.swift diff --git a/iznik-nuxt3/fastlane/Fastfile b/iznik-nuxt3/fastlane/Fastfile index a05fa4053a..b64deb90d7 100644 --- a/iznik-nuxt3/fastlane/Fastfile +++ b/iznik-nuxt3/fastlane/Fastfile @@ -427,6 +427,18 @@ platform :ios do File.read('../ios/App/App.xcodeproj/project.pbxproj').include?('NotificationServiceExtension') UI.message(nse_present ? "✅ NSE target wired into App.xcodeproj" : "ℹ️ NSE not wired — building app without the extension") + # --- Share Extension: ensure the Xcode target exists (idempotent, GATED) --- + # No-op unless ENABLE_SHARE_EXTENSION=true (see add_share_extension_target.rb). + # When the flag is off this changes nothing — the app builds + signs exactly + # as before — so it can never affect the live release until deliberately + # enabled (which also requires the App Group + profiles to exist; see header). + sh("bundle exec ruby fastlane/add_share_extension_target.rb || bundle exec ruby add_share_extension_target.rb || echo '⚠️ Share Extension wiring step did not run'") + share_ext_present = ENV['ENABLE_SHARE_EXTENSION'] == 'true' && + File.exist?('../ios/App/ShareExtension/ShareViewController.swift') && + File.exist?('../ios/App/App.xcodeproj/project.pbxproj') && + File.read('../ios/App/App.xcodeproj/project.pbxproj').include?('ShareExtension') + UI.message(share_ext_present ? "✅ Share Extension target wired into App.xcodeproj" : "ℹ️ Share Extension not wired (disabled or unavailable)") + # Read pre-calculated version from workspace file (same as Android) # Version was already incremented by the increment-version job version_file = '../.new_version' @@ -554,6 +566,35 @@ platform :ios do UI.success("✅ NSE signing configured (profile: #{nse_profile_name})") end + # --- Share Extension signing (only when the target is wired; GATED) --- + # Mirrors the NSE: the App ID + App Store distribution profile for the Share + # Extension are pre-created ONCE by hand in the Apple Developer portal (with + # the App Group capability). We download-only (readonly) here — produce() + # needs interactive 2FA and would hang CI. No-op unless share_ext_present. + share_ext_profile_name = nil + if share_ext_present + share_ext_bundle = "org.ilovefreegle.iphone.ShareExtension" + get_provisioning_profile(app_identifier: share_ext_bundle, adhoc: false, readonly: true) + share_ext_profile_name = lane_context[SharedValues::SIGH_NAME] + share_ext_profile_path = lane_context[SharedValues::SIGH_PROFILE_PATH] || + (lane_context[SharedValues::SIGH_PROFILE_PATHS] || []).first + + update_project_provisioning( + xcodeproj: "ios/App/App.xcodeproj", + target_filter: "ShareExtension", + profile: share_ext_profile_path, + code_signing_identity: "iPhone Distribution" + ) + update_code_signing_settings( + use_automatic_signing: false, + path: "ios/App/App.xcodeproj", + targets: ["ShareExtension"], + code_sign_identity: "iPhone Distribution", + bundle_identifier: share_ext_bundle + ) + UI.success("✅ Share Extension signing configured (profile: #{share_ext_profile_name})") + end + # The Freegle profile is installed from a file; read its Name for the export map. freegle_profile_file = ENV['IOS_PROVISIONING_PROFILE_PATH'] || 'ios_appstore.mobileprovision' freegle_profile_file = "../#{freegle_profile_file}" unless freegle_profile_file.start_with?('/') @@ -564,9 +605,21 @@ platform :ios do freegle_profile_name = m && m[1] end - # Two-target manual export needs an explicit provisioningProfiles map; only enable it - # when everything resolved, otherwise fall back to today's exact single-target build. - do_nse_export = nse_present && nse_profile_name && freegle_profile_name + # Manual multi-target export needs an explicit provisioningProfiles map; only + # enable it when at least one extension is wired AND everything resolved, + # otherwise fall back to today's exact single-target build. When the Share + # Extension is disabled this composes to exactly the previous app(+NSE) map. + manual_profiles = {} + manual_profiles["org.ilovefreegle.iphone"] = freegle_profile_name if freegle_profile_name + if nse_present && nse_profile_name + manual_profiles["org.ilovefreegle.iphone.NotificationServiceExtension"] = nse_profile_name + end + if share_ext_present && share_ext_profile_name + manual_profiles["org.ilovefreegle.iphone.ShareExtension"] = share_ext_profile_name + end + needs_manual_export = freegle_profile_name && + ((nse_present && nse_profile_name) || + (share_ext_present && share_ext_profile_name)) gym_opts = { scheme: "Freegle", @@ -576,18 +629,15 @@ platform :ios do output_directory: "./build", skip_package_dependencies_resolution: true } - if do_nse_export + if needs_manual_export gym_opts[:export_options] = { method: "app-store", signingStyle: "manual", - provisioningProfiles: { - "org.ilovefreegle.iphone" => freegle_profile_name, - "org.ilovefreegle.iphone.NotificationServiceExtension" => nse_profile_name - } + provisioningProfiles: manual_profiles } - UI.success("📦 Exporting app + NSE (profiles: #{freegle_profile_name} / #{nse_profile_name})") + UI.success("📦 Exporting app + extensions (#{manual_profiles.keys.join(', ')})") else - UI.message("📦 Exporting app only (NSE export not enabled this build)") + UI.message("📦 Exporting app only (no extensions enabled this build)") end # Build the app diff --git a/iznik-nuxt3/fastlane/add_share_extension_target.rb b/iznik-nuxt3/fastlane/add_share_extension_target.rb new file mode 100644 index 0000000000..5fae9feb0e --- /dev/null +++ b/iznik-nuxt3/fastlane/add_share_extension_target.rb @@ -0,0 +1,128 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Adds the "Share into Freegle" Share Extension target to ios/App/App.xcodeproj so +# it builds + signs on CircleCI like any other target — no manual Xcode step. +# +# GATED: does nothing unless ENABLE_SHARE_EXTENSION=true. By default it is a no-op, +# so the main app's entitlements + signing are byte-for-byte unaffected and the +# production build is unchanged. Enable ONLY AFTER: +# 1. Registering the App Group `group.org.ilovefreegle.iphone` in the Apple +# Developer portal and enabling it on BOTH App IDs +# (org.ilovefreegle.iphone and org.ilovefreegle.iphone.ShareExtension). +# 2. Regenerating the app + extension App Store provisioning profiles so they +# include that App Group — otherwise signing/export fails. +# +# Idempotent: safe to run on every CI build. Unlike the NSE, the source is +# committed (ios/App/ShareExtension/), so there is nothing to copy in. +# +# Requires the `xcodeproj` gem (a fastlane dependency; run under `bundle exec`). + +require 'fileutils' +require 'xcodeproj' + +unless ENV['ENABLE_SHARE_EXTENSION'] == 'true' + puts 'add_share_extension_target: ENABLE_SHARE_EXTENSION not set — skipping (no-op).' + exit 0 +end + +EXT_NAME = 'ShareExtension' +APP_TARGET = 'Freegle' +APP_BUNDLE_ID = 'org.ilovefreegle.iphone' +EXT_BUNDLE_ID = "#{APP_BUNDLE_ID}.#{EXT_NAME}" +APP_GROUP = 'group.org.ilovefreegle.iphone' +DEPLOY_TARGET = '14.0' + +root = File.expand_path('..', __dir__) # fastlane/ -> project root +proj_path = File.join(root, 'ios/App/App.xcodeproj') +ext_dir = File.join(root, 'ios/App', EXT_NAME) +app_entitle = File.join(root, 'ios/App/App/App.entitlements') + +unless File.directory?(File.dirname(proj_path)) + warn "add_share_extension_target: #{proj_path} not found — has cap sync run? Skipping." + exit 0 +end +unless File.exist?(File.join(ext_dir, 'ShareViewController.swift')) + warn "add_share_extension_target: source not found in #{ext_dir} — skipping." + exit 0 +end + +project = Xcodeproj::Project.open(proj_path) +app_target = project.targets.find { |t| t.name == APP_TARGET } +unless app_target + warn "add_share_extension_target: main target '#{APP_TARGET}' not found — skipping." + exit 0 +end + +def apply_ext_settings(config) + s = config.build_settings + s['PRODUCT_BUNDLE_IDENTIFIER'] = EXT_BUNDLE_ID + s['PRODUCT_NAME'] = '$(TARGET_NAME)' + s['INFOPLIST_FILE'] = "#{EXT_NAME}/Info.plist" + s['CODE_SIGN_ENTITLEMENTS'] = "#{EXT_NAME}/ShareExtension.entitlements" + s['SWIFT_VERSION'] = '5.0' + s['IPHONEOS_DEPLOYMENT_TARGET'] = DEPLOY_TARGET + s['TARGETED_DEVICE_FAMILY'] = '1,2' + s['CODE_SIGN_STYLE'] = 'Manual' + s['SKIP_INSTALL'] = 'NO' + s['MARKETING_VERSION'] ||= '1.0' + s['CURRENT_PROJECT_VERSION'] ||= '1' +end + +ext_target = project.targets.find { |t| t.name == EXT_NAME } +if ext_target + puts 'add_share_extension_target: target already exists — refreshing settings + file refs' + ext_target.build_configurations.each { |c| apply_ext_settings(c) } +else + puts "add_share_extension_target: creating '#{EXT_NAME}' app-extension target" + ext_target = project.new_target(:app_extension, EXT_NAME, :ios, DEPLOY_TARGET) + ext_target.build_configurations.each { |c| apply_ext_settings(c) } + + # Build the app target after the extension, and embed the .appex in the app. + app_target.add_dependency(ext_target) + + embed = app_target.copy_files_build_phases.find { |p| p.symbol_dst_subfolder_spec == :plug_ins } + if embed.nil? + embed = app_target.new_copy_files_build_phase('Embed App Extensions') + embed.symbol_dst_subfolder_spec = :plug_ins + end + build_file = embed.add_file_reference(ext_target.product_reference) + build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] } +end + +# Group + file references, relative to SOURCE_ROOT (the .xcodeproj dir = ios/App). +group = project.main_group.find_subpath(EXT_NAME, true) +group.set_source_tree('SOURCE_ROOT') +group.set_path(EXT_NAME) + +swift_name = 'ShareViewController.swift' +swift_ref = group.files.find { |f| f.display_name == swift_name } || + group.new_reference(swift_name) +unless ext_target.source_build_phase.files_references.include?(swift_ref) + ext_target.add_file_references([swift_ref]) +end +['Info.plist', 'ShareExtension.entitlements'].each do |fname| + group.new_reference(fname) unless group.files.any? { |f| f.display_name == fname } +end + +# The main app must join the same App Group so it can read what the extension +# wrote. Patch App.entitlements at BUILD TIME only (never committed), so the +# default build is untouched. The app's provisioning profile must already +# include this App Group (regenerated by hand — see header). +if File.exist?(app_entitle) + xml = File.read(app_entitle) + unless xml.include?(APP_GROUP) + inject = <<-PLIST + com.apple.security.application-groups + + #{APP_GROUP} + +PLIST + xml = xml.sub('', "#{inject}") + File.write(app_entitle, xml) + puts "add_share_extension_target: added #{APP_GROUP} to main app entitlements (build-time)" + end +end + +project.save +puts "add_share_extension_target: done (#{EXT_BUNDLE_ID})" diff --git a/iznik-nuxt3/ios/App/App/Info.plist b/iznik-nuxt3/ios/App/App/Info.plist index 5a5ace1a7b..2aaf4dbebe 100644 --- a/iznik-nuxt3/ios/App/App/Info.plist +++ b/iznik-nuxt3/ios/App/App/Info.plist @@ -32,6 +32,14 @@ fb134980666550322 + + CFBundleURLName + org.ilovefreegle.iphone.share + CFBundleURLSchemes + + freegleshare + + CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/iznik-nuxt3/ios/App/ShareExtension/Info.plist b/iznik-nuxt3/ios/App/ShareExtension/Info.plist new file mode 100644 index 0000000000..bb795b2c6f --- /dev/null +++ b/iznik-nuxt3/ios/App/ShareExtension/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Freegle + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 10 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/iznik-nuxt3/ios/App/ShareExtension/ShareExtension.entitlements b/iznik-nuxt3/ios/App/ShareExtension/ShareExtension.entitlements new file mode 100644 index 0000000000..39952a2ba9 --- /dev/null +++ b/iznik-nuxt3/ios/App/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.ilovefreegle.iphone + + + diff --git a/iznik-nuxt3/ios/App/ShareExtension/ShareViewController.swift b/iznik-nuxt3/ios/App/ShareExtension/ShareViewController.swift new file mode 100644 index 0000000000..e1e2ce6a90 --- /dev/null +++ b/iznik-nuxt3/ios/App/ShareExtension/ShareViewController.swift @@ -0,0 +1,140 @@ +// +// ShareViewController.swift +// ShareExtension +// +// "Share an image into Freegle" — iOS share-sheet extension. When the user +// shares one or more images to Freegle from Photos / another app, this +// extension copies them into the shared App Group container and opens the host +// app with a freegleshare://shared?p=... URL. The host app's appUrlOpen +// handler (stores/mobile.js) then starts an OFFER with the photo(s) attached. +// +// No storyboard: NSExtensionPrincipalClass in Info.plist points straight here. +// Kept deliberately small — there is no compose UI; we grab the image and hand +// straight off to the app, mirroring the Android ACTION_SEND flow. +// + +import UIKit +import UniformTypeIdentifiers + +class ShareViewController: UIViewController { + // Must match the App Group registered in the Apple Developer portal and the + // entitlements of BOTH this extension and the main app. + private let appGroupId = "group.org.ilovefreegle.iphone" + private let urlScheme = "freegleshare" + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + processShare() + } + + private func processShare() { + guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { + finish([]) + return + } + + let imageType = UTType.image.identifier // "public.image" + let group = DispatchGroup() + let lock = NSLock() + var paths: [String] = [] + + for item in items { + for provider in item.attachments ?? [] { + guard provider.hasItemConformingToTypeIdentifier(imageType) else { continue } + group.enter() + provider.loadItem(forTypeIdentifier: imageType, options: nil) { data, _ in + defer { group.leave() } + var saved: String? + if let url = data as? URL { + saved = self.copyImage(from: url) + } else if let image = data as? UIImage { + saved = self.saveImageData(image.jpegData(compressionQuality: 0.9)) + } else if let raw = data as? Data { + saved = self.saveImageData(raw) + } + if let saved = saved { + lock.lock() + paths.append(saved) + lock.unlock() + } + } + } + } + + group.notify(queue: .main) { + self.finish(paths) + } + } + + private func sharedDir() -> URL? { + guard let base = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupId + ) else { return nil } + let dir = base.appendingPathComponent("SharedImages", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func uniqueName(ext: String) -> String { + let stamp = Int(Date().timeIntervalSince1970 * 1000) + return "share-\(stamp)-\(UUID().uuidString).\(ext)" + } + + private func copyImage(from src: URL) -> String? { + guard let dir = sharedDir() else { return nil } + let ext = src.pathExtension.isEmpty ? "jpg" : src.pathExtension + let dest = dir.appendingPathComponent(uniqueName(ext: ext)) + do { + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.copyItem(at: src, to: dest) + return dest.path + } catch { + return nil + } + } + + private func saveImageData(_ data: Data?) -> String? { + guard let data = data, let dir = sharedDir() else { return nil } + let dest = dir.appendingPathComponent(uniqueName(ext: "jpg")) + do { + try data.write(to: dest) + return dest.path + } catch { + return nil + } + } + + private func finish(_ paths: [String]) { + if !paths.isEmpty, let url = buildURL(paths) { + openHostApp(url) + } + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } + + private func buildURL(_ paths: [String]) -> URL? { + var comps = URLComponents() + comps.scheme = urlScheme + comps.host = "shared" + comps.queryItems = paths.map { URLQueryItem(name: "p", value: $0) } + return comps.url + } + + // Open the host app from an extension. App extensions can't call + // UIApplication.shared.open directly, so we walk the responder chain to the + // UIApplication and invoke openURL: — the long-established technique for + // share extensions that hand off to their container app. + private func openHostApp(_ url: URL) { + let selector = sel_registerName("openURL:") + var responder: UIResponder? = self + while let r = responder { + if r != self, r.responds(to: selector) { + r.perform(selector, with: url) + return + } + responder = r.next + } + } +} diff --git a/iznik-nuxt3/stores/mobile.js b/iznik-nuxt3/stores/mobile.js index 74d6ffbc58..d97ad0e105 100644 --- a/iznik-nuxt3/stores/mobile.js +++ b/iznik-nuxt3/stores/mobile.js @@ -244,6 +244,13 @@ export const useMobileStore = defineStore({ if (process.client) { App.addListener('appUrlOpen', async (event) => { console.log('appUrlOpen', event.url) + // "Share an image into Freegle" on iOS: the Share Extension opens + // freegleshare://shared?p=... — queue the image(s) and route + // into the give flow (mirrors the Android FreegleShare bridge). + if (event.url && event.url.indexOf('freegleshare://') === 0) { + this.handleSharedUrl(event.url) + return + } const lookfor = 'ilovefreegle.org' const ilfpos = event.url.indexOf(lookfor) if (ilfpos !== -1) { @@ -330,6 +337,29 @@ export const useMobileStore = defineStore({ } }, + // iOS share-extension handoff: parse freegleshare://shared?p=&p=, + // convert each shared-container path to a URL the WebView can fetch(), queue + // it, and route into the give flow. Same destination as checkSharedIntent. + handleSharedUrl(url) { + try { + const qpos = url.indexOf('?') + if (qpos === -1) return + const paths = url + .substring(qpos + 1) + .split('&') + .filter((kv) => kv.startsWith('p=')) + .map((kv) => decodeURIComponent(kv.substring(2))) + .filter(Boolean) + if (!paths.length) return + this.pendingSharedImages = paths.map((p) => Capacitor.convertFileSrc(p)) + console.log('Shared images received (iOS)', this.pendingSharedImages.length) + const router = useRouter() + router.push('/give/mobile/photos') + } catch (e) { + console.log('handleSharedUrl failed', e?.message) + } + }, + async initPushNotifications(PushNotifications, Badge) { dbg()?.info('initPushNotifications started', { isiOS: this.isiOS })