Skip to content

Instantly share code, notes, and snippets.

@tadija
Last active June 7, 2021 21:55
Show Gist options
  • Save tadija/55377d1211d0dc0bc2da661a455fc7c6 to your computer and use it in GitHub Desktop.
Save tadija/55377d1211d0dc0bc2da661a455fc7c6 to your computer and use it in GitHub Desktop.
Env
/**
* 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