├── .gitignore ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── Plugins ├── SwiftUIGenPlugin │ └── Plugin.swift └── SwiftUIGenPluginCommand │ └── Plugin.swift ├── README.md ├── Sources ├── SwiftUIGen │ └── App.swift └── SwiftUIGenKit │ ├── Error.swift │ ├── Extensions.swift │ ├── PreviewsGenerator.swift │ └── Shell.swift └── Tests └── SwiftUIGenTests └── SwiftUIGenTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Timberlane Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | release: 2 | swift build --configuration release 3 | cp -f .build/release/swiftuigen swiftuigen 4 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser", 7 | "state" : { 8 | "revision" : "4ad606ba5d7673ea60679a61ff867cc1ff8c8e86", 9 | "version" : "1.2.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftUIGen", 8 | platforms: [.iOS(.v14), .macOS(.v12)], 9 | products: [ 10 | .executable(name: "SwiftUIGen", targets: ["SwiftUIGen"]), 11 | .plugin(name: "SwiftUIGenPlugin", targets: ["SwiftUIGenPlugin"]), 12 | .plugin(name: "SwiftUIGenPluginCommand", targets: ["SwiftUIGenPluginCommand"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.1"), 16 | ], 17 | targets: [ 18 | .executableTarget(name: "SwiftUIGen", dependencies: [ 19 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 20 | "SwiftUIGenKit" 21 | ]), 22 | .target(name: "SwiftUIGenKit", dependencies: []), 23 | .plugin(name: "SwiftUIGenPlugin", capability: .buildTool(), dependencies: ["SwiftUIGen"]), 24 | .plugin( 25 | name: "SwiftUIGenPluginCommand", 26 | capability: .command( 27 | intent: .custom( 28 | verb: "SwiftUIGen", 29 | description: "Generates SwiftUI Preview supporting code" 30 | ), 31 | permissions: [.writeToPackageDirectory(reason: "SwiftUIGen needs write permission to save the generated code")] 32 | ), 33 | dependencies: ["SwiftUIGen"] 34 | ), 35 | .testTarget(name: "SwiftUIGenTests", dependencies: ["SwiftUIGen"]), 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /Plugins/SwiftUIGenPlugin/Plugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIGenPlugin.swift 3 | // SwiftUIGenPlugin 4 | // 5 | // Created by Ian Keen on 2022-10-07. 6 | // 7 | 8 | import Foundation 9 | import PackagePlugin 10 | 11 | @main 12 | struct SwiftUIGenPlugin: BuildToolPlugin { 13 | func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { 14 | guard let sourceTarget = target as? SourceModuleTarget else { return [] } 15 | 16 | return try runSwiftUIGen( 17 | tool: context.tool(named: "SwiftUIGen"), 18 | inputFolder: sourceTarget.directory, 19 | outputFolder: sourceTarget.directory, 20 | inputFiles: sourceTarget.sourceFiles.map { $0 } 21 | ) 22 | } 23 | } 24 | 25 | #if canImport(XcodeProjectPlugin) 26 | import XcodeProjectPlugin 27 | 28 | extension SwiftUIGenPlugin: XcodeBuildToolPlugin { 29 | func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { 30 | return try runSwiftUIGen( 31 | tool: context.tool(named: "SwiftUIGen"), 32 | inputFolder: context.xcodeProject.directory, 33 | outputFolder: context.pluginWorkDirectory, 34 | inputFiles: target.inputFiles.map { $0 } 35 | ) 36 | } 37 | } 38 | #endif 39 | 40 | private extension Path { 41 | var adjusted: Path { 42 | return removingLastComponent().appending([lastComponent.uppercased()]) 43 | } 44 | } 45 | 46 | private func runSwiftUIGen(tool: PluginContext.Tool, inputFolder: Path, outputFolder: Path, inputFiles: [File]) -> [Command] { 47 | guard !inputFiles.isEmpty else { return [] } 48 | 49 | let inputFiles = inputFiles.map { $0.path.adjusted } 50 | let outputFile = outputFolder.appending(["SwiftUI.Generated.swift"]) 51 | 52 | return [ 53 | .buildCommand( 54 | displayName: "SwiftUIGen", 55 | executable: tool.path, 56 | arguments: ["previews", "--output", outputFile.string], 57 | inputFiles: inputFiles, 58 | outputFiles: [outputFile] 59 | ) 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /Plugins/SwiftUIGenPluginCommand/Plugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIGenPluginCommand.swift 3 | // SwiftUIGenPluginCommand 4 | // 5 | // Created by Ian Keen on 2023-02-06. 6 | // 7 | 8 | import Foundation 9 | import PackagePlugin 10 | 11 | @main 12 | struct SwiftUIGenPluginCommand: CommandPlugin { 13 | func performCommand(context: PluginContext, arguments: [String]) async throws { 14 | try runSwiftUIGen( 15 | exe: context.tool(named: "SwiftUIGen"), 16 | output: context.package.directory 17 | ) 18 | } 19 | } 20 | 21 | #if canImport(XcodeProjectPlugin) 22 | import XcodeProjectPlugin 23 | 24 | extension SwiftUIGenPluginCommand: XcodeCommandPlugin { 25 | func performCommand(context: XcodePluginContext, arguments: [String]) throws { 26 | try runSwiftUIGen( 27 | exe: context.tool(named: "SwiftUIGen"), 28 | output: context.xcodeProject.directory 29 | ) 30 | } 31 | } 32 | #endif 33 | 34 | enum PluginError: Error { 35 | case inputMissing 36 | case outputMissing 37 | } 38 | 39 | private func runSwiftUIGen(exe: PluginContext.Tool, output: Path) throws { 40 | guard !output.string.isEmpty else { throw PluginError.outputMissing } 41 | 42 | let outputFile = output.appending(subpath: "SwiftUI.Generated.swift") 43 | 44 | let process = Process() 45 | process.executableURL = URL(fileURLWithPath: exe.path.string) 46 | process.arguments = ["previews", "--output", outputFile.string] 47 | 48 | let outputPipe = Pipe() 49 | process.standardOutput = outputPipe 50 | try process.run() 51 | process.waitUntilExit() 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUIGen 2 | 3 | A utility to generate `PreviewDevice` presets from the available devices 4 | 5 | ### Installation 6 | 7 | - Plugins! 8 | 9 | `SwiftUIGen` has been updated to support build tool and command plugins for Xcode and SPM. Simply add this package as a dependency and generate code however suits your workflow. 10 | 11 | Add the `SwiftUIGen` tool to your `Package.swift` or Xcode Package Dependencies 12 | 13 | ```swift 14 | .package(url: "https://github.com/timberlanelabs/SwiftUIGen", branch: "main") 15 | ``` 16 | 17 | And that's it!. It doesn't need to be added to any targets for the plugins to become available. 18 | 19 | - Manual 20 | 21 | Go to the GitHub page for the [latest release](https://github.com/timberlanelabs/SwiftUIGen/releases/latest)
22 | Download the `swiftuigen.zip` file associated with that release
23 | Extract the content of the zip archive in your project directory 24 | 25 | - Homebrew 26 | 27 | ```terminal 28 | $ brew tap timberlanelabs/tap 29 | $ brew install swiftuigen 30 | ``` 31 | 32 | ### Usage 33 | To generate a file containing the preview devices simply run: 34 | 35 | ```terminal 36 | swiftuigen previews --output file.swift 37 | ``` 38 | 39 | Add the file to your project and you can then use them in your SwiftUI previews: 40 | 41 | ```swift 42 | struct MyApp_Previews: PreviewProvider { 43 | static var previews: some View { 44 | MyView() 45 | .previewDevice(.iPhone(.iPhone11)) 46 | } 47 | } 48 | ``` 49 | 50 | This _could_ be run as part of a build phase if that is desirable, however this could result in some git 'nosiness' if different team members have 51 | different devices so it may be more advantageous to only run this periodically as needed. 52 | -------------------------------------------------------------------------------- /Sources/SwiftUIGen/App.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | import SwiftUIGenKit 4 | 5 | @main 6 | struct SwiftUIGen: ParsableCommand { 7 | static let configuration = CommandConfiguration( 8 | commandName: "swiftuigen", 9 | abstract: "A utility to generate helper code for SwiftUI", 10 | version: "1.0.0", 11 | subcommands: [Previews.self] 12 | ) 13 | 14 | struct Previews: ParsableCommand { 15 | static let configuration = CommandConfiguration(abstract: "Generate `PreviewDevice` presets") 16 | 17 | @Option(help: "The location the generated code will be written") 18 | var output: String 19 | 20 | func run() throws { 21 | try PreviewsGenerator(output: output).run() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftUIGenKit/Error.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Error.swift 3 | // SwiftUIGen 4 | // 5 | // Created by Ian Keen on 2021-09-15. 6 | // 7 | 8 | extension String: Error { } 9 | -------------------------------------------------------------------------------- /Sources/SwiftUIGenKit/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // SwiftUIGenKit 4 | // 5 | // Created by Ian Keen on 2021-09-15. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CharacterSet { 11 | func contains(_ character: Character) -> Bool { 12 | guard let value = character.unicodeScalars.first else { return false } 13 | return contains(value) 14 | } 15 | } 16 | 17 | extension String { 18 | mutating func lowercaseFirst() { 19 | guard !self[startIndex].isLowercase else { return } 20 | replaceSubrange(startIndex...startIndex, with: self[startIndex].lowercased()) 21 | } 22 | func lowercasedFirst() -> String { 23 | guard !self[startIndex].isLowercase else { return self } 24 | return replacingCharacters(in: startIndex...startIndex, with: self[startIndex].lowercased()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftUIGenKit/PreviewsGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewsGenerator.swift 3 | // SwiftUIGenKit 4 | // 5 | // Created by Ian Keen on 2021-09-15. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct PreviewsGenerator { 11 | enum Error: Swift.Error { 12 | case outputMustBeSwiftFile 13 | } 14 | 15 | private let output: String 16 | private let charactersToRemove = CharacterSet.alphanumerics.inverted 17 | 18 | public init(output: String) { 19 | self.output = output 20 | } 21 | 22 | public func run() throws { 23 | var isDirectory: ObjCBool = false 24 | FileManager.default.fileExists(atPath: output, isDirectory: &isDirectory) 25 | if isDirectory.boolValue || !output.hasSuffix(".swift") { 26 | throw "Output '\(output)' must be a .swift file" 27 | } 28 | 29 | let jsonData = try Process().launch(with: "xcrun simctl list devicetypes -j") 30 | let devices = try JSONDecoder().decode(DeviceList.self, from: jsonData) 31 | 32 | let grouped = Dictionary(grouping: devices.devicetypes, by: \.productFamily) 33 | 34 | let previews = grouped.keys.sorted() 35 | .map { generatePreviews(grouped[$0, default: []]) } 36 | .joined() 37 | 38 | let contents = """ 39 | // swiftlint:disable all 40 | // Generated using SwiftUIGen 41 | 42 | import SwiftUI 43 | 44 | \(previews) 45 | """ 46 | 47 | try Data(contents.utf8).write(to: URL(fileURLWithPath: output), options: [.atomic]) 48 | } 49 | 50 | private func generatePreviews(_ devices: [Device]) -> String { 51 | guard let first = devices.first else { return "" } 52 | 53 | var familyName = first.productFamily 54 | familyName.removeAll(where: charactersToRemove.contains) 55 | 56 | return """ 57 | public struct \(familyName)Device: RawRepresentable, Equatable { 58 | public var rawValue: String 59 | public init(rawValue: String) { self.rawValue = rawValue } 60 | } 61 | extension PreviewDevice { 62 | public static func \(familyName.lowercasedFirst())(_ device: \(familyName)Device) -> PreviewDevice { .init(rawValue: device.rawValue) } 63 | } 64 | extension \(familyName)Device { 65 | \(devices.sorted(by: { $0.name < $1.name }) 66 | .map { device -> String in 67 | var deviceName = device.name 68 | deviceName.removeAll(where: charactersToRemove.contains) 69 | deviceName = deviceName 70 | .replacingOccurrences(of: "mini", with: "Mini") 71 | .replacingOccurrences(of: "generation", with: "Generation") 72 | 73 | deviceName.lowercaseFirst() 74 | 75 | return """ 76 | public static let \(deviceName) = \(familyName)Device(rawValue: "\(device.identifier)") 77 | """ 78 | } 79 | .joined(separator: "\n") 80 | ) 81 | } 82 | 83 | """ 84 | } 85 | } 86 | 87 | private struct DeviceList: Decodable, Equatable { 88 | let devicetypes: [Device] 89 | } 90 | private struct Device: Decodable, Equatable { 91 | let name: String 92 | let identifier: String 93 | let productFamily: String 94 | } 95 | -------------------------------------------------------------------------------- /Sources/SwiftUIGenKit/Shell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Shell.swift 3 | // SwiftUIGen 4 | // 5 | // Created by Ian Keen on 2021-09-15. 6 | // 7 | 8 | // Originally from: https://github.com/JohnSundell/ShellOut/blob/master/Sources/ShellOut.swift 9 | 10 | import Foundation 11 | 12 | struct ProcessError: Error { 13 | var terminationStatus: Int32 14 | var errorData: Data 15 | var outputData: Data 16 | } 17 | 18 | extension Process { 19 | @discardableResult 20 | func launch(with command: String, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil) throws -> Data { 21 | launchPath = "/bin/bash" 22 | arguments = ["-c", command] 23 | 24 | // Because FileHandle's readabilityHandler might be called from a 25 | // different queue from the calling queue, avoid a data race by 26 | // protecting reads and writes to outputData and errorData on 27 | // a single dispatch queue. 28 | let outputQueue = DispatchQueue(label: "bash-output-queue") 29 | 30 | var outputData = Data() 31 | var errorData = Data() 32 | 33 | let outputPipe = Pipe() 34 | standardOutput = outputPipe 35 | 36 | let errorPipe = Pipe() 37 | standardError = errorPipe 38 | 39 | outputPipe.fileHandleForReading.readabilityHandler = { handler in 40 | let data = handler.availableData 41 | outputQueue.async { 42 | outputData.append(data) 43 | outputHandle?.write(data) 44 | } 45 | } 46 | 47 | errorPipe.fileHandleForReading.readabilityHandler = { handler in 48 | let data = handler.availableData 49 | outputQueue.async { 50 | errorData.append(data) 51 | errorHandle?.write(data) 52 | } 53 | } 54 | 55 | launch() 56 | 57 | waitUntilExit() 58 | 59 | if let handle = outputHandle, !handle.isStandard { 60 | handle.closeFile() 61 | } 62 | 63 | if let handle = errorHandle, !handle.isStandard { 64 | handle.closeFile() 65 | } 66 | 67 | outputPipe.fileHandleForReading.readabilityHandler = nil 68 | errorPipe.fileHandleForReading.readabilityHandler = nil 69 | 70 | // Block until all writes have occurred to outputData and errorData, 71 | // and then read the data back out. 72 | return try outputQueue.sync { 73 | if terminationStatus != 0 { 74 | throw ProcessError( 75 | terminationStatus: terminationStatus, 76 | errorData: errorData, 77 | outputData: outputData 78 | ) 79 | } 80 | 81 | return outputData 82 | } 83 | } 84 | } 85 | 86 | private extension FileHandle { 87 | var isStandard: Bool { 88 | return self === FileHandle.standardOutput || 89 | self === FileHandle.standardError || 90 | self === FileHandle.standardInput 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/SwiftUIGenTests/SwiftUIGenTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import class Foundation.Bundle 3 | 4 | final class SwiftUIGenTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | 10 | // Some of the APIs that we use below are available in macOS 10.13 and above. 11 | guard #available(macOS 10.13, *) else { 12 | return 13 | } 14 | 15 | // Mac Catalyst won't have `Process`, but it is supported for executables. 16 | #if !targetEnvironment(macCatalyst) 17 | 18 | let fooBinary = productsDirectory.appendingPathComponent("SwiftUIGen") 19 | 20 | let process = Process() 21 | process.executableURL = fooBinary 22 | 23 | let pipe = Pipe() 24 | process.standardOutput = pipe 25 | 26 | try process.run() 27 | process.waitUntilExit() 28 | 29 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 30 | let output = String(data: data, encoding: .utf8) 31 | 32 | XCTAssertEqual(output, "Hello, world!\n") 33 | #endif 34 | } 35 | 36 | /// Returns path to the built products directory. 37 | var productsDirectory: URL { 38 | #if os(macOS) 39 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 40 | return bundle.bundleURL.deletingLastPathComponent() 41 | } 42 | fatalError("couldn't find the products directory") 43 | #else 44 | return Bundle.main.bundleURL 45 | #endif 46 | } 47 | } 48 | --------------------------------------------------------------------------------