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
73 changes: 73 additions & 0 deletions .github/workflows/desktop_make.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ on:
description: 'Linux'
required: false
type: boolean
macOS_appstore:
description: 'macOS App Store (PKG)'
required: false
type: boolean

concurrency:
group: desktop-packaging-${{ github.ref }}
Expand Down Expand Up @@ -154,3 +158,72 @@ jobs:
composeApp/build/compose/binaries/main/deb/ooni-probe_*_amd64.deb
composeApp/build/compose/binaries/main/appimage-workspace/OONI-Probe-*-x86_64.AppImage
retention-days: 7

package-macos-appstore:
name: Package macOS App Store PKG
if: ${{ inputs.macOS_appstore }}
runs-on: macos-latest
env:
ORGANIZATION: ooni
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup (repo action)
uses: ./.github/actions/setup
with:
java_version: '23'

- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/gradle.properties','**/*.gradle','**/*.gradle.kts') }}
restore-keys: |
${{ runner.os }}-gradle-

- name: Install the Apple certificate and provisioning profile
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
# create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=temporary # not relevant, since it's a single-use keychain

# import certificate profile from secrets
echo -n "$APPLE_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH

# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

# import certificate to keychain
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH

- name: Package PKG for Mac App Store
run: |
./gradlew copyBrandingToCommonResources packagePkg \
-Porganization=${{ env.ORGANIZATION }} \
-PdesktopDistribution=appstore \
-Pcompose.desktop.mac.sign=true \
-Pcompose.desktop.mac.signing.identity="Open Observatory of Network Interference (OONI) ETS" \
-Pcompose.desktop.mac.signing.keychain=$RUNNER_TEMP/app-signing.keychain-db \
-Pcompose.desktop.mac.notarization.appleID=${{ secrets.APPLE_ID }} \
-Pcompose.desktop.mac.notarization.password=${{ secrets.APPLE_ASP }} \
-Pcompose.desktop.mac.notarization.teamID=${{ secrets.APPLE_TEAM_ID }}

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktopApps-macos-appstore-${{ github.run_id }}
path: |
composeApp/build/compose/binaries/main/pkg/OONI Probe-*.pkg
retention-days: 7
7 changes: 7 additions & 0 deletions buildSrc/src/main/kotlin/BuildUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import org.gradle.internal.os.OperatingSystem
import java.io.File
import java.util.Properties

/**
* Check if the build targets app store distribution (no Sparkle/WinSparkle).
* Usage: ./gradlew packagePkg -PdesktopDistribution=appstore
*/
fun Project.isAppStoreDistribution(): Boolean =
findProperty("desktopDistribution")?.toString()?.equals("appstore", ignoreCase = true) == true

/**
* Check if F-Droid build task is requested.
*/
Expand Down
140 changes: 82 additions & 58 deletions buildSrc/src/main/kotlin/TaskRegistration.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ fun Project.registerDesktopBuildConfigTask(
versionCode: Int,
) {
val isDebug = isDebugTaskRequested()
val isAppStore = isAppStoreDistribution()
tasks.register("generateDesktopBuildConfig") {
val outputDir = layout.buildDirectory.dir("generated/desktopBuildConfig/kotlin")
inputs.property("versionName", versionName)
inputs.property("versionCode", versionCode)
inputs.property("isDebug", isDebug)
inputs.property("isAppStore", isAppStore)
outputs.dir(outputDir)
doLast {
val dir = outputDir.get().asFile.resolve("org/ooni/probe")
Expand All @@ -77,6 +79,7 @@ fun Project.registerDesktopBuildConfigTask(
| const val VERSION_NAME = "$versionName"
| const val VERSION_CODE = $versionCode
| const val IS_DEBUG = $isDebug
| const val IS_APP_STORE = $isAppStore
|}
""".trimMargin(),
)
Expand All @@ -85,12 +88,20 @@ fun Project.registerDesktopBuildConfigTask(
}

private fun Project.registerDesktopTasks() {
val isAppStore = isAppStoreDistribution()

tasks.register("makeLibrary", Exec::class) {
group = "ooni"
description = "Build native libraries (NetworkTypeFinder and UpdateBridge)"
dependsOn("setupSparkle")
if (!isAppStore) {
dependsOn("setupSparkle")
}
workingDir = file("src/desktopMain")
commandLine = listOf("make", "all")
commandLine = if (isAppStore) {
listOf("make", "desktop-only")
} else {
listOf("make", "all")
}

inputs.files(fileTree("src/desktopMain/c") {
include("*.m", "*.c")
Expand All @@ -110,7 +121,7 @@ private fun Project.registerSparkleTask() {
group = "setup"
description =
"Downloads Sparkle and extracts Sparkle.framework to the destination directory"
onlyIf { isMac() }
onlyIf { isMac() && !isAppStoreDistribution() }
sparkleVersion.set(providers.gradleProperty("sparkleVersion").orElse("2.8.0"))
destDir.set(
providers.gradleProperty("sparkleExtractDir")
Expand All @@ -123,6 +134,10 @@ private fun Project.registerSparkleTask() {
group = "setup"
description = "Generates Sparkle appcast using the specified DMG file."
onlyIf {
if (isAppStoreDistribution()) {
logger.info("Skipping generateSparkleAppCast: App store distribution")
return@onlyIf false
}
val privateKeyFile = rootProject.file("certificates/sparkle_eddsa_private.pem")
if (!isMac()) {
logger.info("Skipping generateSparkleAppCast: Not running on macOS")
Expand All @@ -134,7 +149,9 @@ private fun Project.registerSparkleTask() {
}
true
}
dependsOn("setupSparkle")
if (!isAppStoreDistribution()) {
dependsOn("setupSparkle")
}

sparkleVersion.set(providers.gradleProperty("sparkleVersion").orElse("2.8.0"))
edKeyFile.set(rootProject.file("certificates/sparkle_eddsa_private.pem"))
Expand All @@ -149,7 +166,7 @@ private fun Project.registerWinSparkleTask() {
group = "setup"
description =
"Downloads WinSparkle and extracts WinSparkle.dll to the destination directory"
onlyIf { System.getProperty("os.name").lowercase().contains("win") }
onlyIf { System.getProperty("os.name").lowercase().contains("win") && !isAppStoreDistribution() }
winSparkleVersion.set(providers.gradleProperty("winSparkleVersion").orElse("0.9.1"))
destDir.set(
providers.gradleProperty("winSparkleExtractDir")
Expand Down Expand Up @@ -211,41 +228,45 @@ private fun Project.registerOONIDistributableTask() {
return@doLast
}

val sparkleFramework =
layout.buildDirectory.dir("tmp/desktop/main/macos/Sparkle.framework").get().asFile
val appSparkleLocation = appDirs.first().resolve("Contents/app/resources")

project.logger.lifecycle("Sparkle.framework location: ${sparkleFramework.absolutePath}")
project.logger.lifecycle("Desired Sparkle.framework location: ${appSparkleLocation.absolutePath}")

// Sign the Sparkle framework
fun signSparkle(path: String) {
macOsCodeSign(sparkleFramework.resolve(path).absolutePath)
if (!isAppStoreDistribution()) {
val sparkleFramework =
layout.buildDirectory.dir("tmp/desktop/main/macos/Sparkle.framework").get().asFile
val appSparkleLocation = appDirs.first().resolve("Contents/app/resources")

project.logger.lifecycle("Sparkle.framework location: ${sparkleFramework.absolutePath}")
project.logger.lifecycle("Desired Sparkle.framework location: ${appSparkleLocation.absolutePath}")

// Sign the Sparkle framework
fun signSparkle(path: String) {
macOsCodeSign(sparkleFramework.resolve(path).absolutePath)
}
signSparkle("Versions/B/XPCServices/Installer.xpc")
signSparkle("Versions/B/XPCServices/Downloader.xpc")
signSparkle("Versions/B/Autoupdate")
signSparkle("Versions/B/Updater.app")
signSparkle("") // root folder

// Remove existing Sparkle.framework in destination
project.providers.exec {
commandLine(
"rm",
"-R",
appSparkleLocation.resolve("Sparkle.framework").absolutePath
)
isIgnoreExitValue = true // We don't care if the folder is not there
}.result.get()
// Copy to destination
project.providers.exec {
commandLine(
"cp",
"-a",
sparkleFramework.absolutePath,
appSparkleLocation.absolutePath
)
}.result.get()
} else {
project.logger.lifecycle("App store distribution: skipping Sparkle.framework bundling")
}
signSparkle("Versions/B/XPCServices/Installer.xpc")
signSparkle("Versions/B/XPCServices/Downloader.xpc")
signSparkle("Versions/B/Autoupdate")
signSparkle("Versions/B/Updater.app")
signSparkle("") // root folder

// Remove existing Sparkle.framework in destination
project.providers.exec {
commandLine(
"rm",
"-R",
appSparkleLocation.resolve("Sparkle.framework").absolutePath
)
isIgnoreExitValue = true // We don't care if the folder is not there
}.result.get()
// Copy to destination
project.providers.exec {
commandLine(
"cp",
"-a",
sparkleFramework.absolutePath,
appSparkleLocation.absolutePath
)
}.result.get()

// Sign the .app file
macOsCodeSign(appDirs.first().absolutePath)
Expand Down Expand Up @@ -320,32 +341,35 @@ private fun Project.configureTaskDependencies() {
tasks.findByName("clean")
?.dependsOn("copyBrandingToCommonResources", "cleanCopiedCommonResourcesToFlavor")

// Ensure Sparkle.framework is prepared before packaging desktop apps
val sparkleConsumers = setOf(
"runDistributable",
"createDistributable",
"packageDistributionForCurrentOS",
"packageDmg",
"desktopJar"
)
tasks.matching { it.name in sparkleConsumers }.configureEach {
dependsOn("setupSparkle")
}
if (!isAppStoreDistribution()) {
// Ensure Sparkle.framework is prepared before packaging desktop apps
val sparkleConsumers = setOf(
"runDistributable",
"createDistributable",
"packageDistributionForCurrentOS",
"packageDmg",
"desktopJar"
)
tasks.matching { it.name in sparkleConsumers }.configureEach {
dependsOn("setupSparkle")
}

// Prefer running setupSparkle after desktop resource processing to avoid overwrites
tasks.findByName("setupSparkle")?.let { setup ->
val desktopRes = tasks.matching {
it.name.contains(
"processResources",
ignoreCase = true
) && it.name.contains("desktop", ignoreCase = true)
// Prefer running setupSparkle after desktop resource processing to avoid overwrites
tasks.findByName("setupSparkle")?.let { setup ->
val desktopRes = tasks.matching {
it.name.contains(
"processResources",
ignoreCase = true
) && it.name.contains("desktop", ignoreCase = true)
}
setup.mustRunAfter(desktopRes)
}
setup.mustRunAfter(desktopRes)
}

// Ensure createOONIDistributable runs after createDistributable and before any other task that depends on it
val ooniDistributableTask = tasks.named("createOONIDistributable")
tasks.findByName("packageDmg")?.dependsOn(ooniDistributableTask)
tasks.findByName("packagePkg")?.dependsOn(ooniDistributableTask)
tasks.findByName("packageDistributionForCurrentOS")?.dependsOn(ooniDistributableTask)
tasks.findByName("runDistributable")?.dependsOn(ooniDistributableTask)
}
Expand Down
45 changes: 45 additions & 0 deletions composeApp/InfoPlist-appstore.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ooni</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ooni</string>
</array>
</dict>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>com.apple.WebKit.WebContent</string>
<string>com.apple.WebKit.GPU</string>
<string>com.apple.WebKit.Networking</string>
</array>
<key>com.apple.security.temporary-exception.mach-register.global-name</key>
<array>
<string>com.apple.WebKit.WebContent</string>
<string>com.apple.WebKit.GPU</string>
<string>com.apple.WebKit.Networking</string>
</array>
<key>com.apple.security.temporary-exception.shared-preference.read-write</key>
<array>
<string>APP_ID</string>
</array>
<key>com.apple.runningboard.assertions.webkit</key>
<true/>
Loading
Loading