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
Original file line number Diff line number Diff line change
Expand Up @@ -27,34 +27,48 @@ internal fun patchMachOBuildVersion(

logger.lifecycle("Patching ${binary.name} LC_BUILD_VERSION: minos=$minVersion sdk=$sdkVersion")

// Remove existing code signature (vtool cannot modify signed binaries)
ProcessBuilder("codesign", "--remove-signature", binary.absolutePath)
.redirectErrorStream(true)
.start()
.waitFor()

val vtoolExit =
ProcessBuilder(
vtool.absolutePath,
"-set-build-version",
"macos",
minVersion,
sdkVersion,
"-tool",
"ld",
"0.0",
"-replace",
"-output",
binary.absolutePath,
binary.absolutePath,
).redirectErrorStream(true)
.start()
.waitFor()

if (vtoolExit != 0) {
logger.warn("vtool exited with code $vtoolExit for ${binary.name} β€” build version patch may have failed")
return false
}
return mutateMachOFileSafely(
binary = binary,
operation = "vtool patch LC_BUILD_VERSION",
logger = logger,
mutate = { copy, runner ->
runner.run(
listOf(
vtool.absolutePath,
"-set-build-version",
"macos",
minVersion,
sdkVersion,
"-tool",
"ld",
"0.0",
"-replace",
"-output",
copy.absolutePath,
copy.absolutePath,
),
)
},
validateExtra = { copy, runner ->
val showBuildResult = runner.run(listOf(vtool.absolutePath, "-show-build", copy.absolutePath))
if (showBuildResult.exitCode != 0) {
"vtool -show-build failed (exit=${showBuildResult.exitCode})"
} else if (!hasExpectedBuildVersions(showBuildResult.output, minVersion, sdkVersion)) {
"vtool -show-build output does not contain expected minos/sdk values " +
"(minos=$minVersion, sdk=$sdkVersion)"
} else {
null
}
},
)
}

return true
private fun hasExpectedBuildVersions(
showBuildOutput: String,
minVersion: String,
sdkVersion: String,
): Boolean {
val minPattern = Regex("""\bminos\s+${Regex.escape(minVersion)}\b""", RegexOption.IGNORE_CASE)
val sdkPattern = Regex("""\bsdk\s+${Regex.escape(sdkVersion)}\b""", RegexOption.IGNORE_CASE)
return minPattern.containsMatchIn(showBuildOutput) && sdkPattern.containsMatchIn(showBuildOutput)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package io.github.kdroidfilter.nucleus.desktop.application.internal

import org.gradle.api.logging.Logger
import java.io.File
import java.io.IOException
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption

internal data class MachOCommandResult(
val exitCode: Int,
val output: String = "",
)

internal fun interface MachOCommandRunner {
fun run(command: List<String>): MachOCommandResult
}

internal val defaultMachOCommandRunner =
MachOCommandRunner { command ->
try {
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val exitCode = process.waitFor()
MachOCommandResult(exitCode, output)
} catch (e: Exception) {
val message = e.message ?: e::class.java.simpleName
MachOCommandResult(exitCode = -1, output = message)
}
}

internal fun stripMachOFileSafely(
binary: File,
logger: Logger,
commandRunner: MachOCommandRunner = defaultMachOCommandRunner,
): Boolean =
mutateMachOFileSafely(
binary = binary,
operation = "strip -x",
logger = logger,
commandRunner = commandRunner,
mutate = { copy, runner ->
runner.run(listOf("/usr/bin/strip", "-x", copy.absolutePath))
},
)

internal fun mutateMachOFileSafely(
binary: File,
operation: String,
logger: Logger,
commandRunner: MachOCommandRunner = defaultMachOCommandRunner,
mutate: (copy: File, commandRunner: MachOCommandRunner) -> MachOCommandResult,
validateExtra: ((copy: File, commandRunner: MachOCommandRunner) -> String?)? = null,
): Boolean {
val parent = binary.parentFile?.toPath()
?: throw IOException("Cannot create temporary copy for ${binary.absolutePath}: missing parent directory")
val binaryPath = binary.toPath()
val tempPath = createSiblingTempCopy(binaryPath, parent)

try {
val tempFile = tempPath.toFile()
runCodesignRemoveSignatureBestEffort(tempFile, commandRunner, logger, operation)

val mutationResult = runCatching { mutate(tempFile, commandRunner) }
.getOrElse { error ->
logger.warn(
"Skipping $operation for ${binary.name}: mutation step failed unexpectedly: ${error.message}",
)
return false
}
if (mutationResult.exitCode != 0) {
logger.warn(
"Skipping $operation for ${binary.name}: tool exit=${mutationResult.exitCode}. " +
"Original file kept. Output: ${mutationResult.output.asCompactLogLine()}",
)
return false
}

val otoolResult = commandRunner.run(listOf("/usr/bin/otool", "-l", tempFile.absolutePath))
if (otoolResult.exitCode != 0) {
logger.warn(
"Skipping $operation for ${binary.name}: otool validation failed (exit=${otoolResult.exitCode}). " +
"Original file kept. Output: ${otoolResult.output.asCompactLogLine()}",
)
return false
}

validateExtra?.let { validator ->
val validationError = runCatching { validator(tempFile, commandRunner) }
.getOrElse { error ->
"extra validation threw ${error::class.java.simpleName}: ${error.message}"
}
if (validationError != null) {
logger.warn(
"Skipping $operation for ${binary.name}: $validationError. Original file kept.",
)
return false
}
}

replaceOriginalWithTemp(tempPath, binaryPath)
return true
} finally {
Files.deleteIfExists(tempPath)
}
}

private fun createSiblingTempCopy(
binaryPath: Path,
parent: Path,
): Path {
val tempPath = Files.createTempFile(parent, "${binaryPath.fileName}.nucleus-", ".tmp")
Files.copy(
binaryPath,
tempPath,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.COPY_ATTRIBUTES,
)
return tempPath
}

private fun runCodesignRemoveSignatureBestEffort(
binary: File,
commandRunner: MachOCommandRunner,
logger: Logger,
operation: String,
) {
val result = commandRunner.run(listOf("/usr/bin/codesign", "--remove-signature", binary.absolutePath))
if (result.exitCode == 0) return

logger.warn(
"codesign --remove-signature failed before $operation for ${binary.name} (exit=${result.exitCode}); " +
"continuing. Output: ${result.output.asCompactLogLine()}",
)
}

private fun replaceOriginalWithTemp(
tempPath: Path,
originalPath: Path,
) {
try {
Files.move(
tempPath,
originalPath,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(tempPath, originalPath, StandardCopyOption.REPLACE_EXISTING)
}
}

private fun String.asCompactLogLine(maxLength: Int = 300): String {
val compact = replace('\n', ' ').replace('\r', ' ').trim()
if (compact.length <= maxLength) return compact
return compact.take(maxLength) + "..."
}
Original file line number Diff line number Diff line change
Expand Up @@ -888,14 +888,37 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging(
}

val stripDylibs =
tasks.register<Exec>(
tasks.register<DefaultTask>(
taskNameAction = "strip",
taskNameObject = "graalvmDylibs",
) {
description = "Strip debug symbols from dylibs"
dependsOn(copyAwtDylibs)
val macosDir = appBundleDir.map { it.dir("MacOS") }
commandLine("bash", "-c", "strip -x '${macosDir.get().asFile.absolutePath}'/*.dylib")

doLast {
val macosDir = appBundleDir.get().dir("MacOS").asFile
val dylibs =
macosDir
.listFiles { file -> file.isFile && file.extension == "dylib" }
?.sortedBy { it.name }
.orEmpty()

var successCount = 0
var failureCount = 0

dylibs.forEach { dylib ->
val stripped = stripMachOFileSafely(dylib, logger)
if (stripped) {
successCount++
} else {
failureCount++
}
}

logger.lifecycle(
"stripDylibs summary: total=${dylibs.size}, stripped=$successCount, keptOriginal=$failureCount",
)
}
}

// Patch LC_BUILD_VERSION on all Mach-O binaries and dylibs so that:
Expand Down Expand Up @@ -925,8 +948,22 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging(
.filter { it.isDirectory }
.flatMap { dir -> dir.listFiles()?.asSequence() ?: emptySequence() }
.filter { it.isFile && (it.extension == "dylib" || it.canExecute()) }
.forEach { file ->
patchMachOBuildVersion(file, minVer, sdkVer, logger)
.toList()
.also { files ->
var successCount = 0
var failureCount = 0
files.forEach { file ->
val patched = patchMachOBuildVersion(file, minVer, sdkVer, logger)
if (patched) {
successCount++
} else {
failureCount++
}
}

logger.lifecycle(
"patchBuildVersion summary: total=${files.size}, patched=$successCount, keptOriginal=$failureCount",
)
}
}
}
Expand Down
Loading
Loading