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 })