diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..330d167
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,90 @@
+# Xcode
+#
+# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+
+## User settings
+xcuserdata/
+
+## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
+*.xcscmblueprint
+*.xccheckout
+
+## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
+build/
+DerivedData/
+*.moved-aside
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+
+## Obj-C/Swift specific
+*.hmap
+
+## App packaging
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+## Playgrounds
+timeline.xctimeline
+playground.xcworkspace
+
+# Swift Package Manager
+#
+# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
+# Packages/
+# Package.pins
+# Package.resolved
+# *.xcodeproj
+#
+# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
+# hence it is not needed unless you have added a package configuration file to your project
+# .swiftpm
+
+.build/
+
+# CocoaPods
+#
+# We recommend against adding the Pods directory to your .gitignore. However
+# you should judge for yourself, the pros and cons are mentioned at:
+# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
+#
+# Pods/
+#
+# Add this line if you want to avoid checking in source code from the Xcode workspace
+# *.xcworkspace
+
+# Carthage
+#
+# Add this line if you want to avoid checking in source code from Carthage dependencies.
+# Carthage/Checkouts
+
+Carthage/Build/
+
+# Accio dependency management
+Dependencies/
+.accio/
+
+# fastlane
+#
+# It is recommended to not store the screenshots in the git repo.
+# Instead, use fastlane to re-generate the screenshots whenever they are needed.
+# For more information about the recommended setup visit:
+# https://docs.fastlane.tools/best-practices/source-control/#source-control
+
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
+
+# Code Injection
+#
+# After new code Injection tools there's a generated folder /iOSInjectionProject
+# https://github.com/johnno1962/injectionforxcode
+
+iOSInjectionProject/
diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3762864
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Egeniq
+
+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.
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..8391f59
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,32 @@
+{
+ "pins" : [
+ {
+ "identity" : "app-remote-config",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/egeniq/app-remote-config",
+ "state" : {
+ "revision" : "57b8c68082d4e3ec52fe68387229064f846485d8",
+ "version" : "0.0.2"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser",
+ "state" : {
+ "revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "yams",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/jpsim/Yams.git",
+ "state" : {
+ "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3",
+ "version" : "5.0.6"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..ae75674
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,29 @@
+// swift-tools-version: 5.9
+
+import PackageDescription
+
+let package = Package(
+ name: "care",
+ defaultLocalization: "en",
+ platforms: [.iOS(.v15), .macOS(.v12), .tvOS(.v15), .watchOS(.v8), .macCatalyst(.v15)],
+ products: [
+ .executable(
+ name: "care",
+ targets: ["care"]
+ )
+ ],
+ dependencies: [
+ .package(url: "https://github.com/egeniq/app-remote-config", from: "0.0.2"),
+ .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
+ .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.6")
+ ],
+ targets: [
+ .executableTarget(
+ name: "care",
+ dependencies: [
+ .product(name: "AppRemoteConfig", package: "app-remote-config"),
+ .product(name: "Yams", package: "Yams"),
+ .product(name: "ArgumentParser", package: "swift-argument-parser")
+ ]),
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..35e4e15
--- /dev/null
+++ b/README.md
@@ -0,0 +1,90 @@
+# AppRemoteConfig
+
+Configure apps remotely: A simple but effective way to manage apps remotely.
+
+Create a simple configuration file that is easy to maintain and host, yet provides important flexibility to specify settings based on your needs.
+
+## Schema
+
+The JSON/YAML schema is defined [here](https://raw.githubusercontent.com/egeniq/app-remote-config/main/Schema/appremoteconfig.schema.json).
+
+## CLI Utility
+
+Use the `care` CLI utility to initialize, verify, resolve and prepare configuration files.
+
+To install use:
+
+ brew install egeniq/app-utilities/care
+
+## Multiplatform
+
+### Swift
+
+Import the package in your `Package.swift` file:
+
+ .package(url: "https://github.com/egeniq/app-remote-config", from: "0.0.2"),
+
+Then a good approach is to create your own `AppRemoteConfigClient`.
+
+ // App Remote Config
+ .target(
+ name: "AppRemoteConfigClient",
+ dependencies: [
+ .product(name: "AppRemoteConfigMacros", package: "app-remote-config"),
+ .product(name: "AppRemoteConfigService", package: "app-remote-config"),
+ .product(name: "Dependencies", package: "swift-dependencies"),
+ .product(name: "DependenciesAdditions", package: "swift-dependencies-additions"),
+ .product(name: "DependenciesMacros", package: "swift-dependencies"),
+ .product(name: "Perception", package: "swift-perception")
+ ]
+ )
+
+Using these dependencies:
+
+ .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
+ .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.0.0"),
+ .package(url: "https://github.com/tgrapperon/swift-dependencies-additions", from: "1.0.0")
+
+Then your `AppRemoteConfigClient.swift` is something like this:
+
+ import AppRemoteConfigService
+ import AppRemoteConfigMacros
+ import Dependencies
+ import DependenciesMacros
+ import Foundation
+ import Perception
+
+ @AppRemoteConfigValues @Perceptible
+ public class Values {
+ public private(set) var updateRecommended: Bool = false
+ public private(set) var updateRequired: Bool = false
+ }
+
+ @DependencyClient
+ public struct AppRemoteConfigClient {
+ public var values: () -> Values = { Values() }
+ }
+
+ extension DependencyValues {
+ public var configClient: AppRemoteConfigClient {
+ get { self[AppRemoteConfigClient.self] }
+ set { self[AppRemoteConfigClient.self] = newValue }
+ }
+ }
+
+ extension AppRemoteConfigClient: TestDependencyKey {
+ public static let testValue = Self()
+ }
+
+ extension AppRemoteConfigClient: DependencyKey {
+ public static let liveValue = {
+ let url = URL(string: "https://www.example.com/config.json")!
+ let values = Values()
+ let service = AppRemoteConfigService(url: url, apply: values.apply(settings:))
+ return Self(values: { values })
+ }()
+ }
+
+### Android
+
+Support for Android can be found [here](https://github.com/egeniq/app-remote-config-android).
diff --git a/Sources/care/ANSIEffect.swift b/Sources/care/ANSIEffect.swift
new file mode 100644
index 0000000..2761fb1
--- /dev/null
+++ b/Sources/care/ANSIEffect.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+enum ANSIEffect: String {
+ case bold = "\u{001B}[0;1m"
+ case faint = "\u{001B}[0;2m"
+ case black = "\u{001B}[0;30m"
+ case red = "\u{001B}[0;31m"
+ case green = "\u{001B}[0;32m"
+ case yellow = "\u{001B}[0;33m"
+ case blue = "\u{001B}[0;34m"
+ case magenta = "\u{001B}[0;35m"
+ case cyan = "\u{001B}[0;36m"
+ case white = "\u{001B}[0;37m"
+ case `default` = "\u{001B}[0;0m"
+}
+
+extension DefaultStringInterpolation {
+ mutating func appendInterpolation(_ value: T, effect: ANSIEffect) {
+ appendInterpolation("\(effect.rawValue)\(value)\(ANSIEffect.default.rawValue)")
+ }
+}
diff --git a/Sources/care/Error.swift b/Sources/care/Error.swift
new file mode 100644
index 0000000..d14c150
--- /dev/null
+++ b/Sources/care/Error.swift
@@ -0,0 +1,6 @@
+import Foundation
+
+enum CareError: Error {
+ case unexpectedData
+ case invalidDate
+}
diff --git a/Sources/care/Init.swift b/Sources/care/Init.swift
new file mode 100644
index 0000000..3c2f2e6
--- /dev/null
+++ b/Sources/care/Init.swift
@@ -0,0 +1,124 @@
+import AppRemoteConfig
+import ArgumentParser
+import Foundation
+
+extension Care {
+ struct Init: ParsableCommand {
+ static var configuration =
+ CommandConfiguration(abstract: "Prepare a new configuration.")
+
+ enum Kind: String, ExpressibleByArgument, CaseIterable {
+ case yaml, json
+ }
+
+ @Option(help: "The kind of configuration file.")
+ var kind: Kind = .yaml
+
+ @Argument(
+ help: "The file that will contain the configuration.",
+ completion: .file(extensions: ["yaml", "yml", "json"]), transform: URL.init(fileURLWithPath:))
+ var outputFile: URL
+
+ mutating func run() throws {
+ // Just writing text to disk, so we can add some helpful comments
+ switch kind {
+ case .yaml:
+ let yaml = """
+ $schema: https://raw.githubusercontent.com/egeniq/app-remote-config/main/Schema/appremoteconfig.schema.json
+
+ # Settings for the current app.
+ settings:
+ foo: 42
+ coolFeature: false
+
+ # Keep track of keys that are no longer in use.
+ deprecatedKeys:
+ - bar
+
+ # Override settings
+ overrides:
+ - matching:
+ # If any of the following combinations match
+ - appVersion: <=0.9.0
+ platform: Android
+ - appVersion: <1.0.0
+ platform: iOS
+ - platformVersion: <15.0.0
+ platform: iOS.iPad
+ # These settings get overriden.
+ settings:
+ bar: low
+
+ # Or release a new feature at a specific time
+ - schedule:
+ from: '2024-12-31T00:00:00Z'
+ settings:
+ coolFeature: true
+
+ # Store metadata here
+ meta:
+ author: Your Name
+ """
+ try yaml.write(to: outputFile, atomically: true, encoding: .utf8)
+ case .json:
+ let json = """
+ {
+ "$schema": "https://raw.githubusercontent.com/egeniq/app-remote-config/main/Schema/appremoteconfig.schema.json",
+ "settings": {
+ "coolFeature": false,
+ "foo": 42
+ },
+ "deprecatedKeys": [
+ "bar"
+ ],
+ "overrides": [
+ {
+ "matching": [
+ {
+ "appVersion": "<=0.9.0",
+ "platform": "Android"
+ },
+ {
+ "appVersion": "<1.0.0",
+ "platform": "iOS"
+ },
+ {
+ "platform": "iOS.iPad",
+ "platformVersion": "<15.0.0"
+ }
+ ],
+ "settings": {
+ "bar": "low"
+ }
+ },
+ {
+ "schedule": {
+ "from": "2024-12-31T00:00:00Z"
+ },
+ "settings": {
+ "coolFeature": true
+ }
+ }
+ ],
+ "meta": {
+ "author": "Your Name"
+ }
+ }
+ """
+ try json.write(to: outputFile, atomically: true, encoding: .utf8)
+ }
+ let data = try Data(contentsOf: outputFile)
+ let results = try Verify().verify(from: data).filter { $0.level != .info }
+ if results.isEmpty {
+ print("This configuration is \("created", effect: .green).")
+ print("\("[HINT]", effect: .cyan) Use the resolve command to verify the output is as expected for an app.")
+ print("\("[HINT]", effect: .cyan) Use the prepare command to prepare the configuration for publication.")
+ } else {
+ print("This configuration has \(results.count, effect: .bold) issue(s).")
+ results.forEach {
+ print("\($0.level.text) \($0.message) - \($0.keyPath, effect: .faint)")
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/care/Prepare.swift b/Sources/care/Prepare.swift
new file mode 100644
index 0000000..7888db9
--- /dev/null
+++ b/Sources/care/Prepare.swift
@@ -0,0 +1,61 @@
+import AppRemoteConfig
+import ArgumentParser
+import Foundation
+import Yams
+
+extension Care {
+ struct Prepare: ParsableCommand {
+ static var configuration =
+ CommandConfiguration(abstract: "Prepare a configuration for publication.")
+
+ @Argument(
+ help: "The file that contains the configuration.",
+ completion: .file(extensions: ["yaml", "yml", "json"]), transform: URL.init(fileURLWithPath:))
+ var inputFile: URL
+
+ @Argument(
+ help: "The file that will contain the configuration suitable for publication.",
+ completion: .file(extensions: ["json"]), transform: URL.init(fileURLWithPath:))
+ var outputFile: URL
+
+ mutating func run() throws {
+ let data = try Data(contentsOf: inputFile)
+ var object: [String: Any]
+
+ if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
+ if let jsonDict = jsonObject as? [String: Any] {
+ object = jsonDict
+ } else {
+ throw CareError.unexpectedData
+ }
+ } else {
+ let string = String(data: data, encoding: .utf8)!
+ if let yamlObject = try? Yams.load(yaml: string) {
+ if let yamlDict = yamlObject as? [String: Any] {
+ object = yamlDict
+ } else {
+ throw CareError.unexpectedData
+ }
+ } else {
+ throw CareError.unexpectedData
+ }
+ }
+
+ let dataOut = try JSONSerialization.data(withJSONObject: object)
+ let results = try Verify().verify(from: dataOut)
+ if results.filter({ $0.level == .error }).isEmpty {
+ try dataOut.write(to: outputFile)
+ print("This configuration is \("prepared", effect: .green).")
+ results.forEach {
+ print("\($0.level.text) \($0.message) - \($0.keyPath, effect: .faint)")
+ }
+ } else {
+ print("This configuration has \(results.count, effect: .bold) issue(s).")
+ results.forEach {
+ print("\($0.level.text) \($0.message) - \($0.keyPath, effect: .faint)")
+ }
+ }
+
+ }
+ }
+}
diff --git a/Sources/care/Resolve.swift b/Sources/care/Resolve.swift
new file mode 100644
index 0000000..64163f2
--- /dev/null
+++ b/Sources/care/Resolve.swift
@@ -0,0 +1,142 @@
+import AppRemoteConfig
+import ArgumentParser
+import Foundation
+import Yams
+
+extension Care {
+ struct Resolve: ParsableCommand {
+ static var configuration =
+ CommandConfiguration(abstract: "Resolve a configuration for an app to verify output.")
+
+ @Argument(
+ help: "The file that contains the configuration.",
+ completion: .file(extensions: ["yaml", "yml", "json"]), transform: URL.init(fileURLWithPath:))
+ var inputFile: URL
+
+ @Option(
+ name: [.long, .customShort("v")],
+ help: "The version of the app.",
+ completion: .none)
+ var appVersion: String = "1.0.0"
+
+ @Option(
+ name: [.customShort("d"), .long],
+ help: "The date the app runs at in ISO8601 format. (default: now)"
+ )
+ var date: String?
+
+ @Option(
+ name: [.customShort("p"), .long],
+ help: "The platform the app runs on."
+ )
+ var platform: Platform = .iOS
+
+ @Option(
+ help: "The version of the platform the app runs on.",
+ completion: .none)
+ var platformVersion: String = "1.0.0"
+
+ @Option(help: "The variant of the app.")
+ var variant: String?
+
+ @Option(help: "The build variant of the app.")
+ var buildVariant: BuildVariant = .release
+
+ @Option(help: "The 2 character code of the language the app runs in.")
+ var language: String?
+
+ mutating func run() throws {
+ let data = try Data(contentsOf: inputFile)
+ var object: [String: Any]
+
+ if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
+ if let jsonDict = jsonObject as? [String: Any] {
+ object = jsonDict
+ } else {
+ throw CareError.unexpectedData
+ }
+ } else {
+ let string = String(data: data, encoding: .utf8)!
+ if let yamlObject = try? Yams.load(yaml: string) {
+ if let yamlDict = yamlObject as? [String: Any] {
+ object = yamlDict
+ } else {
+ throw CareError.unexpectedData
+ }
+ } else {
+ throw CareError.unexpectedData
+ }
+ }
+ let config = try Config(json: object)
+ let parsedDate: Date
+ if let date {
+ guard let date = ISO8601DateFormatter().date(from: date) else {
+ throw CareError.invalidDate
+ }
+ parsedDate = date
+ } else {
+ parsedDate = Date()
+ }
+ let platformVersion = try Version(platformVersion).operatingSystemVersion
+ let appVersion = try Version(appVersion)
+
+ var relevantResolutionDates = config.relevantResolutionDates(
+ platform: platform,
+ platformVersion: platformVersion,
+ appVersion: appVersion,
+ variant: variant,
+ buildVariant: buildVariant,
+ language: language
+ )
+ .filter { $0.timeIntervalSince(parsedDate) > 0 }
+ relevantResolutionDates.insert(parsedDate, at: 0)
+
+ print("Resolving for:")
+ print(" platform : \(platform.rawValue)")
+ print(" platform version : \(platformVersion.majorVersion).\(platformVersion.minorVersion).\(platformVersion.patchVersion)")
+ print(" app version : \(appVersion.rawValue)")
+ if let variant {
+ print(" variant : \(variant)")
+ }
+ print(" build variant : \(buildVariant)")
+ if let language {
+ print(" language : \(language)")
+ }
+
+ let defaultSettings = config.settings
+
+ for relevantResolutionDate in relevantResolutionDates {
+ let resolvedSettings = config.resolve(
+ date: relevantResolutionDate,
+ platform: platform,
+ platformVersion: platformVersion,
+ appVersion: appVersion,
+ variant: variant,
+ buildVariant: buildVariant,
+ language: language
+ )
+ print("")
+ print("Settings on \(relevantResolutionDate):")
+ for key in resolvedSettings.keys.sorted() {
+ let paddedKey = key + Array(repeating: " ", count: max(20 - key.count, 0))
+ if defaultSettings.keys.contains(key) {
+ let defaultValue = "\(defaultSettings[key]!)"
+ let resolvedValue = "\(resolvedSettings[key]!)"
+ if defaultValue == resolvedValue {
+ print(" \(paddedKey): \(resolvedValue)")
+ } else {
+ print(" \(paddedKey): \(defaultValue, effect: .faint) -> \(resolvedValue)")
+ }
+ } else {
+ print(" \(paddedKey): \("[deprecated]", effect: .faint) -> \(resolvedSettings[key]!)")
+ }
+ }
+ }
+ print("")
+ print("No further overrides scheduled.")
+ }
+ }
+}
+
+extension BuildVariant: ExpressibleByArgument { }
+extension Platform: ExpressibleByArgument { }
diff --git a/Sources/care/Verify.swift b/Sources/care/Verify.swift
new file mode 100644
index 0000000..bf9b930
--- /dev/null
+++ b/Sources/care/Verify.swift
@@ -0,0 +1,320 @@
+import AppRemoteConfig
+import ArgumentParser
+import Foundation
+import Yams
+
+extension Care {
+ struct Verify: ParsableCommand {
+ static var configuration =
+ CommandConfiguration(abstract: "Verify that the configuration is valid.")
+
+ @Argument(
+ help: "The file that contains the configuration.",
+ completion: .file(extensions: ["yaml", "yml", "json"]), transform: URL.init(fileURLWithPath:))
+ var inputFile: URL
+
+ mutating func run() throws {
+ let data = try Data(contentsOf: inputFile)
+ let results = try verify(from: data)
+ if results.filter({ $0.level == .error }).isEmpty {
+ print("This configuration is \("valid", effect: .green).")
+ results.forEach {
+ print("\($0.level.text) \($0.message) - \($0.keyPath, effect: .faint)")
+ }
+ print("\("[HINT]", effect: .cyan) Use the resolve command to verify the output is as expected for an app.")
+ print("\("[HINT]", effect: .cyan) Use the prepare command to prepare the configuration for publication.")
+
+ } else {
+ print("This configuration has \(results.count, effect: .bold) issue(s).")
+ results.forEach {
+ print("\($0.level.text) \($0.message) - \($0.keyPath, effect: .faint)")
+ }
+ }
+ }
+
+ func verify(from data: Data) throws -> [VerificationResult] {
+ var results = [VerificationResult]()
+
+ var object: [String: Any]
+
+ if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
+ if let jsonDict = jsonObject as? [String: Any] {
+ object = jsonDict
+ } else {
+ results.append(.init(level: .error, message: "Expected a dictionary with string keys.", keyPath: "/"))
+ return results
+ }
+ } else {
+ let string = String(data: data, encoding: .utf8)!
+ if let yamlObject = try? Yams.load(yaml: string) {
+ if let yamlDict = yamlObject as? [String: Any] {
+ object = yamlDict
+ results.append(.init(level: .info, message: "This configuration is in YAML. This is not suitable for publication. Use the prepare command to convert to JSON.", keyPath: "/"))
+ } else {
+ results.append(.init(level: .error, message: "Expected a dictionary with string keys.", keyPath: "/"))
+ return results
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected YAML or JSON file.", keyPath: ""))
+ return results
+ }
+ }
+
+ var configKeys: [String] = []
+ if let settings = object["settings"] {
+ let keyPath = "/settings"
+ if let settings = settings as? [String: Any] {
+ // No further checks (yet)
+ configKeys = Array(settings.keys)
+ } else {
+ results.append(.init(level: .error, message: "Expected a dictionary with string keys.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Missing settings.", keyPath: "/"))
+ }
+
+ var deprecatedKeys2: [String] = []
+ if let deprecatedKeys = object["deprecatedKeys"] {
+ let keyPath = "/deprecatedKeys"
+ if let deprecatedKeys = deprecatedKeys as? [String] {
+ deprecatedKeys2 = deprecatedKeys
+ // no duplicates in keys??
+ if let duplicateKey = deprecatedKeys.first(where: { configKeys.contains($0) } ) {
+ results.append(.init(level: .error, message: "Deprecated key '\(duplicateKey)' is still used.", keyPath: keyPath))
+ }
+
+ let duplicateKeys = Dictionary(grouping: deprecatedKeys, by: { $0 }).filter { $1.count > 1 }.keys
+ for duplicateKey in duplicateKeys {
+ results.append(.init(level: .warning, message: "Deprecated key '\(duplicateKey)' is listed more than once.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected an array of strings.", keyPath: keyPath))
+ }
+ }
+
+ if let overrides = object["overrides"] {
+ let keyPath = "/overrides"
+ if let overrides = overrides as? [[String: Any]] {
+ for (index, override) in overrides.enumerated() {
+ let keyPath = "/overrides[\(index)]"
+
+ var hasConditions = false
+ if let conditions = override["matching"] {
+ let keyPath = "\(keyPath)/matching"
+ if let conditions = conditions as? [[String: Any]] {
+ for (index, condition) in conditions.enumerated() {
+ hasConditions = true
+
+ let keyPath = "\(keyPath)[\(index)]"
+ let conditionKeys: [String] = Array(condition.keys)
+
+ // matching should have no unknown keys
+ let unknownKeys = conditionKeys.filter({ !["platform", "platformVersion", "appVersion", "variant", "buildVariant", "language"].contains($0) })
+ for unknownKey in unknownKeys {
+ results.append(.init(level: .error, message: "Unexpected key '\(unknownKey)' in condition.", keyPath: keyPath))
+ }
+
+ // matching should have at least one key
+ if conditionKeys.isEmpty {
+ results.append(.init(level: .error, message: "No keys in condition.", keyPath: keyPath))
+ }
+
+ // platform should not be unknown/other
+ if let platform = condition["platform"] {
+ let keyPath = "\(keyPath)/platform"
+ if let platform = platform as? String {
+ let parsedPlatform = Platform(rawValue: platform)
+ if parsedPlatform == nil || parsedPlatform == .unknown {
+ results.append(.init(level: .error, message: "Unknown platform '\(platform)'.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected a string.", keyPath: keyPath))
+ }
+ }
+
+ // versions range is valid
+ if let platformVersion = condition["platformVersion"] {
+ let keyPath = "\(keyPath)/platformVersion"
+ if let platformVersion = platformVersion as? String {
+ let parsedVersionRange = try? VersionRange(platformVersion)
+ if parsedVersionRange == nil {
+ results.append(.init(level: .error, message: "Invalid platform version range '\(platformVersion)'.", keyPath: keyPath))
+ }
+ let invalidCharacters = CharacterSet(charactersIn: "1234567890-<>=.").inverted
+ if platformVersion.rangeOfCharacter(from: invalidCharacters) != nil {
+ results.append(.init(level: .error, message: "Invalid platform version range '\(platformVersion)'.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected a string.", keyPath: keyPath))
+ }
+ }
+
+ // versions range is valid
+ if let appVersion = condition["appVersion"] {
+ let keyPath = "\(keyPath)/appVersion"
+ if let appVersion = appVersion as? String {
+ let parsedVersionRange = try? VersionRange(appVersion)
+ if parsedVersionRange == nil {
+ results.append(.init(level: .error, message: "Invalid app version range '\(appVersion)'.", keyPath: keyPath))
+ }
+ let invalidCharacters = CharacterSet(charactersIn: "1234567890-<>=.").inverted
+ if appVersion.rangeOfCharacter(from: invalidCharacters) != nil {
+ results.append(.init(level: .error, message: "Invalid app version range '\(appVersion)'.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected a string.", keyPath: keyPath))
+ }
+ }
+
+ // variant should be string
+ if let variant = condition["variant"] {
+ let keyPath = "\(keyPath)/variant"
+ if let _ = variant as? String {
+ // No further checks (yet)
+ } else {
+ results.append(.init(level: .error, message: "Expected a string.", keyPath: keyPath))
+ }
+ }
+
+ // buildVariant should not be unknown
+ if let buildVariant = condition["buildVariant"] {
+ let keyPath = "\(keyPath)/buildVariant"
+ if let buildVariant = buildVariant as? String {
+ let parsedBuildVariant = BuildVariant(rawValue: buildVariant)
+ if parsedBuildVariant == nil || parsedBuildVariant == .unknown {
+ results.append(.init(level: .error, message: "Unknown buildVariant '\(buildVariant)'.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected a string.", keyPath: keyPath))
+ }
+ }
+
+ // language should be lower cased and two letters
+ if let language = condition["language"] {
+ let keyPath = "\(keyPath)/language"
+ if let language = language as? String {
+ if language.count != 2 || language.trimmingCharacters(in: CharacterSet.lowercaseLetters.inverted).count != 2 {
+ results.append(.init(level: .error, message: "Invalid language code '\(language)'. Must be 2 lowercase characters.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected a string.", keyPath: keyPath))
+ }
+ }
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected an array of dictionaries with string keys.", keyPath: keyPath))
+ }
+ }
+ // Check for
+
+ var hasSchedule = false
+ if let schedule = override["schedule"] {
+ let keyPath = "\(keyPath)/schedule"
+ if let schedule = schedule as? [String: String] {
+ hasSchedule = true
+ // schedule should have from and/or until
+ let fromDate: Date?
+ if let from = schedule["from"] {
+ fromDate = ISO8601DateFormatter().date(from: from)
+ if fromDate == nil {
+ results.append(.init(level: .error, message: "Invalid date '\(from)' is not ISO8601.", keyPath: keyPath + "/from"))
+ }
+ } else {
+ fromDate = nil
+ }
+
+ let untilDate: Date?
+ if let until = schedule["until"] {
+ untilDate = ISO8601DateFormatter().date(from: until)
+ if untilDate == nil {
+ results.append(.init(level: .error, message: "Invalid date '\(until)' is not ISO8601.", keyPath: keyPath + "/until"))
+ }
+ } else {
+ untilDate = nil
+ }
+
+ if fromDate == nil && untilDate == nil {
+ results.append(.init(level: .error, message: "Expected at least one of keys from and until.", keyPath: keyPath))
+ }
+
+ // from until should be ordered
+ if let fromDate, let untilDate, fromDate >= untilDate {
+ results.append(.init(level: .error, message: "Expected until date '\(untilDate)' to be later than from date '\(fromDate)'.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected a dictionary with string keys and values.", keyPath: keyPath))
+ }
+ }
+
+ // must have matching and/or schedule key
+ if !hasConditions && !hasSchedule {
+ results.append(.init(level: .error, message: "Expected at least one of keys matching and schedule.", keyPath: keyPath))
+ }
+
+ // using deprecated key must not match with current app version/code (warning?)
+
+
+ if let settings = override["settings"] {
+ let keyPath = "\(keyPath)/settings"
+ if let settings = settings as? [String: Any] {
+ let overrideSettingsKeys = Array(settings.keys)
+ // settings should not be empty
+ if overrideSettingsKeys.isEmpty {
+ results.append(.init(level: .error, message: "Expected a non-empty dictionary with string keys.", keyPath: keyPath))
+ }
+ for key in overrideSettingsKeys {
+ // keys in settings must be used or deprecated
+ if !configKeys.contains(key) && !deprecatedKeys2.contains(key) {
+ results.append(.init(level: .error, message: "Key '\(key)' is not used in settings or listed in deprecated keys.", keyPath: keyPath))
+ }
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected a dictionary with string keys.", keyPath: keyPath))
+ }
+ } else {
+ results.append(.init(level: .error, message: "Missing settings.", keyPath: "\(keyPath)/settings"))
+ }
+ }
+ } else {
+ results.append(.init(level: .error, message: "Expected an array of dictionaries with string keys.", keyPath: keyPath))
+ }
+ }
+
+ if let meta = object["meta"] {
+ let keyPath = "/meta"
+ if let _ = meta as? [String: Any] {
+ // No further checks (yet)
+ } else {
+ results.append(.init(level: .error, message: "Expected a dictionary with string keys.", keyPath: keyPath))
+ }
+ }
+
+ return results
+ }
+ }
+}
+
+struct VerificationResult {
+ enum Level {
+ case info
+ case warning
+ case error
+ }
+ let level: Level
+ let message: String
+ let keyPath: String
+}
+
+extension VerificationResult.Level {
+ var text: String {
+ switch self {
+ case .info:
+ "\("[INFO]", effect: .green)"
+ case .warning:
+ "\("[WARNING]", effect: .yellow)"
+ case .error:
+ "\("[ERROR]", effect: .red)"
+ }
+ }
+}
+
diff --git a/Sources/care/care.swift b/Sources/care/care.swift
new file mode 100644
index 0000000..cd3ff1d
--- /dev/null
+++ b/Sources/care/care.swift
@@ -0,0 +1,15 @@
+import ArgumentParser
+import Foundation
+
+@main
+struct Care: ParsableCommand {
+ static var configuration = CommandConfiguration(
+ abstract: "Configure apps remotely.",
+ discussion: """
+ A simple but effective way to manage apps remotely.
+
+ Create a simple configuration file that is easy to maintain and host, yet provides important flexibility to specify settings based on your needs.
+ """,
+ version: "0.0.2",
+ subcommands: [Init.self, Verify.self, Resolve.self, Prepare.self])
+}