diff --git a/admin/osx/mac-crafter/Sources/Utils/Craft.swift b/admin/osx/mac-crafter/Sources/Utils/Craft.swift new file mode 100644 index 0000000000000..738933f0e074b --- /dev/null +++ b/admin/osx/mac-crafter/Sources/Utils/Craft.swift @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2024 by Claudio Cambra + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +func archToCraftTarget(_ arch: String) -> String { + return arch == "arm64" ? "macos-clang-arm64" : "macos-64-clang" +} diff --git a/admin/osx/mac-crafter/Sources/Utils/Packaging.swift b/admin/osx/mac-crafter/Sources/Utils/Packaging.swift new file mode 100644 index 0000000000000..836f68a82e13b --- /dev/null +++ b/admin/osx/mac-crafter/Sources/Utils/Packaging.swift @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2024 by Claudio Cambra + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +import Foundation + +enum PackagingError: Error { + case projectNameSettingError(String) + case packageBuildError(String) + case packageSigningError(String) + case packageNotarisationError(String) + case packageSparkleBuildError(String) + case packageSparkleSignError(String) +} + +/// NOTE: Requires Packages utility. http://s.sudre.free.fr/Software/Packages/about.html +fileprivate func buildPackage(appName: String, buildWorkPath: String, productPath: String) throws -> String { + let packageFile = "\(appName).pkg" + let pkgprojPath = "\(buildWorkPath)/admin/osx/macosx.pkgproj" + + guard shell("packagesutil --file \(pkgprojPath) set project name \(appName)") == 0 else { + throw PackagingError.projectNameSettingError("Could not set project name in pkgproj!") + } + guard shell("packagesbuild -v --build-folder \(productPath) -F \(productPath) \(pkgprojPath)") == 0 else { + throw PackagingError.packageBuildError("Error building pkg file!") + } + return "\(productPath)/\(packageFile)" +} + +fileprivate func signPackage(packagePath: String, packageSigningId: String) throws { + let packagePathNew = "\(packagePath).new" + guard shell("productsign --timestamp --sign '\(packageSigningId)' \(packagePath) \(packagePathNew)") == 0 else { + throw PackagingError.packageSigningError("Could not sign pkg file!") + } + let fm = FileManager.default + try fm.removeItem(atPath: packagePath) + try fm.moveItem(atPath: packagePathNew, toPath: packagePath) +} + +fileprivate func notarisePackage( + packagePath: String, appleId: String, applePassword: String, appleTeamId: String +) throws { + guard shell("xcrun notarytool submit \(packagePath) --apple-id \(appleId) --password \(applePassword) --team-id \(appleTeamId) --wait") == 0 else { + throw PackagingError.packageNotarisationError("Failure when notarising package!") + } + guard shell("xcrun stapler staple \(packagePath)") == 0 else { + throw PackagingError.packageNotarisationError("Could not staple notarisation on package!") + } +} + +fileprivate func buildSparklePackage(packagePath: String, buildPath: String) throws -> String { + let sparkleTbzPath = "\(packagePath).tbz" + guard shell("tar cf \(sparkleTbzPath) \(packagePath)") == 0 else { + throw PackagingError.packageSparkleBuildError("Could not create Sparkle package tbz!") + } + return sparkleTbzPath +} + +fileprivate func signSparklePackage(sparkleTbzPath: String, buildPath: String, signKey: String) throws { + guard shell("\(buildPath)/bin/sign_update -s \(signKey) \(sparkleTbzPath)") == 0 else { + throw PackagingError.packageSparkleSignError("Could not sign Sparkle package tbz!") + } +} + +func packageAppBundle( + productPath: String, + buildPath: String, + craftTarget: String, + craftBlueprintName: String, + appName: String, + packageSigningId: String?, + appleId: String?, + applePassword: String?, + appleTeamId: String?, + sparklePackageSignKey: String? +) throws { + print("Creating pkg file for client…") + let buildWorkPath = "\(buildPath)/\(craftTarget)/build/\(craftBlueprintName)/work/build" + let packagePath = try buildPackage( + appName: appName, + buildWorkPath: buildWorkPath, + productPath: productPath + ) + + if let packageSigningId { + print("Signing pkg with \(packageSigningId)…") + try signPackage(packagePath: packagePath, packageSigningId: packageSigningId) + + if let appleId, let applePassword, let appleTeamId { + print("Notarising pkg with Apple ID \(appleId)…") + try notarisePackage( + packagePath: packagePath, + appleId: appleId, + applePassword: applePassword, + appleTeamId: appleTeamId + ) + } + } + + print("Creating Sparkle TBZ file…") + let sparklePackagePath = + try buildSparklePackage(packagePath: packagePath, buildPath: buildPath) + + if let sparklePackageSignKey { + print("Signing Sparkle TBZ file…") + try signSparklePackage( + sparkleTbzPath: sparklePackagePath, + buildPath: buildPath, + signKey: sparklePackageSignKey + ) + } +} diff --git a/admin/osx/mac-crafter/Sources/main.swift b/admin/osx/mac-crafter/Sources/main.swift index 0492d9fc527b8..de22b799e57bf 100644 --- a/admin/osx/mac-crafter/Sources/main.swift +++ b/admin/osx/mac-crafter/Sources/main.swift @@ -65,6 +65,21 @@ struct Build: ParsableCommand { @Option(name: [.long], help: "Git clone command; include options such as depth.") var gitCloneCommand = "git clone --depth=1" + @Option(name: [.long], help: "Apple ID, used for notarisation.") + var appleId: String? + + @Option(name: [.long], help: "Apple ID password, used for notarisation.") + var applePassword: String? + + @Option(name: [.long], help: "Apple Team ID, used for notarisation.") + var appleTeamId: String? + + @Option(name: [.long], help: "Apple package signing ID.") + var packageSigningId: String? + + @Option(name: [.long], help: "Sparkle package signing key.") + var sparklePackageSignKey: String? + @Flag(help: "Reconfigure KDE Craft.") var reconfigureCraft = false @@ -86,6 +101,9 @@ struct Build: ParsableCommand { @Flag(help: "Run a full rebuild.") var fullRebuild = false + @Flag(help: "Create an installer package.") + var package = false + mutating func run() throws { print("Configuring build tooling.") @@ -110,7 +128,7 @@ struct Build: ParsableCommand { let craftMasterDir = "\(buildPath)/craftmaster" let craftMasterIni = "\(repoRootDir)/craftmaster.ini" let craftMasterPy = "\(craftMasterDir)/CraftMaster.py" - let craftTarget = arch == "arm64" ? "macos-clang-arm64" : "macos-64-clang" + let craftTarget = archToCraftTarget(arch) let craftCommand = "python3 \(craftMasterPy) --config \(craftMasterIni) --target \(craftTarget) -c" @@ -171,7 +189,7 @@ struct Build: ParsableCommand { ) } - print("Crafting Nextcloud Desktop Client...") + print("Crafting \(appName) Desktop Client...") let allOptionsString = craftOptions.map({ "--options \"\($0)\"" }).joined(separator: " ") @@ -209,6 +227,21 @@ struct Build: ParsableCommand { } try fm.copyItem(atPath: clientAppDir, toPath: "\(productPath)/\(appName).app") + if package { + try packageAppBundle( + productPath: productPath, + buildPath: buildPath, + craftTarget: craftTarget, + craftBlueprintName: craftBlueprintName, + appName: appName, + packageSigningId: packageSigningId, + appleId: appleId, + applePassword: applePassword, + appleTeamId: appleTeamId, + sparklePackageSignKey: sparklePackageSignKey + ) + } + print("Done!") } } @@ -227,14 +260,61 @@ struct Codesign: ParsableCommand { } } +struct Package: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Packaging script for the client.") + + @Option(name: [.short, .long], help: "Architecture.") + var arch = "arm64" + + @Option(name: [.short, .long], help: "Path for build files to be written.") + var buildPath = "\(FileManager.default.currentDirectoryPath)/build" + + @Option(name: [.short, .long], help: "Path for the final product to be put.") + var productPath = "\(FileManager.default.currentDirectoryPath)/product" + + @Option(name: [.long], help: "Nextcloud Desktop Client craft blueprint name.") + var craftBlueprintName = "nextcloud-client" + + @Option(name: [.long], help: "The application's branded name.") + var appName = "Nextcloud" + + @Option(name: [.long], help: "Apple ID, used for notarisation.") + var appleId: String? + + @Option(name: [.long], help: "Apple ID password, used for notarisation.") + var applePassword: String? + + @Option(name: [.long], help: "Apple Team ID, used for notarisation.") + var appleTeamId: String? + + @Option(name: [.long], help: "Apple package signing ID.") + var packageSigningId: String? + + @Option(name: [.long], help: "Sparkle package signing key.") + var sparklePackageSignKey: String? + + mutating func run() throws { + try packageAppBundle( + productPath: productPath, + buildPath: buildPath, + craftTarget: archToCraftTarget(arch), + craftBlueprintName: craftBlueprintName, + appName: appName, + packageSigningId: packageSigningId, + appleId: appleId, + applePassword: applePassword, + appleTeamId: appleTeamId, + sparklePackageSignKey: sparklePackageSignKey + ) + } +} + struct MacCrafter: ParsableCommand { static let configuration = CommandConfiguration( abstract: "A tool to easily build a fully-functional Nextcloud Desktop Client for macOS.", - subcommands: [Build.self, Codesign.self], + subcommands: [Build.self, Codesign.self, Package.self], defaultSubcommand: Build.self ) - - } MacCrafter.main()