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
20 changes: 20 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ kover {
}
}

val archiveIosApp = tasks.register<org.neotech.plugin.IosArchiveTask>("archiveIosApp") {
xcodeProjectDirectory = layout.projectDirectory.dir("iosApp")
scheme = "iosApp"
configuration = "Release"
outputDirectory = layout.projectDirectory.dir("iosApp/build")
}

tasks.register<org.neotech.plugin.IosExportTask>("exportIosApp") {
dependsOn(archiveIosApp)
archivePath = layout.projectDirectory.dir("iosApp/build/iosApp.xcarchive")
val localProperties = java.util.Properties()
try {
localProperties.load(rootProject.file("local.properties").inputStream())
} catch (_: Exception) {
logger.warn("w: Unable to load local.properties file!")
}
teamId = localProperties.getProperty("apple.teamId") ?: ""
outputDirectory = layout.projectDirectory.dir("iosApp/build/export")
}

tasks.register<org.neotech.plugin.FrameScreenshotsTask>("frameAndroidScreenshots") {
group = "store"
description = "Frames Nothing Phone screenshots into device bezels."
Expand Down
106 changes: 106 additions & 0 deletions buildSrc/src/main/kotlin/org/neotech/plugin/IosArchiveTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Abysner - Dive planner
* Copyright (C) 2026 Neotech
*
* Abysner is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License version 3,
* as published by the Free Software Foundation.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

package org.neotech.plugin

import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction

/**
* Builds an iOS .xcarchive using xcodebuild. This task must run on macOS with Xcode installed.
* Use [IosExportTask] to export the archive as an IPA for App Store upload.
*/
abstract class IosArchiveTask : DefaultTask() {

@get:InputDirectory
abstract val xcodeProjectDirectory: DirectoryProperty

@get:Input
abstract val scheme: Property<String>

@get:Input
@get:Optional
abstract val configuration: Property<String>

@get:InputDirectory
@get:Optional
abstract val xcodeAppPath: DirectoryProperty

@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty

init {
group = "release"
description = "Archives the iOS app into an .xcarchive bundle."
}

@TaskAction
fun execute() {
val projectDirectory = xcodeProjectDirectory.get().asFile
val schemeName = scheme.get()
val config = configuration.getOrElse("Release")
val outputDir = outputDirectory.get().asFile
val archivePath = outputDir.resolve("$schemeName.xcarchive")

outputDir.mkdirs()

logger.lifecycle("Archiving iOS app (scheme=$schemeName, configuration=$config)...")

val archiveResult = runCommand(
listOf(
"xcodebuild",
"-project", projectDirectory.resolve("iosApp.xcodeproj").absolutePath,
"-scheme", schemeName,
"-configuration", config,
"-archivePath", archivePath.absolutePath,
"-destination", "generic/platform=iOS",
"archive",
),
workingDirectory = projectDirectory,
)

if (archiveResult != 0) {
throw GradleException("xcodebuild archive failed with exit code $archiveResult")
}

logger.lifecycle("Archive created at: ${archivePath.absolutePath}")
}

private fun runCommand(command: List<String>, workingDirectory: java.io.File): Int {
logger.info("Running: ${command.joinToString(" ")}")
val process = ProcessBuilder(command)
.directory(workingDirectory)
.redirectErrorStream(true)
.also { builder ->
if (xcodeAppPath.isPresent) {
val developerDir = xcodeAppPath.get().asFile.resolve("Contents/Developer")
logger.info("Using Xcode at: ${xcodeAppPath.get().asFile.absolutePath}")
builder.environment()["DEVELOPER_DIR"] = developerDir.absolutePath
}
}
.start()

process.inputStream.bufferedReader().forEachLine { line ->
logger.info(" $line")
}

return process.waitFor()
}
}

97 changes: 97 additions & 0 deletions buildSrc/src/main/kotlin/org/neotech/plugin/IosExportTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Abysner - Dive planner
* Copyright (C) 2026 Neotech
*
* Abysner is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License version 3,
* as published by the Free Software Foundation.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

package org.neotech.plugin

import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction

/**
* Exports an existing .xcarchive as an IPA and uploads it to App Store Connect using
* xcodebuild. Generates the ExportOptions.plist at build time from the provided [teamId].
* Typically depends on [IosArchiveTask] to produce the archive first.
*/
abstract class IosExportTask : DefaultTask() {

@get:InputDirectory
abstract val archivePath: DirectoryProperty

@get:Input
abstract val teamId: Property<String>

@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty

init {
group = "release"
description = "Exports an .xcarchive as an IPA and uploads it to App Store Connect."
}

@TaskAction
fun execute() {
val archive = archivePath.get().asFile
val outputDir = outputDirectory.get().asFile
val exportOptionsFile = outputDir.resolve("ExportOptions.plist")

outputDir.mkdirs()

exportOptionsFile.writeText(
"""
|<?xml version="1.0" encoding="UTF-8"?>
|<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|<plist version="1.0">
|<dict>
| <key>method</key>
| <string>app-store-connect</string>
| <key>teamID</key>
| <string>${teamId.get()}</string>
| <key>uploadSymbols</key>
| <true/>
| <key>destination</key>
| <string>upload</string>
|</dict>
|</plist>
""".trimMargin()
)

logger.lifecycle("Exporting and uploading IPA from ${archive.name} to App Store Connect...")

val process = ProcessBuilder(
listOf(
"xcodebuild",
"-exportArchive",
"-archivePath", archive.absolutePath,
"-exportOptionsPlist", exportOptionsFile.absolutePath,
"-exportPath", outputDir.absolutePath,
),
)
.redirectErrorStream(true)
.start()

process.inputStream.bufferedReader().forEachLine { line ->
logger.info(" $line")
}

val exitCode = process.waitFor()
if (exitCode != 0) {
throw GradleException("xcodebuild -exportArchive failed with exit code $exitCode")
}

logger.lifecycle("IPA exported and uploaded to App Store Connect.")
}
}
Loading