Add iOS save action extension

This commit is contained in:
Rijad Zuzo
2026-05-18 20:50:23 +02:00
parent 42a59e959f
commit d81215db73
6 changed files with 425 additions and 0 deletions
+170
View File
@@ -20,6 +20,8 @@
A1B2C3021111111111111111 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30D1111111111111111 /* ShareViewController.swift */; };
A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3181111111111111111 /* MainInterface.storyboard */; };
A1B2C3041111111111111111 /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A1B2C30C1111111111111111 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B2C3D4012222222222222222 /* SaveActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4062222222222222222 /* SaveActionViewController.swift */; };
B2C3D4022222222222222222 /* Save Action.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B2C3D4052222222222222222 /* Save Action.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -37,6 +39,13 @@
remoteGlobalIDString = A1B2C3131111111111111111;
remoteInfo = "Share Extension";
};
B2C3D4042222222222222222 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = B2C3D40B2222222222222222;
remoteInfo = "Save Action";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -57,6 +66,7 @@
dstSubfolderSpec = 13;
files = (
A1B2C3041111111111111111 /* Share Extension.appex in Embed App Extensions */,
B2C3D4022222222222222222 /* Save Action.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -91,6 +101,10 @@
A1B2C30E1111111111111111 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
A1B2C30F1111111111111111 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A1B2C3101111111111111111 /* Share Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Share Extension.entitlements"; sourceTree = "<group>"; };
B2C3D4052222222222222222 /* Save Action.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Save Action.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
B2C3D4062222222222222222 /* SaveActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveActionViewController.swift; sourceTree = "<group>"; };
B2C3D4072222222222222222 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B2C3D4082222222222222222 /* Save Action.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Save Action.entitlements"; sourceTree = "<group>"; };
CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E9B506D809ABF2A50BF6135B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
@@ -112,6 +126,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B2C3D4092222222222222222 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
E2BAEB33085D4BFE88A55D45 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -170,6 +191,7 @@
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
A1B2C3121111111111111111 /* Share Extension */,
B2C3D40A2222222222222222 /* Save Action */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
3D195739A0A78D037BF244F8 /* Pods */,
@@ -182,6 +204,7 @@
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
A1B2C30C1111111111111111 /* Share Extension.appex */,
B2C3D4052222222222222222 /* Save Action.appex */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -215,6 +238,16 @@
path = "Share Extension";
sourceTree = "<group>";
};
B2C3D40A2222222222222222 /* Save Action */ = {
isa = PBXGroup;
children = (
B2C3D4082222222222222222 /* Save Action.entitlements */,
B2C3D4062222222222222222 /* SaveActionViewController.swift */,
B2C3D4072222222222222222 /* Info.plist */,
);
path = "Save Action";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -255,6 +288,7 @@
);
dependencies = (
A1B2C3171111111111111111 /* PBXTargetDependency */,
B2C3D40E2222222222222222 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
@@ -278,6 +312,23 @@
productReference = A1B2C30C1111111111111111 /* Share Extension.appex */;
productType = "com.apple.product-type.app-extension";
};
B2C3D40B2222222222222222 /* Save Action */ = {
isa = PBXNativeTarget;
buildConfigurationList = B2C3D4122222222222222222 /* Build configuration list for PBXNativeTarget "Save Action" */;
buildPhases = (
B2C3D40D2222222222222222 /* Sources */,
B2C3D4092222222222222222 /* Frameworks */,
B2C3D40C2222222222222222 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Save Action";
productName = "Save Action";
productReference = B2C3D4052222222222222222 /* Save Action.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -299,6 +350,9 @@
A1B2C3131111111111111111 = {
CreatedOnToolsVersion = 15.0.1;
};
B2C3D40B2222222222222222 = {
CreatedOnToolsVersion = 15.0.1;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@@ -316,6 +370,7 @@
targets = (
97C146ED1CF9000F007C117D /* Runner */,
A1B2C3131111111111111111 /* Share Extension */,
B2C3D40B2222222222222222 /* Save Action */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
@@ -348,6 +403,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B2C3D40C2222222222222222 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -472,6 +534,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B2C3D40D2222222222222222 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B2C3D4012222222222222222 /* SaveActionViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -485,6 +555,11 @@
target = A1B2C3131111111111111111 /* Share Extension */;
targetProxy = A1B2C3051111111111111111 /* PBXContainerItemProxy */;
};
B2C3D40E2222222222222222 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B2C3D40B2222222222222222 /* Save Action */;
targetProxy = B2C3D4042222222222222222 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@@ -520,6 +595,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction";
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
@@ -651,6 +727,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction";
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
@@ -713,6 +790,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver;
APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction";
APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared;
APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests";
APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension";
@@ -895,6 +973,88 @@
};
name = Profile;
};
B2C3D40F2222222222222222 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Save Action/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_MODULE_NAME = SaveAction;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
B2C3D4102222222222222222 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Save Action/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_MODULE_NAME = SaveAction;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
B2C3D4112222222222222222 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = "$(APP_GROUP_ID)";
DEVELOPMENT_TEAM = SR3UKQVKP6;
INFOPLIST_FILE = "Save Action/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)";
PRODUCT_MODULE_NAME = SaveAction;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -938,6 +1098,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B2C3D4122222222222222222 /* Build configuration list for PBXNativeTarget "Save Action" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B2C3D40F2222222222222222 /* Debug */,
B2C3D4102222222222222222 /* Release */,
B2C3D4112222222222222222 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
+41
View File
@@ -0,0 +1,41 @@
<?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>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleDisplayName</key>
<string>Save to Relationship Saver</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SaveActionViewController</string>
</dict>
</dict>
</plist>
+10
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>$(CUSTOM_GROUP_ID)</string>
</array>
</dict>
</plist>
@@ -0,0 +1,194 @@
import MobileCoreServices
import UniformTypeIdentifiers
import UIKit
private let schemePrefix = "ShareMedia"
private let userDefaultsKey = "ShareKey"
private let userDefaultsMessageKey = "ShareMessageKey"
private let appGroupIdKey = "AppGroupId"
private struct SharedMediaFile: Codable {
let path: String
let mimeType: String?
let thumbnail: String?
let duration: Double?
let message: String?
let type: SharedMediaType
}
private enum SharedMediaType: String, Codable {
case text
case url
}
final class SaveActionViewController: UIViewController {
private var hostAppBundleIdentifier = ""
private var appGroupId = ""
private var sharedMedia: [SharedMediaFile] = []
private let group = DispatchGroup()
private var didStart = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !didStart else {
return
}
didStart = true
loadIdentifiers()
loadSharedContent()
}
private func loadIdentifiers() {
let actionExtensionIdentifier = Bundle.main.bundleIdentifier ?? ""
if let lastDotIndex = actionExtensionIdentifier.lastIndex(of: ".") {
hostAppBundleIdentifier = String(actionExtensionIdentifier[..<lastDotIndex])
} else {
hostAppBundleIdentifier = actionExtensionIdentifier
}
let defaultAppGroupId = "group.\(hostAppBundleIdentifier)"
appGroupId =
Bundle.main.object(forInfoDictionaryKey: appGroupIdKey) as? String
?? defaultAppGroupId
}
private func loadSharedContent() {
guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem] else {
finish()
return
}
for item in inputItems {
appendAttributedText(item.attributedContentText)
for provider in item.attachments ?? [] {
load(provider)
}
}
group.notify(queue: .main) { [weak self] in
self?.finish()
}
}
private func load(_ provider: NSItemProvider) {
if provider.hasItemConformingToTypeIdentifier(urlTypeIdentifier) {
group.enter()
provider.loadItem(forTypeIdentifier: urlTypeIdentifier, options: nil) { [weak self] item, _ in
defer {
self?.group.leave()
}
if let url = item as? URL {
self?.appendLiteral(url.absoluteString, type: .url, mimeType: nil)
} else if let text = item as? String {
self?.appendLiteral(text, type: .url, mimeType: nil)
}
}
return
}
if provider.hasItemConformingToTypeIdentifier(textTypeIdentifier) {
group.enter()
provider.loadItem(forTypeIdentifier: textTypeIdentifier, options: nil) { [weak self] item, _ in
defer {
self?.group.leave()
}
if let text = item as? String {
self?.appendLiteral(text, type: .text, mimeType: "text/plain")
}
}
}
}
private var textTypeIdentifier: String {
if #available(iOS 14.0, *) {
return UTType.text.identifier
}
return kUTTypeText as String
}
private var urlTypeIdentifier: String {
if #available(iOS 14.0, *) {
return UTType.url.identifier
}
return kUTTypeURL as String
}
private func appendAttributedText(_ text: NSAttributedString?) {
guard let value = text?.string else {
return
}
appendLiteral(value, type: .text, mimeType: "text/plain")
}
private func appendLiteral(_ value: String, type: SharedMediaType, mimeType: String?) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
guard !sharedMedia.contains(where: { $0.type == type && $0.path == trimmed }) else {
return
}
sharedMedia.append(
SharedMediaFile(
path: trimmed,
mimeType: mimeType,
thumbnail: nil,
duration: nil,
message: nil,
type: type
)
)
}
private func finish() {
guard !sharedMedia.isEmpty else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
saveAndRedirect()
}
private func saveAndRedirect() {
guard let encodedData = try? JSONEncoder().encode(sharedMedia) else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
let userDefaults = UserDefaults(suiteName: appGroupId)
userDefaults?.set(encodedData, forKey: userDefaultsKey)
userDefaults?.set(nil, forKey: userDefaultsMessageKey)
userDefaults?.synchronize()
redirectToHostApp()
}
private func redirectToHostApp() {
guard let url = URL(string: "\(schemePrefix)-\(hostAppBundleIdentifier):share") else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
var responder: UIResponder? = self
let selectorOpenUrl = sel_registerName("openURL:")
if #available(iOS 18.0, *) {
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
} else {
while responder != nil {
if responder?.responds(to: selectorOpenUrl) == true {
_ = responder?.perform(selectorOpenUrl, with: url)
break
}
responder = responder?.next
}
}
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}