Last active
June 7, 2021 21:55
-
-
Save tadija/55377d1211d0dc0bc2da661a455fc7c6 to your computer and use it in GitHub Desktop.
Env
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
/** | |
* https://gist.github.com/tadija/55377d1211d0dc0bc2da661a455fc7c6 | |
* Revision 3 | |
* Copyright © 2021 Marko Tadić | |
* Licensed under the MIT license | |
*/ | |
import Foundation | |
/// A namespace that encapsulates the shared "environment" of an app. | |
/// | |
/// This type only implements various other types which can be useful in its context. | |
/// It's up to you to build up a custom "environment" that best suits your needs. | |
/// | |
/// Here's an example of a possible implementation: | |
/// | |
/// public extension Env { | |
/// | |
/// static let device = Device() | |
/// | |
/// static let info = Info() | |
/// | |
/// static let version = Version(current: .init(info.version)) | |
/// | |
/// static var description: String { | |
/// """ | |
/// + device \n\(device)\n | |
/// + info \n\(info)\n | |
/// + version \n\(version)\n | |
/// """ | |
/// } | |
/// | |
/// } | |
/// | |
/// Printing the above `description` would make this kind of output: | |
/// | |
/// ``` | |
/// + device | |
/// model: x86_64 | |
/// kind: mac | |
/// platform: macOS | |
/// os version: 11.4.0 | |
/// simulator: false | |
/// | |
/// + info | |
/// product: MyApp | |
/// version (build): 0.1.1 (1) | |
/// bundle id: net.tadija.my-app | |
/// | |
/// + version | |
/// version: 0.1.1 | |
/// history: [0.1.0] | |
/// state: update(from: 0.1.0, to: 0.1.1) | |
/// ``` | |
/// | |
/// For more examples, check out other available types: | |
/// - `Device` | |
/// - `Info` | |
/// - `Version` | |
/// - `AppGroup` | |
/// | |
public enum Env {} | |
// MARK: - Device | |
public extension Env { | |
/// A collection of information about the current device. | |
/// | |
/// Sometimes it's needed to tweak something based on the device kind, | |
/// its platform, operating system version, simulator etc. | |
/// | |
/// This utility gives you a strongly typed access to this kind of information (and more): | |
/// | |
/// public enum Kind: String { | |
/// case mac, iPhone, iPad, iPod, watch, tv, unknown | |
/// } | |
/// | |
/// public enum Platform: String { | |
/// case macOS, macCatalyst, iOS, watchOS, tvOS, linux, unknown | |
/// } | |
/// | |
struct Device: CustomStringConvertible { | |
public enum Kind: String { | |
case mac, iPhone, iPad, iPod, watch, tv, unknown | |
} | |
public enum Platform: String { | |
case macOS, macCatalyst, iOS, watchOS, tvOS, linux, unknown | |
} | |
public let model: String = modelIdentifier() | |
public let kind: Kind = kind() | |
public let platform: Platform = platform() | |
public let osVersion: String = osVersion() | |
public let isSimulator: Bool = isSimulator() | |
public init() {} | |
public var description: String { | |
""" | |
model: \(model) | |
kind: \(kind) | |
platform: \(platform) | |
os version: \(osVersion) | |
simulator: \(isSimulator) | |
""" | |
} | |
private static func modelIdentifier() -> String { | |
if let simulatorID = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] { | |
return simulatorID | |
} else { | |
var sysinfo = utsname() | |
uname(&sysinfo) | |
return String( | |
bytes: Data(bytes: &sysinfo.machine, count: Int(_SYS_NAMELEN)), | |
encoding: .ascii | |
)?.trimmingCharacters(in: .controlCharacters) ?? "" | |
} | |
} | |
private static func kind() -> Kind { | |
switch platform() { | |
case .macOS, .macCatalyst: | |
return .mac | |
case .watchOS: | |
return .watch | |
case .tvOS: | |
return .tv | |
case .iOS: | |
switch modelIdentifier() { | |
case let id where id.contains(Kind.iPhone.rawValue): | |
return .iPhone | |
case let id where id.contains(Kind.iPad.rawValue): | |
return .iPad | |
case let id where id.contains(Kind.iPod.rawValue): | |
return .iPod | |
default: | |
return .unknown | |
} | |
case .linux, .unknown: | |
return .unknown | |
} | |
} | |
private static func platform() -> Platform { | |
#if os(macOS) | |
return .macOS | |
#elseif os(watchOS) | |
return .watchOS | |
#elseif os(tvOS) | |
return .tvOS | |
#elseif os(iOS) | |
#if targetEnvironment(macCatalyst) | |
return .macCatalyst | |
#else | |
return .iOS | |
#endif | |
#elseif os(Linux) | |
return .linux | |
#else | |
return .unknown | |
#endif | |
} | |
private static func osVersion() -> String { | |
let os = ProcessInfo().operatingSystemVersion | |
let version = "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)" | |
return version | |
} | |
private static func isSimulator() -> Bool { | |
#if targetEnvironment(simulator) | |
return true | |
#else | |
return false | |
#endif | |
} | |
} | |
} | |
// MARK: - Info | |
public extension Env { | |
/// A simple wrapper around the "Info.plist" file. | |
/// | |
/// Provides basic access to bundle's info dictionary. | |
/// | |
/// Convenience to decode custom "Config" like dictionaries from "Info.plist". | |
/// Here's an example of a possible implementation: | |
/// | |
/// public extension Env { | |
/// | |
/// static let info = Info() | |
/// | |
/// static let config: Config = try! info.decode("Config") | |
/// | |
/// struct Config: Codable { | |
/// public let buildConfiguration: String | |
/// public let baseURL: String public | |
/// | |
/// enum CodingKeys: String, CodingKey { | |
/// case buildConfiguration = "Build Configuration" | |
/// case baseURL = "Base URL" | |
/// } | |
/// } | |
/// | |
/// static var isLive: Bool { | |
/// config.buildConfiguration | |
/// .lowercased() | |
/// .contains("live") | |
/// } | |
/// | |
/// static var baseURL: URL { | |
/// URL(string: config.baseURL)! | |
/// } | |
/// | |
/// } | |
/// | |
struct Info: CustomStringConvertible { | |
public let bundle: Bundle | |
public init(bundle: Bundle = .main) { | |
self.bundle = bundle | |
} | |
public var description: String { | |
""" | |
product: \(productName) | |
version (build): \(versionBuild) | |
bundle id: \(bundleID) | |
""" | |
} | |
public var dict: [String: Any] { | |
bundle.infoDictionary ?? [:] | |
} | |
public var bundleID: String { | |
string(forKey: kCFBundleIdentifierKey as String) | |
} | |
public var productName: String { | |
string(forKey: kCFBundleNameKey as String) | |
} | |
public var version: String { | |
string(forKey: "CFBundleShortVersionString") | |
} | |
public var build: String { | |
string(forKey: kCFBundleVersionKey as String) | |
} | |
public var versionBuild: String { | |
"\(version) (\(build))" | |
} | |
public func object(forKey key: String) -> Any? { | |
bundle.object(forInfoDictionaryKey: key) | |
} | |
public func array(forKey key: String) -> [Any] { | |
object(forKey: key) as? [Any] ?? [] | |
} | |
public func dictionary(forKey key: String) -> [String: Any] { | |
object(forKey: key) as? [String: Any] ?? [:] | |
} | |
public func bool(forKey key: String) -> Bool { | |
object(forKey: key) as? Bool ?? false | |
} | |
public func data(forKey key: String) -> Data { | |
object(forKey: key) as? Data ?? .init() | |
} | |
public func date(forKey key: String) -> Date { | |
object(forKey: key) as? Date ?? .init() | |
} | |
public func number(forKey key: String) -> NSNumber { | |
object(forKey: key) as? NSNumber ?? .init() | |
} | |
public func string(forKey key: String) -> String { | |
object(forKey: key) as? String ?? .init() | |
} | |
public func decode<T: Codable>(_ key: String) throws -> T { | |
let data = try JSONSerialization.data( | |
withJSONObject: dictionary(forKey: key) | |
) | |
let result = try JSONDecoder().decode(T.self, from: data) | |
return result | |
} | |
} | |
} | |
// MARK: - Version | |
public extension Env { | |
/// A mechanism to check the current app version state. | |
/// | |
/// Useful for checking if it's a clean install or an app update. | |
/// Here's an example of a possible implementation (at the call site): | |
/// | |
/// switch Env.version.state { | |
/// case .new: | |
/// // clean install | |
/// case .equal: | |
/// // nothing changed | |
/// case .update(from: let oldVersion, to: let newVersion): | |
/// // update is here | |
/// case .rollback(from: let previousVersion, to: let currentVersion): | |
/// // there must be a good reason | |
/// } | |
/// | |
struct Version: CustomStringConvertible { | |
public let current: SemVer | |
public let history: History | |
public let state: State | |
public init(current: SemVer, history: History = .load()) { | |
self.current = current | |
self.history = history | |
self.state = State(old: history.all.last, new: current) | |
if state != .equal { | |
history.save(current) | |
} | |
} | |
public var description: String { | |
""" | |
version: \(current) | |
history: \(history) | |
state: \(state) | |
""" | |
} | |
public struct SemVer: Equatable, Comparable, Codable, CustomStringConvertible { | |
public let major: Int | |
public let minor: Int | |
public let patch: Int | |
public var description: String { | |
"\(major).\(minor).\(patch)" | |
} | |
public init(_ major: Int, _ minor: Int, _ patch: Int = 0) { | |
self.major = major | |
self.minor = minor | |
self.patch = patch | |
} | |
public init(_ value: String) { | |
let components = value.components(separatedBy: ".") | |
let major = components.indices ~= 0 ? Int(components[0]) ?? 0 : 0 | |
let minor = components.indices ~= 1 ? Int(components[1]) ?? 0 : 0 | |
let patch = components.indices ~= 2 ? Int(components[2]) ?? 0 : 0 | |
self = .init(major, minor, patch) | |
} | |
public static func < (lhs: SemVer, rhs: SemVer) -> Bool { | |
guard lhs != rhs else { return false } | |
let lhsComparators = [lhs.major, lhs.minor, lhs.patch] | |
let rhsComparators = [rhs.major, rhs.minor, rhs.patch] | |
return lhsComparators.lexicographicallyPrecedes(rhsComparators) | |
} | |
} | |
public struct History: CustomStringConvertible { | |
public let all: [SemVer] | |
public init(_ all: [SemVer]) { | |
self.all = all | |
} | |
public var description: String { | |
all.description | |
} | |
public static func load( | |
key: String = Self.key, | |
defaults: UserDefaults = Self.defaults | |
) -> Self { | |
self.defaults = defaults | |
self.key = key | |
guard | |
let savedData = defaults.object(forKey: key) as? Data, | |
let savedHistory = try? JSONDecoder() | |
.decode([SemVer].self, from: savedData) | |
else { | |
return .init([]) | |
} | |
return .init(savedHistory) | |
} | |
public func save(_ current: SemVer) { | |
let newHistory = all + [current] | |
if let newData = try? JSONEncoder().encode(newHistory) { | |
Self.defaults.set(newData, forKey: Self.key) | |
Self.defaults.synchronize() | |
} | |
} | |
public private(set) static var key: String = "Env.Version.History" | |
public private(set) static var defaults: UserDefaults = .standard | |
} | |
public enum State: Equatable { | |
case new | |
case equal | |
case update(from: SemVer, to: SemVer) | |
case rollback(from: SemVer, to: SemVer) | |
public init(old: SemVer?, new: SemVer) { | |
guard let old = old else { | |
self = .new; return | |
} | |
if old == new { | |
self = .equal | |
} else if old < new { | |
self = .update(from: old, to: new) | |
} else { | |
self = .rollback(from: old, to: new) | |
} | |
} | |
public static func == (lhs: Self, rhs: Self) -> Bool { | |
switch (lhs, rhs) { | |
case (new, new): | |
return true | |
case (equal, equal): | |
return true | |
case (update(from: let lf, to: let lt), update(from: let rf, to: let rt)): | |
return lf == rf && lt == rt | |
case (rollback(from: let lf, to: let lt), rollback(from: let rf, to: let rt)): | |
return lf == rf && lt == rt | |
default: | |
return false | |
} | |
} | |
} | |
} | |
} | |
// MARK: - App Group | |
public extension Env { | |
/// A simple utility related to the "App Groups" capability. | |
/// | |
/// Convenience to accesss the group's shared data (`UserDefaults` or the file system) | |
/// | |
/// It can be created with an explicit `groupID` or with a *standard one*. | |
/// Here's an example of a possible implementation: | |
/// | |
/// public extension Env { | |
/// | |
/// static let info = Info() | |
/// | |
/// static let config: Config = try! info.decode("Config") | |
/// | |
/// static let group = AppGroup.standard( | |
/// bundleID: info.bundleID, | |
/// teamID: config.teamID | |
/// ) | |
/// | |
/// } | |
/// | |
struct AppGroup: CustomStringConvertible { | |
public let groupID: String | |
init(groupID: String) { | |
self.groupID = groupID | |
} | |
public var description: String { | |
""" | |
group id: \(groupID) | |
directory: \(directory?.absoluteString ?? "n/a") | |
""" | |
} | |
public var defaults: UserDefaults { | |
UserDefaults(suiteName: groupID) ?? .standard | |
} | |
public var directory: URL? { | |
FileManager.default | |
.containerURL(forSecurityApplicationGroupIdentifier: groupID) | |
} | |
public static func standard( | |
bundleID: String, | |
teamID: String | |
) -> Self { | |
let prefix = "group" | |
let groupID: String | |
#if os(macOS) | |
groupID = "\(teamID).\(prefix).\(bundleID)" | |
#else | |
groupID = "\(prefix).\(bundleID)" | |
#endif | |
return Self(groupID: groupID) | |
} | |
} | |
} | |
// MARK: - Helpers | |
public extension Env { | |
/// A flag to determine if code is run in the context of Xcode's "Live Preview" | |
static var isXcodePreview: Bool { | |
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment