Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
furby-tm authored Nov 29, 2024
2 parents d303b15 + 3d04010 commit 73fe891
Show file tree
Hide file tree
Showing 12 changed files with 354 additions and 7 deletions.
2 changes: 2 additions & 0 deletions Sources/swift-bundler/Bundler/AppImageBundler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Parsing
enum AppImageBundler: Bundler {
typealias Context = Void

static let outputIsRunnable = true

/// Computes the location of the desktop file created in the given context.
static func desktopFileLocation(for context: BundlerContext) -> URL {
context.outputDirectory.appendingPathComponent(
Expand Down
18 changes: 18 additions & 0 deletions Sources/swift-bundler/Bundler/ArchiveTool.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// A general tool for working with various archive formats.
enum ArchiveTool {
/// Creates a `.tar.gz` archive of the given directory.
static func createTarGz(
of directory: URL,
at outputFile: URL
) -> Result<Void, ArchiveToolError> {
let arguments = ["--create", "--file", outputFile.path, directory.lastPathComponent]
let workingDirectory = directory.deletingLastPathComponent()
return Process.create("tar", arguments: arguments, directory: workingDirectory)
.runAndWait()
.mapError { error in
.failedToCreateTarGz(directory: directory, outputFile: outputFile, error)
}
}
}
16 changes: 16 additions & 0 deletions Sources/swift-bundler/Bundler/ArchiveToolError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

/// An error returned by ``ArchiveTool``.
enum ArchiveToolError: LocalizedError {
case failedToCreateTarGz(directory: URL, outputFile: URL, ProcessError)

var errorDescription: String? {
switch self {
case .failedToCreateTarGz(let directory, let outputFile, _):
return """
Failed to create .tar.gz archive of '\(directory.relativePath)' at \
'\(outputFile.relativePath)'
"""
}
}
}
5 changes: 5 additions & 0 deletions Sources/swift-bundler/Bundler/Bundler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ protocol Bundler {
associatedtype Context
associatedtype Error: LocalizedError

/// Indicates whether the output of the bundler will be runnable or not. For
/// example, the output of ``RPMBundler`` is not runnable but the output of
/// ``AppImageBundler`` is.
static var outputIsRunnable: Bool { get }

/// Computes the bundler's own context given the generic bundler context
/// and Swift bundler's parsed command-line arguments, options, and flags.
///
Expand Down
2 changes: 2 additions & 0 deletions Sources/swift-bundler/Bundler/DarwinBundler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Foundation

/// The bundler for creating macOS apps.
enum DarwinBundler: Bundler {
static let outputIsRunnable = true

struct Context {
/// Whether the build products were created by Xcode or not.
var isXcodeBuild: Bool
Expand Down
27 changes: 22 additions & 5 deletions Sources/swift-bundler/Bundler/GenericLinuxBundler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,22 @@ import Parsing
/// take the output and bundle it up into an often distro-specific package file
/// or standalone executable.
enum GenericLinuxBundler: Bundler {
static let outputIsRunnable = true

struct Context {
/// Used in log messages to avoid exposing that everything's just the
/// generic Linux bundler all the way down. Doesn't affect the fact
/// that the generic bundler's output will have the `.generic` file
/// extension. It's up to other bundlers to transform that output
/// into their desired output format.
var cosmeticBundleName: String?
/// The full path to the bundle when installed on a system. Used when
/// generating the app's `.desktop` file. Useful for packaging bundlers
/// such as ``RPMBundler`` that know where the app will get installed
/// on the system. For example, a value of `/` would mean that the app
/// has been installed to the standard Linux locations, with the
/// executable going to `/usr/bin` etc. Defaults to `/`.
var installationRoot = URL(fileURLWithPath: "/")
}

/// A parser for the output of ldd. Parses a single line.
Expand Down Expand Up @@ -199,10 +208,15 @@ enum GenericLinuxBundler: Bundler {
)
},
{
createDesktopFile(
let relativeExecutablePath = structure.mainExecutable.path(
relativeTo: structure.root
)
return createDesktopFile(
at: structure.desktopFile,
appName: context.appName,
appConfiguration: context.appConfiguration
appConfiguration: context.appConfiguration,
installedExecutableLocation:
additionalContext.installationRoot / relativeExecutablePath
)
},
{
Expand Down Expand Up @@ -382,20 +396,23 @@ enum GenericLinuxBundler: Bundler {
/// - desktopFile: The desktop file to create.
/// - appName: The app's name.
/// - appConfiguration: The app's configuration.
/// - installedExecutableLocation: The location the the executable will end
/// up at on disk once installed.
/// - Returns: If an error occurs, a failure is returned.
private static func createDesktopFile(
at desktopFile: URL,
appName: String,
appConfiguration: AppConfiguration
appConfiguration: AppConfiguration,
installedExecutableLocation: URL
) -> Result<Void, GenericLinuxBundlerError> {
log.info("Creating '\(desktopFile.lastPathComponent)'")

let properties = [
("Type", "Application"),
("Version", "1.0"),
("Version", "1.0"), // The version of the target desktop spec, not the app
("Name", appName),
("Comment", ""),
("Exec", "\(appName) %F"),
("Exec", "\(installedExecutableLocation.path)"),
("Icon", appName),
("Terminal", "false"),
("Categories", ""),
Expand Down
221 changes: 221 additions & 0 deletions Sources/swift-bundler/Bundler/RPMBundler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import Foundation
import Parsing

/// The bundler for creating Linux RPM packages. The output of this bundler
/// isn't executable.
enum RPMBundler: Bundler {
typealias Context = Void

static let outputIsRunnable = false

static func intendedOutput(
in context: BundlerContext,
_ additionalContext: Void
) -> BundlerOutputStructure {
let bundle = context.outputDirectory
.appendingPathComponent("\(context.appName).rpm")
return BundlerOutputStructure(
bundle: bundle,
executable: nil,
additionalOutputs: []
)
}

static func bundle(
_ context: BundlerContext,
_ additionalContext: Context
) -> Result<BundlerOutputStructure, RPMBundlerError> {
let outputStructure = intendedOutput(in: context, additionalContext)
let bundleName = outputStructure.bundle.lastPathComponent

let appVersion = context.appConfiguration.version
let rpmBuildDirectory = RPMBuildDirectory(
at: context.outputDirectory / "rpmbuild",
appName: context.appName,
appVersion: appVersion
)

// The 'source' directory for our RPM. Doesn't actual contain source code
// cause it's all pre-compiled.
let sourceDirectory = context.outputDirectory / "\(context.appName)-\(appVersion)"

let installationRoot = URL(fileURLWithPath: "/opt/\(context.appName)")
return GenericLinuxBundler.bundle(
context,
GenericLinuxBundler.Context(
cosmeticBundleName: bundleName,
installationRoot: installationRoot
)
)
.mapError(RPMBundlerError.failedToRunGenericBundler)
.andThenDoSideEffect { _ in
// Create the an `rpmbuild` directory with the structure required by the
// rpmbuild tool.
rpmBuildDirectory.createDirectories()
}
.andThenDoSideEffect { structure in
// Copy `.generic` bundle to give it the name we want it to have inside
// the .tar.gz archive.
FileManager.default.copyItem(
at: structure.root,
to: sourceDirectory,
onError: RPMBundlerError.failedToCopyGenericBundle
)
}
.andThenDoSideEffect { structure in
// Generate an archive of the source directory. Again, it's not actually
// the source code of the app, but it is according to RPM terminology.
log.info("Archiving bundle")
return ArchiveTool.createTarGz(
of: sourceDirectory,
at: rpmBuildDirectory.appSourceArchive
).mapError(RPMBundlerError.failedToArchiveSources)
}
.andThenDoSideEffect { structure in
// Generate the RPM spec for our 'build' process (no actual building
// happens in our rpmbuild step, only copying and system setup such as
// installing desktop files).
log.info("Creating RPM spec file")
let specContents = generateSpec(
appName: context.appName,
appVersion: appVersion,
bundleStructure: structure,
sourceArchiveName: rpmBuildDirectory.appSourceArchive.lastPathComponent,
installationRoot: installationRoot
)
return specContents.write(to: rpmBuildDirectory.appSpec)
.mapError { error in
.failedToWriteSpecFile(rpmBuildDirectory.appSpec, error)
}
}
.andThenDoSideEffect { _ in
// Build the actual RPM.
log.info("Running rpmbuild")
let command = "rpmbuild"
let arguments = [
"--define", "_topdir \(rpmBuildDirectory.root.path)",
"-v", "-bb", rpmBuildDirectory.appSpec.path,
]
return Process.create(command, arguments: arguments)
.runAndWait()
.mapError { error in
.failedToRunRPMBuildTool(command, error)
}
}
.andThen { _ in
// Find the produced RPM because rpmbuild doesn't really tell us where
// it'll end up.
FileManager.default.enumerator(
at: rpmBuildDirectory.rpms,
includingPropertiesForKeys: nil
)
.okOr(RPMBundlerError.failedToEnumerateRPMs(rpmBuildDirectory.rpms))
.andThen { files in
files.compactMap { file in
file as? URL
}.filter { file in
file.pathExtension == "rpm"
}.first.okOr(RPMBundlerError.failedToFindProducedRPM(rpmBuildDirectory.rpms))
}
}
.andThen { rpmFile in
// Copy the rpm file to the previously declared output location
FileManager.default.copyItem(
at: rpmFile,
to: outputStructure.bundle,
onError: RPMBundlerError.failedToCopyRPMToOutputDirectory
)
}
.replacingSuccessValue(with: outputStructure)
}

/// Generates an RPM spec for the given application.
static func generateSpec(
appName: String,
appVersion: String,
bundleStructure: GenericLinuxBundler.BundleStructure,
sourceArchiveName: String,
installationRoot: URL
) -> String {
let relativeDesktopFileLocation = bundleStructure.desktopFile.path(
relativeTo: bundleStructure.root
)
return """
Name: \(appName)
Version: \(appVersion)
Release: 1%{?dist}
Summary: An app bundled by Swift Bundler
License: MIT
Source0: \(sourceArchiveName)
%global debug_package %{nil}
%description
An app bundled by Swift Bundler
%prep
%setup
%build
%install
rm -rf $RPM_BUILD_ROOT
mkdir -p $RPM_BUILD_ROOT\(installationRoot.path)
cp -R * $RPM_BUILD_ROOT\(installationRoot.path)
desktop-file-install $RPM_BUILD_ROOT\(installationRoot.path)/\(relativeDesktopFileLocation)
%clean
rm -rf $RPM_BUILD_ROOT
%files
\(installationRoot.path)
/usr/share/applications/\(bundleStructure.desktopFile.lastPathComponent)
"""
}

/// The structure of an `rpmbuild` directory.
struct RPMBuildDirectory {
/// The root directory of the structure.
var root: URL
var build: URL
var buildRoot: URL
var rpms: URL
var sources: URL
/// The app's `.tar.gz` source archive.
var appSourceArchive: URL
var specs: URL
/// The app's RPM `.spec` file.
var appSpec: URL
var srpms: URL

/// All directories described by this structure.
var directories: [URL] {
[root, build, buildRoot, rpms, sources, specs, srpms]
}

/// Describes the structure of an `rpmbuild` directory. Doesn't create
/// anything on disk (see ``RPMBuildDirectory/createDirectories()``).
init(at root: URL, appName: String, appVersion: String) {
self.root = root
build = root / "BUILD"
buildRoot = root / "BUILDROOT"
rpms = root / "RPMS"
sources = root / "SOURCES"
appSourceArchive = sources / "\(appName)-\(appVersion).tar.gz"
specs = root / "SPECS"
appSpec = specs / "\(appName).spec"
srpms = root / "SRPMS"
}

/// Creates all directories described by this directory structure.
func createDirectories() -> Result<Void, RPMBundlerError> {
directories.tryForEach { directory in
FileManager.default.createDirectory(
at: directory,
onError: RPMBundlerError.failedToCreateRPMBuildDirectory
)
}
}
}
}
Loading

0 comments on commit 73fe891

Please sign in to comment.