Skip to content

Instantly share code, notes, and snippets.

@krzysztofzablocki
Last active March 17, 2024 16:23
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save krzysztofzablocki/a502f984dbe939723b9f3186706c926b to your computer and use it in GitHub Desktop.
Save krzysztofzablocki/a502f984dbe939723b9f3186706c926b to your computer and use it in GitHub Desktop.
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
}
}
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))
}
}
}
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()
}
}
}
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