diff --git a/admin/osx/mac-crafter/Package.swift b/admin/osx/mac-crafter/Package.swift index 7f6560def355b..98898d97c1c58 100644 --- a/admin/osx/mac-crafter/Package.swift +++ b/admin/osx/mac-crafter/Package.swift @@ -11,7 +11,7 @@ import PackageDescription let package = Package( name: "mac-crafter", platforms: [ - .macOS(.v11), + .macOS(.v12), ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", from: "1.4.0") diff --git a/admin/osx/mac-crafter/README.md b/admin/osx/mac-crafter/README.md index 1a234f65952a3..d3a9c5af7dd16 100644 --- a/admin/osx/mac-crafter/README.md +++ b/admin/osx/mac-crafter/README.md @@ -2,15 +2,15 @@ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - SPDX-License-Identifier: GPL-2.0-or-later --> -# mac-crafter +# Mac Crafter mac-crafter is a tool to easily build a fully functional Nextcloud Desktop Client for macOS. -It automates cloning, configuring, crafting, codesigning, packaging, and even DMG creation of the client. -The tool is built with Swift’s ArgumentParser and it drives the KDE Craft build system along with some Python scripts and shell commands. +It automates cloning, configuring, crafting, codesigning, packaging, and even disk image creation of the client. +The tool is built with Apple’s ArgumentParser and it drives the KDE Craft build system along with some Python scripts and shell commands. ## System Requirements -- macOS 11 Big Sur or newer +- macOS 12 Monterey or newer - Xcode - Python3 - Homebrew (for installing additional tools like `inkscape`, `pyenv`, and `create-dmg`) @@ -28,139 +28,8 @@ The script will also clone the KDE Craft repository if it is not already present ## Usage -mac-crafter comes with several subcommands: - -### Build - -This is the default command and it handles: -- Configuring and/or cloning KDE Craft (using the CraftMaster repository) -- Adding the Nextcloud Desktop Client blueprints -- Crafting KDE Craft projects and installing dependencies -- Building the client with options for a full rebuild, offline mode, and more - -**Usage Example:** - -``` -swift run mac-crafter [options] -``` - -**Common Options:** - -- **Repository and Build Paths:** - - `--repo-root-dir`: Path to the Nextcloud Desktop Client git repository (default is `../../../` relative to the current directory). - - `--build-path`: Directory where build files are written. - - `--product-path`: Directory where the final product (app bundle) will be placed. - -- **Build Settings:** - - `--arch`: Architecture to build for (e.g. `arm64`, `x86_64`). - - `--build-type`: Build type (e.g. `Release`, `RelWithDebInfo`, `Debug`). - - `--craft-blueprint-name`: Blueprint name for Nextcloud Desktop Client (default is `"nextcloud-client"`). - - `--full-rebuild`: Forces a full rebuild by wiping existing build artifacts. - - `--offline`: Run the build offline (do not update craft). - -- **Code Signing & Notarisation:** - - `--code-sign-identity (-c)`: Code signing identity for the client and libraries. - - `--apple-id`, `--apple-password`, `--apple-team-id`: Credentials for notarisation. - - `--package-signing-id`: Identifier used for package signing. - -- **Advanced Options:** - - `--disable-autoupdater`: Build without the Sparkle auto-updater. - - `--build-tests`: Optionally build the test suite. - - `--build-file-provider-module`: Build the File Provider Module. - - `--dev`: Build in developer mode which, for example, appends "Dev" to the app name and sets a dev flag in the craft options. - - `--override-server-url` and `--force-override-server-url`: Override server URL settings for the client. - -The build process automatically ensures necessary tools (like git, inkscape, python3) are installed—invoking installation commands on missing dependencies. - -### Codesign - -Use this subcommand to codesign an existing Nextcloud Desktop Client app bundle. - -**Usage Example:** - -``` -swift run mac-crafter codesign -c "Apple Development: " -``` - -- **Options:** - - `appBundlePath`: Path to the app bundle. - - `--code-sign-identity (-c)`: Code signing identity to use. - -### Package - -This command is used to package the client after building. It prepares the app bundle and can also perform package signing and notarisation. - -**Usage Example:** - -``` -swift run mac-crafter package [options] -``` - -- **Options:** - - `--arch`: Target architecture. - - `--build-path`, `--product-path`: Build and product directories. - - `--craft-blueprint-name`: Blueprint name. - - `--app-name`: The branded name of the application. - - Various notarisation options (`--apple-id`, `--apple-password`, `--apple-team-id`). - - Signing options such as `--package-signing-id` and `--sparkle-package-sign-key`. - -### CreateDMG - -This subcommand creates a DMG (disk image) for the client app bundle. - -**Usage Example:** - -``` -swift run mac-crafter createDMG [options] -``` - -- **Options:** - - `appBundlePath`: The app bundle’s path. - - `--product-path`: Where the final DMG and product will be placed. - - `--build-path`: Directory for temporary build files. - - `--app-name`: Application's name. - - Notarisation and signing options similar to the Package command. - -## How It Works - -1. **Tooling Configuration:** - The build command checks for necessary tools (like `codesign`, `git`, `brew`, `inkscape`, and `python3`) and auto-installs missing dependencies if needed. -2. **KDE Craft Configuration:** - - If KDE Craft isn’t already cloned or if a reconfiguration is triggered, the tool clones the CraftMaster repository and configures it using a provided INI file. - - Next, it adds the Nextcloud Desktop Client blueprints, then crafts KDE Craft and installs the required dependencies. -3. **Craft Options Setup:** - The build process assembles a set of options including source directory, architecture, build tests, auto-updater settings, and more. -4. **Building, Codesigning, and Packaging:** - The tool then builds the client, optionally performs a full rebuild, and if a codesign identity is provided, signs the final app bundle. Finally, it copies the finished app bundle to the product directory. -5. **Optional DMG Creation:** - Use the CreateDMG subcommand to bundle the built client into a DMG for distribution. - -## Quick Start - -For a basic build and codesigning: -``` -swift run mac-crafter -c "Apple Development: MyCertificate" -``` - -For a full rebuild on a specific architecture: -``` -swift run mac-crafter --arch arm64 --full-rebuild -c "Apple Development: MyCertificate" -``` - -To package the app: -``` -swift run mac-crafter package -c "Apple Development: MyCertificate" --arch arm64 -``` - -To create a DMG: -``` -swift run mac-crafter createDMG /path/to/Nextcloud.app --app-name Nextcloud -``` - -For more details on all available options, run: -``` -swift run mac-crafter --help -``` +mac-crafter comes with several subcommands. +To see a full reference, run `mac-crafter --help` or `mac-crafter --help` for further specific information about the command. ## Additional Information diff --git a/admin/osx/mac-crafter/Sources/Commands/Build.swift b/admin/osx/mac-crafter/Sources/Commands/Build.swift index 7a4865570daf1..bbdea2038eaf3 100644 --- a/admin/osx/mac-crafter/Sources/Commands/Build.swift +++ b/admin/osx/mac-crafter/Sources/Commands/Build.swift @@ -106,6 +106,20 @@ struct Build: AsyncParsableCommand { @Flag(help: "Build in developer mode.") var dev = false + /// + /// Download the Sparkle framework archive with URLSession. + /// + private func downloadSparkle() async throws -> URL { + guard let url = URL(string: sparkleDownloadUrl) else { + throw MacCrafterError.downloadError("Sparkle download URL appears to be invalid: \(sparkleDownloadUrl)") + } + + let request = URLRequest(url: url) + let (file, _) = try await URLSession.shared.download(for: request) + + return file + } + mutating func run() async throws { let stopwatch = Stopwatch() @@ -131,59 +145,56 @@ struct Build: AsyncParsableCommand { print("Build dependencies are installed.") let fm = FileManager.default - let craftMasterDir = "\(buildPath)/craftmaster" - let craftMasterIni = "\(repoRootDir)/craftmaster.ini" - let craftMasterPy = "\(craftMasterDir)/CraftMaster.py" + let buildURL = URL(fileURLWithPath: buildPath).standardized + let repoRootURL = URL(fileURLWithPath: repoRootDir).standardized + let craftMasterDir = buildURL.appendingPathComponent("craftmaster") + let craftMasterIni = repoRootURL.appendingPathComponent("craftmaster.ini") + let craftMasterPy = craftMasterDir.appendingPathComponent("CraftMaster.py") let craftTarget = archToCraftTarget(arch) - let craftCommand = - "python3 \(craftMasterPy) --config \(craftMasterIni) --target \(craftTarget) -c" - - if !fm.fileExists(atPath: craftMasterDir) || reconfigureCraft { - print("Configuring KDE Craft.") - - print("Configuring KDE Craft...") + let craftCommand = "python3 \(craftMasterPy.path) --config \(craftMasterIni.path) --target \(craftTarget) -c" + + if !fm.fileExists(atPath: craftMasterDir.path) || reconfigureCraft { stopwatch.record("KDE Craft Setup") - if fm.fileExists(atPath: craftMasterDir) { + if fm.fileExists(atPath: craftMasterDir.path) { print("KDE Craft is already cloned.") } else { print("Cloning KDE Craft...") - guard await shell("\(gitCloneCommand) \(craftMasterGitUrl) \(craftMasterDir)") == 0 else { - throw MacCrafterError.gitError("The referenced CraftMaster repository could not be cloned.") + guard await shell("\(gitCloneCommand) \(craftMasterGitUrl) \(craftMasterDir.path)") == 0 else { + throw MacCrafterError.gitError("The referenced CraftMaster repository could not be cloned from \(craftMasterGitUrl) to \(craftMasterDir.path)") } } print("Configuring required KDE Craft blueprint repositories...") - guard await shell("\(craftCommand) --add-blueprint-repository '\(kdeBlueprintsGitUrl)|\(kdeBlueprintsGitRef)|'") == 0 else { stopwatch.record("Craft Blueprints Configuration") + guard await shell("\(craftCommand) --add-blueprint-repository '\(kdeBlueprintsGitUrl)|\(kdeBlueprintsGitRef)|'") == 0 else { throw MacCrafterError.craftError("Error adding KDE blueprint repository.") } - guard await shell("\(craftCommand) --add-blueprint-repository '\(clientBlueprintsGitUrl)|\(clientBlueprintsGitRef)|'") == 0 else { + guard await shell("\(craftCommand) --add-blueprint-repository '\(clientBlueprintsGitUrl)|\(clientBlueprintsGitRef)|'") == 0 else { throw MacCrafterError.craftError("Error adding Nextcloud Client blueprint repository.") } print("Crafting KDE Craft...") - guard await shell("\(craftCommand) craft") == 0 else { stopwatch.record("Craft Crafting") + guard await shell("\(craftCommand) craft") == 0 else { throw MacCrafterError.craftError("Error crafting KDE Craft.") } print("Crafting Nextcloud Desktop Client dependencies...") - guard await shell("\(craftCommand) --install-deps \(craftBlueprintName)") == 0 else { stopwatch.record("Nextcloud Client Dependencies Crafting") + guard await shell("\(craftCommand) --install-deps \(craftBlueprintName)") == 0 else { throw MacCrafterError.craftError("Error installing dependencies.") } } else { print("Skipping KDE Craft configuration because it is already and no reconfiguration was requested.") } - var craftOptions = [ - "\(craftBlueprintName).srcDir=\(repoRootDir)", + "\(craftBlueprintName).srcDir=\(repoRootURL.path)", "\(craftBlueprintName).osxArchs=\(arch)", "\(craftBlueprintName).buildTests=\(buildTests ? "True" : "False")", "\(craftBlueprintName).buildMacOSBundle=\(disableAppBundle ? "False" : "True")", @@ -200,55 +211,56 @@ struct Build: AsyncParsableCommand { craftOptions.append("\(craftBlueprintName).devMode=True") } - if !disableAutoUpdater { + if disableAutoUpdater == false { print("Configuring Sparkle auto-updater.") stopwatch.record("Sparke Configuration") - let sparkleDownloadResult = await shell("wget \(sparkleDownloadUrl) -O \(buildPath)/Sparkle.tar.xz") - + let downloadedArchive = try await downloadSparkle() let fm = FileManager.default - guard fm.fileExists(atPath: "\(buildPath)/Sparkle.tar.xz") || - sparkleDownloadResult == 0 - else { - throw MacCrafterError.environmentError("Error downloading sparkle.") - } - let sparkleUnarchiveResult = await shell("tar -xvf \(buildPath)/Sparkle.tar.xz -C \(buildPath)") + let sparkleUnarchiveResult = await shell("tar -xvf \(downloadedArchive.path) -C \(buildPath)") - guard fm.fileExists(atPath: "\(buildPath)/Sparkle.framework") || - sparkleUnarchiveResult == 0 - else { + guard fm.fileExists(atPath: "\(buildPath)/Sparkle.framework") || sparkleUnarchiveResult == 0 else { throw MacCrafterError.environmentError("Error unpacking sparkle.") } - craftOptions.append( - "\(craftBlueprintName).sparkleLibPath=\(buildPath)/Sparkle.framework" - ) + craftOptions.append("\(craftBlueprintName).sparkleLibPath=\(buildPath)/Sparkle.framework") } - let clientBuildDir = "\(buildPath)/\(craftTarget)/build/\(craftBlueprintName)" + let clientBuildURL = buildURL + .appendingPathComponent(craftTarget) + .appendingPathComponent("build") + .appendingPathComponent(craftBlueprintName) + print("Crafting \(appName) Desktop Client...") stopwatch.record("Desktop Client Crafting") if fullRebuild { do { - try fm.removeItem(atPath: clientBuildDir) + try fm.removeItem(atPath: clientBuildURL.path) } catch let error { - print("WARNING! Error removing build directory: \(error)") + print("ERROR: Error removing build directory: \(error)") + throw MacCrafterError.craftError("Failed to remove existing build directory!") } } else { // HACK: When building the client we often run into issues with the shell integration // component -- particularly the FileProviderExt part. So we wipe out the build // artifacts so this part gets build first. Let's first check if we have an existing // build in the folder we expect - let shellIntegrationDir = "\(clientBuildDir)/work/build/shell_integration/MacOSX" - if fm.fileExists(atPath: shellIntegrationDir) { + let shellIntegrationURL = clientBuildURL + .appendingPathComponent("work") + .appendingPathComponent("build") + .appendingPathComponent("shell_integration") + .appendingPathComponent("MacOSX") + + if fm.fileExists(atPath: shellIntegrationURL.path) { print("Removing existing shell integration build artifacts...") do { - try fm.removeItem(atPath: shellIntegrationDir) + try fm.removeItem(atPath: shellIntegrationURL.path) } catch let error { - print("WARNING! Error removing shell integration build directory: \(error)") + print("ERROR: Error removing shell integration build directory: \(error)") + throw MacCrafterError.craftError("Failed to remove existing shell integration build directory!") } } } @@ -257,41 +269,62 @@ struct Build: AsyncParsableCommand { let offlineMode = offline ? "--offline" : "" let allOptionsString = craftOptions.map({ "--options \"\($0)\"" }).joined(separator: " ") - guard await shell( - "\(craftCommand) --buildtype \(buildType) \(buildMode) \(offlineMode) \(allOptionsString) \(craftBlueprintName)" - ) == 0 else { + guard await shell("\(craftCommand) --buildtype \(buildType) \(buildMode) \(offlineMode) \(allOptionsString) \(craftBlueprintName)") == 0 else { // Troubleshooting: This can happen because a CraftMaster repository was cloned which does not contain the commit defined in craftmaster.ini of this project due to use of customized forks. throw MacCrafterError.craftError("Error crafting Nextcloud Desktop Client.") } - let clientAppDir = "\(clientBuildDir)/image-\(buildType)-master/\(appName).app" + let clientAppURL = clientBuildURL + .appendingPathComponent("image-\(buildType)-master") + .appendingPathComponent("\(appName).app") if let codeSignIdentity { - print("Code-signing Nextcloud Desktop Client libraries and frameworks...") + print("Signing Nextcloud Desktop Client libraries and frameworks...") stopwatch.record("Code Signing") - let entitlementsPath = "\(clientBuildDir)/work/build/admin/osx/macosx.entitlements" - try await codesignClientAppBundle( - at: clientAppDir, - withCodeSignIdentity: codeSignIdentity, - usingEntitlements: entitlementsPath, - dev: dev - ) + let appEntitlements = clientBuildURL + .appendingPathComponent("work") + .appendingPathComponent("build") + .appendingPathComponent("admin") + .appendingPathComponent("osx") + .appendingPathComponent("macosx.entitlements") + + let entitlementsDirectory = clientBuildURL + .appendingPathComponent("work") + .appendingPathComponent("build") + .appendingPathComponent("shell_integration") + .appendingPathComponent("MacOSX") + + let entitlements: [String: URL] = [ + "\(appName).app": appEntitlements, + "FileProviderExt.appex": entitlementsDirectory.appendingPathComponent("FileProviderExt.entitlements"), + "FileProviderUIExt.appex": entitlementsDirectory.appendingPathComponent("FileProviderUIExt.entitlements"), + "FinderSyncExt.appex": entitlementsDirectory.appendingPathComponent("FinderSyncExt.entitlements"), + ] + + for file in entitlements.values { + if FileManager.default.fileExists(atPath: file.path) { + print("Using entitlement manifest: \(file.path)") + } else { + print("ERROR: Entitlement manifest does not exist: \(file.path)") + } + } + + try await Signer.signMainBundle(at: clientAppURL, codeSignIdentity: codeSignIdentity, entitlements: entitlements) } print("Placing Nextcloud Desktop Client in \(productPath)...") if !fm.fileExists(atPath: productPath) { - try fm.createDirectory( - atPath: productPath, withIntermediateDirectories: true, attributes: nil - ) + try fm.createDirectory(atPath: productPath, withIntermediateDirectories: true, attributes: nil) } + if fm.fileExists(atPath: "\(productPath)/\(appName).app") { try fm.removeItem(atPath: "\(productPath)/\(appName).app") } - try fm.copyItem(atPath: clientAppDir, toPath: "\(productPath)/\(appName).app") - + try fm.copyItem(atPath: clientAppURL.path, toPath: "\(productPath)/\(appName).app") + if package { stopwatch.record("Packaging App Bundle") diff --git a/admin/osx/mac-crafter/Sources/Commands/Codesign.swift b/admin/osx/mac-crafter/Sources/Commands/Codesign.swift index f4d960c99d2fa..e9607833b3f73 100644 --- a/admin/osx/mac-crafter/Sources/Commands/Codesign.swift +++ b/admin/osx/mac-crafter/Sources/Commands/Codesign.swift @@ -9,25 +9,35 @@ import Foundation struct Codesign: AsyncParsableCommand { static let configuration = CommandConfiguration(abstract: "Codesigning script for the client.") - @Argument(help: "Path to the Nextcloud Desktop Client app bundle.") + @Argument(help: "Path to the Nextcloud desktop client app bundle.") var appBundlePath = "\(FileManager.default.currentDirectoryPath)/product/Nextcloud.app" @Option(name: [.short, .long], help: "Code signing identity for desktop client and libs.") var codeSignIdentity: String - - @Option(name: [.short, .long], help: "Entitlements to apply to the app bundle.") - var entitlementsPath: String? - + + @Argument(help: "Location of the entitlements manifest for the app.") + var appEntitlements: String + + @Argument(help: "Location of the entitlements manifest for the file provider extension.") + var fileProviderEntitlements: String + + @Argument(help: "Location of the entitlements manifest for the file provider UI extension.") + var fileProviderUIEntitlements: String + + @Argument(help: "Location of the entitlements manifest for the Finder sync extension.") + var finderSyncEntitlements: String + mutating func run() async throws { - let absolutePath = appBundlePath.hasPrefix("/") - ? appBundlePath - : "\(FileManager.default.currentDirectoryPath)/\(appBundlePath)" - - try await codesignClientAppBundle( - at: absolutePath, - withCodeSignIdentity: codeSignIdentity, - usingEntitlements: entitlementsPath, - dev: false - ) + let absolutePath = appBundlePath.hasPrefix("/") ? appBundlePath : "\(FileManager.default.currentDirectoryPath)/\(appBundlePath)" + let url = URL(fileURLWithPath: absolutePath) + + let entitlements = [ + url.lastPathComponent: URL(fileURLWithPath: appEntitlements), + "FileProviderExt.appex": URL(fileURLWithPath: fileProviderEntitlements), + "FileProviderUIExt.appex": URL(fileURLWithPath: fileProviderUIEntitlements), + "FinderSyncExt.appex": URL(fileURLWithPath: finderSyncEntitlements), + ] + + try await Signer.signMainBundle(at: url, codeSignIdentity: codeSignIdentity, entitlements: entitlements) } } diff --git a/admin/osx/mac-crafter/Sources/MacCrafterError.swift b/admin/osx/mac-crafter/Sources/MacCrafterError.swift index 7b9de6de62cf9..f0e3b6fc50338 100644 --- a/admin/osx/mac-crafter/Sources/MacCrafterError.swift +++ b/admin/osx/mac-crafter/Sources/MacCrafterError.swift @@ -3,21 +3,27 @@ // SPDX-License-Identifier: GPL-2.0-or-later enum MacCrafterError: Error, CustomStringConvertible { - case failedEnumeration(String) + case craftError(String) + case downloadError(String) case environmentError(String) + case failedEnumeration(String) case gitError(String) - case craftError(String) + case signing(String) var description: String { switch self { - case .failedEnumeration(let message): - return "Failed enumeration: \(message)" + case .craftError(let message): + return "Craft: \(message)" + case .downloadError(let message): + return "Download: \(message)" case .environmentError(let message): return "Environment: \(message)" + case .failedEnumeration(let message): + return "Failed enumeration: \(message)" case .gitError(let message): return "Git: \(message)" - case .craftError(let message): - return "Craft: \(message)" + case .signing(let message): + return "Signing: \(message)" } } } diff --git a/admin/osx/mac-crafter/Sources/Utils/Codesigning.swift b/admin/osx/mac-crafter/Sources/Utils/Codesigning.swift deleted file mode 100644 index c1d0d681b98fb..0000000000000 --- a/admin/osx/mac-crafter/Sources/Utils/Codesigning.swift +++ /dev/null @@ -1,204 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -import Foundation - -fileprivate let defaultCodesignOptions = "--timestamp --force --preserve-metadata=entitlements --verbose=4 --options runtime" - -enum CodeSigningError: Error { - case failedToCodeSign(String) -} - -enum AppBundleSigningError: Error { - case doesNotExist(String) - case couldNotEnumerate(String) -} - -func isLibrary(_ path: String) -> Bool { - path.hasSuffix(".dylib") || path.hasSuffix(".framework") -} - -func isAppExtension(_ path: String) -> Bool { - path.hasSuffix(".appex") -} - -func isExecutable(_ path: String) async throws -> Bool { - let outPipe = Pipe() - let errPipe = Pipe() - let task = Process() - task.standardOutput = outPipe - task.standardError = errPipe - - let command = "file \"\(path)\"" - guard await run("/bin/zsh", ["-c", command], task: task) == 0 else { - throw CodeSigningError.failedToCodeSign("Failed to determine if \(path) is an executable.") - } - - let outputFileHandle = outPipe.fileHandleForReading - let outputData = outputFileHandle.readDataToEndOfFile() - try outputFileHandle.close() - let output = String(data: outputData, encoding: .utf8) ?? "" - return output.contains("Mach-O 64-bit executable") -} - -func codesign(identity: String, path: String, options: String = defaultCodesignOptions) async throws { - print("Code-signing \(path)...") - let command = "codesign -s \"\(identity)\" \(options) \"\(path)\"" - for _ in 1...5 { - guard await shell(command) == 0 else { - print("Code-signing failed, retrying ...") - continue - } - - // code signing was successful - return - } - - throw CodeSigningError.failedToCodeSign("Failed to code-sign \(path).") -} - -func recursivelyCodesign( - path: String, - identity: String, - options: String = defaultCodesignOptions, - skip: [String] = [] -) async throws { - let fm = FileManager.default - guard fm.fileExists(atPath: path) else { - throw AppBundleSigningError.doesNotExist("Item at \(path) does not exist.") - } - - let enumeratedItems: [String] - do { - enumeratedItems = try fm.subpathsOfDirectory(atPath: path) - } catch { - throw AppBundleSigningError.couldNotEnumerate( - "Failed to enumerate directory at \(path)." - ) - } - - for enumeratedItem in enumeratedItems { - let enumeratedItemPath = "\(path)/\(enumeratedItem)" - guard !skip.contains(enumeratedItemPath) else { - print("Skipping \(enumeratedItemPath)...") - continue - } - let isExecutableFile = try await isExecutable(enumeratedItemPath) - guard isLibrary(enumeratedItem) || isAppExtension(enumeratedItem) || isExecutableFile else { - continue - } - try await codesign(identity: identity, path: enumeratedItemPath, options: options) - } -} - -func saveCodesignEntitlements(target: String, path: String) async throws { - let command = "codesign -d --entitlements \"\(path)\" --xml \"\(target)\"" - - guard await shell(command) == 0 else { - throw CodeSigningError.failedToCodeSign("Failed to save entitlements for \(target).") - } -} - -/// -/// Sign an app bundle and all necessary content within it. -/// -func codesignClientAppBundle(at clientAppDir: String, withCodeSignIdentity codeSignIdentity: String, usingEntitlements entitlementsPath: String? = nil, dev: Bool) async throws { - print("Code-signing Nextcloud Desktop Client libraries, frameworks and plugins...") - - let clientContentsDir = "\(clientAppDir)/Contents" - let frameworksPath = "\(clientContentsDir)/Frameworks" - let pluginsPath = "\(clientContentsDir)/PlugIns" - - try await recursivelyCodesign(path: frameworksPath, identity: codeSignIdentity) - try await recursivelyCodesign(path: pluginsPath, identity: codeSignIdentity) - try await recursivelyCodesign(path: "\(clientContentsDir)/Resources", identity: codeSignIdentity) - - print("Code-signing QtWebEngineProcess...") - let qtWebEngineProcessPath = - "\(frameworksPath)/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app" - try await codesign(identity: codeSignIdentity, - path: qtWebEngineProcessPath, - options: "--timestamp --force --verbose=4 --options runtime --deep --entitlements \"\(qtWebEngineProcessPath)/Contents/Resources/QtWebEngineProcess.entitlements\"") - - print("Code-signing QtWebEngine...") - try await codesign(identity: codeSignIdentity, path: "\(frameworksPath)/QtWebEngineCore.framework") - - // Time to fix notarisation issues. - // Multiple components of the app will now have the get-task-allow entitlements. - // We need to strip these out manually. - - let sparkleFrameworkPath = "\(frameworksPath)/Sparkle.framework" - if FileManager.default.fileExists(atPath: sparkleFrameworkPath) { - print("Code-signing Sparkle...") - try await codesign( - identity: codeSignIdentity, - path: "\(sparkleFrameworkPath)/Versions/B/XPCServices/Installer.xpc", - options: "-f -o runtime" - ) - try await codesign( - identity: codeSignIdentity, - path: "\(sparkleFrameworkPath)/Versions/B/XPCServices/Downloader.xpc", - options: "-f -o runtime --preserve-metadata=entitlements" - ) - try await codesign( - identity: codeSignIdentity, - path: "\(sparkleFrameworkPath)/Versions/B/Autoupdate", - options: "-f -o runtime" - ) - try await codesign( - identity: codeSignIdentity, - path: "\(sparkleFrameworkPath)/Versions/B/Updater.app", - options: "-f -o runtime" - ) - try await codesign( - identity: codeSignIdentity, path: sparkleFrameworkPath, options: "-f -o runtime" - ) - } else { - print("Build does not have Sparkle, skipping.") - } - - print("Code-signing app extensions...") - - let fm = FileManager.default - let appExtensionPaths = try fm.contentsOfDirectory(atPath: pluginsPath).filter(isAppExtension) - - for appExtension in appExtensionPaths { - let appExtensionPath = "\(pluginsPath)/\(appExtension)" - let tmpEntitlementXmlPath = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString).path.appending(".xml") - try await saveCodesignEntitlements(target: appExtensionPath, path: tmpEntitlementXmlPath) - - if dev == false { - print("Stripping get-task-allow entitlement from \(appExtensionPath)") - let xmlEntitlements = try String(contentsOfFile: tmpEntitlementXmlPath) - let entitlementKeyValuePair = "com.apple.security.get-task-allow" - let strippedEntitlements = xmlEntitlements.replacingOccurrences(of: entitlementKeyValuePair, with: "") - try strippedEntitlements.write(toFile: tmpEntitlementXmlPath, atomically: true, encoding: .utf8) - } - - try await codesign(identity: codeSignIdentity, path: appExtensionPath, options: "--timestamp --force --verbose=4 --options runtime --deep --entitlements \"\(tmpEntitlementXmlPath)\"") - } - - // Now we do the final codesign bit - let binariesDir = "\(clientContentsDir)/MacOS" - print("Code-signing Nextcloud Desktop Client binaries...") - - guard let appName = clientAppDir.components(separatedBy: "/").last, clientAppDir.hasSuffix(".app") else { - throw AppBundleSigningError.couldNotEnumerate("Failed to determine main executable name.") - } - - // Sign the main executable last - let mainExecutableName = String(appName.dropLast(".app".count)) - let mainExecutablePath = "\(binariesDir)/\(mainExecutableName)" - try await recursivelyCodesign(path: binariesDir, identity: codeSignIdentity, skip: [mainExecutablePath]) - - var mainExecutableCodesignOptions = defaultCodesignOptions - if let entitlementsPath { - mainExecutableCodesignOptions = - "--timestamp --force --verbose=4 --options runtime --entitlements \"\(entitlementsPath)\"" - } - try await codesign( - identity: codeSignIdentity, path: mainExecutablePath, options: mainExecutableCodesignOptions - ) -} diff --git a/admin/osx/mac-crafter/Sources/Utils/Packaging.swift b/admin/osx/mac-crafter/Sources/Utils/Packaging.swift index 6dda212a6d854..3b85041d80907 100644 --- a/admin/osx/mac-crafter/Sources/Utils/Packaging.swift +++ b/admin/osx/mac-crafter/Sources/Utils/Packaging.swift @@ -124,7 +124,7 @@ func createDmgForAppBundle( appleTeamId: String?, sparklePackageSignKey: String? ) async throws { - print("Creating DMG for the client…") + print("Creating disk image for the client…") let dmgFilePath = URL(fileURLWithPath: productPath) .appendingPathComponent(appName) @@ -137,10 +137,11 @@ func createDmgForAppBundle( if let packageSigningId { print("Signing DMG with \(packageSigningId)…") - try await codesign(identity: packageSigningId, path: dmgFilePath, options: "--force") + await Signer.sign(at: URL(fileURLWithPath: dmgFilePath), with: packageSigningId, entitlements: nil) if let appleId, let applePassword, let appleTeamId { print("Notarising DMG with Apple ID \(appleId)…") + try await notarisePackage( packagePath: dmgFilePath, appleId: appleId, @@ -151,11 +152,11 @@ func createDmgForAppBundle( } print("Creating Sparkle TBZ file…") - let sparklePackagePath = - try await buildSparklePackage(packagePath: dmgFilePath, buildPath: buildPath) + let sparklePackagePath = try await buildSparklePackage(packagePath: dmgFilePath, buildPath: buildPath) if let sparklePackageSignKey { print("Signing Sparkle TBZ file…") + try await signSparklePackage( sparkleTbzPath: sparklePackagePath, buildPath: buildPath, diff --git a/admin/osx/mac-crafter/Sources/Utils/Signer.swift b/admin/osx/mac-crafter/Sources/Utils/Signer.swift new file mode 100644 index 0000000000000..07d6a5661d187 --- /dev/null +++ b/admin/osx/mac-crafter/Sources/Utils/Signer.swift @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import Foundation + +/// +/// Signing features. +/// +protocol Signing { + static func sign(at location: URL, with codeSignIdentity: String, entitlements: URL?) async +} + +/// +/// Used as a namespace for stateless signing methods. +/// +enum Signer: Signing { + + // MARK: - Private + + private static func findDynamicLibraries(at url: URL) throws -> [URL] { + let dynamicFrameworksLocation = url + .appendingPathComponent("Contents") + .appendingPathComponent("PlugIns") + + guard let enumerator = FileManager.default.enumerator(at: dynamicFrameworksLocation, includingPropertiesForKeys: nil) else { + fatalError("ERROR: Failed to get enumerator for: \(url.path)") + } + + let dynamicLibaries: [URL] = enumerator.compactMap { element in + guard let candidate = element as? URL else { + return nil + } + + guard candidate.path.contains(".appex/") == false else { + return nil + } + + guard candidate.pathExtension == "dylib" else { + return nil + } + + return candidate + } + + return dynamicLibaries + } + + /// + /// Find all extension bundles in the bundle at the given location. + /// + /// This assumes the internal structure of the bundle at the given location to have `Contents/PlugIns`. + /// + private static func findExtensions(at url: URL) throws -> [URL] { + let pluginsLocation = url + .appendingPathComponent("Contents") + .appendingPathComponent("PlugIns") + + var items = try FileManager.default.contentsOfDirectory(at: pluginsLocation, includingPropertiesForKeys: nil) + + items.removeAll { item in + if item.path.hasSuffix(".appex") == false { + return true + } + + print("Found extension bundle: \(item.path)") + return false + } + + return items + } + + /// + /// Find all framework bundles in the bundle at the given location. + /// + /// This assumes the internal structure of the bundle at the given location to have `Contents/Frameworks`. + /// + private static func findFrameworks(at url: URL) throws -> [URL] { + let frameworksLocation = url + .appendingPathComponent("Contents") + .appendingPathComponent("Frameworks") + + var items = try FileManager.default.contentsOfDirectory(at: frameworksLocation, includingPropertiesForKeys: nil) + + items.removeAll { item in + if item.path.hasSuffix(".framework") == false { + return true + } + + print("Found framework bundle: \(item.path)") + return false + } + + return items + } + + private static func verify(at location: URL) async { + print("Verifying: \(location.path)") + await shell("codesign --verify --deep --strict --verbose=2 \"\(location.path)\"") + } + + // MARK: - Public + + /// + /// Entry point for signing a whole desktop client app bundle. + /// + static func signMainBundle(at location: URL, codeSignIdentity: String, entitlements: [String: URL]) async throws { + let extensions = try findExtensions(at: location) + + for extensionInMainBundle in extensions { + let frameworksInsideExtension = try findFrameworks(at: extensionInMainBundle) + + try await withThrowingTaskGroup(of: Void.self) { group in + for frameworkInExtension in frameworksInsideExtension { + group.addTask { + await sign(at: frameworkInExtension, with: codeSignIdentity, entitlements: nil) + } + } + + try await group.waitForAll() + } + + guard let extensionEntitlements = entitlements[extensionInMainBundle.lastPathComponent] else { + throw MacCrafterError.signing("No entitlements provided for: \(extensionInMainBundle.path)") + } + + await sign(at: extensionInMainBundle, with: codeSignIdentity, entitlements: extensionEntitlements) + } + + let frameworksInsideMainBundle = try findFrameworks(at: location) + + try await withThrowingTaskGroup(of: Void.self) { group in + for frameworkInMainBundle in frameworksInsideMainBundle { + group.addTask { + await sign(at: frameworkInMainBundle, with: codeSignIdentity, entitlements: nil) + } + } + + try await group.waitForAll() + } + + let dynamicLibraries = try findDynamicLibraries(at: location) + + try await withThrowingTaskGroup(of: Void.self) { group in + for dynamicLibrary in dynamicLibraries { + group.addTask { + await sign(at: dynamicLibrary, with: codeSignIdentity, entitlements: nil) + } + } + + try await group.waitForAll() + } + + guard let mainAppEntitlements = entitlements[location.lastPathComponent] else { + throw MacCrafterError.signing("No entitlements provided for: \(location.path)") + } + + await sign(at: location, with: codeSignIdentity, entitlements: mainAppEntitlements) + await verify(at: location) + } + + /// + /// Shell out to `codesign`. + /// + /// - Parameters: + /// - location: The top-level item to sign. Might be a bundle or file. + /// - codeSignIdentity: The common name of the certificate available in the keychain to use for signing. + /// + static func sign(at location: URL, with codeSignIdentity: String, entitlements: URL?) async { + print("Signing: \(location.path)") + + var command = [ + "codesign", + location.path, + "--timestamp", + "--verbose=4", + "--preserve-metadata=entitlements", + "--force", + "--sign=\"\(codeSignIdentity)\"" + ] + + if let entitlements { + command.append(" --entitlements=\"\(entitlements.path)\"") + } + + await shell(command.joined(separator: " ")) + } +} diff --git a/shell_integration/MacOSX/CMakeLists.txt b/shell_integration/MacOSX/CMakeLists.txt index 5311006b667bf..de06baa8a4881 100644 --- a/shell_integration/MacOSX/CMakeLists.txt +++ b/shell_integration/MacOSX/CMakeLists.txt @@ -57,6 +57,8 @@ if(APPLE) set(OSX_PLUGINS_BINARY_DIR ${CMAKE_CURRENT_BINARY_DIR}/${XCODE_TARGET_CONFIGURATION}) set(OSX_PLUGINS_INSTALL_DIR ${OWNCLOUD_OSX_BUNDLE}/Contents/PlugIns) + configure_file(FinderSyncExt.entitlements.cmake ${CMAKE_CURRENT_BINARY_DIR}/FinderSyncExt.entitlements) + install(DIRECTORY ${OSX_PLUGINS_BINARY_DIR}/FinderSyncExt.appex DESTINATION ${OSX_PLUGINS_INSTALL_DIR} USE_SOURCE_PERMISSIONS) @@ -65,6 +67,9 @@ if(APPLE) install(DIRECTORY DESTINATION ${OSX_PLUGINS_INSTALL_DIR}/FinderSyncExt.appex/Contents/Library) if (BUILD_FILE_PROVIDER_MODULE) + configure_file(FileProviderExt.entitlements.cmake ${CMAKE_CURRENT_BINARY_DIR}/FileProviderExt.entitlements) + configure_file(FileProviderUIExt.entitlements.cmake ${CMAKE_CURRENT_BINARY_DIR}/FileProviderUIExt.entitlements) + install(DIRECTORY ${OSX_PLUGINS_BINARY_DIR}/FileProviderExt.appex DESTINATION ${OSX_PLUGINS_INSTALL_DIR} USE_SOURCE_PERMISSIONS) diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExt.entitlements b/shell_integration/MacOSX/FileProviderExt.entitlements.cmake similarity index 86% rename from shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExt.entitlements rename to shell_integration/MacOSX/FileProviderExt.entitlements.cmake index 2981bf99d426e..4b2d676b9ee79 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExt.entitlements +++ b/shell_integration/MacOSX/FileProviderExt.entitlements.cmake @@ -6,7 +6,7 @@ com.apple.security.application-groups - $(DEVELOPMENT_TEAM).$(OC_APPLICATION_REV_DOMAIN) + @DEVELOPMENT_TEAM@.@APPLICATION_REV_DOMAIN@ com.apple.security.network.client diff --git a/shell_integration/MacOSX/FileProviderExt.entitlements.cmake.license b/shell_integration/MacOSX/FileProviderExt.entitlements.cmake.license new file mode 100644 index 0000000000000..7be3270ab43ae --- /dev/null +++ b/shell_integration/MacOSX/FileProviderExt.entitlements.cmake.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: GPL-2.0-or-later diff --git a/shell_integration/MacOSX/NextcloudIntegration/FileProviderUIExt/FileProviderUIExt.entitlements b/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake similarity index 86% rename from shell_integration/MacOSX/NextcloudIntegration/FileProviderUIExt/FileProviderUIExt.entitlements rename to shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake index 2981bf99d426e..4b2d676b9ee79 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FileProviderUIExt/FileProviderUIExt.entitlements +++ b/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake @@ -6,7 +6,7 @@ com.apple.security.application-groups - $(DEVELOPMENT_TEAM).$(OC_APPLICATION_REV_DOMAIN) + @DEVELOPMENT_TEAM@.@APPLICATION_REV_DOMAIN@ com.apple.security.network.client diff --git a/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake.license b/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake.license new file mode 100644 index 0000000000000..7be3270ab43ae --- /dev/null +++ b/shell_integration/MacOSX/FileProviderUIExt.entitlements.cmake.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: GPL-2.0-or-later diff --git a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncExt.entitlements b/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake similarity index 81% rename from shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncExt.entitlements rename to shell_integration/MacOSX/FinderSyncExt.entitlements.cmake index 7160407cf0217..27ccc7c06c746 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/FinderSyncExt/FinderSyncExt.entitlements +++ b/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake @@ -6,7 +6,7 @@ com.apple.security.application-groups - $(DEVELOPMENT_TEAM).$(OC_APPLICATION_REV_DOMAIN) + @DEVELOPMENT_TEAM@.@APPLICATION_REV_DOMAIN@ diff --git a/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake.license b/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake.license new file mode 100644 index 0000000000000..7be3270ab43ae --- /dev/null +++ b/shell_integration/MacOSX/FinderSyncExt.entitlements.cmake.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: GPL-2.0-or-later diff --git a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj index 46aa481e7d062..76d495e664ecc 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj +++ b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj @@ -972,8 +972,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = FileProviderExt/FileProviderExt.entitlements; - CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; ENABLE_TESTING_SEARCH_PATHS = YES; @@ -1024,8 +1022,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = FileProviderExt/FileProviderExt.entitlements; - CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; ENABLE_NS_ASSERTIONS = NO; @@ -1197,7 +1193,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = FileProviderUIExt/FileProviderUIExt.entitlements; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1260,8 +1255,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = FileProviderUIExt/FileProviderUIExt.entitlements; - CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; ENABLE_NS_ASSERTIONS = NO; @@ -1565,7 +1558,6 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1618,8 +1610,6 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; - CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; ENABLE_NS_ASSERTIONS = NO; diff --git a/src/gui/macOS/fileproviderxpc_mac_utils.mm b/src/gui/macOS/fileproviderxpc_mac_utils.mm index 2e2edb71553df..6ccc86c418bb7 100644 --- a/src/gui/macOS/fileproviderxpc_mac_utils.mm +++ b/src/gui/macOS/fileproviderxpc_mac_utils.mm @@ -65,16 +65,19 @@ for (NSFileProviderManager *const manager in managers) { dispatch_group_enter(group); + [manager getServiceWithName:nsClientCommunicationServiceName itemIdentifier:NSFileProviderRootContainerItemIdentifier completionHandler:^(NSFileProviderService *const service, NSError *const error) { + if (error != nil) { - qCWarning(lcFileProviderXPCUtils) << "Error getting file provider service" << error; + qCWarning(lcFileProviderXPCUtils) << "Failed to resolve service for file provider domain: " << error; } else if (service == nil) { - qCWarning(lcFileProviderXPCUtils) << "Service is nil"; + qCWarning(lcFileProviderXPCUtils) << "Service is nil!"; } else { [fpServices addObject:@{service.name: service}]; } + dispatch_group_leave(group); }]; dispatch_group_wait(group, DISPATCH_TIME_FOREVER);