Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Generating Code And Other Mischief With Swift P...

Ellen Shapiro
September 08, 2022

Generating Code And Other Mischief With Swift Package Manager Plugins - iOS Dev UK, Aberystwyth, Wales, September 2022

It’s the build tooling step everyone hates: “Now, add a new Run Script Build Phase.” The Swift and Xcode teams have worked to try to make things at least a little bit better for things which need to happen at build time by adding Build Tool Plugins and Command Plugins to Swift Package manager.

In this talk, you’ll get a look at how to set these up to generate code and documentation, plus a look at some other silly things you can make it do.

Sample App: https://github.com/designatednerd/RadioactiveCat
Sample Plugin: https://github.com/designatednerd/DatedImageGenerator

Ellen Shapiro

September 08, 2022
Tweet

More Decks by Ellen Shapiro

Other Decks in Technology

Transcript

  1. GENERATING CODE AND OTHER MISCHIEF WITH SWIFT PACKAGE MANAGER PLUGINS

    IOSDEVUK | ABERYSTWYTH, WALES | SEPTEMBER 2022 ELLEN SHAPIRO | @DESIGNATEDNERD | GUSTO.COM
  2. !

  3. REQUIRED HOST MAC APP ▸ ! Can now be distributed

    easily on the Mac App store ▸ " Sandboxing restrictions
  4. REQUIRED HOST MAC APP ▸ ! Can now be distributed

    easily on the Mac App store ▸ " Sandboxing restrictions ▸ # Often winds up with an empty app
  5. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting
  6. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting ▸ ☹ Only ever the current file
  7. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting ▸ ☹ Only ever the current file ▸ # No access to the AST
  8. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting ▸ ☹ Only ever the current file ▸ # No access to the AST ▸ $ No access to file or project structure
  9. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting ▸ ☹ Only ever the current file ▸ # No access to the AST ▸ $ No access to file or project structure ▸ $ No user interface permitted
  10. FURTHER INVOCATION LIMITATIONS ▸ ! Cannot run in background ▸

    " Must be actively invoked by the user ▸ # No default key binding
  11. // Package.swift .plugin( name: "GenerateContributors", capability: .command( intent: .custom(verb: "regenerate-contributors-list",

    description: "Generates the CONTRIBUTORS.txt file based on Git logs"), permissions: [ .writeToPackageDirectory(reason: "This command write the new CONTRIBUTORS.txt to the source root.") ] )),
  12. // Package.swift .plugin( name: "GenerateContributors", capability: .command( intent: .custom(verb: "regenerate-contributors-list",

    description: "Generates the CONTRIBUTORS.txt file based on Git logs"), permissions: [ .writeToPackageDirectory(reason: "This command write the new CONTRIBUTORS.txt to the source root.") ] )),
  13. COMMAND PLUGINS ▸ Can ask permission to write in the

    package directory ▸ Can be invoked at the command line
  14. // Get all plugins available on a particular package swift

    package plugin --list // Run a specific plugin, in this case GenerateContributors swift package regenerate-contributors-list
  15. COMMAND PLUGINS ▸ Can ask permission to write in the

    package directory ▸ Can be invoked at the command line ▸ Can be invoked from a menu item in Xcode
  16. COMMAND PLUGINS ▸ Can ask permission to write in the

    package directory ▸ Can be invoked at the command line ▸ Can be invoked from a menu item Xcode ▸ Either way, must be invoked separately from the build process
  17. // Package.swift .target( name: "Cats", dependencies: [ ], plugins: [

    .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  18. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  19. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  20. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  21. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  22. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage // <-- Exported library used in executable and main app " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  23. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage // <-- Exported Framework used in executable and main app " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable // <-- The thing that does all the work " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  24. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  25. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { // TODO: Create info for build command return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, // more to come here arguments: "TODO", outputFiles: ["TODO"] ) ] } }
  26. // Sources/DatedImageGeneratorExecutable/ // DatedImageGeneratorExecutable.swift @main struct DatedImageGeneratorExecutable { static func

    main() throws { // Grab the 2nd argument as a string ProcessInfo.processInfo.arguments[1] } }
  27. // Sources/DatedImageGeneratorExecutable/ // DatedImageGeneratorExecutable.swift @main struct DatedImageGeneratorExecutable { static func

    main() throws { // Turn it into Data Data(ProcessInfo.processInfo.arguments[1].utf8)) } }
  28. // Sources/DatedImageGeneratorExecutable/ // DatedImageGeneratorExecutable.swift @main struct DatedImageGeneratorExecutable { static func

    main() throws { // Decode it into a codable object! let invocation = try JSONDecoder() .decode(PluginInvocation.self, from: Data(ProcessInfo.processInfo.arguments[1].utf8)) } }
  29. // One copy of this in the plugin, one in

    the executable struct PluginInvocation: Codable { let catalogPaths: [String] let outputPath: String func encodedString() throws -> String { let data = try JSONEncoder().encode(self) return String(decoding: data, as: UTF8.self) } }
  30. // One copy of this in the plugin, one in

    the executable // (or share with a common lib) struct PluginInvocation: Codable { let catalogPaths: [String] let outputPath: String func encodedString() throws -> String { let data = try JSONEncoder().encode(self) return String(decoding: data, as: UTF8.self) } }
  31. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  32. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  33. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  34. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  35. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  36. QUICK RECAP OF PLUGIN CREATION ▸ Create a plugin in

    Plugins ▸ Create an executable it will call in Sources
  37. QUICK RECAP OF PLUGIN CREATION ▸ Create a plugin in

    Plugins ▸ Create an executable it will call in Sources ▸ Use a codable object to pass data between them
  38. QUICK RECAP OF PLUGIN CREATION ▸ Create a plugin in

    Plugins ▸ Create an executable it will call in Sources ▸ Use a codable object to pass data between them ▸ Note that the plugin depends on but cannot import the executable
  39. // In Cats/Package.swift dependencies: [ .package(url: "https://github.com/designatednerd/ DatedImageGenerator.git", from: "0.0.1"),

    .package(url: "https://github.com/icanzilb/ DoNilDisturbPlugin.git", from: "0.0.5"), ],
  40. // In Cats/Package.swift .target( name: "Cats", dependencies: [ .product(name: "DatedImage",

    package: "DatedImageGenerator"), ], plugins: [ .plugin(name: "DatedImageGenerator", package: "DatedImageGenerator"), .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  41. // In Cats/Package.swift .target( name: "Cats", dependencies: [ .product(name: "DatedImage",

    package: "DatedImageGenerator"), ], plugins: [ .plugin(name: "DatedImageGenerator", package: "DatedImageGenerator"), .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  42. // In Cats/Package.swift .target( name: "Cats", dependencies: [ .product(name: "DatedImage",

    package: "DatedImageGenerator"), ], plugins: [ .plugin(name: "DatedImageGenerator", package: "DatedImageGenerator"), .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  43. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  44. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  45. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  46. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  47. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  48. SPM PLUGIN LIMITATIONS (that I've found so far) ▸ Still

    no UI for you ▸ You can't even ask to use the network
  49. SPM PLUGIN LIMITATIONS (that I've found so far) ▸ Still

    no UI for you ▸ You can't even ask to use the network ▸ You can't share data between plugins
  50. SPM PLUGIN LIMITATIONS (that I've found so far) ▸ Still

    no UI for you ▸ You can't even ask to use the network ▸ You can't share data between plugins ▸ Can't do anything with build results
  51. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

    ▸ SPM Plugins to the rescue! ▸ Command plugins: On demand, Build tool plugins: On every build
  52. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

    ▸ SPM Plugins to the rescue! ▸ Command plugins: On demand, Build tool plugins: On every build ▸ Security: Be super-mindful of which plugins you use
  53. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

    ▸ SPM Plugins to the rescue! ▸ Command plugins: On demand, Build tool plugins: On every build ▸ Security: Be super-mindful of which plugins you use ▸ Building plugins is a bit complicated, but also pretty fun
  54. SWIFT-EVOLUTION LINKS! ▸ SE-0303: Extensible Build Tools github.com/apple/swift-evolution/ blob/main/proposals/0303-swiftpm-extensible-build-tools.md ▸

    SE-0325: Additional Plugin APIs github.com/apple/swift-evolution/ blob/main/proposals/0325-swiftpm-additional-plugin-apis.md ▸ SE-0332: SPM Command Plugins github.com/apple/swift-evolution/ blob/main/proposals/0332-swiftpm-command-plugins.md
  55. ! SLIGHTLY TERRIFYING LINKS! ▸ Xcode Ghost details: en.wikipedia.org/wiki/XcodeGhost ▸

    Strawhorse leaked document: theintercept.com/document/ 2015/03/10/strawhorse-attacking-macos-ios-software- development-kit ▸ Strawhorse Context: theintercept.com/2015/03/10/ispy-cia- campaign-steal-apples-secrets/