import java.time.LocalDateTime import org.apache.tools.ant.taskdefs.condition.Os import static groovy.io.FileType.* task jpackageSanityChecks { description 'Interactive sanity checks on the version of the code that will be packaged' doLast { // Enforce JDK 15 for packaging. This will ensure: // - Java 15 is used to compile the jars // - jpackager from JDK 15 is used to package the binaries // - JRE 15 is bundled in the released binaries // TODO Use jpackage flag "--runtime-image jdk-11" to include another version of JRE in generated binaries // (But buggy in jpackager v15? Flag didn't work in last v15 test) assert JavaVersion.current() == JavaVersion.VERSION_15: "JDK 15 is required when packaging" executeCmd("git --no-pager log -5 --oneline") ant.input(message: "Above you see the current HEAD and its recent history.\n" + "Is this the right commit for packaging? (y=continue, n=abort)", addproperty: "sanity-check-1", validargs: "y,n") if (ant.properties['sanity-check-1'] == 'n') { ant.fail('Aborting') } executeCmd("git status --short --branch") ant.input(message: "Above you see any local changes that are not in the remote branch.\n" + "If you have any local changes, please abort, get them merged, get the latest branch and try again.\n" + "Continue with packaging? (y=continue, n=abort)", addproperty: "sanity-check-2", validargs: "y,n") if (ant.properties['sanity-check-2'] == 'n') { ant.fail('Aborting') } } } task createNewTempFolder { description 'Create new temp folder where the packaging files will be placed' dependsOn 'jpackageSanityChecks' doLast { // The build directory will be deleted next time the clean task runs // Therefore, we can use it to store any temp files (separate JDK for jpackage, etc) and resulting build artefacts // We create a temp folder in the build directory which holds all jpackage-related artefacts (not just the final installers) String tempRootDirName = 'temp-' + LocalDateTime.now().format('yyyy.MM.dd-HHmmssSSS') File tempRootDir = new File(project.buildDir, tempRootDirName) tempRootDir.mkdirs() ext.tempRootDir = tempRootDir println "Created temp root folder " + tempRootDir File binariesFolderPath = new File(tempRootDir, "binaries") binariesFolderPath.mkdirs() ext.binariesFolderPath = binariesFolderPath } } task packageInstallers { description 'Call jpackage to prepare platform-specific binaries for this platform' dependsOn 'createNewTempFolder' dependsOn rootProject.clean dependsOn ':desktop:build' // Full build needed for "desktop/build/app/lib", used for raspi package doLast { String jPackageFilePath = "jpackage" // Binary is in the PATH because we're running JDK v15 File binariesFolderPath = file(createNewTempFolder.property('binariesFolderPath')) File tempRootDir = createNewTempFolder.property("tempRootDir") as File // The jpackageTempDir stores temp files used by jpackage for building the installers // It can be inspected in order to troubleshoot the packaging process File jpackageTempDir = new File(tempRootDir, "jpackage-temp") jpackageTempDir.mkdirs() // ALL contents of this folder will be included in the resulting installers // However, the fat jar is the only one we need // Therefore, this location should point to a folder that ONLY contains the fat jar // If later we will need to include other non-jar resources, we can do that by adding --resource-dir to the jpackage opts String fatJarFolderPath = "${project(':desktop').buildDir}/libs/fatJar" String mainJarName = shadowJar.getArchiveFileName().get() delete(fatJarFolderPath) mkdir(fatJarFolderPath) copy { from "${project(':desktop').buildDir}/libs/${mainJarName}" into fatJarFolderPath } // We convert the fat jar into a deterministic one by stripping out comments with date, etc. // jar file created from https://github.com/ManfredKarrer/tools executeCmd("java -jar \"${project(':desktop').projectDir}/package/tools-1.0.jar\" ${fatJarFolderPath}/${mainJarName}") // TODO For non-modular applications: use jlink to create a custom runtime containing only the modules required // See jpackager argument documentation: // https://docs.oracle.com/en/java/javase/15/docs/specs/man/jpackage.html // Remove the -SNAPSHOT suffix from the version string (originally defined in build.gradle) // Having it in would have resulted in an invalid version property for several platforms (mac, linux/rpm) String appVersion = version.replaceAll("-SNAPSHOT", "") println "Packaging Bisq version ${appVersion}" // zip jar lib for Raspberry Pi only on macOS as it is only needed once for the release if (Os.isFamily(Os.FAMILY_MAC)) { println "Zipping jar lib for raspberry pi" ant.zip(basedir: "${project(':desktop').buildDir}/app/lib", destfile: "${binariesFolderPath}/jar-lib-for-raspberry-pi-${appVersion}.zip") } String appDescription = 'A decentralized bitcoin exchange network.' String appCopyright = '© 2021 Bisq' String appNameAndVendor = 'Bisq' String commonOpts = new String( // Generic options " --dest \"${binariesFolderPath}\"" + " --name ${appNameAndVendor}" + " --description \"${appDescription}\"" + " --app-version ${appVersion}" + " --copyright \"${appCopyright}\"" + " --vendor ${appNameAndVendor}" + " --temp \"${jpackageTempDir}\"" + // Options for creating the application image " --input ${fatJarFolderPath}" + // Options for creating the application launcher " --main-jar ${mainJarName}" + " --main-class bisq.desktop.app.BisqAppMain" + " --java-options -Xss1280k" + " --java-options -XX:MaxRAM=8g" + " --java-options -XX:+UseG1GC" + " --java-options -XX:MaxHeapFreeRatio=10" + " --java-options -XX:MinHeapFreeRatio=5" + " --java-options -XX:+UseStringDeduplication" + " --java-options -Djava.net.preferIPv4Stack=true" // Warning: this will cause guice reflection exceptions and lead to issues with the guice internal cache // resulting in the UI not loading // " --java-options -Djdk.module.illegalAccess=deny" + ) if (Os.isFamily(Os.FAMILY_WINDOWS)) { // TODO Found no benefit in using --resource-dir "..package/windows", it has the same outcome as opts below String windowsOpts = new String( " --icon \"${project(':desktop').projectDir}/package/windows/Bisq.ico\"" + " --resource-dir \"${project(':desktop').projectDir}/package/windows\"" + " --win-dir-chooser" + " --win-per-user-install" + " --win-menu" + " --win-shortcut" ) executeCmd(jPackageFilePath + commonOpts + windowsOpts + " --type exe") // Set the necessary permissions before calling signtool executeCmd("\"attrib -R \"${binariesFolderPath}/Bisq-${appVersion}.exe\"\"") // In addition to the groovy quotes around the string, the entire Windows command must also be surrounded // by quotes, plus each path inside the command has to be quoted as well // Reason for this is that the path to the called executable contains spaces // See https://stackoverflow.com/questions/6376113/how-do-i-use-spaces-in-the-command-prompt/6378038#6378038 executeCmd("\"\"C:\\Program Files (x86)\\Windows Kits\\10\\App Certification Kit\\signtool.exe\" sign /v /fd SHA256 /a \"${binariesFolderPath}/Bisq-${appVersion}.exe\"\"") } else if (Os.isFamily(Os.FAMILY_MAC)) { // See https://docs.oracle.com/en/java/javase/14/jpackage/override-jpackage-resources.html // for details of "--resource-dir" String macOpts = new String( " --resource-dir \"${project(':desktop').projectDir}/package/macosx\"" ) // Env variable can be set by calling "export BISQ_PACKAGE_SIGNING_IDENTITY='Some value'" // See "man codesign" for details about the expected signing identity String envVariableSigningID = "$System.env.BISQ_PACKAGE_SIGNING_IDENTITY" println "Environment variable BISQ_PACKAGE_SIGNING_IDENTITY is: ${envVariableSigningID}" ant.input(message: "Sign the app using the above signing identity? (y=yes, n=no)", addproperty: "macos-sign-check", validargs: "y,n") if (ant.properties['macos-sign-check'] == 'y') { // Create a temp folder to extract the macos-specific dylibs that need to be signed File tempDylibFolderPath = new File(tempRootDir, "dylibs-to-sign") tempDylibFolderPath.mkdirs() // Dylibs relevant for signing (paths relative to the tempDylibFolderPath) String dylibsToSign = new String( " libjavafx_iio.dylib" + " libglass.dylib" + " libjavafx_font.dylib" + " libprism_common.dylib" + " libprism_es2.dylib" + " libdecora_sse.dylib" + " libprism_sw.dylib" + " META-INF/native/libio_grpc_netty_shaded_netty_tcnative_osx_x86_64.jnilib" ) // macOS step 1: Sign dylibs and replace them in the shadow jar // Extract dylibss for signing executeCmd("cd ${tempDylibFolderPath} &&" + " jar xf ${fatJarFolderPath}/${mainJarName}" + dylibsToSign) // Sign them executeCmd("cd ${tempDylibFolderPath} &&" + " codesign -vvv --options runtime --deep --force --sign \"${envVariableSigningID}\"" + dylibsToSign) // Verify signature executeCmd("cd ${tempDylibFolderPath} &&" + " codesign -vvv --deep --strict " + dylibsToSign) // Replace unsigned files in jar file executeCmd("cd ${tempDylibFolderPath} &&" + " jar uf ${fatJarFolderPath}/${mainJarName}" + dylibsToSign) // macOS step 2: Build app-image using the shadow jar above (containing signed dylibs) // NOTE: licensing file cannot be added at this point only when creating the dmg later executeCmd(jPackageFilePath + commonOpts + macOpts + " --type app-image") // macOS step 3: Sign app (hardened runtime) File bisqAppImageFullPath = new File(binariesFolderPath, "Bisq.app") executeCmd("codesign" + " --sign \"${envVariableSigningID}\"" + " --options runtime" + " --entitlements '${project(':desktop').projectDir}/package/macosx/macos.entitlements'" + " --force" + " --verbose" + " ${bisqAppImageFullPath}/Contents/runtime/Contents/MacOS/libjli.dylib") executeCmd("codesign" + " --sign \"${envVariableSigningID}\"" + " --options runtime" + " --entitlements '${project(':desktop').projectDir}/package/macosx/macos.entitlements'" + " --force" + " --verbose" + " ${bisqAppImageFullPath}/Contents/MacOS/Bisq") executeCmd("codesign" + " --sign \"${envVariableSigningID}\"" + " --options runtime" + " --entitlements '${project(':desktop').projectDir}/package/macosx/macos.entitlements'" + " --force" + " --verbose" + " ${bisqAppImageFullPath}") // macOS step 4: Package the app-image into a dmg bundle executeCmd(jPackageFilePath + " --dest \"${binariesFolderPath}\"" + " --name ${appNameAndVendor}" + " --description \"${appDescription}\"" + " --app-version ${appVersion}" + " --copyright \"${appCopyright}\"" + " --vendor ${appNameAndVendor}" + " --temp \"${jpackageTempDir}\"" + " --app-image ${bisqAppImageFullPath}" + " --mac-sign" + macOpts + " --type dmg") // macOS step 5: Delete unused app image delete(bisqAppImageFullPath) // macOS step 6: Sign dmg bundle executeCmd("codesign" + " --sign \"${envVariableSigningID}\"" + " --options runtime" + " --entitlements '${project(':desktop').projectDir}/package/macosx/macos.entitlements'" + " -vvvv" + " --deep" + " '${binariesFolderPath}/Bisq-${appVersion}.dmg'") // macOS step 7: Upload for notarization // See https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow#3087734 String envVariableAcUsername = "$System.env.BISQ_PACKAGE_NOTARIZATION_AC_USERNAME" String envVariableAscProvider = "$System.env.BISQ_PACKAGE_NOTARIZATION_ASC_PROVIDER" // e.g. network.bisq.CAT is used when binaries are built by @ripcurlx String envVariablePrimaryBundleId = "$System.env.BISQ_PRIMARY_BUNDLE_ID" def uploadForNotarizationOutput = executeCmd("xcrun altool --notarize-app" + " --primary-bundle-id '${envVariablePrimaryBundleId}'" + " --username '${envVariableAcUsername}'" + " --password '@keychain:AC_PASSWORD'" + " --asc-provider '${envVariableAscProvider}'" + " --file '${binariesFolderPath}/Bisq-${appVersion}.dmg'") // Response: // No errors uploading '[PATH_TO_BISQ_REPO]/bisq/desktop/build/temp-620637000/binaries/Bisq-1.1.1.dmg'. // RequestUUID = ea8bba77-97b7-4c15-a53f-8bbccf627190 def requestUUID = uploadForNotarizationOutput.split('RequestUUID = ')[1].trim() println "Extracted RequestUUID: " + requestUUID // Every 1 minute, check the status def notarizationEndedInSuccess = false def notarizationEndedInFailure = false while (!(notarizationEndedInSuccess || notarizationEndedInFailure)) { println "Current time is:" executeCmd('date') println "Waiting for 1 minute..." sleep(1 * 60 * 1000) println "Checking notarization status" def checkNotarizationStatusOutput = executeCmd("xcrun altool --notarization-info" + " '${requestUUID}'" + " --username '${envVariableAcUsername}'" + " --password '@keychain:AC_PASSWORD'") notarizationEndedInSuccess = checkNotarizationStatusOutput.contains('success') notarizationEndedInFailure = checkNotarizationStatusOutput.contains('invalid') } if (notarizationEndedInFailure) { ant.fail('Notarization failed, aborting') } if (notarizationEndedInSuccess) { println "Notarization was successful" // macOS step 8: Staple ticket on dmg executeCmd("xcrun stapler staple" + " '${binariesFolderPath}/Bisq-${appVersion}.dmg'") } } else { // If user didn't confirm the optional signing step, then generate a plain non-signed dmg executeCmd(jPackageFilePath + commonOpts + macOpts + " --type dmg") } } else { String linuxOpts = new String( " --icon ${project(':desktop').projectDir}/package/linux/icon.png" + // This defines the first part of the resulting packages (the application name) // deb requires lowercase letters, therefore the application name is written in lowercase " --linux-package-name bisq" + // This represents the linux package version (revision) // By convention, this is part of the deb/rpm package names, in addition to the software version " --linux-app-release 1" + " --linux-menu-group Network" + " --linux-shortcut" ) // Package deb executeCmd(jPackageFilePath + commonOpts + linuxOpts + " --linux-deb-maintainer noreply@bisq.network" + " --type deb") // Clean jpackage temp folder, needs to be empty for the next packaging step (rpm) jpackageTempDir.deleteDir() jpackageTempDir.mkdirs() // Package rpm executeCmd(jPackageFilePath + commonOpts + linuxOpts + " --linux-rpm-license-type AGPLv3" + // https://fedoraproject.org/wiki/Licensing:Main?rd=Licensing#Good_Licenses " --type rpm") } // After binaries have been generated, copy the (deterministic, signed) fat jar to the binaries folder copy { from "${fatJarFolderPath}/${mainJarName}" into binariesFolderPath // desktop-1.6.4-SNAPSHOT-all.jar => desktop-1.6.4-SNAPSHOT-all-mac.jar (or -win.jar, or -linux.jar) rename { String fileName -> fileName.replace('-all.jar', "-all-" + os + ".jar") } } // Checksum each file present in the binaries folder ant.checksum(algorithm: 'SHA-256') { ant.fileset(dir: "${binariesFolderPath}") } println "The binaries and checksums are ready:" FileCollection collection = layout.files { binariesFolderPath.listFiles() } collection.collect { it.path }.sort().each { println it } // After binaries are ready, copy them to shared folder // Env variable can be set by calling "export BISQ_SHARED_FOLDER='Some value'" // This is to copy the final binary/ies to a shared folder for further processing if a VM is used. String envVariableSharedFolder = "$System.env.BISQ_SHARED_FOLDER" println "Environment variable BISQ_SHARED_FOLDER is: ${envVariableSharedFolder}" ant.input(message: "Copy the created binary to a shared folder? (y=yes, n=no)", addproperty: "copy-to-shared-folder", validargs: "y,n") if (ant.properties['copy-to-shared-folder'] == 'y') { copy { from binariesFolderPath into envVariableSharedFolder } // Try to open a native file explorer window at the shared folder location if (Os.isFamily(Os.FAMILY_WINDOWS)) { executeCmd("start '${envVariableSharedFolder}'") } else if (Os.isFamily(Os.FAMILY_MAC)) { executeCmd("open '${envVariableSharedFolder}'") } else { executeCmd("nautilus '${envVariableSharedFolder}'") } } } } def executeCmd(String cmd) { String shell String shellArg if (Os.isFamily(Os.FAMILY_WINDOWS)) { shell = 'cmd' shellArg = '/c' } else { shell = 'bash' shellArg = '-c' } println "Executing command:\n${cmd}\n" // See "Executing External Processes" section of // http://docs.groovy-lang.org/next/html/documentation/ def commands = [shell, shellArg, cmd] def process = commands.execute(null, project.rootDir) def result if (process.waitFor() == 0) { result = process.text println "Command output (stdout):\n${result}" } else { result = process.err.text println "Command output (stderr):\n${result}" } return result }