From 67ac02abec536e501e249f5709e2b6a47b238a4a Mon Sep 17 00:00:00 2001
From: xuty <xty50337@hotmail.com>
Date: Sun, 29 Dec 2024 23:46:40 +0800
Subject: [PATCH] Initial version of Shaft builder (#3)

---
 Package.swift                                 |  14 ++
 .../BuilderPlugin/AppleBundleStructure.swift  |  60 +++++++++
 Plugins/BuilderPlugin/ApplePlatform.swift     |   7 +
 Plugins/BuilderPlugin/BuildSpec.swift         |  27 ++++
 Plugins/BuilderPlugin/Options.swift           |  29 +++++
 Plugins/BuilderPlugin/Utils.swift             |   6 +
 Plugins/BuilderPlugin/main.swift              | 121 ++++++++++++++++++
 Sources/Playground/Build.json                 |   8 ++
 8 files changed, 272 insertions(+)
 create mode 100644 Plugins/BuilderPlugin/AppleBundleStructure.swift
 create mode 100644 Plugins/BuilderPlugin/ApplePlatform.swift
 create mode 100644 Plugins/BuilderPlugin/BuildSpec.swift
 create mode 100644 Plugins/BuilderPlugin/Options.swift
 create mode 100644 Plugins/BuilderPlugin/Utils.swift
 create mode 100644 Plugins/BuilderPlugin/main.swift
 create mode 100644 Sources/Playground/Build.json

diff --git a/Package.swift b/Package.swift
index 8ccd8a1..9f99d27 100644
--- a/Package.swift
+++ b/Package.swift
@@ -116,6 +116,20 @@ let package = Package(
             ]
         ),
 
+        .plugin(
+            name: "BuilderPlugin",
+            capability: .command(
+                intent: .custom(verb: "build", description: "Build application bundle"),
+                permissions: [
+                    .allowNetworkConnections(
+                        scope: .all(),
+                        reason: "To retrieve additional resources"
+                    ),
+                    .writeToPackageDirectory(reason: "To read configuration files"),
+                ]
+            )
+        ),
+
         .executableTarget(
             name: "CSkiaSetup",
             dependencies: [
diff --git a/Plugins/BuilderPlugin/AppleBundleStructure.swift b/Plugins/BuilderPlugin/AppleBundleStructure.swift
new file mode 100644
index 0000000..d780d8c
--- /dev/null
+++ b/Plugins/BuilderPlugin/AppleBundleStructure.swift
@@ -0,0 +1,60 @@
+import Foundation
+
+/// foo.app/
+///     Contents/
+///         Info.plist
+///         MacOS/
+///             foo
+///         Resources/
+///             foo.icns
+///         Libraries/
+///             libfoo.dylib
+///         Frameworks/
+///             Foo.framework
+struct AppleBundleStructure {
+    let contentsDirectory: URL
+    let resourcesDirectory: URL
+    let librariesDirectory: URL
+    let frameworksDirectory: URL
+    let executableDirectory: URL
+    let infoPlistFile: URL
+    let appIconFile: URL
+    let mainExecutable: URL
+
+    init(at bundleDirectory: URL, platform: ApplePlatform, appName: String) {
+        switch platform {
+        case .macOS:
+            contentsDirectory = bundleDirectory.appendingPathComponent("Contents")
+            executableDirectory = contentsDirectory.appendingPathComponent("MacOS")
+            resourcesDirectory = contentsDirectory.appendingPathComponent("Resources")
+            frameworksDirectory = contentsDirectory.appendingPathComponent("Frameworks")
+        case .iOS, .tvOS, .visionOS, .watchOS:
+            contentsDirectory = bundleDirectory
+            executableDirectory = contentsDirectory
+            resourcesDirectory = contentsDirectory
+            frameworksDirectory = contentsDirectory
+        }
+
+        librariesDirectory = contentsDirectory.appendingPathComponent("Libraries")
+        infoPlistFile = contentsDirectory.appendingPathComponent("Info.plist")
+        appIconFile = resourcesDirectory.appendingPathComponent("AppIcon.icns")
+
+        mainExecutable = executableDirectory.appendingPathComponent(appName)
+    }
+
+    func ensureDirectoriesExist() throws {
+        for directory in [
+            contentsDirectory,
+            executableDirectory,
+            resourcesDirectory,
+            librariesDirectory,
+            frameworksDirectory,
+        ] {
+            try FileManager.default.createDirectory(
+                at: directory,
+                withIntermediateDirectories: true,
+                attributes: nil
+            )
+        }
+    }
+}
diff --git a/Plugins/BuilderPlugin/ApplePlatform.swift b/Plugins/BuilderPlugin/ApplePlatform.swift
new file mode 100644
index 0000000..44618af
--- /dev/null
+++ b/Plugins/BuilderPlugin/ApplePlatform.swift
@@ -0,0 +1,7 @@
+enum ApplePlatform: String {
+    case iOS
+    case macOS
+    case tvOS
+    case watchOS
+    case visionOS
+}
diff --git a/Plugins/BuilderPlugin/BuildSpec.swift b/Plugins/BuilderPlugin/BuildSpec.swift
new file mode 100644
index 0000000..864ef7f
--- /dev/null
+++ b/Plugins/BuilderPlugin/BuildSpec.swift
@@ -0,0 +1,27 @@
+// {
+//     "app": {
+//         "name": "Playground",
+//         "identifier": "dev.shaftui.playground",
+//         "version": "1.0.0",
+//         "product": "Playground"
+//     }
+// }
+struct BuildSpec: Codable {
+    let app: AppSpec?
+}
+
+/// The specification for the app
+struct AppSpec: Codable {
+    /// The name of the app
+    let name: String
+
+    /// The identifier of the app. This is usually a reverse domain name.
+    let identifier: String
+
+    /// The version of the app
+    let version: String
+
+    /// Name of the product defined in the package. The product will be used to
+    /// create the bundle.
+    let product: String
+}
diff --git a/Plugins/BuilderPlugin/Options.swift b/Plugins/BuilderPlugin/Options.swift
new file mode 100644
index 0000000..7cec06e
--- /dev/null
+++ b/Plugins/BuilderPlugin/Options.swift
@@ -0,0 +1,29 @@
+import PackagePlugin
+
+/// Options for the builder plugin command
+struct BuilderOptions {
+    var targetName: String
+
+    var configuration: BuilderConfiguration
+}
+
+enum BuilderConfiguration: String {
+    case debug
+
+    case release
+}
+
+func extractOptions(from arguments: [String]) -> BuilderOptions {
+    var extractor = ArgumentExtractor(arguments)
+
+    guard let targetName = extractor.remainingArguments.first else {
+        printAndExit("Target name not provided")
+    }
+
+    let configurationString = extractor.extractOption(named: "--configuration").last ?? "debug"
+    guard let configuration = BuilderConfiguration(rawValue: configurationString) else {
+        printAndExit("Invalid configuration: \(configurationString)")
+    }
+
+    return BuilderOptions(targetName: targetName, configuration: configuration)
+}
diff --git a/Plugins/BuilderPlugin/Utils.swift b/Plugins/BuilderPlugin/Utils.swift
new file mode 100644
index 0000000..6e596a8
--- /dev/null
+++ b/Plugins/BuilderPlugin/Utils.swift
@@ -0,0 +1,6 @@
+import Foundation
+
+func printAndExit(_ message: String, _ exitCode: Int32 = 1) -> Never {
+    print(message)
+    exit(exitCode)
+}
diff --git a/Plugins/BuilderPlugin/main.swift b/Plugins/BuilderPlugin/main.swift
new file mode 100644
index 0000000..9138254
--- /dev/null
+++ b/Plugins/BuilderPlugin/main.swift
@@ -0,0 +1,121 @@
+import Foundation
+import PackagePlugin
+
+@main
+struct BuilderPlugin: CommandPlugin {
+
+    func performCommand(
+        context: PluginContext,
+        arguments: [String]
+    ) async throws {
+        let options = extractOptions(from: arguments)
+        let targetName = options.targetName
+
+        // Ensure the product exists in the package
+        guard let target = findTarget(targetName, in: context.package) else {
+            print("Target \(targetName) not found. Valid targets are:")
+            for target in context.package.targets {
+                print("  - \(target.name)")
+            }
+            return
+        }
+
+        print("Building \(target.name) in \(options.configuration.rawValue) mode")
+
+        var buildParameters = PackagePlugin.PackageManager.BuildParameters(echoLogs: true)
+        buildParameters.otherLinkerFlags = ["-L.shaft/skia"]
+
+        let buildResult = try self.packageManager.build(
+            .target(targetName),
+            parameters: buildParameters
+        )
+
+        if !buildResult.succeeded {
+            print("Build failed. See logs above for details.")
+            return
+        }
+
+        guard let mainArtifect = buildResult.builtArtifacts.first else {
+            print("No built artifacts found. Skipping bundle creation.")
+            return
+        }
+
+        if mainArtifect.kind != .executable {
+            print("Main artifact is \(mainArtifect.kind). Skipping bundle creation.")
+            return
+        }
+
+        let buildSpec = findBuildSpec(in: target)
+        guard let appSpec = buildSpec?.app else {
+            print("No app spec found for \(target.name). Skipping bundle creation.")
+            return
+        }
+
+        print("Creating bundle with app spec: \(appSpec)")
+        createBundle(from: mainArtifect, appSpec: appSpec)
+    }
+}
+
+/// Find a target with the given name in the package
+func findTarget(_ targetName: String, in package: Package) -> Target? {
+    return package.targets.first { $0.name == targetName }
+}
+
+/// Try read the Build.json file in the target directory
+func findBuildSpec(in target: Target) -> BuildSpec? {
+    let path = target.directory.appending(subpath: "Build.json").string
+
+    guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
+        return nil
+    }
+
+    do {
+        let decoder = JSONDecoder()
+        return try decoder.decode(BuildSpec.self, from: data)
+    } catch {
+        print("Error decoding BuildSpec: \(error)")
+        return nil
+    }
+}
+
+func createBundle(from artifect: PackageManager.BuildResult.BuiltArtifact, appSpec: AppSpec) {
+    let outputDirectory = URL(fileURLWithPath: artifect.path.string)
+        .deletingLastPathComponent()
+        .appendingPathComponent("\(appSpec.product).app")
+
+    print("Creating bundle at \(outputDirectory)")
+
+    let structure = AppleBundleStructure(
+        at: outputDirectory,
+        platform: .macOS,
+        appName: appSpec.product
+    )
+
+    try! structure.ensureDirectoriesExist()
+
+    // Copy the main executable to the bundle
+    try! FileManager.default.copyItem(
+        at: URL(fileURLWithPath: artifect.path.string),
+        to: structure.mainExecutable
+    )
+
+    // Write the Info.plist
+    let infoPlist = """
+        <?xml version="1.0" encoding="UTF-8"?>
+        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+        <plist version="1.0">
+        <dict>
+            <key>CFBundleExecutable</key>
+            <string>\(appSpec.product)</string>
+            <key>CFBundleIdentifier</key>
+            <string>\(appSpec.identifier)</string>
+            <key>CFBundleName</key>
+            <string>\(appSpec.name)</string>
+            <key>CFBundleVersion</key>
+            <string>\(appSpec.version)</string>
+        </dict>
+        </plist>
+        """
+
+    try! infoPlist.write(to: structure.infoPlistFile, atomically: true, encoding: .utf8)
+}
diff --git a/Sources/Playground/Build.json b/Sources/Playground/Build.json
new file mode 100644
index 0000000..3582803
--- /dev/null
+++ b/Sources/Playground/Build.json
@@ -0,0 +1,8 @@
+{
+    "app": {
+        "name": "Playground",
+        "identifier": "dev.shaftui.playground",
+        "version": "1.0.0",
+        "product": "Playground"
+    }
+}
\ No newline at end of file