$30 off During Our Annual Pro Sale. View Details »

Generating Code And Other Mischief With Swift Package Manager Plugins - 360iDev, Denver, CO, August 2022

Generating Code And Other Mischief With Swift Package Manager Plugins - 360iDev, Denver, CO, August 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
PRO

August 30, 2022
Tweet

More Decks by Ellen Shapiro

Other Decks in Technology

Transcript

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

    360IDEV | DENVER, CO | AUGUST 2022 ELLEN SHAPIRO | @DESIGNATEDNERD | GUSTO.COM
  2. None
  3. None
  4. None
  5. None
  6. ALCATRAZ

  7. None
  8. None
  9. None
  10. None
  11. None
  12. None
  13. None
  14. None
  15. None
  16. None
  17. None
  18. ! XCODE GHOST

  19. ! XCODE GHOST

  20. ! STRAWHORSE

  21. None
  22. None
  23. !

  24. None
  25. XCODE SOURCE EXTENSIONS

  26. REQUIRED HOST MAC APP

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

    easily on the Mac App store
  28. REQUIRED HOST MAC APP ▸ ! Can now be distributed

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

    easily on the Mac App store ▸ " Sandboxing restrictions ▸ # Often winds up with an empty app
  30. func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void) {

    // Actual code of plugin }
  31. None
  32. ONLY SINGLE-FILE TEXT BUFFER ACCESS

  33. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

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

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

    least some sorting, linting, formatting ▸ ☹ Only ever the current file ▸ # No access to the AST
  36. 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
  37. 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
  38. FURTHER INVOCATION LIMITATIONS

  39. FURTHER INVOCATION LIMITATIONS ▸ ! Cannot run in background

  40. FURTHER INVOCATION LIMITATIONS ▸ ! Cannot run in background ▸

    " Must be actively invoked by the user
  41. FURTHER INVOCATION LIMITATIONS ▸ ! Cannot run in background ▸

    " Must be actively invoked by the user ▸ # No default key binding
  42. None
  43. None
  44. None
  45. None
  46. None
  47. SWIFT PACKAGE MANAGER ! PLUGINS

  48. None
  49. None
  50. None
  51. None
  52. ✅ SWIFT 5.6

  53. ✅ SWIFT 5.6 ! XCODE 14

  54. 1. COMMAND PLUGINS 2. BUILD TOOL PLUGINS

  55. COMMAND PLUGINS

  56. CODE FROM "CREATE SWIFT PACKAGE PLUGINS" HTTPS://DEVELOPER.APPLE.COM/VIDEOS/PLAY/WWDC2022/110401/

  57. COMMAND PLUGINS ▸ Can ask permission to write in the

    package directory
  58. // 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.") ] )),
  59. // 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.") ] )),
  60. COMMAND PLUGINS ▸ Can ask permission to write in the

    package directory ▸ Can be invoked at the command line
  61. // 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
  62. None
  63. 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
  64. None
  65. None
  66. None
  67. None
  68. None
  69. 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
  70. BUILD TOOL PLUGINS

  71. BUILD TOOL PLUGINS RUN AUTOMATICALLY ON EVERY BUILD

  72. BUILD TOOL PLUGINS (THE THING I REALLY WANT TO USE)

  73. THE MORE COMPLICATED BUT LESS COMPLICATED WAY: EMBED A PACKAGE

    IN YOUR .XCODEPROJ
  74. MORE COMPLICATED (YOU HAVE TO ADD A WHOLE SWIFT PACKAGE

    TO YOUR PROJECT)
  75. LESS COMPLICATED (IT WORKS WAY MORE RELIABLY)

  76. SAMPLE APP https://github.com/designatednerd/ RadioactiveCat

  77. via https://energyeducation.ca/encyclopedia/Half_life

  78. None
  79. None
  80. None
  81. BUILD TOOL EASY MODE BRING IN SOMEONE ELSE'S PLUGIN

  82. DO NIL DISTURB HTTPS://GITHUB.COM/ICANZILB/ DONILDISTURBPLUGIN

  83. // Package.swift .dependencies: [ .package(url: "https://github.com/icanzilb/ DoNilDisturbPlugin.git", from: "0.0.5"), ]

  84. // Package.swift .target( name: "Cats", dependencies: [ ], plugins: [

    .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  85. None
  86. DO NOT BLINDLY CLICK TRUST & ENABLE ALL

  87. None
  88. None
  89. None
  90. None
  91. None
  92. BUILD TOOL HARD MODE: CREATE YOUR OWN PLUGIN

  93. DON'T BUILD YOUR PLUGIN AND THE PACKAGE YOU'RE TESTING IT

    AGAINST IN THE SAME REPO
  94. GITHUB.COM/DESIGNATEDNERD/ DATEDIMAGEGENERATOR

  95. PLUGIN VS. EXECUTABLE

  96. // 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
  97. // 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
  98. // 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
  99. // 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
  100. // 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
  101. // 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
  102. // 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
  103. // 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"] ) ] } }
  104. WHAT'S IN THE EXECUTABLE?

  105. // Sources/DatedImageGeneratorExecutable/ // DatedImageGeneratorExecutable.swift @main struct DatedImageGeneratorExecutable { static func

    main() throws { // How do we get information from the Plugin? } }
  106. // Sources/DatedImageGeneratorExecutable/ // DatedImageGeneratorExecutable.swift @main struct DatedImageGeneratorExecutable { static func

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

    main() throws { // Turn it into Data Data(ProcessInfo.processInfo.arguments[1].utf8)) } }
  108. // 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)) } }
  109. "I should just put the codable object in the executable

    and import it into the plugin"
  110. None
  111. None
  112. None
  113. // 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) } }
  114. // 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) } }
  115. // 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] ) ] } }
  116. // 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] ) ] } }
  117. ! TARGET (TECHNICALLY PACKAGEPLUGIN.TARGET)

  118. // 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] ) ] } }
  119. ! PLUGIN CONTEXT (TECHNICALLY PACKAGEPLUGIN.PLUGINCONTEXT)

  120. PLUGINWORKDIRECTORY IS YOUR SANDBOX

  121. // 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] ) ] } }
  122. // 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] ) ] } }
  123. None
  124. QUICK RECAP OF PLUGIN CREATION

  125. QUICK RECAP OF PLUGIN CREATION ▸ Create a plugin in

    Plugins
  126. QUICK RECAP OF PLUGIN CREATION ▸ Create a plugin in

    Plugins ▸ Create an executable it will call in Sources
  127. 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
  128. 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
  129. ! BACK TO THE KITTAHS!

  130. // 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"), ],
  131. // In Cats/Package.swift .target( name: "Cats", dependencies: [ .product(name: "DatedImage",

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

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

    package: "DatedImageGenerator"), ], plugins: [ .plugin(name: "DatedImageGenerator", package: "DatedImageGenerator"), .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  134. None
  135. None
  136. None
  137. None
  138. None
  139. None
  140. WHAT ABOUT RUNNING PLUGINS ON AN APP TARGET?

  141. XCODE PROJECT PLUGIN

  142. XCODE PROJECT PLUGIN (A KIND OF BUILD TOOL PLUGIN)

  143. #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
  144. #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
  145. #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
  146. #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
  147. #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
  148. None
  149. None
  150. None
  151. None
  152. None
  153. None
  154. SPM PLUGIN LIMITATIONS

  155. SPM PLUGIN LIMITATIONS (that I've found so far)

  156. SPM PLUGIN LIMITATIONS (that I've found so far) ▸ Still

    no UI for you
  157. SPM PLUGIN LIMITATIONS (that I've found so far) ▸ Still

    no UI for you ▸ You can't even ask to use the network
  158. 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
  159. 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
  160. ! PLUGIN WISH LIST

  161. PLUGIN WISH LIST ▸ File metadata

  162. PLUGIN WISH LIST ▸ File metadata ▸ Ability to show

    UI in Xcode
  163. OBLIGATORY SUMMARY SLIDE

  164. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz

  165. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

  166. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

    ▸ SPM Plugins to the rescue!
  167. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

    ▸ SPM Plugins to the rescue! ▸ Command plugins: On demand, Build tool plugins: On every build
  168. 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
  169. 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
  170. THANK YOU!

  171. 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
  172. ! 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/
  173. ! PLUGIN LINKS! ▸ Do Nil Disturb: github.com/icanzilb/DoNilDisturbPlugin ▸ Swift

    SafeURL: github.com/baguio/SwiftSafeURL