-
-
Save krzysztofzablocki/a502f984dbe939723b9f3186706c926b to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import RemoteAssetManager | |
import UIKit | |
import Combine | |
struct RemoteAssetConfiguration<Type> { | |
var bundle: URL | |
var remote: URL | |
var materializer: Materializer<Type> | |
} | |
enum RemoteAsset { | |
enum Configuration { | |
#if DEBUG | |
static var dataProvider: DataProvider = .dataTask(.shared).autoRefresh(interval: 1/60) | |
#else | |
static var dataProvider: DataProvider = .dataTask(.shared).autoRefresh(interval: 3600) | |
#endif | |
static var cacheDirectory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] | |
} | |
static var icon = RemoteAssetConfiguration( | |
bundle: Bundle.main.url(forResource: "Icon", withExtension: "png")!, | |
remote: URL(fileURLWithPath: "/Users/merowing/Downloads/Icon.png"), | |
materializer: .image.swiftUI | |
) | |
} | |
extension RemoteAssetConfiguration { | |
var manager: RemoteAssetManager<Type> { | |
try! .init( | |
baseAsset: bundle, | |
cacheDirectory: RemoteAsset.Configuration.cacheDirectory, | |
remoteAsset: remote, | |
materialize: materializer, | |
dataProvider: RemoteAsset.Configuration.dataProvider | |
) | |
} | |
} | |
struct ContentView: View { | |
@ObservedObject | |
private var remoteImage = RemoteAsset.icon.manager | |
var body: some View { | |
remoteImage.asset | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import UIKit | |
import SwiftUI | |
public struct NotMaterializable: Error {} | |
extension Materializer { | |
public static var image: Materializer<UIImage> { | |
Materializer<UIImage> { data -> UIImage in | |
guard let img = UIImage(data: data) else { | |
throw NotMaterializable() | |
} | |
return img | |
} | |
} | |
} | |
extension Materializer where Type == UIImage { | |
public var swiftUI: Materializer<Image> { | |
.init { data in | |
Image(uiImage: try self.closure(data)) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import Combine | |
public extension DataProvider { | |
/// Provides data using `dataTaskPublisher` on the provided `session` | |
static func dataTask(_ session: URLSession) -> Self { | |
DataProvider { url in | |
session.dataTaskPublisher(for: url) | |
.map { Optional<Data>.some($0.data) } | |
.catch { _ in | |
Just(Optional<Data>.none) | |
} | |
.compactMap { $0 } | |
.receive(on: DispatchQueue.main) | |
.eraseToAnyPublisher() | |
} | |
} | |
func autoRefresh(interval: TimeInterval) -> DataProvider { | |
DataProvider { url in | |
Timer.publish(every: 1/60, on: .main, in: .common) | |
.autoconnect() | |
.flatMap { _ in | |
self.closure(url) | |
} | |
.eraseToAnyPublisher() | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import Combine | |
import SwiftUI | |
private let appVersionAttribute = "RemoteAssetManager_AppVersion" | |
public struct DataProvider { | |
let closure: (URL) -> AnyPublisher<Data, Never> | |
func callAsFunction(_ url: URL) -> AnyPublisher<Data, Never> { | |
closure(url) | |
} | |
public init(closure: @escaping (URL) -> AnyPublisher<Data, Never>) { | |
self.closure = closure | |
} | |
} | |
public struct Materializer<Type> { | |
let closure: (Data) throws -> Type | |
func callAsFunction(_ data: Data) throws -> Type { | |
try closure(data) | |
} | |
public init(closure: @escaping (Data) throws -> Type) { | |
self.closure = closure | |
} | |
} | |
/// Class for managing assets that are provided in Bundle but updatable remotely | |
/// Notable behaviour: | |
/// - Asset is first fetched from the bundle | |
/// - Manager tries to fetch updated asset from network | |
/// - If the remote asset is validated it replaces the bundle asset | |
public class RemoteAssetManager<Type>: ObservableObject { | |
private let appVersion: String | |
private let baseAsset: URL | |
private let cachedPath: URL | |
private let remoteAsset: URL | |
private let materialize: Materializer<Type> | |
private let dataProvider: DataProvider | |
private var cancellables: Set<AnyCancellable> = [] | |
private let current: CurrentValueSubject<Type, Never> | |
public var asset: Type { | |
current.value | |
} | |
public init( | |
baseAsset: URL, | |
cacheDirectory: URL, | |
remoteAsset: URL, | |
materialize: Materializer<Type>, | |
dataProvider: DataProvider | |
) throws { | |
let releaseVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String | |
let buildVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String | |
self.appVersion = "\(releaseVersion ?? "")_\(buildVersion ?? "")" | |
self.baseAsset = baseAsset | |
self.remoteAsset = remoteAsset | |
let path = cacheDirectory.appendingPathComponent("RemoteAssetManager_\(Type.self)_\((baseAsset.path as NSString).lastPathComponent)") | |
self.cachedPath = path | |
self.materialize = materialize | |
self.dataProvider = dataProvider | |
try Self.copyFromBundleIfNeeded(baseAsset, cachedPath: path, appVersion: appVersion) | |
self.current = .init(try Self.materializeExisting(cachedPath, materialize: materialize)) | |
subscribeForUpdates() | |
} | |
private func subscribeForUpdates() { | |
dataProvider(remoteAsset) | |
.tryMap { [unowned self] data in | |
let materialized = try materialize(data) | |
try data.write(to: cachedPath, options: .atomic) | |
if let data = appVersion.data(using: .utf8) { | |
try cachedPath.setExtendedAttribute(data: data, forName: appVersionAttribute) | |
} | |
return materialized | |
} | |
.catch { _ in | |
Just(Optional<Type>.none) | |
} | |
.compactMap { $0 } | |
.sink { [unowned self] value in | |
self.objectWillChange.send() | |
self.current.send(value) | |
} | |
.store(in: &cancellables) | |
} | |
// TODO: If an app version changed, always copy from bundle first! | |
private static func copyFromBundleIfNeeded(_ basePath: URL, cachedPath: URL, appVersion: String) throws { | |
var fileExists = FileManager.default.fileExists(atPath: cachedPath.path) | |
if fileExists, String(data: try cachedPath.extendedAttribute(forName: appVersionAttribute), encoding: .utf8) != appVersion { | |
// version changed, remove old file | |
try FileManager.default.removeItem(at: cachedPath) | |
fileExists = false | |
} | |
if !fileExists { | |
try FileManager.default.copyItem(at: basePath, to: cachedPath) | |
} | |
if let data = appVersion.data(using: .utf8) { | |
try cachedPath.setExtendedAttribute(data: data, forName: appVersionAttribute) | |
} | |
} | |
private static func materializeExisting(_ cachedPath: URL, materialize: Materializer<Type>) throws -> Type { | |
let data = try Data(contentsOf: cachedPath) | |
return try materialize(data) | |
} | |
} | |
// Source: https://stackoverflow.com/questions/38343186/write-extend-file-attributes-swift-example | |
extension URL { | |
/// Get extended attribute. | |
func extendedAttribute(forName name: String) throws -> Data { | |
let data = try self.withUnsafeFileSystemRepresentation { fileSystemPath -> Data in | |
// Determine attribute size: | |
let length = getxattr(fileSystemPath, name, nil, 0, 0, 0) | |
guard length >= 0 else { throw URL.posixError(errno) } | |
// Create buffer with required size: | |
var data = Data(count: length) | |
// Retrieve attribute: | |
let result = data.withUnsafeMutableBytes { [count = data.count] in | |
getxattr(fileSystemPath, name, $0.baseAddress, count, 0, 0) | |
} | |
guard result >= 0 else { throw URL.posixError(errno) } | |
return data | |
} | |
return data | |
} | |
/// Set extended attribute. | |
func setExtendedAttribute(data: Data, forName name: String) throws { | |
try self.withUnsafeFileSystemRepresentation { fileSystemPath in | |
let result = data.withUnsafeBytes { | |
setxattr(fileSystemPath, name, $0.baseAddress, data.count, 0, 0) | |
} | |
guard result >= 0 else { throw URL.posixError(errno) } | |
} | |
} | |
/// Helper function to create an NSError from a Unix errno. | |
private static func posixError(_ err: Int32) -> NSError { | |
return NSError(domain: NSPOSIXErrorDomain, code: Int(err), | |
userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(err))]) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment