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