feat(ios): implement native share target for images

Implement iOS Share Extension to enable native image sharing from Photos
and other apps directly into TimeSafari. Users can now share images from
the iOS share sheet, which will open in SharedPhotoView for use as gifts
or profile pictures.

iOS Native Implementation:
- Add TimeSafariShareExtension target with ShareViewController
- Configure App Groups for data sharing between extension and main app
- Implement ShareViewController to process shared images and convert to base64
- Store shared image data in App Group UserDefaults
- Add ShareImageBridge utility to read shared data from App Group
- Update AppDelegate to handle shared-photo deep link and bridge data to JS

JavaScript Integration:
- Add checkAndStoreNativeSharedImage() in main.capacitor.ts to read shared
  images from native layer via temporary file bridge
- Convert base64 data to data URL format for compatibility with base64ToBlob
- Integrate with existing SharedPhotoView component
- Add "shared-photo" to deep link validation schema

Build System:
- Integrate Xcode 26 / CocoaPods compatibility workaround into build-ios.sh
- Add run_pod_install_with_workaround() for explicit pod install
- Add run_cap_sync_with_workaround() for Capacitor sync (which runs pod
  install internally)
- Automatically detect project format version 70 and apply workaround
- Remove standalone pod-install-workaround.sh script

Code Cleanup:
- Remove verbose debug logs from ShareViewController, AppDelegate, and
  main.capacitor.ts
- Retain essential logger calls for production debugging

Documentation:
- Add ios-share-extension-setup.md with manual Xcode setup instructions
- Add ios-share-extension-git-commit-guide.md for version control best practices
- Add ios-share-implementation-status.md tracking implementation progress
- Add native-share-target-implementation.md with overall architecture
- Add xcode-26-cocoapods-workaround.md documenting the compatibility issue

The implementation uses a temporary file bridge (AppDelegate writes to Documents
directory, JS reads via Capacitor Filesystem plugin) as a workaround for
Capacitor plugin auto-discovery issues. This can be improved in the future by
properly registering ShareImagePlugin in Capacitor's plugin registry.
This commit is contained in:
Jose Olarte III
2025-11-24 20:46:58 +08:00
parent 1b4ab7a500
commit ae49c0e907
16 changed files with 1839 additions and 4 deletions

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -15,8 +15,35 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86585E72ED45A3D00824752 /* ShareImageBridge.swift */; };
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
C86585DD2ED456DE00824752 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = C86585D42ED456DE00824752;
remoteInfo = TimeSafariShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
C86585E02ED456DE00824752 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
@@ -28,10 +55,28 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImageBridge.swift; sourceTree = "<group>"; };
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImagePlugin.swift; sourceTree = "<group>"; };
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -41,6 +86,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D22ED456DE00824752 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -56,6 +108,7 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
504EC3051FED79650016851F /* Products */,
BA325FFCDCE8D334E5C7AEBE /* Pods */,
4B546315E668C7A13939F417 /* Frameworks */,
@@ -66,6 +119,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -73,6 +127,9 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */,
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -108,16 +165,40 @@
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
C86585DE2ED456DE00824752 /* PBXTargetDependency */,
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
C86585D42ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */;
buildPhases = (
C86585D12ED456DE00824752 /* Sources */,
C86585D22ED456DE00824752 /* Frameworks */,
C86585D32ED456DE00824752 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
);
name = TimeSafariShareExtension;
packageProductDependencies = (
);
productName = TimeSafariShareExtension;
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -125,7 +206,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1630;
TargetAttributes = {
504EC3031FED79650016851F = {
@@ -133,6 +214,9 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
C86585D42ED456DE00824752 = {
CreatedOnToolsVersion = 26.1.1;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -149,6 +233,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
);
};
/* End PBXProject section */
@@ -167,6 +252,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D32ED456DE00824752 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -253,12 +345,29 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */,
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D12ED456DE00824752 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
C86585DE2ED456DE00824752 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
targetProxy = C86585DD2ED456DE00824752 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -402,6 +511,7 @@
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
DEVELOPMENT_TEAM = GM3FS5JQPH;
@@ -429,6 +539,7 @@
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
DEVELOPMENT_TEAM = GM3FS5JQPH;
@@ -450,6 +561,80 @@
};
name = Release;
};
C86585E12ED456DE00824752 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
C86585E22ED456DE00824752 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -471,6 +656,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C86585E12ED456DE00824752 /* Debug */,
C86585E22ED456DE00824752 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari</string>
</array>
</dict>
</plist>

View File

@@ -39,6 +39,33 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Check if this is a shared-photo deep link and store image data in a way JS can access
if url.scheme == "timesafari" && url.host == "shared-photo" {
// Try to get shared image from App Group and store it in a temp file that JS can read
// This is a workaround until the plugin is properly registered
if let sharedData = getSharedImageData() {
// Write to a temp file in the app's Documents directory that JavaScript can read via Filesystem plugin
let fileManager = FileManager.default
if let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
let tempFileURL = documentsDir.appendingPathComponent("timesafari_shared_photo.json")
// Create JSON data
let jsonData: [String: String] = [
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
]
if let json = try? JSONSerialization.data(withJSONObject: jsonData, options: []) {
do {
try json.write(to: tempFileURL)
} catch {
// Error writing temp file - will be handled by JS layer
}
}
}
}
}
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
@@ -50,5 +77,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
/**
* Check for shared image from Share Extension
* Reads from App Group UserDefaults and returns shared image data if available
*
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/
func getSharedImageData() -> [String: String]? {
let appGroupIdentifier = "group.app.timesafari"
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return nil
}
guard let base64 = userDefaults.string(forKey: "sharedPhotoBase64"),
let fileName = userDefaults.string(forKey: "sharedPhotoFileName") else {
return nil
}
// Clear the shared data after reading
userDefaults.removeObject(forKey: "sharedPhotoBase64")
userDefaults.removeObject(forKey: "sharedPhotoFileName")
userDefaults.synchronize()
return ["base64": base64, "fileName": fileName]
}
}

View File

@@ -0,0 +1,48 @@
import Foundation
/**
* Share Image Bridge
*
* Provides a bridge between JavaScript and native iOS code to access
* shared images stored in App Group UserDefaults by the Share Extension.
*
* This bridge allows the JavaScript layer to read shared image data
* that was stored by the Share Extension.
*
* Note: This class doesn't need Capacitor - it's a simple Swift utility
* that reads from App Group UserDefaults. The JavaScript bridge will be
* implemented separately.
*/
@objc(ShareImageBridge)
public class ShareImageBridge: NSObject {
private static let appGroupIdentifier = "group.app.timesafari"
private static let sharedPhotoBase64Key = "sharedPhotoBase64"
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
/**
* Get shared image data from App Group UserDefaults
* Called from JavaScript via Capacitor bridge
*
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/
@objc public static func getSharedImageData() -> [String: String]? {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("ShareImageBridge: Failed to access App Group UserDefaults")
return nil
}
guard let base64 = userDefaults.string(forKey: sharedPhotoBase64Key),
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) else {
return nil
}
// Clear the shared data after reading
userDefaults.removeObject(forKey: sharedPhotoBase64Key)
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
userDefaults.synchronize()
return ["base64": base64, "fileName": fileName]
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
import Capacitor
/**
* Share Image Plugin
*
* Capacitor plugin that exposes ShareImageBridge functionality to JavaScript.
* Allows JavaScript to retrieve shared images from App Group UserDefaults.
*/
@objc(ShareImagePlugin)
public class ShareImagePlugin: CAPPlugin {
@objc func getSharedImageData(_ call: CAPPluginCall) {
guard let sharedData = ShareImageBridge.getSharedImageData() else {
call.resolve(["success": false, "data": NSNull()])
return
}
call.resolve([
"success": true,
"data": [
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
]
])
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,170 @@
//
// ShareViewController.swift
// TimeSafariShareExtension
//
// Created by Aardimus on 11/24/25.
//
import UIKit
import Social
import UniformTypeIdentifiers
class ShareViewController: SLComposeServiceViewController {
private let appGroupIdentifier = "group.app.timesafari"
private let sharedPhotoBase64Key = "sharedPhotoBase64"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
override func viewDidLoad() {
super.viewDidLoad()
// Set placeholder text (required for SLComposeServiceViewController)
self.placeholder = "Share image to TimeSafari"
// Validate content on load
self.validateContent()
}
override func isContentValid() -> Bool {
// Validate that we have image attachments
guard let extensionContext = extensionContext else {
return false
}
guard let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
return false
}
for item in inputItems {
if let attachments = item.attachments {
for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
return true
}
}
}
}
return false
}
override func didSelectPost() {
// Extract and process the shared image
guard let extensionContext = extensionContext else {
return
}
guard let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
return
}
// Process the first image found
processSharedImage(from: inputItems) { [weak self] success in
guard let self = self else {
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
return
}
if success {
// Open the main app via deep link
self.openMainApp()
}
// Complete the extension context
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
}
}
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
// Find the first image attachment
for item in items {
guard let attachments = item.attachments else {
continue
}
for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
guard let self = self else {
completion(false)
return
}
if let error = error {
completion(false)
return
}
// Handle different image data types
var imageData: Data?
var fileName: String = "shared-image.jpg"
if let url = data as? URL {
// Image provided as file URL
imageData = try? Data(contentsOf: url)
fileName = url.lastPathComponent
} else if let image = data as? UIImage {
// Image provided as UIImage
imageData = image.jpegData(compressionQuality: 0.9)
fileName = "shared-image.jpg"
} else if let data = data as? Data {
// Image provided as raw Data
imageData = data
fileName = "shared-image.jpg"
}
guard let finalImageData = imageData else {
completion(false)
return
}
// Convert to base64
let base64String = finalImageData.base64EncodedString()
// Store in App Group UserDefaults
guard let userDefaults = UserDefaults(suiteName: self.appGroupIdentifier) else {
completion(false)
return
}
userDefaults.set(base64String, forKey: self.sharedPhotoBase64Key)
userDefaults.set(fileName, forKey: self.sharedPhotoFileNameKey)
userDefaults.synchronize()
completion(true)
}
return // Process only the first image
}
}
}
// No image found
completion(false)
}
private func openMainApp() {
// Open the main app via deep link
guard let url = URL(string: "timesafari://shared-photo") else {
return
}
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return
}
responder = responder?.next
}
// Fallback: use extension context
extensionContext?.open(url, completionHandler: nil)
}
override func configurationItems() -> [Any]! {
// No additional configuration options needed
return []
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari</string>
</array>
</dict>
</plist>