Skip to content

Commit

Permalink
Initial version of Shaft builder (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
xtyxtyx authored Dec 29, 2024
1 parent 1e31812 commit 67ac02a
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 0 deletions.
14 changes: 14 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
60 changes: 60 additions & 0 deletions Plugins/BuilderPlugin/AppleBundleStructure.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
7 changes: 7 additions & 0 deletions Plugins/BuilderPlugin/ApplePlatform.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
enum ApplePlatform: String {
case iOS
case macOS
case tvOS
case watchOS
case visionOS
}
27 changes: 27 additions & 0 deletions Plugins/BuilderPlugin/BuildSpec.swift
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions Plugins/BuilderPlugin/Options.swift
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 6 additions & 0 deletions Plugins/BuilderPlugin/Utils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

func printAndExit(_ message: String, _ exitCode: Int32 = 1) -> Never {
print(message)
exit(exitCode)
}
121 changes: 121 additions & 0 deletions Plugins/BuilderPlugin/main.swift
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions Sources/Playground/Build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"app": {
"name": "Playground",
"identifier": "dev.shaftui.playground",
"version": "1.0.0",
"product": "Playground"
}
}

0 comments on commit 67ac02a

Please sign in to comment.