diff --git a/Sources/MultipeerKit/Internal API/MockMultipeerConnection.swift b/Sources/MultipeerKit/Internal API/MockMultipeerConnection.swift index 1f4dfc4..bdce7f3 100644 --- a/Sources/MultipeerKit/Internal API/MockMultipeerConnection.swift +++ b/Sources/MultipeerKit/Internal API/MockMultipeerConnection.swift @@ -3,17 +3,16 @@ import MultipeerConnectivity.MCPeerID final class MockMultipeerConnection: MultipeerProtocol { - let localPeer: Peer = { - let underlyingPeer = MCPeerID(displayName: "MockPeer") - return try! Peer(peer: underlyingPeer, discoveryInfo: nil) - }() + let localPeer = Peer.mock var didReceiveData: ((Data, Peer) -> Void)? var didFindPeer: ((Peer) -> Void)? var didLosePeer: ((Peer) -> Void)? var didConnectToPeer: ((Peer) -> Void)? var didDisconnectFromPeer: ((Peer) -> Void)? - + var didStartReceivingResource: ((_ sender: Peer, _ resourceName: String, _ progress: Progress) -> Void)? + var didFinishReceivingResource: ((_ sender: Peer, _ resourceName: String, _ result: Result) -> Void)? + var isRunning = false func resume() { @@ -32,6 +31,13 @@ final class MockMultipeerConnection: MultipeerProtocol { } + @available(iOS 13.0, macOS 10.15, *) + func send(_ resourceURL: URL, to peer: Peer) -> MultipeerTransceiver.ResourceUploadStream { + AsyncThrowingStream { continuation in + continuation.finish() + } + } + func invite(_ peer: Peer, with context: Data?, timeout: TimeInterval, completion: InvitationCompletionHandler?) { } diff --git a/Sources/MultipeerKit/Internal API/MultipeerConnection.swift b/Sources/MultipeerKit/Internal API/MultipeerConnection.swift index 8787cfc..7afb319 100644 --- a/Sources/MultipeerKit/Internal API/MultipeerConnection.swift +++ b/Sources/MultipeerKit/Internal API/MultipeerConnection.swift @@ -34,6 +34,8 @@ final class MultipeerConnection: NSObject, MultipeerProtocol { var didLosePeer: ((Peer) -> Void)? var didConnectToPeer: ((Peer) -> Void)? var didDisconnectFromPeer: ((Peer) -> Void)? + var didStartReceivingResource: ((_ sender: Peer, _ resourceName: String, _ progress: Progress) -> Void)? + var didFinishReceivingResource: ((_ sender: Peer, _ resourceName: String, _ result: Result) -> Void)? private var discoveredPeers: [MCPeerID: Peer] = [:] @@ -109,6 +111,44 @@ final class MultipeerConnection: NSObject, MultipeerProtocol { try session.send(data, toPeers: ids, with: .reliable) } + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + func send(_ resourceURL: URL, to peer: Peer) -> MultipeerTransceiver.ResourceUploadStream { + AsyncThrowingStream { [weak self] continuation in + guard let self else { + return continuation.finish(throwing: CancellationError()) + } + + guard resourceURL.isFileURL else { + return continuation.finish(throwing: MultipeerError(localizedDescription: "Resource must be a local file URL, remote URLs are not supported.")) + } + + let peerID = peer.underlyingPeer + + /// Ensure resource names are unique even if the file name is the same, while keeping the file name to aid in debugging. + let resourceID = "\(UUID().uuidString)-\(resourceURL.lastPathComponent)" + + let progress = session.sendResource(at: resourceURL, withName: resourceID, toPeer: peerID) { error in + if let error { + continuation.finish(throwing: error) + } else { + continuation.finish() + } + } + + guard let progress else { + return continuation.finish(throwing: MultipeerError(localizedDescription: "Failed to start resource upload.")) + } + + let cancellable = progress.publisher(for: \.fractionCompleted).sink { progress in + continuation.yield(progress) + } + + continuation.onTermination = { @Sendable _ in + cancellable.cancel() + } + } + } + private var invitationCompletionHandlers: [MCPeerID: InvitationCompletionHandler] = [:] func invite(_ peer: Peer, with context: Data?, timeout: TimeInterval, completion: InvitationCompletionHandler?) { @@ -155,10 +195,11 @@ extension MultipeerConnection: MCSessionDelegate { func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { os_log("%{public}@", log: log, type: .debug, #function) - if let peer = try? Peer(peer: peerID, discoveryInfo: nil) { + do { + let peer = try Peer(peer: peerID, discoveryInfo: nil) didReceiveData?(data, peer) - } else { - os_log("Received data, but cannot create peer for %s", log: log, type: .error, #function, peerID.displayName) + } catch { + os_log("Received data, but cannot create peer for %@: %{public}@", log: log, type: .error, #function, peerID.displayName, String(describing: error)) } } @@ -168,10 +209,33 @@ extension MultipeerConnection: MCSessionDelegate { func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { os_log("%{public}@", log: log, type: .debug, #function) + + do { + let peer = try Peer(peer: peerID, discoveryInfo: nil) + + didStartReceivingResource?(peer, resourceName, progress) + } catch { + os_log("Started receiving resource, but cannot create peer for %@: %{public}@", log: log, type: .error, #function, peerID.displayName, String(describing: error)) + } } func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) { os_log("%{public}@", log: log, type: .debug, #function) + + do { + let peer = try Peer(peer: peerID, discoveryInfo: nil) + + let result: Result + if let localURL { + result = .success(localURL) + } else { + result = .failure(error ?? MultipeerError(localizedDescription: "Unknown MultipeerConnectivity error.")) + } + + didFinishReceivingResource?(peer, resourceName, result) + } catch { + os_log("Finished receiving resource, but cannot create peer for %@: %{public}@", log: log, type: .error, #function, peerID.displayName, String(describing: error)) + } } } diff --git a/Sources/MultipeerKit/Internal API/MultipeerProtocol.swift b/Sources/MultipeerKit/Internal API/MultipeerProtocol.swift index 1612ba3..9f24980 100644 --- a/Sources/MultipeerKit/Internal API/MultipeerProtocol.swift +++ b/Sources/MultipeerKit/Internal API/MultipeerProtocol.swift @@ -9,6 +9,8 @@ protocol MultipeerProtocol: AnyObject { var didLosePeer: ((Peer) -> Void)? { get set } var didConnectToPeer: ((Peer) -> Void)? { get set } var didDisconnectFromPeer: ((Peer) -> Void)? { get set } + var didStartReceivingResource: ((_ sender: Peer, _ resourceName: String, _ progress: Progress) -> Void)? { get set } + var didFinishReceivingResource: ((_ sender: Peer, _ resourceName: String, _ result: Result) -> Void)? { get set } func resume() func stop() @@ -16,7 +18,10 @@ protocol MultipeerProtocol: AnyObject { func invite(_ peer: Peer, with context: Data?, timeout: TimeInterval, completion: InvitationCompletionHandler?) func broadcast(_ data: Data) throws func send(_ data: Data, to peers: [Peer]) throws - + + @available(iOS 13.0, macOS 10.15, *) + func send(_ resourceURL: URL, to peer: Peer) -> MultipeerTransceiver.ResourceUploadStream + func getLocalPeer() -> Peer? } diff --git a/Sources/MultipeerKit/Public API/Models/Peer.swift b/Sources/MultipeerKit/Public API/Models/Peer.swift index e30b100..1bec960 100644 --- a/Sources/MultipeerKit/Public API/Models/Peer.swift +++ b/Sources/MultipeerKit/Public API/Models/Peer.swift @@ -51,3 +51,11 @@ fileprivate extension Data { } } + +public extension Peer { + /// A mock peer that can be used for testing or SwiftUI previews. + static let mock: Peer = { + let underlyingPeer = MCPeerID(displayName: "MockPeer") + return try! Peer(peer: underlyingPeer, discoveryInfo: nil) + }() +} diff --git a/Sources/MultipeerKit/Public API/MultipeerDataSource.swift b/Sources/MultipeerKit/Public API/MultipeerDataSource.swift index 1f7b95e..9c151a6 100644 --- a/Sources/MultipeerKit/Public API/MultipeerDataSource.swift +++ b/Sources/MultipeerKit/Public API/MultipeerDataSource.swift @@ -1,14 +1,18 @@ import Foundation +import Combine + +@available(iOS 14.0, tvOS 14.0, macOS 11.0, *) +public typealias ObservablePeer = MultipeerDataSource.ObservablePeer /// An ObservableObject wrapper around ``MultipeerTransceiver``, /// useful for use with `Combine` and SwiftUI apps. -@available(tvOS 13.0, *) -@available(OSX 10.15, *) -@available(iOS 13.0, *) +@available(iOS 13.0, tvOS 13.0, macOS 10.15, *) public final class MultipeerDataSource: ObservableObject { public let transceiver: MultipeerTransceiver + public static let isSwiftUIPreview: Bool = { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" }() + /// Initializes a new data source. /// - Parameter transceiver: The transceiver to be used by this data source. /// Note that the data source will set ``MultipeerTransceiver/availablePeersDidChange`` on the @@ -22,9 +26,92 @@ public final class MultipeerDataSource: ObservableObject { } availablePeers = transceiver.availablePeers + + #if DEBUG + if Self.isSwiftUIPreview { + availablePeers.append(.mock) + } + #endif } /// Peers currently available for invitation, connection and data transmission. @Published public private(set) var availablePeers: [Peer] = [] + /// Manually invites a peer. + /// + /// For more details, read the documentation for ``MultipeerTransceiver/invite(_:with:timeout:completion:)``. + public func invite(_ peer: Peer, with data: Data? = nil, timeout: TimeInterval = 30) async throws { + try await withCheckedThrowingContinuation { [weak self] (continuation: CheckedContinuation) in + guard let self else { return continuation.resume(throwing: CancellationError()) } + + self.transceiver.invite(peer, with: data, timeout: timeout) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Returns an ``ObservablePeer`` instance that automatically updates when the + /// specified peer is updated, such as when it connects or disconnects. + /// + /// Use an observable peer as the input to a SwiftUI view if you'd like the view to be + /// updated when the state of the observed peer changes, such as when it connects/disconnects from the local peer. + @available(iOS 14.0, tvOS 14.0, macOS 11.0, *) + public func observablePeer(_ peer: Peer) -> ObservablePeer { + ObservablePeer(peer: peer, dataSource: self) + } + + /// Convenient `ObservableObject` that tracks updates to the state of a specific ``Peer``. + @available(iOS 14.0, tvOS 14.0, macOS 11.0, *) + public final class ObservablePeer: ObservableObject { + /// This is updated from the transceiver. + /// If the peer goes away, this may be set to `nil`. + /// Doesn't affect availability of the original `Peer` model to API clients, + /// since that's stored on `init` and provided as fallback. + @Published fileprivate var dynamicPeer: Peer? + + /// The peer that's being observed by the object. + /// + /// - note: Data on the peer might be stale if the remote peer goes away + /// during the lifetime of the observable peer object. For connection state, + /// prefer reading directly from the ``isConnected`` property. + public var observedPeer: Peer { dynamicPeer ?? initialPeer } + + /// The unique identifier for the peer. + public var id: String + + /// The peer's display name. + public var name: String { dynamicPeer?.name ?? initialPeer.name } + + /// Discovery info provided by the peer. + public var discoveryInfo: [String: String]? { dynamicPeer?.discoveryInfo ?? initialPeer.discoveryInfo } + + /// `true` if we are currently connected to this peer. + public var isConnected: Bool { dynamicPeer?.isConnected ?? false } + + private lazy var cancellables = Set() + + private let initialPeer: Peer + + fileprivate init(peer: Peer, dataSource: MultipeerDataSource?) { + self.dynamicPeer = peer + self.initialPeer = peer + self.id = peer.id + + guard let dataSource else { return } + + dataSource + .$availablePeers + .map({ $0.first(where: { $0.id == peer.id }) }) + .removeDuplicates() + .assign(to: &$dynamicPeer) + } + + public static let mock = ObservablePeer(peer: .mock, dataSource: nil) + } + } diff --git a/Sources/MultipeerKit/Public API/MultipeerTransceiver.swift b/Sources/MultipeerKit/Public API/MultipeerTransceiver.swift index 7cf6bde..019702d 100644 --- a/Sources/MultipeerKit/Public API/MultipeerTransceiver.swift +++ b/Sources/MultipeerKit/Public API/MultipeerTransceiver.swift @@ -5,6 +5,27 @@ import os.log /// Handles all aspects related to the multipeer communication. public final class MultipeerTransceiver { + /// Represents events that occur when the local peer is receiving a resource from a remote peer. + /// + /// When a remote peer uploads a file resource to the local peer via ``MultipeerTransceiver/send(_:to:)-592bk``, + /// the local peer receives a callback registered using ``MultipeerTransceiver/receiveResources(using:)``. + /// + /// The callback's argument is a stream of events reporting the resource download progress, then eventually + /// a download completion with either the local URL to the file sent by the remote peer, or an error if the transfer has failed. + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + public enum ResourceDownloadEvent { + /// The resource transfer is in progress. + /// + /// The first associated value is the name of the file being uploaded to the local peer from the remote peer, + /// the second associated value is a percentage from `0.0` to `1.0`. + case progress(_ resourceName: String, _ progress: Double) + /// The resource transfer has completed. + /// + /// The associated value is a result with either the URL to the local file that was transferred from the remote peer, + /// or an error if the transfer has failed. + case completion(Result) + } + private let log = MultipeerKit.log(for: MultipeerTransceiver.self) let connection: MultipeerProtocol @@ -23,7 +44,19 @@ public final class MultipeerTransceiver { /// Called on the main queue when the connection with a peer is interrupted. public var peerDisconnected: (Peer) -> Void = { _ in } - + + /// An `AsyncStream` provided by the transceiver when the local peer is sending a resource to a remote peer. + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + public typealias ResourceUploadStream = AsyncThrowingStream + + /// An `AsyncStream` of ``MultipeerTransceiver/ResourceDownloadEvent``. + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + public typealias ResourceEventStream = AsyncStream + + /// Handles remote resource uploads to the local peer. Registered via ``receiveResources(using:)``. + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + public typealias ResourceEventHandler = (ResourceEventStream) -> Void + /// The current device's peer id public var localPeer: Peer? { return connection.getLocalPeer() @@ -78,6 +111,15 @@ public final class MultipeerTransceiver { connection.didDisconnectFromPeer = { [weak self] peer in DispatchQueue.main.async { self?.handlePeerDisconnected(peer) } } + + if #available(iOS 13.0, tvOS 13.0, macOS 10.15, *) { + connection.didStartReceivingResource = { [weak self] peer, resourceName, progress in + DispatchQueue.main.async { self?.handleResourceReceiveStart(from: peer, resourceName: resourceName, progress: progress) } + } + connection.didFinishReceivingResource = { [weak self] peer, resourceName, result in + DispatchQueue.main.async { self?.handleResourceReceiveFinished(from: peer, resourceName: resourceName, result: result) } + } + } } /// Configures a new handler for a specific `Codable` type. @@ -94,6 +136,32 @@ public final class MultipeerTransceiver { public func receive(_ type: T.Type, using closure: @escaping (_ payload: T, _ sender: Peer) -> Void) { MultipeerMessage.register(type, for: String(describing: type), closure: closure) } + + /// Unregisters a receiver previously registered using ``receive(_:using:)``. + /// - Parameter type: The type of payload to unregister the receiver for. + /// + /// Use this method when you no longer wish to receive callbacks for a specific payload type. + /// + /// In most applications, there will be no need to unregister a receiver, since you can just register the new one + /// by calling ``receive(_:using:)`` again. + public func unregisterReceiver(for type: T.Type) { + + } + + /// Registers a closure to be called whenever a remote peer sends a file resource to the local peer. + /// - Parameter closure: A closure to be called when a remote peer sends a file resource. + /// + /// Use this method to register the local peer to receive file resources from remote peers. + /// + /// Unlike typed payload messages, file resources are designed for transferring potentially large files, + /// and the stream type received in the callback can be used to report progress in your app's UI during transfers. + /// + /// - note: There can only be a single resource receiver registered at any given time with the transceiver. + /// Calling this method again after registering a resource receiver replaces the existing callback. + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + public func receiveResources(using closure: @escaping ResourceEventHandler) { + resourceEventHandler = closure + } /// Resumes the transceiver, allowing this peer to be discovered and to discover remote peers. public func resume() { @@ -139,6 +207,38 @@ public final class MultipeerTransceiver { } } + /// Uploads a file to the remote peer. + /// - Parameters: + /// - resourceURL: URL to the local file that will be uploaded. Remote URLs are not supported. + /// - peer: The remote peer that will receive the uploaded file. + /// - Returns: A stream that produces a new value for each change in upload progress. + /// The stream throws if uploading fails, and terminates when the upload completes successfully. + /// + /// Use `for try await` syntax in order to get updates on the upload, example: + /// + /// ```swift + /// let peer = ... + /// let url = URL(filePath: ...) + /// do { + /// for try await progress in transceiver.send(url, to: peer) { + /// print("Upload progress: \(progress)") + /// } + /// + /// print("Upload finished") + /// } catch { + /// print("Upload failed: \(error)") + /// } + /// ``` + /// + /// - Note: Make sure your app registers for resources by calling ``receiveResources(using:)`` on the transceiver. + /// If MultipeerKit receives a resource from a remote peer without having a resource receiver registered, it throws an assert in debug builds and logs a fault to the console. + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + public func send(_ resourceURL: URL, to peer: Peer) -> MultipeerTransceiver.ResourceUploadStream { + os_log("%{public}@", log: log, type: .debug, #function) + + return connection.send(resourceURL, to: peer) + } + private func handleDataReceived(_ data: Data, from peer: Peer) { os_log("%{public}@", log: log, type: .debug, #function) @@ -165,7 +265,7 @@ public final class MultipeerTransceiver { /// /// - warning: If the invitation parameter is not set to `.none`, you shouldn't call this method, /// since the transceiver does the inviting automatically. - public func invite(_ peer: Peer, with context: Data?, timeout: TimeInterval, completion: InvitationCompletionHandler?) { + public func invite(_ peer: Peer, with context: Data?, timeout: TimeInterval = 30, completion: InvitationCompletionHandler?) { connection.invite(peer, with: context, timeout: timeout, completion: completion) } @@ -203,4 +303,72 @@ public final class MultipeerTransceiver { availablePeers[idx] = mutablePeer } + // MARK: - Resource Support + + /// Storage for `resourceEventHandler` because that must be 10.15 and up only, + /// but stored properties can't have availability annotations. + private var _resourceEventHandler: Any? + + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + private var resourceEventHandler: ResourceEventHandler? { + get { _resourceEventHandler as? ResourceEventHandler } + set { _resourceEventHandler = newValue } + } + + /// Storage for `resourceContinuations`. + private var _resourceContinuations: Any? + + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + private var resourceContinuations: [String: ResourceEventStream.Continuation] { + get { _resourceContinuations as? [String: ResourceEventStream.Continuation] ?? [:] } + set { _resourceContinuations = newValue } + } + + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + private func handleResourceReceiveStart(from peer: Peer, resourceName: String, progress: Progress) { + os_log("Start receiving resource %@ from %@", log: self.log, type: .debug, resourceName, peer.name) + + guard let resourceEventHandler else { + os_log("Received a resource from remote peer, but you haven't configured the transceiver to receive resources. Make sure your app calls receiveResources() before sending a resource from a remote peer to the local peer.", log: self.log, type: .fault) + assertionFailure("Received a resource from remote peer, but you haven't configured the transceiver to receive resources. Make sure your app calls receiveResources() before sending a resource from a remote peer to the local peer.") + return + } + + let (stream, continuation) = ResourceEventStream.makeStream() + resourceContinuations[resourceName] = continuation + + resourceEventHandler(stream) + + /// Yield an initial progress event with 0% + continuation.yield(.progress(resourceName, 0)) + + let cancellable = progress.publisher(for: \.fractionCompleted).sink { fraction in + continuation.yield(.progress(resourceName, fraction)) + } + + continuation.onTermination = { @Sendable [weak self] _ in + guard let self else { return } + cancellable.cancel() + resourceContinuations[resourceName] = nil + } + } + + @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) + private func handleResourceReceiveFinished(from peer: Peer, resourceName: String, result: Result) { + switch result { + case .success(let url): + os_log("Finished receiving resource %@ from %@. Resource is at %@", log: self.log, type: .debug, resourceName, peer.name, url.path) + case .failure(let error): + os_log("Error receiving resource %@ from %@: %{public}@", log: self.log, type: .error, resourceName, peer.name, String(describing: error)) + } + + guard let continuation = resourceContinuations[resourceName] else { + os_log("Received a resource finished event for %@, but couldn't find a continuation for reporting the completion!", log: self.log, type: .fault, resourceName) + return + } + + continuation.yield(.completion(result)) + continuation.finish() + } + } diff --git a/Tests/MultipeerKitTests/MultipeerKitTests.swift b/Tests/MultipeerKitTests/MultipeerKitTests.swift index 2094df8..abb9fa9 100644 --- a/Tests/MultipeerKitTests/MultipeerKitTests.swift +++ b/Tests/MultipeerKitTests/MultipeerKitTests.swift @@ -39,7 +39,6 @@ final class MultipeerKitTests: XCTestCase { mock.receive(TestPayload.self) { payload, sender in XCTAssertEqual(payload, tsPayload) XCTAssertEqual(sender.id, mock.localPeer!.id) - XCTAssertEqual(sender.id, mock.localPeerId!) expect.fulfill() } diff --git a/example/MultipeerKitExample/MultipeerKitExample.xcodeproj/project.pbxproj b/example/MultipeerKitExample/MultipeerKitExample.xcodeproj/project.pbxproj index 8c7eed1..155c8b2 100644 --- a/example/MultipeerKitExample/MultipeerKitExample.xcodeproj/project.pbxproj +++ b/example/MultipeerKitExample/MultipeerKitExample.xcodeproj/project.pbxproj @@ -3,18 +3,24 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ DDADA096240A83B300842749 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDADA095240A83B300842749 /* AppDelegate.swift */; }; DDADA098240A83B300842749 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDADA097240A83B300842749 /* SceneDelegate.swift */; }; - DDADA09A240A83B300842749 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDADA099240A83B300842749 /* ContentView.swift */; }; + DDADA09A240A83B300842749 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDADA099240A83B300842749 /* RootView.swift */; }; DDADA09C240A83B400842749 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDADA09B240A83B400842749 /* Assets.xcassets */; }; DDADA09F240A83B400842749 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDADA09E240A83B400842749 /* Preview Assets.xcassets */; }; DDADA0A2240A83B400842749 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DDADA0A0240A83B400842749 /* LaunchScreen.storyboard */; }; DDADA0AC240A83FF00842749 /* MultipeerKit in Frameworks */ = {isa = PBXBuildFile; productRef = DDADA0AB240A83FF00842749 /* MultipeerKit */; }; - DDADA0AE240A844B00842749 /* ExamplePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDADA0AD240A844B00842749 /* ExamplePayload.swift */; }; + F4BF19E32B03E413004561EA /* PhotoPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BF19E22B03E413004561EA /* PhotoPickerViewModel.swift */; }; + F4BF19E52B03E9CD004561EA /* URL+Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BF19E42B03E9CD004561EA /* URL+Archive.swift */; }; + F4F675DE2B03C38000E3A652 /* ExampleTransceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F675DD2B03C38000E3A652 /* ExampleTransceiver.swift */; }; + F4F675E02B03C51600E3A652 /* PeerSelectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F675DF2B03C51600E3A652 /* PeerSelectionScreen.swift */; }; + F4F675E22B03C54E00E3A652 /* ExamplesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F675E12B03C54E00E3A652 /* ExamplesScreen.swift */; }; + F4F675E42B03C56700E3A652 /* ChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F675E32B03C56700E3A652 /* ChatScreen.swift */; }; + F4F675E62B03C57D00E3A652 /* AirDropScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F675E52B03C57D00E3A652 /* AirDropScreen.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,13 +28,19 @@ DDADA092240A83B300842749 /* MultipeerKitExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MultipeerKitExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; DDADA095240A83B300842749 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; DDADA097240A83B300842749 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - DDADA099240A83B300842749 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + DDADA099240A83B300842749 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; DDADA09B240A83B400842749 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DDADA09E240A83B400842749 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DDADA0A1240A83B400842749 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; DDADA0A3240A83B400842749 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DDADA0A9240A83F800842749 /* MultipeerKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MultipeerKit; path = ../..; sourceTree = ""; }; - DDADA0AD240A844B00842749 /* ExamplePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplePayload.swift; sourceTree = ""; }; + F4BF19E22B03E413004561EA /* PhotoPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerViewModel.swift; sourceTree = ""; }; + F4BF19E42B03E9CD004561EA /* URL+Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Archive.swift"; sourceTree = ""; }; + F4F675DD2B03C38000E3A652 /* ExampleTransceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleTransceiver.swift; sourceTree = ""; }; + F4F675DF2B03C51600E3A652 /* PeerSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerSelectionScreen.swift; sourceTree = ""; }; + F4F675E12B03C54E00E3A652 /* ExamplesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesScreen.swift; sourceTree = ""; }; + F4F675E32B03C56700E3A652 /* ChatScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScreen.swift; sourceTree = ""; }; + F4F675E52B03C57D00E3A652 /* AirDropScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirDropScreen.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -65,10 +77,16 @@ isa = PBXGroup; children = ( DD2DAF67240B04DF003D7A4D /* MultipeerKitExample.entitlements */, - DDADA0AD240A844B00842749 /* ExamplePayload.swift */, DDADA095240A83B300842749 /* AppDelegate.swift */, + F4F675DD2B03C38000E3A652 /* ExampleTransceiver.swift */, + DDADA099240A83B300842749 /* RootView.swift */, + F4F675DF2B03C51600E3A652 /* PeerSelectionScreen.swift */, + F4F675E12B03C54E00E3A652 /* ExamplesScreen.swift */, + F4F675E32B03C56700E3A652 /* ChatScreen.swift */, + F4F675E52B03C57D00E3A652 /* AirDropScreen.swift */, + F4BF19E22B03E413004561EA /* PhotoPickerViewModel.swift */, + F4BF19E42B03E9CD004561EA /* URL+Archive.swift */, DDADA097240A83B300842749 /* SceneDelegate.swift */, - DDADA099240A83B300842749 /* ContentView.swift */, DDADA09B240A83B400842749 /* Assets.xcassets */, DDADA0A0240A83B400842749 /* LaunchScreen.storyboard */, DDADA0A3240A83B400842749 /* Info.plist */, @@ -121,8 +139,9 @@ DDADA08A240A83B300842749 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1140; - LastUpgradeCheck = 1140; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = "Guilherme Rambo"; TargetAttributes = { DDADA091240A83B300842749 = { @@ -167,9 +186,15 @@ buildActionMask = 2147483647; files = ( DDADA096240A83B300842749 /* AppDelegate.swift in Sources */, - DDADA0AE240A844B00842749 /* ExamplePayload.swift in Sources */, + F4F675E62B03C57D00E3A652 /* AirDropScreen.swift in Sources */, + F4BF19E32B03E413004561EA /* PhotoPickerViewModel.swift in Sources */, + F4F675E22B03C54E00E3A652 /* ExamplesScreen.swift in Sources */, + F4BF19E52B03E9CD004561EA /* URL+Archive.swift in Sources */, + F4F675E42B03C56700E3A652 /* ChatScreen.swift in Sources */, + F4F675E02B03C51600E3A652 /* PeerSelectionScreen.swift in Sources */, DDADA098240A83B300842749 /* SceneDelegate.swift in Sources */, - DDADA09A240A83B300842749 /* ContentView.swift in Sources */, + DDADA09A240A83B300842749 /* RootView.swift in Sources */, + F4F675DE2B03C38000E3A652 /* ExampleTransceiver.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -213,6 +238,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -223,6 +249,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -273,6 +300,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -283,6 +311,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -305,13 +334,15 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOL_FRAMEWORKS = SwiftUI; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_ENTITLEMENTS = MultipeerKitExample/MultipeerKitExample.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"MultipeerKitExample/Preview Content\""; DEVELOPMENT_TEAM = 8C7439RJLG; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = MultipeerKitExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -328,13 +359,15 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOL_FRAMEWORKS = SwiftUI; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_ENTITLEMENTS = MultipeerKitExample/MultipeerKitExample.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"MultipeerKitExample/Preview Content\""; DEVELOPMENT_TEAM = 8C7439RJLG; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = MultipeerKitExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/example/MultipeerKitExample/MultipeerKitExample/AirDropScreen.swift b/example/MultipeerKitExample/MultipeerKitExample/AirDropScreen.swift new file mode 100644 index 0000000..27ffbcc --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/AirDropScreen.swift @@ -0,0 +1,358 @@ +import SwiftUI +import MultipeerKit +import PhotosUI + +@available(iOS 17.0, *) +@MainActor +final class AirDropViewModel: ObservableObject { + + let transceiver: MultipeerTransceiver + let remotePeer: Peer + + enum OperationState { + case idle + case progress(String, Double?) + case failure(Error) + case success(String?, [Image]?) + } + + var canUpload: Bool { + switch operationState { + case .idle, .failure, .success: + return true + default: + return false + } + } + + @Published var receivedImages: [Image]? = nil + + @Published private(set) var operationState = OperationState.idle { + didSet { + if case .success(_, let images) = operationState { + self.receivedImages = images + } + } + } + + init(transceiver: MultipeerTransceiver, remotePeer: Peer) { + self.transceiver = transceiver + self.remotePeer = remotePeer + + transceiver.receiveResources { [weak self] stream in + self?.handleReceive(stream) + } + + #if DEBUG + if MultipeerDataSource.isSwiftUIPreview { + self.operationState = .success(nil, [ + Image(.testPhoto1), + Image(.testPhoto2), + Image(.testPhoto3), + Image(.testPhoto4), + ]) + } + #endif + } + + func performUpload(with attachments: [PhotoPickerViewModel.ImageAttachment]) { + Task { + do { + print("Upload requested with \(attachments.count) attachment(s)") + + operationState = .progress("Preparing", nil) + + let temporaryURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("MKUpload-\(UUID().uuidString)") + + try FileManager.default.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + + for attachment in attachments { + guard case .finished(let data, _) = attachment.imageStatus else { + print("WARN: Ignoring image that's not been loaded yet") + continue + } + + let id = UUID().uuidString + let fileURL = temporaryURL + .appendingPathComponent(id) + .appendingPathExtension("jpg") + + print("Copying attachment \(id) to \(fileURL.path)") + + try data.write(to: fileURL, options: .atomic) + } + + print("All attachments copied, preparing archive") + + let compressedURL = temporaryURL + .deletingPathExtension() + .appendingPathExtension("aar") + + try temporaryURL.compress(to: compressedURL) + + print("Compressed archive written to \(compressedURL.path)") + + let stream = transceiver.send(compressedURL, to: remotePeer) + + for try await progress in stream { + operationState = .progress("Uploading", progress) + } + + operationState = .success("Finished Uploading", nil) + + do { + try FileManager.default.removeItem(at: temporaryURL) + try FileManager.default.removeItem(at: compressedURL) + } catch { + print("Cleanup failed: \(error)") + } + } catch { + operationState = .failure(error) + } + } + } + + private func handleReceive(_ stream: MultipeerTransceiver.ResourceEventStream) { + Task { + do { + for try await event in stream { + switch event { + case .progress(_, let progress): + operationState = .progress("Receiving Upload", progress) + case .completion(let result): + let localArchiveURL = try result.get() + + let images = try await handleDownloadedArchive(at: localArchiveURL) + + operationState = .success("Finished Receiving Upload", images) + } + } + } catch { + operationState = .failure(error) + } + } + } + + private func handleDownloadedArchive(at url: URL) async throws -> [Image] { + print("Extracting downloaded archive from \(url.path)") + + let temporaryURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("MKDownload-\(UUID().uuidString)") + + try url.extractDirectory(to: temporaryURL) + + print("Extracted to \(temporaryURL.path)") + + guard let enumerator = FileManager.default.enumerator(at: temporaryURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles, .skipsPackageDescendants], errorHandler: nil) else { + throw Failure("Couldn't enumerate downloaded files") + } + + let images: [Image] = enumerator.allObjects + .compactMap { $0 as? URL } + .compactMap { url in + print("- \(url.path)") + + guard let uiImage = UIImage(contentsOfFile: url.path) else { + print("WARN: Failed to load image at \(url.path)") + return nil + } + + let image = Image(uiImage: uiImage) + + return image + } + + try FileManager.default.removeItem(at: temporaryURL) + + print("Successfully extracted \(images.count) image(s)") + + return images + } + +} + +@available(iOS 17.0, *) +struct AirDropScreen: View { + @StateObject private var photoPickerViewModel = PhotoPickerViewModel() + @StateObject private var viewModel: AirDropViewModel + + init(transceiver: MultipeerTransceiver, peer: Peer) { + let viewModel = AirDropViewModel(transceiver: transceiver, remotePeer: peer) + self._viewModel = .init(wrappedValue: viewModel) + } + + @State private var showingReceivedImages = false + + var body: some View { + VStack(spacing: 24) { + if viewModel.canUpload { + uploader + } + + switch viewModel.operationState { + case .idle: + EmptyView() + case .success(let message, _): + if let message { + Text(message) + .foregroundStyle(.green) + } + case .failure(let error): + Text(String(describing: error)) + .foregroundStyle(.red) + case .progress(let message, let fraction): + VStack { + ProgressView(value: fraction) + + Text(message) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .padding() + .multilineTextAlignment(.center) + .onChange(of: viewModel.receivedImages) { images in + if images != nil { + self.showingReceivedImages = true + } + } + .onAppear { + if viewModel.receivedImages != nil { + self.showingReceivedImages = true + } + } + .inspector(isPresented: .constant(viewModel.canUpload && !showingReceivedImages)) { + PhotosPicker( + selection: $photoPickerViewModel.selection, + + // Enable the app to dynamically respond to user adjustments. + selectionBehavior: .continuousAndOrdered, + matching: .images, + preferredItemEncoding: .compatible, + photoLibrary: .shared() + ) { + Text("Select Photos") + } + + // Configure a half-height Photos picker. + .photosPickerStyle(.inline) + + // Disable the cancel button for an inline use case. + .photosPickerDisabledCapabilities(.selectionActions) + + // Hide padding around all edges in the picker UI. + .photosPickerAccessoryVisibility(.hidden, edges: .all) + .ignoresSafeArea() + } + .overlay { + if let images = viewModel.receivedImages, showingReceivedImages { + receivedImagesOverlay(images) + } + } + .animation(.snappy, value: showingReceivedImages) + } + + @ViewBuilder + private var uploader: some View { + if photoPickerViewModel.attachments.isEmpty { + Text("Select photos to upload") + .font(.headline) + } + ZStack { + ForEach(photoPickerViewModel.attachments.indices, id: \.self) { i in + let attachment = photoPickerViewModel.attachments[i] + ImageAttachmentView(imageAttachment: attachment) + .shadow(radius: 10) + .rotationEffect(.degrees(Double(i) * ((i % 2 == 0) ? 2.5 : -2.5))) + .transition(.scale(scale: 2).combined(with: .opacity)) + .zIndex(Double(i)) + } + } + .padding(.top, 32) + .animation(.bouncy, value: photoPickerViewModel.attachments.count) + + if !photoPickerViewModel.attachments.isEmpty { + Button("Upload") { + viewModel.performUpload(with: photoPickerViewModel.attachments) + } + .buttonStyle(.borderedProminent) + } + } + + @ViewBuilder + private func receivedImagesOverlay(_ images: [Image]) -> some View { + ScrollView(.vertical) { + LazyVGrid(columns: [.init(.adaptive(minimum: 120, maximum: 220), spacing: 16)], spacing: 16) { + ForEach(images.indices, id: \.self) { i in + let image = images[i] + image + .resizable() + .aspectRatio(contentMode: .fit) + .clipped() + } + } + .padding() + } + .safeAreaInset(edge: .top) { + HStack { + Button { + viewModel.receivedImages = nil + showingReceivedImages = false + } label: { + Image(systemName: "xmark.circle.fill") + } + + Text("Received Images") + } + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .padding(.top) + } + .background { + Rectangle() + .foregroundStyle(.thinMaterial) + .ignoresSafeArea() + .transition(.opacity) + } + .transition(.scale(scale: 1.4).combined(with: .opacity)) + } +} + +struct ImageAttachmentView: View { + + /// An image that a person selects in the Photos picker. + @ObservedObject var imageAttachment: PhotoPickerViewModel.ImageAttachment + + /// A container view for the row. + var body: some View { + ZStack { + switch imageAttachment.imageStatus { + case .finished(_, let image): + image + .resizable() + case .failed: + Rectangle() + .fill(.tertiary) + Image(systemName: "exclamationmark.triangle.fill") + default: + Rectangle() + .fill(.tertiary) + ProgressView() + } + } + .aspectRatio(contentMode: .fit) + .frame(height: 100) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .task { await imageAttachment.loadImage() } + } +} + + +@available(iOS 17.0, *) +#Preview { + AirDropScreen(transceiver: .example, peer: .mock) + .environmentObject(MultipeerDataSource.example) +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/AppDelegate.swift b/example/MultipeerKitExample/MultipeerKitExample/AppDelegate.swift index b4554a9..41beb33 100644 --- a/example/MultipeerKitExample/MultipeerKitExample/AppDelegate.swift +++ b/example/MultipeerKitExample/MultipeerKitExample/AppDelegate.swift @@ -31,6 +31,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler([.alert, .sound]) + completionHandler([.banner, .sound, .badge]) } } diff --git a/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-1.imageset/Contents.json b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-1.imageset/Contents.json new file mode 100644 index 0000000..26e0f13 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-1.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "TestPhoto-1.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-1.imageset/TestPhoto-1.jpg b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-1.imageset/TestPhoto-1.jpg new file mode 100644 index 0000000..47e3840 Binary files /dev/null and b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-1.imageset/TestPhoto-1.jpg differ diff --git a/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-2.imageset/Contents.json b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-2.imageset/Contents.json new file mode 100644 index 0000000..d3e70c0 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-2.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "TestPhoto-2.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-2.imageset/TestPhoto-2.jpg b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-2.imageset/TestPhoto-2.jpg new file mode 100644 index 0000000..d3239c3 Binary files /dev/null and b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-2.imageset/TestPhoto-2.jpg differ diff --git a/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-3.imageset/Contents.json b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-3.imageset/Contents.json new file mode 100644 index 0000000..3f9c8a3 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-3.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "TestPhoto-3.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-3.imageset/TestPhoto-3.jpg b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-3.imageset/TestPhoto-3.jpg new file mode 100644 index 0000000..3e1a7d7 Binary files /dev/null and b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-3.imageset/TestPhoto-3.jpg differ diff --git a/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-4.imageset/Contents.json b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-4.imageset/Contents.json new file mode 100644 index 0000000..d667cf2 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-4.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "TestPhoto-4.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-4.imageset/TestPhoto-4.jpg b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-4.imageset/TestPhoto-4.jpg new file mode 100644 index 0000000..9d1ec63 Binary files /dev/null and b/example/MultipeerKitExample/MultipeerKitExample/Assets.xcassets/TestPhoto-4.imageset/TestPhoto-4.jpg differ diff --git a/example/MultipeerKitExample/MultipeerKitExample/ChatScreen.swift b/example/MultipeerKitExample/MultipeerKitExample/ChatScreen.swift new file mode 100644 index 0000000..a3e147a --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/ChatScreen.swift @@ -0,0 +1,97 @@ +import SwiftUI +import MultipeerKit +import Combine + +struct ChatPayload: Codable { + var message: String +} + +final class ChatViewModel: ObservableObject { + + struct Message: Identifiable, Hashable { + var id = UUID() + var date = Date() + var senderName: String + var text: String + } + + @Published private(set) var messages = [Message]() + + let transceiver: MultipeerTransceiver + let remotePeer: Peer + + init(transceiver: MultipeerTransceiver, remotePeer: Peer) { + self.transceiver = transceiver + self.remotePeer = remotePeer + + transceiver.receive(ChatPayload.self) { [weak self] payload, sender in + guard let self = self else { return } + + let message = Message(senderName: sender.name, text: payload.message) + + messages.insert(message, at: 0) + } + } + + func send(_ text: String) { + let localMessage = Message(senderName: "You Sent", text: text) + messages.insert(localMessage, at: 0) + + let payload = ChatPayload(message: text) + transceiver.send(payload, to: [remotePeer]) + } + +} + +struct ChatScreen: View { + @StateObject private var viewModel: ChatViewModel + + init(transceiver: MultipeerTransceiver, peer: Peer) { + let viewModel = ChatViewModel(transceiver: transceiver, remotePeer: peer) + self._viewModel = .init(wrappedValue: viewModel) + } + + @State private var messageText = "" + + var body: some View { + List(viewModel.messages) { message in + VStack(alignment: .leading) { + Text(message.senderName) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.tertiary) + Text(message.text) + } + .listRowSeparator(.hidden) + } + .listStyle(.inset) + .animation(.default, value: viewModel.messages.count) + .safeAreaInset(edge: .bottom) { + VStack(alignment: .leading) { + Text("Send Message") + .font(.headline) + HStack { + TextField("Message", text: $messageText) + .onSubmit(send) + .textFieldStyle(.roundedBorder) + Button("Send", action: send) + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + } + .padding() + .background(Material.thick) + .overlay(alignment: .top) { Divider() } + } + .navigationTitle(Text("Chat")) + } + + private func send() { + guard !messageText.isEmpty else { return } + viewModel.send(messageText) + messageText = "" + } +} + +#Preview { + ChatScreen(transceiver: .example, peer: .mock) +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/ContentView.swift b/example/MultipeerKitExample/MultipeerKitExample/ContentView.swift deleted file mode 100644 index 95fccb0..0000000 --- a/example/MultipeerKitExample/MultipeerKitExample/ContentView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// ContentView.swift -// MultipeerKitExample -// -// Created by Guilherme Rambo on 29/02/20. -// Copyright © 2020 Guilherme Rambo. All rights reserved. -// - -import SwiftUI -import MultipeerKit -import Combine - -final class ViewModel: ObservableObject { - @Published var message: String = "" - @Published var selectedPeers: [Peer] = [] - - func toggle(_ peer: Peer) { - if selectedPeers.contains(peer) { - selectedPeers.remove(at: selectedPeers.firstIndex(of: peer)!) - } else { - selectedPeers.append(peer) - } - } -} - -struct ContentView: View { - @ObservedObject private(set) var viewModel = ViewModel() - @EnvironmentObject var dataSource: MultipeerDataSource - - @State private var showErrorAlert = false - - var body: some View { - VStack { - Form { - TextField("Message", text: $viewModel.message) - - Button(action: { self.sendToSelectedPeers(self.viewModel.message) }) { - Text("SEND") - } - } - - VStack(alignment: .leading) { - Text("Peers").font(.system(.headline)).padding() - - List { - ForEach(dataSource.availablePeers) { peer in - HStack { - Circle() - .frame(width: 12, height: 12) - .foregroundColor(peer.isConnected ? .green : .gray) - - Text(peer.name) - - Spacer() - - if self.viewModel.selectedPeers.contains(peer) { - Image(systemName: "checkmark") - } - }.onTapGesture { - self.viewModel.toggle(peer) - } - } - } - } - }.alert(isPresented: $showErrorAlert) { - Alert(title: Text("Please select a peer"), message: nil, dismissButton: nil) - } - } - - func sendToSelectedPeers(_ message: String) { - guard !self.viewModel.selectedPeers.isEmpty else { - showErrorAlert = true - return - } - - let payload = ExamplePayload(message: self.viewModel.message) - dataSource.transceiver.send(payload, to: viewModel.selectedPeers) - } -} - diff --git a/example/MultipeerKitExample/MultipeerKitExample/ExamplePayload.swift b/example/MultipeerKitExample/MultipeerKitExample/ExamplePayload.swift deleted file mode 100644 index 047fef8..0000000 --- a/example/MultipeerKitExample/MultipeerKitExample/ExamplePayload.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// ExamplePayload.swift -// MultipeerKitExample -// -// Created by Guilherme Rambo on 29/02/20. -// Copyright © 2020 Guilherme Rambo. All rights reserved. -// - -import Foundation - -struct ExamplePayload: Hashable, Codable { - let message: String -} diff --git a/example/MultipeerKitExample/MultipeerKitExample/ExampleTransceiver.swift b/example/MultipeerKitExample/MultipeerKitExample/ExampleTransceiver.swift new file mode 100644 index 0000000..a0284e0 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/ExampleTransceiver.swift @@ -0,0 +1,21 @@ +import SwiftUI +import MultipeerKit + +extension MultipeerTransceiver { + static let example: MultipeerTransceiver = { + var config = MultipeerConfiguration.default + config.serviceType = "MPKitDemo" + + config.security.encryptionPreference = .none + + let t = MultipeerTransceiver(configuration: config) + + return t + }() +} + +extension MultipeerDataSource { + static let example: MultipeerDataSource = { + MultipeerDataSource(transceiver: .example) + }() +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/ExamplesScreen.swift b/example/MultipeerKitExample/MultipeerKitExample/ExamplesScreen.swift new file mode 100644 index 0000000..2913069 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/ExamplesScreen.swift @@ -0,0 +1,41 @@ +import SwiftUI +import MultipeerKit + +struct ExamplesScreen: View { + @EnvironmentObject private var dataSource: MultipeerDataSource + @StateObject var peer: MultipeerDataSource.ObservablePeer + + var body: some View { + TabView { + ChatScreen(transceiver: dataSource.transceiver, peer: peer.observedPeer) + .tabItem { + Label("Chat", systemImage: "bubble.fill") + } + Group { + if #available(iOS 17.0, *) { + AirDropScreen(transceiver: dataSource.transceiver, peer: peer.observedPeer) + } else { + Text("This demo requires iOS 17 or later.") + .foregroundStyle(.secondary) + .navigationTitle(Text("AirDrop")) + } + } + .tabItem { + Label("AirDrop", systemImage: "dot.radiowaves.right") + } + } + .navigationTitle(Text(peer.name)) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + PeerStateIndicator(peer: peer) + } + } + } +} + +#Preview { + NavigationStack { + ExamplesScreen(peer: .mock) + } + .environmentObject(MultipeerDataSource.example) +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/PeerSelectionScreen.swift b/example/MultipeerKitExample/MultipeerKitExample/PeerSelectionScreen.swift new file mode 100644 index 0000000..e4b8d76 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/PeerSelectionScreen.swift @@ -0,0 +1,68 @@ +import SwiftUI +import MultipeerKit + +struct PeerSelectionScreen: View { + @EnvironmentObject private var dataSource: MultipeerDataSource + + var body: some View { + List{ + Section { + ForEach(dataSource.availablePeers) { peer in + NavigationLink(value: peer) { + PeerListItem(peer: peer) + } + } + } footer: { + Text("Choose a remote device to communicate with.") + } + } + .overlay { + if dataSource.availablePeers.isEmpty { + ProgressOverlay(message: "Looking for peers") + } + } + .animation(.default, value: dataSource.availablePeers.isEmpty) + } +} + +struct ProgressOverlay: View { + var message: LocalizedStringKey + + var body: some View { + VStack(spacing: 6) { + ProgressView() + Text(message) + .foregroundStyle(.secondary) + .font(.headline) + } + .transition(.scale(scale: 1.5).combined(with: .opacity)) + } +} + +struct PeerStateIndicator: View { + @StateObject var peer: ObservablePeer + + var body: some View { + Circle() + .fill(peer.isConnected ? .green : .gray) + .frame(width: 8, height: 8) + } +} + +struct PeerListItem: View { + @EnvironmentObject private var dataSource: MultipeerDataSource + var peer: Peer + + var body: some View { + HStack { + PeerStateIndicator(peer: dataSource.observablePeer(peer)) + + Text(peer.name) + } + } +} + +#Preview { + PeerSelectionScreen() + .environmentObject(MultipeerDataSource.example) +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/PhotoPickerViewModel.swift b/example/MultipeerKitExample/MultipeerKitExample/PhotoPickerViewModel.swift new file mode 100644 index 0000000..eda601b --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/PhotoPickerViewModel.swift @@ -0,0 +1,124 @@ +/** + Copyright © 2023 Apple Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** +Abstract: +A class that responds to Photos picker events. +*/ + +import SwiftUI +import PhotosUI + +/// A view model that integrates a Photos picker. +@MainActor final class PhotoPickerViewModel: ObservableObject { + + /// A class that manages an image that a person selects in the Photos picker. + @MainActor final class ImageAttachment: ObservableObject, Identifiable { + + /// Statuses that indicate the app's progress in loading a selected photo. + enum Status { + + /// A status indicating that the app has requested a photo. + case loading + + /// A status indicating that the app has loaded a photo. + case finished(Data, Image) + + /// A status indicating that the photo has failed to load. + case failed(Error) + + /// Determines whether the photo has failed to load. + var isFailed: Bool { + return switch self { + case .failed: true + default: false + } + } + } + + /// An error that indicates why a photo has failed to load. + enum LoadingError: Error { + case contentTypeNotSupported + } + + /// A reference to a selected photo in the picker. + private let pickerItem: PhotosPickerItem + + /// A load progress for the photo. + @Published var imageStatus: Status? + + /// A textual description for the photo. + @Published var imageDescription: String = "" + + /// An identifier for the photo. + nonisolated var id: String { + pickerItem.identifier + } + + /// Creates an image attachment for the given picker item. + init(_ pickerItem: PhotosPickerItem) { + self.pickerItem = pickerItem + } + + /// Loads the photo that the picker item features. + func loadImage() async { + guard imageStatus == nil || imageStatus?.isFailed == true else { + return + } + imageStatus = .loading + do { + if let data = try await pickerItem.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) { + imageStatus = .finished(data, Image(uiImage: uiImage)) + } else { + throw LoadingError.contentTypeNotSupported + } + } catch { + imageStatus = .failed(error) + } + } + } + + /// An array of items for the picker's selected photos. + /// + /// On set, this method updates the image attachments for the current selection. + @Published var selection = [PhotosPickerItem]() { + didSet { + // Update the attachments according to the current picker selection. + let newAttachments = selection.map { item in + // Access an existing attachment, if it exists; otherwise, create a new attachment. + attachmentByIdentifier[item.identifier] ?? ImageAttachment(item) + } + // Update the saved attachments array for any new attachments loaded in scope. + let newAttachmentByIdentifier = newAttachments.reduce(into: [:]) { partialResult, attachment in + partialResult[attachment.id] = attachment + } + // To support asynchronous access, assign new arrays to the instance properties rather than updating the existing arrays. + attachments = newAttachments + attachmentByIdentifier = newAttachmentByIdentifier + } + } + + /// An array of image attachments for the picker's selected photos. + @Published var attachments = [ImageAttachment]() + + /// A dictionary that stores previously loaded attachments for performance. + private var attachmentByIdentifier = [String: ImageAttachment]() +} + +/// A extension that handles the situation in which a picker item lacks a photo library. +private extension PhotosPickerItem { + var identifier: String { + guard let identifier = itemIdentifier else { + fatalError("The photos picker lacks a photo library.") + } + return identifier + } +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/RootView.swift b/example/MultipeerKitExample/MultipeerKitExample/RootView.swift new file mode 100644 index 0000000..cf2ac15 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/RootView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import MultipeerKit + +struct RootView: View { + @EnvironmentObject var dataSource: MultipeerDataSource + + var body: some View { + NavigationStack { + PeerSelectionScreen() + .navigationTitle(Text("MultipeerKit")) + .navigationDestination(for: Peer.self) { peer in + ExamplesScreen(peer: dataSource.observablePeer(peer)) + .navigationTitle(Text(peer.name)) + } + } + } +} + +#Preview { + RootView() + .environmentObject(MultipeerDataSource.example) +} diff --git a/example/MultipeerKitExample/MultipeerKitExample/SceneDelegate.swift b/example/MultipeerKitExample/MultipeerKitExample/SceneDelegate.swift index 9de1890..cdb0fde 100644 --- a/example/MultipeerKitExample/MultipeerKitExample/SceneDelegate.swift +++ b/example/MultipeerKitExample/MultipeerKitExample/SceneDelegate.swift @@ -1,102 +1,43 @@ -// -// SceneDelegate.swift -// MultipeerKitExample -// -// Created by Guilherme Rambo on 29/02/20. -// Copyright © 2020 Guilherme Rambo. All rights reserved. -// - import UIKit import SwiftUI import MultipeerKit -import UserNotifications import Security class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private lazy var transceiver: MultipeerTransceiver = { - var config = MultipeerConfiguration.default - config.serviceType = "MPKitDemo" - - config.security.encryptionPreference = .required - - let t = MultipeerTransceiver(configuration: config) - - t.receive(ExamplePayload.self) { [weak self] payload, peer in - print("Got payload: \(payload)") - - self?.notify(with: payload, peer: peer) - } + private let transceiver = MultipeerTransceiver.example - return t - }() - - private lazy var dataSource: MultipeerDataSource = { - MultipeerDataSource(transceiver: transceiver) - }() - - private func notify(with payload: ExamplePayload, peer: Peer) { - let content = UNMutableNotificationContent() - content.body = "\"\(payload.message)\" from \(peer.name)" - let request = UNNotificationRequest(identifier: payload.message, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) { _ in - - } - } + private lazy var dataSource = MultipeerDataSource.example func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + activateTransceiver() - transceiver.resume() - - // Create the SwiftUI view that provides the window contents. - let contentView = ContentView().environmentObject(dataSource) - - // Use a UIHostingController as window root view controller. - if let windowScene = scene as? UIWindowScene { - let window = UIWindow(windowScene: windowScene) - window.rootViewController = UIHostingController(rootView: contentView) - self.window = window - window.makeKeyAndVisible() - } + let rootView = RootView() + .environmentObject(dataSource) - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in - - } + guard let windowScene = scene as? UIWindowScene else { fatalError() } + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = UIHostingController(rootView: rootView) + self.window = window + window.makeKeyAndVisible() } - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). + private func activateTransceiver() { + transceiver.resume() } - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } + func sceneDidDisconnect(_ scene: UIScene) { } - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } + func sceneDidBecomeActive(_ scene: UIScene) { } - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } + func sceneWillResignActive(_ scene: UIScene) { } - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } + func sceneWillEnterForeground(_ scene: UIScene) { } + func sceneDidEnterBackground(_ scene: UIScene) { } } diff --git a/example/MultipeerKitExample/MultipeerKitExample/URL+Archive.swift b/example/MultipeerKitExample/MultipeerKitExample/URL+Archive.swift new file mode 100644 index 0000000..d06f3c8 --- /dev/null +++ b/example/MultipeerKitExample/MultipeerKitExample/URL+Archive.swift @@ -0,0 +1,213 @@ +import Foundation +import AppleArchive +import System + +struct Failure: LocalizedError { + var errorDescription: String? + init(_ errorDescription: String) { + self.errorDescription = errorDescription + } +} + +public extension URL { + + func compress(to outputURL: URL, using algorithm: ArchiveCompression = .lzfse) throws { + var isDir = ObjCBool.init(false) + + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { + throw Failure("File doesn't exist at \(path)") + } + + if isDir.boolValue { + try compressDirectory(to: outputURL, using: algorithm) + } else { + try compressFile(to: outputURL, using: algorithm) + } + } + +} + +extension URL { + + func compressFile(to outputURL: URL, using algorithm: ArchiveCompression) throws { + let sourceFilePath = FilePath(self.path) + + guard let readFileStream = ArchiveByteStream.fileStream( + path: sourceFilePath, + mode: .readOnly, + options: [ ], + permissions: FilePermissions(rawValue: 0o644)) + else { + throw Failure("Failed to create file stream for reading") + } + defer { try? readFileStream.close() } + + let archiveFilePath = FilePath(outputURL.path) + + guard let writeFileStream = ArchiveByteStream.fileStream( + path: archiveFilePath, + mode: .writeOnly, + options: [ .create ], + permissions: FilePermissions(rawValue: 0o644)) else + { + throw Failure("Failed to create file stream for writing") + } + defer { try? writeFileStream.close() } + + guard let compressStream = ArchiveByteStream.compressionStream( + using: algorithm, + writingTo: writeFileStream) + else { + throw Failure("Failed to create compression stream") + } + defer { try? compressStream.close() } + + do { + _ = try ArchiveByteStream.process( + readingFrom: readFileStream, + writingTo: compressStream + ) + } catch { + throw Failure("Failed to compress the file: \(error)") + } + } + + func extractFile(to outputURL: URL) throws { + let archiveFilePath = FilePath(self.path) + + guard let readFileStream = ArchiveByteStream.fileStream( + path: archiveFilePath, + mode: .readOnly, + options: [ ], + permissions: FilePermissions(rawValue: 0o644)) + else { + throw Failure("Failed to create file stream for reading") + } + defer { try? readFileStream.close() } + + let destinationFilePath = FilePath(outputURL.path) + + guard let writeFileStream = ArchiveByteStream.fileStream( + path: destinationFilePath, + mode: .writeOnly, + options: [ .create ], + permissions: FilePermissions(rawValue: 0o644)) + else { + throw Failure("Failed to create file stream for writing") + } + defer { try? writeFileStream.close() } + + guard let decompressStream = ArchiveByteStream.decompressionStream(readingFrom: readFileStream) else { + throw Failure("Failed to create decompression stream") + } + defer { try? decompressStream.close() } + + do { + _ = try ArchiveByteStream.process( + readingFrom: decompressStream, + writingTo: writeFileStream + ) + } catch { + throw Failure("Extraction failed: \(error)") + } + } + + func compressDirectory(to outputURL: URL, using algorithm: ArchiveCompression) throws { + let archiveFilePath = FilePath(outputURL.path) + + guard let writeFileStream = ArchiveByteStream.fileStream( + path: archiveFilePath, + mode: .writeOnly, + options: [ .create ], + permissions: FilePermissions(rawValue: 0o644)) + else { + throw Failure("Failed to create file stream") + } + + defer { try? writeFileStream.close() } + + guard let compressStream = ArchiveByteStream.compressionStream( + using: algorithm, + writingTo: writeFileStream) + else { + throw Failure("Failed to create compression stream") + } + defer { try? compressStream.close() } + + guard let encodeStream = ArchiveStream.encodeStream(writingTo: compressStream) else { + throw Failure("Failed to create encode stream") + } + defer { try? encodeStream.close() } + + guard let keySet = ArchiveHeader.FieldKeySet("TYP,PAT,LNK,DEV,DAT,UID,GID,MOD,FLG,MTM,BTM,CTM") else { + throw Failure("Failed to create key set") + } + + let source = FilePath(self.path) + let parent = source.removingLastComponent() + + guard let sourceDirComponent = source.lastComponent else { + throw Failure("Couldn't find source directory component") + } + + do { + try encodeStream.writeDirectoryContents( + archiveFrom: parent, + path: FilePath(sourceDirComponent.string), + keySet: keySet + ) + } catch { + throw Failure("Failed to write the archive: \(error)") + } + } + + func extractDirectory(to outputURL: URL) throws { + let archiveFilePath = FilePath(self.path) + + guard let readFileStream = ArchiveByteStream.fileStream( + path: archiveFilePath, + mode: .readOnly, + options: [ ], + permissions: FilePermissions(rawValue: 0o644)) else + { + throw Failure("Failed to create file stream for reading") + } + defer { try? readFileStream.close() } + + guard let decompressStream = ArchiveByteStream.decompressionStream(readingFrom: readFileStream) else { + throw Failure("Failed to create decompression stream") + } + defer { try? decompressStream.close() } + + guard let decodeStream = ArchiveStream.decodeStream(readingFrom: decompressStream) else { + throw Failure("Failed to create decode stream") + } + defer { try? decodeStream.close() } + + if !FileManager.default.fileExists(atPath: outputURL.path) { + do { + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + } catch { + throw Failure("Failed to create output directory: \(error)") + } + } + + let decompressDestination = FilePath(outputURL.path) + + guard let extractStream = ArchiveStream.extractStream( + extractingTo: decompressDestination, + flags: [ .ignoreOperationNotPermitted ] + ) + else { + throw Failure("Failed to create extract stream") + } + defer { try? extractStream.close() } + + do { + _ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: extractStream) + } catch { + throw Failure("Extraction failed: \(error)") + } + } + +}