Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Sources/SWBBuildSystem/CleanOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package import SWBProtocol
package import SWBUtil

package import class Foundation.FileManager
package import class Foundation.Process
package import var Foundation.NSCocoaErrorDomain
package import var Foundation.NSFileNoSuchFileError
package import var Foundation.NSLocalizedDescriptionKey
Expand Down Expand Up @@ -78,7 +79,7 @@ package final class CleanOperation: BuildSystemOperation, TargetDependencyResolv
}

package func buildDataDirectory() throws -> Path {
return try BuildDescriptionManager.cacheDirectory(buildRequest, buildRequestContext: buildRequestContext, workspaceContext: workspaceContext).join("XCBuildData")
return try buildRequestContext.cacheDirectory(for: buildRequest).join("XCBuildData")
}

package func build() async -> BuildOperationEnded.Status {
Expand Down Expand Up @@ -146,6 +147,7 @@ package final class CleanOperation: BuildSystemOperation, TargetDependencyResolv
}
}

await unregisterFromLaunchServices(buildDataDirectory: buildDataDirectory)
clean(folders: Set(foldersToClean), buildOutputDelegate: buildOutputDelegate)

return delegate.buildComplete(self, status: nil, delegate: buildOutputDelegate, metrics: nil)
Expand Down Expand Up @@ -341,6 +343,18 @@ package final class CleanOperation: BuildSystemOperation, TargetDependencyResolv
}
}

private func unregisterFromLaunchServices(buildDataDirectory: Path) async {
guard workspaceContext.core.hostOperatingSystem == .macOS else { return }

let recordPath = buildDataDirectory.join(registeredLaunchServicesFilename)
guard let content = try? workspaceContext.fs.read(recordPath).asString else { return }

let paths = content.split(separator: "\n", omittingEmptySubsequences: true).map(String.init)
guard !paths.isEmpty else { return }

try? await Process.run(url: URL(fileURLWithPath: lsregisterToolPath), arguments: ["-u"] + paths)
}

private func deleteFolder(_ folderPath: Path) throws {
let folderUrl = URL(fileURLWithPath: folderPath.str)
let tmpdir: URL
Expand Down
42 changes: 42 additions & 0 deletions Sources/SWBCore/BuildRequestContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,48 @@ public final class BuildRequestContext: Sendable {
workspaceContext.fs
}

/// Returns the path in which the `XCBuildData` directory will live. That location is used to cache build descriptions for a particular workspace and request, the manifest, and the `build.db` database for llbuild.
package func cacheDirectory(for request: BuildRequest) throws -> Path {
// Make this more efficient for index queries if the index build arena is enabled.
if request.enableIndexBuildArena, let arena = request.parameters.arena {
return arena.buildIntermediatesPath
}

// Get settings for the sole project if there is only one, otherwise the workspace-global settings.
let settings: Settings = {
if let onlyProject = workspaceContext.workspace.projects.only {
return getCachedSettings(request.parameters, project: onlyProject)
}
// FIXME: For project-style builds (no workspace arena), we shouldn't grab the first project, because "first" doesn't have any special meaning. Ideally we'd pick the top-level project specifically. However, that is not currently possible due to the fact that the PIF is flattened. So we preserve existing behavior for now to avoid breaking the non-workspace, nested-projects use case.
if let firstProject = workspaceContext.workspace.projects.first, !(request.parameters.arena?.buildIntermediatesPath.isAbsolute ?? false) {
return getCachedSettings(request.parameters, project: firstProject)
}
return getCachedSettings(request.parameters)
}()

// This is an override to specifically enable a legacy build location workflow for some projects (rdar://52005109). It should not be leveraged, relied upon, or in any way considered a good thing to build upon.
let overrideDir = settings.globalScope.evaluate(BuiltinMacros.BUILD_DESCRIPTION_CACHE_DIR)
if !overrideDir.isEmpty {
return Path(overrideDir)
}

// NOTE: The way that `Path()` works is that any absolute paths provided via `join()` will essentially disregard the path information before it. This is subtle and *is* relied upon here by other places in the build system where `OBJROOT` is provided as an absolute path
let objroot = settings.globalScope.evaluate(BuiltinMacros.SRCROOT).join(settings.globalScope.evaluate(BuiltinMacros.OBJROOT))
if objroot.isAbsolute {
return objroot
}

// Fall back to the arena info if the objroot wasn't absolute. This can happen if we have a Settings for a workspace and SRCROOT therefore isn't absolute itself.
if let arena = request.parameters.arena {
guard arena.buildIntermediatesPath.isAbsolute else {
throw StubError.error("The workspace arena does not have an absolute build intermediates path to contain the build cache directory.")
}
return arena.buildIntermediatesPath
}

throw StubError.error("There is no workspace arena to determine the build cache directory path.")
}

// Cache toolset.json access per-build request. Don't cache at the session level because toolsets may change between builds.
private let toolsetCache = Registry<Path, SwiftSDK.Toolset>()
public func loadToolset(_ path: Path) throws -> SwiftSDK.Toolset {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
//
//===----------------------------------------------------------------------===//

package let lsregisterToolPath = "/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister"

package let registeredLaunchServicesFilename = "registered-launchservices.txt"

final class LaunchServicesRegisterToolSpec : GenericCommandLineToolSpec, SpecIdentifierType, @unchecked Sendable {
static let identifier = "com.apple.build-tasks.ls-register-url"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -646,11 +646,22 @@ private extension ApplicationProductTypeSpec {
guard let launchServicesRegisterSpec = context.launchServicesRegisterSpec else { return }

let path = scope.evaluate(BuiltinMacros.TARGET_BUILD_DIR).join(scope.evaluate(BuiltinMacros.WRAPPER_NAME))
let planRequest = producer.context.globalProductPlan.planRequest
let recordPathArgs: [String]
if let cacheDir = try? planRequest.buildRequestContext.cacheDirectory(for: planRequest.buildRequest) {
let recordPath = cacheDir.join("XCBuildData").join(registeredLaunchServicesFilename)
recordPathArgs = ["--record-path", recordPath.str]
} else {
recordPathArgs = []
}

let commandLine = ["builtin-lsregisterurl"] + recordPathArgs + ["--", lsregisterToolPath, "-f", "-R", "-trusted", path.str]

await producer.appendGeneratedTasks(&tasks) { delegate in
// Mutating tasks *require* the input node, otherwise this task will not properly run for incremental builds.
let vnode = delegate.createVirtualNode("LSRegisterURL \(path.str)")

await launchServicesRegisterSpec.constructTasks(CommandBuildContext(producer: context, scope: scope, inputs: [FileToBuild(context: context, absolutePath: path)], output: path, commandOrderingOutputs: [vnode]), delegate)
await launchServicesRegisterSpec.constructTasks(CommandBuildContext(producer: context, scope: scope, inputs: [FileToBuild(context: context, absolutePath: path)], output: path, commandOrderingOutputs: [vnode]), delegate, specialArgs: [], commandLine: commandLine)
}
}
}
Expand Down
46 changes: 2 additions & 44 deletions Sources/SWBTaskExecution/BuildDescriptionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -484,54 +484,12 @@ package final class BuildDescriptionManager: Sendable {

/// Returns the path in which the`XCBuildData` directory will live. That location is uses to cache build descriptions for a particular workspace and request, the manifest, and the `build.db` database for llbuild.
package static func cacheDirectory(_ request: BuildPlanRequest) throws -> Path {
return try cacheDirectory(request.buildRequest, buildRequestContext: request.buildRequestContext, workspaceContext: request.workspaceContext)
return try request.buildRequestContext.cacheDirectory(for: request.buildRequest)
}

/// Returns the path in which the`XCBuildData` directory will live. That location is uses to cache build descriptions for a particular workspace and request, the manifest, and the `build.db` database for llbuild.
package static func cacheDirectory(_ request: BuildDescriptionRequest) throws -> Path {
return try cacheDirectory(request.buildRequest, buildRequestContext: request.buildRequestContext, workspaceContext: request.workspaceContext)
}

/// Returns the path in which the`XCBuildData` directory will live. That location is uses to cache build descriptions for a particular workspace and request, the manifest, and the `build.db` database for llbuild.
package static func cacheDirectory(_ request: BuildRequest, buildRequestContext: BuildRequestContext, workspaceContext: WorkspaceContext) throws -> Path {
// Make this more efficient for index queries if the index build arena is enabled.
if request.enableIndexBuildArena, let arena = request.parameters.arena {
return arena.buildIntermediatesPath
}

// Get settings for the sole project if there is only one, otherwise the workspace-global settings.
let settings: Settings = {
if let onlyProject = workspaceContext.workspace.projects.only {
return buildRequestContext.getCachedSettings(request.parameters, project: onlyProject)
}
// FIXME: For project-style builds (no workspace arena), we shouldn't grab the first project, because "first" doesn't have any special meaning. Ideally we'd pick the top-level project specifically. However, that is not currently possible due to the fact that the PIF is flattened. So we preserve existing behavior for now to avoid breaking the non-workspace, nested-projects use case.
if let firstProject = workspaceContext.workspace.projects.first, !(request.parameters.arena?.buildIntermediatesPath.isAbsolute ?? false) {
return buildRequestContext.getCachedSettings(request.parameters, project: firstProject)
}
return buildRequestContext.getCachedSettings(request.parameters)
}()

// This is an override to specifically enable a legacy build location workflow for some projects (rdar://52005109). It should not be leveraged, relied upon, or in any way considered a good thing to build upon.
let overrideDir = settings.globalScope.evaluate(BuiltinMacros.BUILD_DESCRIPTION_CACHE_DIR)
if !overrideDir.isEmpty {
return Path(overrideDir)
}

// NOTE: The way that `Path()` works is that any absolute paths provided via `join()` will essentially disregard the path information before it. This is subtle and *is* relied upon here by other places in the build system where `OBJROOT` is provided as an absolute path
let objroot = settings.globalScope.evaluate(BuiltinMacros.SRCROOT).join(settings.globalScope.evaluate(BuiltinMacros.OBJROOT))
if objroot.isAbsolute {
return objroot
}

// Fall back to the arena info if the objroot wasn't absolute. This can happen if we have a Settings for a workspace and SRCROOT therefore isn't absolute itself.
if let arena = request.parameters.arena {
guard arena.buildIntermediatesPath.isAbsolute else {
throw StubError.error("The workspace arena does not have an absolute build intermediates path to contain the build cache directory.")
}
return arena.buildIntermediatesPath
}

throw StubError.error("There is no workspace arena to determine the build cache directory path.")
return try request.buildRequestContext.cacheDirectory(for: request.buildRequest)
}

private func loadBuildDescription(request: BuildDescriptionRequest, signature: BuildDescriptionSignature, onDiskPath: Path, clientDelegate: any TaskPlanningClientDelegate, constructionDelegate: any BuildDescriptionConstructionDelegate, activity: ActivityID) async throws -> (buildDescription: BuildDescription, source: BuildDescriptionRetrievalSource) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,26 @@ public final class LSRegisterURLTaskAction: TaskAction {
}

override public func performTaskAction(_ task: any ExecutableTask, dynamicExecutionDelegate: any DynamicTaskExecutionDelegate, executionDelegate: any TaskExecutionDelegate, clientDelegate: any TaskExecutionClientDelegate, outputDelegate: any TaskOutputDelegate) async -> CommandResult {
let commandLine = Array(task.commandLineAsStrings.dropFirst())
let recordPath: Path?
let lsregisterCommandLine: [String]

if let separatorIndex = commandLine.firstIndex(of: "--") {
let args = commandLine[..<separatorIndex]
if let recordIndex = args.firstIndex(of: "--record-path"), args.index(after: recordIndex) < args.endIndex {
recordPath = Path(String(args[args.index(after: recordIndex)]))
} else {
recordPath = nil
}
lsregisterCommandLine = Array(commandLine[commandLine.index(after: separatorIndex)...])
} else {
recordPath = nil
lsregisterCommandLine = commandLine
}

let processDelegate = TaskProcessDelegate(outputDelegate: outputDelegate)
do {
try await spawn(commandLine: Array(task.commandLineAsStrings), environment: task.environment.bindingsDictionary, workingDirectory: task.workingDirectory, dynamicExecutionDelegate: dynamicExecutionDelegate, clientDelegate: clientDelegate, processDelegate: processDelegate)
try await spawn(commandLine: lsregisterCommandLine, environment: task.environment.bindingsDictionary, workingDirectory: task.workingDirectory, dynamicExecutionDelegate: dynamicExecutionDelegate, clientDelegate: clientDelegate, processDelegate: processDelegate)
} catch {
outputDelegate.error(error.localizedDescription)
return .failed
Expand All @@ -37,6 +54,8 @@ public final class LSRegisterURLTaskAction: TaskAction {
if processDelegate.commandResult != .succeeded {
outputDelegate.note("LaunchServices registration failed and was skipped")
outputDelegate.updateResult(.exit(exitStatus: .exit(0), metrics: nil))
} else if let recordPath, let appPath = task.inputPaths.first {
try? executionDelegate.fs.append(recordPath, contents: ByteString(encodingAsUTF8: appPath.str + "\n"))
}

return .succeeded
Expand Down
51 changes: 51 additions & 0 deletions Tests/SWBBuildSystemTests/CleanOperationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -459,4 +459,55 @@ fileprivate struct CleanOperationTests: CoreBasedTests {
}
}
}

@Test(.requireSDKs(.macOS))
func launchServicesRegistrationRecordedAndCleaned() async throws {
try await withTemporaryDirectory { tmpDirPath in
let testWorkspace = try await TestWorkspace(
"Test",
sourceRoot: tmpDirPath.join("Test"),
projects: [
TestProject(
"aProject",
groupTree: TestGroup(
"Sources", children: [
TestFile("main.swift"),
]),
buildConfigurations: [TestBuildConfiguration(
"Debug",
buildSettings: [
"PRODUCT_NAME": "$(TARGET_NAME)",
"SWIFT_VERSION": swiftVersion,
"GENERATE_INFOPLIST_FILE": "YES",
"SDKROOT": "macosx",
]
)],
targets: [
TestStandardTarget(
"App", type: .application,
buildPhases: [
TestSourcesBuildPhase(["main.swift"]),
])
])
])

let tester = try await BuildOperationTester(getCore(), testWorkspace, simulated: false)
try await tester.fs.writeFileContents(testWorkspace.sourceRoot.join("aProject/main.swift")) { $0 <<< "print(\"hello\")\n" }

try await tester.checkBuild(runDestination: .macOS, persistent: true) { results in
results.checkTask(.matchRuleType("RegisterWithLaunchServices")) { _ in }
results.checkNoDiagnostics()
}

let recordPath = tmpDirPath.join("Test/aProject/build/XCBuildData/registered-launchservices.txt")
#expect(tester.fs.exists(recordPath), "registered-launchservices.txt should exist after build")
let content = try tester.fs.read(recordPath).asString
#expect(content.contains("App.app"), "registered-launchservices.txt should contain the registered .app path")

try await tester.checkBuild(runDestination: .macOS, buildCommand: .cleanBuildFolder(style: .regular), persistent: true) { results in
results.checkNoDiagnostics()
}
#expect(!tester.fs.exists(recordPath), "registered-launchservices.txt should be removed after clean")
}
}
}
4 changes: 2 additions & 2 deletions Tests/SWBTaskExecutionTests/LSRegisterURLTaskTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ fileprivate struct LSRegisterURLTests {
}

await checkDiagnostics([], commandResult: .failed, errors: ["Invalid number of arguments"])
await checkDiagnostics(["/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister", "-f", "-R", "-trusted", "foo"], commandResult: .succeeded, errors: [])
await checkDiagnostics(["builtin-lsregisterurl", "--", "/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister", "-f", "-R", "-trusted", "foo"], commandResult: .succeeded, errors: [])
}

// FIXME: We should have some kind of test that we LSRegisterURL correctly. This probably makes more sense in a Quicklook test that actually verifies the end to end integration.

@Test
func failedRegister() async throws {
let action = LSRegisterURLTaskAction()
let task = Task(forTarget: nil, ruleInfo: [], commandLine: ["/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister", "-f", "-R", "-trusted", Path.null.str], workingDirectory: .root, outputs: [], action: action, execDescription: "")
let task = Task(forTarget: nil, ruleInfo: [], commandLine: ["builtin-lsregisterurl", "--", "/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister", "-f", "-R", "-trusted", Path.null.str], workingDirectory: .root, outputs: [], action: action, execDescription: "")
let executionDelegate = MockExecutionDelegate()
let outputDelegate = MockTaskOutputDelegate()
let result = await action.performTaskAction(task, dynamicExecutionDelegate: MockDynamicTaskExecutionDelegate(), executionDelegate: executionDelegate, clientDelegate: MockTaskExecutionClientDelegate(), outputDelegate: outputDelegate
Expand Down
Loading