Only use the appGroupTemporaryDirectory to access a file from the share extension. (#4002)

… and switch back to the plain `URL.temporaryDirectory` for everything else.

* Fix documentation

Co-authored-by: Stefan Ceriu <stefan.ceriu@gmail.com>
This commit is contained in:
Doug
2025-04-10 09:56:25 +01:00
committed by GitHub
parent 031ba13c3d
commit 188439eef7
6 changed files with 99 additions and 27 deletions

View File

@@ -125,6 +125,9 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
if let previousVersion = appSettings.lastVersionLaunched.flatMap(Version.init) { if let previousVersion = appSettings.lastVersionLaunched.flatMap(Version.init) {
performMigrationsIfNecessary(from: previousVersion, to: currentVersion) performMigrationsIfNecessary(from: previousVersion, to: currentVersion)
// Manual clean to handle the potential case where the app crashes before moving a shared file.
cleanAppGroupTemporaryDirectory()
} else { } else {
// The app has been deleted since the previous run. Reset everything. // The app has been deleted since the previous run. Reset everything.
wipeUserData(includingSettings: true) wipeUserData(includingSettings: true)
@@ -252,12 +255,17 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
} else { } else {
handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias)) handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias))
} }
case .share: case .share(let payload):
guard isExternalURL else { guard isExternalURL else {
MXLog.error("Received unexpected internal share route") MXLog.error("Received unexpected internal share route")
break break
} }
handleAppRoute(route)
do {
try handleAppRoute(.share(payload.withDefaultTemporaryDirectory()))
} catch {
MXLog.error("Failed moving payload out of the app group container: \(error)")
}
default: default:
break break
} }
@@ -408,6 +416,31 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
userSessionStore.reset() userSessionStore.reset()
} }
/// Manually cleans up any files in the app group's `tmp` directory.
///
/// **Note:** If there is a single file we consider it to be an active share payload and ignore it.
private func cleanAppGroupTemporaryDirectory() {
let fileURLs: [URL]
do {
fileURLs = try FileManager.default.contentsOfDirectory(at: URL.appGroupTemporaryDirectory, includingPropertiesForKeys: nil, options: [])
} catch {
MXLog.warning("Failed to enumerate app group temporary directory: \(error)")
return
}
guard fileURLs.count > 1 else {
return // If there is only a single item in here, there's likely a pending share payload that is yet to be processed.
}
for url in fileURLs {
do {
try FileManager.default.removeItem(at: url)
} catch {
MXLog.warning("Failed to remove file from app group temporary directory: \(error)")
}
}
}
private func setupStateMachine() { private func setupStateMachine() {
stateMachine.addTransitionHandler { [weak self] context in stateMachine.addTransitionHandler { [weak self] context in
guard let self else { return } guard let self else { return }

View File

@@ -37,8 +37,9 @@ extension FileManager {
} }
@discardableResult @discardableResult
func writeDataToTemporaryDirectory(data: Data, fileName: String) throws -> URL { func writeDataToTemporaryDirectory(data: Data, fileName: String, withinAppGroupContainer: Bool = false) throws -> URL {
let newURL = URL.appGroupTemporaryDirectory.appendingPathComponent(fileName) let baseURL: URL = withinAppGroupContainer ? .appGroupTemporaryDirectory : .temporaryDirectory
let newURL = baseURL.appendingPathComponent(fileName)
try data.write(to: newURL) try data.write(to: newURL)

View File

@@ -28,20 +28,23 @@ extension NSItemProvider {
try? await loadItem(forTypeIdentifier: UTType.text.identifier) as? String try? await loadItem(forTypeIdentifier: UTType.text.identifier) as? String
} }
func storeData() async -> URL? { /// Stores the item's data from the provider within the temporary directory, returning the URL on success.
/// - Parameter withinAppGroupContainer: Whether the data needs to be shared between bundles.
/// If passing `true` you will need to manually clean up the file once you have the data in the receiving bundle.
func storeData(withinAppGroupContainer: Bool = false) async -> URL? {
guard let contentType = preferredContentType else { guard let contentType = preferredContentType else {
MXLog.error("Invalid NSItemProvider: \(self)") MXLog.error("Invalid NSItemProvider: \(self)")
return nil return nil
} }
if contentType.type.identifier == UTType.image.identifier { if contentType.type.identifier == UTType.image.identifier {
return await generateURLForUIImage(contentType) return await generateURLForUIImage(contentType, withinAppGroupContainer: withinAppGroupContainer)
} else { } else {
return await generateURLForGenericData(contentType) return await generateURLForGenericData(contentType, withinAppGroupContainer: withinAppGroupContainer)
} }
} }
private func generateURLForUIImage(_ contentType: PreferredContentType) async -> URL? { private func generateURLForUIImage(_ contentType: PreferredContentType, withinAppGroupContainer: Bool) async -> URL? {
guard let uiImage = try? await loadItem(forTypeIdentifier: contentType.type.identifier) as? UIImage else { guard let uiImage = try? await loadItem(forTypeIdentifier: contentType.type.identifier) as? UIImage else {
MXLog.error("Failed casting UIImage, invalid NSItemProvider: \(self)") MXLog.error("Failed casting UIImage, invalid NSItemProvider: \(self)")
return nil return nil
@@ -52,22 +55,25 @@ extension NSItemProvider {
return nil return nil
} }
let filename = if let suggestedName = suggestedName as NSString?,
// Suggestions are nice but their extension is `jpeg`
let filename = (suggestedName.deletingPathExtension as NSString).appendingPathExtension(contentType.fileExtension) {
filename
} else {
"\(UUID().uuidString).\(contentType.fileExtension)"
}
do { do {
if let suggestedName = suggestedName as? NSString, return try FileManager.default.writeDataToTemporaryDirectory(data: pngData,
// Suggestions are nice but their extension is `jpeg` fileName: filename,
let filename = (suggestedName.deletingPathExtension as NSString).appendingPathExtension(contentType.fileExtension) { withinAppGroupContainer: withinAppGroupContainer)
return try FileManager.default.writeDataToTemporaryDirectory(data: pngData, fileName: filename)
} else {
let filename = "\(UUID().uuidString).\(contentType.fileExtension)"
return try FileManager.default.writeDataToTemporaryDirectory(data: pngData, fileName: filename)
}
} catch { } catch {
MXLog.error("Failed storing NSItemProvider data \(self) with error: \(error)") MXLog.error("Failed storing NSItemProvider data \(self) with error: \(error)")
return nil return nil
} }
} }
private func generateURLForGenericData(_ contentType: PreferredContentType) async -> URL? { private func generateURLForGenericData(_ contentType: PreferredContentType, withinAppGroupContainer: Bool) async -> URL? {
let providerDescription = description let providerDescription = description
let shareData: Data? = await withCheckedContinuation { continuation in let shareData: Data? = await withCheckedContinuation { continuation in
_ = loadDataRepresentation(for: contentType.type) { data, error in _ = loadDataRepresentation(for: contentType.type) { data, error in
@@ -92,15 +98,19 @@ extension NSItemProvider {
return nil return nil
} }
let filename = if let suggestedName = suggestedName as NSString?,
suggestedName.hasPathExtension {
suggestedName as String
} else if let suggestedName {
"\(suggestedName).\(contentType.fileExtension)"
} else {
"\(UUID().uuidString).\(contentType.fileExtension)"
}
do { do {
if let filename = suggestedName { return try FileManager.default.writeDataToTemporaryDirectory(data: shareData,
let hasExtension = !(filename as NSString).pathExtension.isEmpty fileName: filename,
let filename = hasExtension ? filename : "\(filename).\(contentType.fileExtension)" withinAppGroupContainer: withinAppGroupContainer)
return try FileManager.default.writeDataToTemporaryDirectory(data: shareData, fileName: filename)
} else {
let filename = "\(UUID().uuidString).\(contentType.fileExtension)"
return try FileManager.default.writeDataToTemporaryDirectory(data: shareData, fileName: filename)
}
} catch { } catch {
MXLog.error("Failed storing NSItemProvider data \(self) with error: \(error)") MXLog.error("Failed storing NSItemProvider data \(self) with error: \(error)")
return nil return nil
@@ -164,3 +174,7 @@ extension NSItemProvider {
return mimeType.hasPrefix("image/") || mimeType.hasPrefix("video/") || mimeType.hasPrefix("application/") return mimeType.hasPrefix("image/") || mimeType.hasPrefix("video/") || mimeType.hasPrefix("application/")
} }
} }
private extension NSString {
var hasPathExtension: Bool { !pathExtension.isEmpty }
}

View File

@@ -75,7 +75,10 @@ extension URL: @retroactive ExpressibleByStringLiteral {
return url return url
} }
/// The app group temporary directory /// The app group temporary directory (useful for transferring files between different bundles).
///
/// **Note:** This `tmp` directory doesn't appear to behave as expected as it isn't being tidied up by the system.
/// Make sure to manually tidy up any files you place in here once you've transferred them from one bundle to another.
static var appGroupTemporaryDirectory: URL { static var appGroupTemporaryDirectory: URL {
let url = appGroupContainerDirectory let url = appGroupContainerDirectory
.appendingPathComponent("tmp", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true)

View File

@@ -22,9 +22,30 @@ enum ShareExtensionPayload: Hashable, Codable {
roomID roomID
} }
} }
/// Moves any files in the payload from our `appGroupTemporaryDirectory` to the
/// system's `temporaryDirectory` returning a modified payload with updated file URLs.
func withDefaultTemporaryDirectory() throws -> Self {
switch self {
case .mediaFile(let roomID, let mediaFile):
let path = mediaFile.url.path.replacing(URL.appGroupTemporaryDirectory.path, with: "").trimmingPrefix("/")
let newURL = URL.temporaryDirectory.appending(path: path)
try? FileManager.default.removeItem(at: newURL)
try FileManager.default.moveItem(at: mediaFile.url, to: newURL)
return .mediaFile(roomID: roomID, mediaFile: mediaFile.replacingURL(with: newURL))
case .text:
return self
}
}
} }
struct ShareExtensionMediaFile: Hashable, Codable { struct ShareExtensionMediaFile: Hashable, Codable {
let url: URL let url: URL
let suggestedName: String? let suggestedName: String?
fileprivate func replacingURL(with newURL: URL) -> ShareExtensionMediaFile {
ShareExtensionMediaFile(url: newURL, suggestedName: suggestedName)
}
} }

View File

@@ -44,7 +44,7 @@ class ShareExtensionViewController: UIViewController {
let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier
if let fileURL = await itemProvider.storeData() { if let fileURL = await itemProvider.storeData(withinAppGroupContainer: true) {
return .mediaFile(roomID: roomID, mediaFile: .init(url: fileURL, suggestedName: fileURL.lastPathComponent)) return .mediaFile(roomID: roomID, mediaFile: .init(url: fileURL, suggestedName: fileURL.lastPathComponent))
} else if let url = await itemProvider.loadTransferable(type: URL.self) { } else if let url = await itemProvider.loadTransferable(type: URL.self) {
return .text(roomID: roomID, text: url.absoluteString) return .text(roomID: roomID, text: url.absoluteString)