From b0a42865f5c89182012b83876cef7c358ad3e58d Mon Sep 17 00:00:00 2001 From: xuty Date: Wed, 1 Jan 2025 08:53:02 +0800 Subject: [PATCH] Shaft builder v0.2 --- Build.json | 15 +++ .../BuilderPlugin/AppleBundleStructure.swift | 2 + Plugins/BuilderPlugin/BuildSpec.swift | 97 +++++++++++--- Plugins/BuilderPlugin/Options.swift | 13 +- .../BuilderPlugin/Steps/MacOSBundleStep.swift | 99 ++++++++++++++ Plugins/BuilderPlugin/main.swift | 125 +++++------------- 6 files changed, 234 insertions(+), 117 deletions(-) create mode 100644 Build.json create mode 100644 Plugins/BuilderPlugin/Steps/MacOSBundleStep.swift diff --git a/Build.json b/Build.json new file mode 100644 index 0000000..60ae05f --- /dev/null +++ b/Build.json @@ -0,0 +1,15 @@ +{ + "steps": [ + { + "name": "Build Playground.app", + "type": "macos-bundle", + "with": { + "name": "Playground", + "identifier": "dev.shaftui.playground", + "version": "1.0.0", + "product": "Playground", + "output": ".build/Playground.app" + } + } + ] +} \ No newline at end of file diff --git a/Plugins/BuilderPlugin/AppleBundleStructure.swift b/Plugins/BuilderPlugin/AppleBundleStructure.swift index d780d8c..4e337b8 100644 --- a/Plugins/BuilderPlugin/AppleBundleStructure.swift +++ b/Plugins/BuilderPlugin/AppleBundleStructure.swift @@ -20,6 +20,7 @@ struct AppleBundleStructure { let infoPlistFile: URL let appIconFile: URL let mainExecutable: URL + let mainExecutableDSYM: URL init(at bundleDirectory: URL, platform: ApplePlatform, appName: String) { switch platform { @@ -40,6 +41,7 @@ struct AppleBundleStructure { appIconFile = resourcesDirectory.appendingPathComponent("AppIcon.icns") mainExecutable = executableDirectory.appendingPathComponent(appName) + mainExecutableDSYM = mainExecutable.appendingPathExtension("dSYM") } func ensureDirectoriesExist() throws { diff --git a/Plugins/BuilderPlugin/BuildSpec.swift b/Plugins/BuilderPlugin/BuildSpec.swift index 864ef7f..a8c40cd 100644 --- a/Plugins/BuilderPlugin/BuildSpec.swift +++ b/Plugins/BuilderPlugin/BuildSpec.swift @@ -1,27 +1,90 @@ // { -// "app": { -// "name": "Playground", -// "identifier": "dev.shaftui.playground", -// "version": "1.0.0", -// "product": "Playground" -// } +// "steps": [ +// { +// "name": "Build ShaftBrowser.app", +// "type": "macos-bundle", +// "with": { +// "name": "ShaftBrowser", +// "identifier": "dev.shaftui.browser", +// "version": "1.0.0", +// "product": "ShaftBrowser", +// "output": ".build/ShaftBrowser.app" +// } +// }, +// { +// "name": "Build ShaftBrowser Helper.app", +// "type": "macos-bundle", +// "with": { +// "name": "ShaftBrowser Helper", +// "identifier": "dev.shaftui.browserhelper", +// "version": "1.0.0", +// "product": "ShaftBrowserHelper", +// "output": ".build/ShaftBrowser Helper.app" +// } +// }, +// { +// "name": "Move ShaftBrowser Helper.app to ShaftBrowser.app/Contents/Frameworks", +// "type": "move", +// "with": { +// "source": ".build/ShaftBrowser Helper.app", +// "destination": ".build/ShaftBrowser.app/Contents/Frameworks" +// } +// } +// ] // } + struct BuildSpec: Codable { - let app: AppSpec? + let steps: [BuildStep] } -/// The specification for the app -struct AppSpec: Codable { - /// The name of the app - let name: String +enum BuildStepType: String, Codable { + case macOSBundle = "macos-bundle" + case move = "move" +} - /// The identifier of the app. This is usually a reverse domain name. - let identifier: String +enum BuildStep: Codable { + case macOSBundle(MacOSBundleInput) + case move(MoveInput) - /// The version of the app - let version: String + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(BuildStepType.self, forKey: .type) + switch type { + case .macOSBundle: + self = .macOSBundle(try container.decode(MacOSBundleInput.self, forKey: .with)) + case .move: + self = .move(try container.decode(MoveInput.self, forKey: .with)) + } + } + + func encode(to encoder: Encoder) throws { + switch self { + case .macOSBundle(let input): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(BuildStepType.macOSBundle, forKey: .type) + try container.encode(input, forKey: .with) + case .move(let input): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(BuildStepType.move, forKey: .type) + try container.encode(input, forKey: .with) + } + } - /// Name of the product defined in the package. The product will be used to - /// create the bundle. + private enum CodingKeys: String, CodingKey { + case type + case with + } +} + +struct MacOSBundleInput: Codable { + let name: String + let identifier: String + let version: String let product: String + let output: String +} + +struct MoveInput: Codable { + let source: String + let destination: String } diff --git a/Plugins/BuilderPlugin/Options.swift b/Plugins/BuilderPlugin/Options.swift index 7cec06e..73dff47 100644 --- a/Plugins/BuilderPlugin/Options.swift +++ b/Plugins/BuilderPlugin/Options.swift @@ -2,7 +2,7 @@ import PackagePlugin /// Options for the builder plugin command struct BuilderOptions { - var targetName: String + // var targetName: String var configuration: BuilderConfiguration } @@ -16,14 +16,17 @@ enum BuilderConfiguration: String { func extractOptions(from arguments: [String]) -> BuilderOptions { var extractor = ArgumentExtractor(arguments) - guard let targetName = extractor.remainingArguments.first else { - printAndExit("Target name not provided") - } + // 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) + return BuilderOptions( + // targetName: targetName, + configuration: configuration + ) } diff --git a/Plugins/BuilderPlugin/Steps/MacOSBundleStep.swift b/Plugins/BuilderPlugin/Steps/MacOSBundleStep.swift new file mode 100644 index 0000000..8e4c52f --- /dev/null +++ b/Plugins/BuilderPlugin/Steps/MacOSBundleStep.swift @@ -0,0 +1,99 @@ +import Foundation +import PackagePlugin + +func executeMacOSBundleStep(_ input: MacOSBundleInput, context: StepContext) { + // Ensure the product exists in the package + guard let product = context.findProduct(input.product) else { + print("Product \(input.product) not found. Valid products are:") + for product in context.package.products { + print(" - \(product.name)") + } + exit(1) + } + + print("Building \(product.name) in \(context.configuration.rawValue) mode") + + var buildParameters = PackageManager.BuildParameters(echoLogs: true) + buildParameters.otherLinkerFlags = ["-L.shaft/skia"] + + let buildResult = try! context.packageManager.build( + .product(input.product), + 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 + } + print("Main artifact: \(mainArtifect.path.string)") + + if mainArtifect.kind != .executable { + print("Main artifact is \(mainArtifect.kind). Skipping bundle creation.") + return + } + + print("Creating bundle with input: \(input)") + createBundle(from: mainArtifect, input: input, configuration: context.configuration) +} + +private func createBundle( + from artifect: PackageManager.BuildResult.BuiltArtifact, + input: MacOSBundleInput, + configuration: BuilderConfiguration +) { + let outputDirectory = URL(fileURLWithPath: input.output) + + print("Creating bundle at \(outputDirectory)") + + let structure = AppleBundleStructure( + at: outputDirectory, + platform: .macOS, + appName: input.name + ) + + try! structure.ensureDirectoriesExist() + + /// Copy the main executable + copyFile(from: artifect.path.string, to: structure.mainExecutable.path) + + /// Copy .dSYM file in debug mode + if configuration == .debug { + copyFile(from: artifect.path.string + ".dSYM", to: structure.mainExecutableDSYM.path) + } + + // Write the Info.plist + let infoPlist = """ + + + + + CFBundleExecutable + \(input.product) + CFBundleIdentifier + \(input.identifier) + CFBundleName + \(input.name) + CFBundleVersion + \(input.version) + + + """ + + try! infoPlist.write(to: structure.infoPlistFile, atomically: true, encoding: .utf8) + + print("Bundle created at \(outputDirectory)") +} + +private func copyFile(from source: String, to destination: String) { + if FileManager.default.fileExists(atPath: destination) { + print("Removing existing file at \(destination)") + try! FileManager.default.removeItem(atPath: destination) + } + print("Copying \(source) to \(destination)") + try! FileManager.default.copyItem(atPath: source, toPath: destination) +} diff --git a/Plugins/BuilderPlugin/main.swift b/Plugins/BuilderPlugin/main.swift index 4e6646e..02164e2 100644 --- a/Plugins/BuilderPlugin/main.swift +++ b/Plugins/BuilderPlugin/main.swift @@ -9,119 +9,54 @@ struct BuilderPlugin: CommandPlugin { 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)") - } + let buildSpec = loadBuildSpec(directory: context.package.directory) + guard let buildSpec else { + print("No Build.json found in \(context.package.directory). Skipped.") 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 + let context = StepContext( + package: context.package, + packageManager: self.packageManager, + configuration: options.configuration ) - if !buildResult.succeeded { - print("Build failed. See logs above for details.") - return - } + for step in buildSpec.steps { + print("Executing step: \(step)") - 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 + switch step { + case .macOSBundle(let input): + executeMacOSBundleStep(input, context: context) + // case .move(let input): + // executeMoveStep(input, context: context) + default: + print("Unknown step type: \(step)") + } } - - 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 +/// Try read the Build.json file in the package directory +func loadBuildSpec(directory: Path) -> BuildSpec? { + let path = 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 - } + let decoder = JSONDecoder() + return try! decoder.decode(BuildSpec.self, from: data) } -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() +struct StepContext { + let package: Package + let packageManager: PackageManager + let configuration: BuilderConfiguration +} - // Copy the main executable to the bundle - if FileManager.default.fileExists(atPath: structure.mainExecutable.path) { - print("Removing existing executable at \(structure.mainExecutable.path)") - try! FileManager.default.removeItem(at: structure.mainExecutable) +extension StepContext { + func findProduct(_ productName: String) -> Product? { + return package.products.first { $0.name == productName } } - try! FileManager.default.copyItem( - at: URL(fileURLWithPath: artifect.path.string), - to: structure.mainExecutable - ) - - // Write the Info.plist - let infoPlist = """ - - - - - CFBundleExecutable - \(appSpec.product) - CFBundleIdentifier - \(appSpec.identifier) - CFBundleName - \(appSpec.name) - CFBundleVersion - \(appSpec.version) - - - """ - - try! infoPlist.write(to: structure.infoPlistFile, atomically: true, encoding: .utf8) - - print("Bundle created at \(outputDirectory)") }