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]) +}