/* * Copyright 2015 - 2016 i-net software * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.inet.gradle.setup.dmg; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.attribute.FileAttribute; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import javax.imageio.ImageIO; import org.gradle.api.internal.file.FileResolver; import org.w3c.dom.Element; import com.inet.gradle.setup.SetupBuilder; import com.inet.gradle.setup.Template; import com.inet.gradle.setup.abstracts.AbstractBuilder; import com.inet.gradle.setup.abstracts.Application; import com.inet.gradle.setup.abstracts.DesktopStarter; import com.inet.gradle.setup.abstracts.LocalizedResource; import com.inet.gradle.setup.abstracts.Service; import com.inet.gradle.setup.image.ImageFactory; import com.inet.gradle.setup.util.XmlFileBuilder; /** * Build a DMG image for OSX. * * @author Volker Berlin */ public class DmgBuilder extends AbstractBuilder<Dmg, SetupBuilder> { private String applicationName, applicationIdentifier, imageSourceRoot; private SetupBuilder setup; /** * Create a new instance * * @param dmg the calling task * @param setup the shared settings * @param fileResolver the file Resolver */ public DmgBuilder( Dmg dmg, SetupBuilder setup, FileResolver fileResolver ) { super( dmg, fileResolver ); this.setup = setup; } /** * Build the dmg file. * * @throws RuntimeException if any error occur */ public void build() throws RuntimeException { try { if( setup.getServices().isEmpty() && setup.getDesktopStarters().isEmpty() ) { throw new IllegalArgumentException( "No Services or DesktopStarters have been defined. Will stop now." ); } // Build all services for( Service service : setup.getServices() ) { new OSXApplicationBuilder( task, setup, fileResolver ).buildService( service ); } // Build all standalone applications for( DesktopStarter application : setup.getDesktopStarters() ) { new OSXApplicationBuilder( task, setup, fileResolver ).buildApplication( application ); } applicationIdentifier = setup.getAppIdentifier(); applicationName = setup.getApplication(); imageSourceRoot = buildDir.toString() + "/" + setup.getApplication() + ".app"; if( !setup.getServices().isEmpty() ) { // Create installer package createPackageFromApp(); } /* * new File ( task.getSetupFile().toString() ).createNewFile(); * / */ createBinary(); //*/ } catch( RuntimeException ex ) { ex.printStackTrace(); throw ex; } catch( Exception ex ) { throw new RuntimeException( ex ); } catch( Throwable ex ) { ex.printStackTrace(); } } /** * Create the binary with native tools. * * @throws Throwable */ private void createBinary() throws Throwable { createTempImage(); attach(); setVolumeIcon(); applescript(); detach(); finalImage(); new File( setup.getDestinationDir(), "pack.temp.dmg" ).delete(); } /** * Create the service files and the pre- and post installer scripts * * @throws IOException in case of errors */ private void createServiceFiles() throws IOException { Application core = new Application( setup ); // Create Pre and Post install scripts DesktopStarter runAfter = setup.getRunAfter(); DesktopStarter runBeforeUninstall = setup.getRunBeforeUninstall(); OSXScriptBuilder preinstall = new OSXScriptBuilder( core, "template/preinstall.txt" ); preinstall.addScript( new OSXScriptBuilder( task.getPreinst() ) ); OSXScriptBuilder postinstall = new OSXScriptBuilder( core, "template/postinstall.txt" ); preinstall.addScript( new OSXScriptBuilder( task.getPostinst() ) ); OSXScriptBuilder uninstall = new OSXScriptBuilder( core, "template/uninstall.txt" ); uninstall.addScript( new OSXScriptBuilder( task.getPrerm() ) ); OSXScriptBuilder watchUninstall = new OSXScriptBuilder( core, "service/watchuninstall.plist" ); // Set the daemon user, so that it can be created and removed. if( task.getDaemonUser() != "root" ) { // Create String home = "/Library/Application Support/" + setup.getApplication(); OSXScriptBuilder createUser = new OSXScriptBuilder( core, "template/preinstall.createuser.txt" ); // Remove OSXScriptBuilder removeUser = new OSXScriptBuilder( core, "template/postuninstall.remove.user.txt" ); // Set the daemonUser on each object OSXScriptBuilder[] list = { createUser, removeUser, postinstall }; Arrays.asList( list ).forEach( item -> { item.setPlaceholder( "daemonUser", task.getDaemonUser() ); item.setPlaceholder( "homeDirectory", home); } ); preinstall.addScript( createUser ); uninstall.addScript( removeUser ); } for( Service service : setup.getServices() ) { if( runBeforeUninstall != null ) { runBeforeUninstall.setDisplayName( service.getDisplayName() ); uninstall.addScript( new OSXScriptBuilder( runBeforeUninstall, "template/runBeforeAfter.txt" ).setPlaceholder( "installationSubdirectory", installationSubdirectory() ).setPlaceholder( "inBackground", "NO" ).setPlaceholder( "startArgument", runBeforeUninstall.getStartArguments() ) ); } preinstall.addScript( new OSXScriptBuilder( service, "template/preinstall.remove-service.txt" ) ); postinstall.addScript( new OSXScriptBuilder( service, "template/postinstall.install-service.txt" ).setPlaceholder( "installationSubdirectory", installationSubdirectory() ) ); // Unload service in uninstall as well. uninstall.addScript( new OSXScriptBuilder( service, "template/preinstall.remove-service.txt" ) ); // patch runafter if( runAfter != null ) { runAfter.setDisplayName( service.getDisplayName() ); postinstall.addScript( new OSXScriptBuilder( runAfter, "template/runBeforeAfter.txt" ).setPlaceholder( "installationSubdirectory", installationSubdirectory() ).setPlaceholder( "startArgument", runAfter.getStartArguments() ) ); } } uninstall.addScript( new OSXScriptBuilder( task.getPostrm() ) ); uninstall.writeTo( TempPath.getTempFile( "scripts", "uninstall.sh" ) ); watchUninstall.writeTo( TempPath.getTempFile( "scripts", "watchuninstall.plist" ) ); preinstall.writeTo( TempPath.getTempFile( "scripts", "preinstall" ) ); postinstall.writeTo( TempPath.getTempFile( "scripts", "postinstall" ) ); } /** * Create a package from the specified app files * * @throws Throwable in case of errors */ private void createPackageFromApp() throws Throwable { createServiceFiles(); extractApplicationInformation(); createAndPatchDistributionXML(); imageSourceRoot = TempPath.get( "distribution" ).toString(); File resultingPackage = new File( imageSourceRoot, applicationName + ".pkg" ); // Build Product for packaging ArrayList<String> command = new ArrayList<>(); command.add( "/usr/bin/productbuild" ); command.add( "--distribution" ); command.add( TempPath.getTempString( "distribution.xml" ) ); command.add( "--package-path" ); command.add( TempPath.get( "packages" ).toString() ); command.add( "--resources" ); command.add( TempPath.get( "resources" ).toString() ); // Sign the final package command.add( resultingPackage.getAbsolutePath() ); exec( command ); if( task.getCodeSign() != null ) { task.getCodeSign().signProduct( resultingPackage ); } packageApplescript(); Files.copy( resultingPackage.toPath(), new File( setup.getDestinationDir(), "/" + applicationName + ".pkg" ).toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING ); } /** * Extract the application information to use for the package builder * * @throws IOException in case of errors */ private void extractApplicationInformation() throws IOException { // Create application information plist ArrayList<String> command = new ArrayList<>(); command.add( "/usr/bin/pkgbuild" ); command.add( "--analyze" ); command.add( "--root" ); command.add( buildDir.toString() ); command.add( TempPath.getTempString( applicationIdentifier + ".plist" ) ); exec( command ); // set identifier, create package command = new ArrayList<>(); command.add( "/usr/bin/pkgbuild" ); command.add( "--root" ); command.add( buildDir.toString() ); command.add( "--component-plist" ); command.add( TempPath.getTempString( applicationIdentifier + ".plist" ) ); command.add( "--identifier" ); command.add( setup.getMainClass() != null ? setup.getMainClass() : setup.getAppIdentifier() ); command.add( "--version" ); command.add( setup.getVersion() ); command.add( "--scripts" ); command.add( TempPath.get( "scripts" ).toString() ); command.add( "--install-location" ); // Application as default directory except there are more application parts to install. command.add( "/Applications/" + installationSubdirectory() ); command.add( TempPath.getTempString( "packages", applicationName + ".pkg" ) ); exec( command ); Files.copy( TempPath.getTempFile( "packages", applicationName + ".pkg" ).toPath(), new File( setup.getDestinationDir(), "/" + applicationIdentifier + ".pkgbuild.pkg" ).toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING ); } /** * Returns a subdirectory if needed because of the installation * * @return subdirectory or "" */ private String installationSubdirectory() { return (setup.getServices().size() + setup.getDesktopStarters().size() > 1 ? setup.getApplication() + "/" : ""); } /** * Create and patch the ditribution xml file that defines the package * * @throws Throwable in case of error */ private void createAndPatchDistributionXML() throws Throwable { ArrayList<String> command; // Synthesize Distribution xml command = new ArrayList<>(); command.add( "/usr/bin/productbuild" ); command.add( "--synthesize" ); command.add( "--package" ); command.add( TempPath.getTempFile( "packages", applicationName + ".pkg" ).toString() ); command.add( TempPath.getTempFile( "distribution.xml" ).toString() ); exec( command ); patchDistributionXML(); } /** * Patch the distriubiton file with custom settings * * @throws Throwable in case of errors */ private void patchDistributionXML() throws Throwable { File xml = TempPath.getTempFile( "distribution.xml" ); URL url = xml.toURI().toURL(); @SuppressWarnings( "rawtypes" ) XmlFileBuilder xmlFile = new XmlFileBuilder<Dmg>( task, setup, xml, buildDir, url ); Element distribution = (Element)xmlFile.doc.getFirstChild(); if( !"installer-gui-script".equals( distribution.getTagName() ) ) { throw new IllegalArgumentException( "Template does not contain an installer-gui-script root: " + distribution.getTagName() ); } // The title of the installer Element title = xmlFile.getOrCreateChild( distribution, "title" ); xmlFile.addNodeText( title, applicationName ); // Product node File backgroundImage = task.getSetupBackgroundImage(); if( backgroundImage != null ) { Files.copy( backgroundImage.toPath(), TempPath.getTempFile( "resources", backgroundImage.getName() ).toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING ); Element background = xmlFile.getOrCreateChild( distribution, "background", false ); xmlFile.addAttributeIfNotExists( background, "file", backgroundImage.getName() ); xmlFile.addAttributeIfNotExists( background, "alignment", "left" ); xmlFile.addAttributeIfNotExists( background, "proportional", "left" ); } // Welcome Node List<LocalizedResource> welcomePages = task.getWelcomePages(); for( LocalizedResource localizedResource : welcomePages ) { File welcomePage = checkSetupTextFile( localizedResource.getResource() ); if( welcomePage != null ) { Files.copy( welcomePage.toPath(), TempPath.getTempFile( "resources/" + localizedResource.getLanguage() + ".lproj", "Welcome" ).toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING ); Element license = xmlFile.getOrCreateChild( distribution, "license", false ); xmlFile.addAttributeIfNotExists( license, "file", "Welcome" ); } } // License node List<LocalizedResource> licenseFiles = setup.getLicenseFiles(); for( LocalizedResource localizedResource : licenseFiles ) { File licenseFile = checkSetupTextFile( localizedResource.getResource() ); if( licenseFile != null ) { Files.copy( licenseFile.toPath(), TempPath.getTempFile( "resources/" + localizedResource.getLanguage() + ".lproj", "License" ).toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING ); Element license = xmlFile.getOrCreateChild( distribution, "license", false ); xmlFile.addAttributeIfNotExists( license, "file", "License" ); } } // Conclusion Node List<LocalizedResource> conclusionPages = task.getConclusionPages(); for( LocalizedResource localizedResource : conclusionPages ) { File welcomePage = checkSetupTextFile( localizedResource.getResource() ); if( welcomePage != null ) { Files.copy( welcomePage.toPath(), TempPath.getTempFile( "resources/" + localizedResource.getLocale().getLanguage() + ".lproj", "Conclusion" ).toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING ); Element license = xmlFile.getOrCreateChild( distribution, "conclusion", false ); xmlFile.addAttributeIfNotExists( license, "file", "Conclusion" ); } } xmlFile.save(); } /** * Check a file for the correct setup text-resource type * * @param file to check * @return file if ok, or null */ private File checkSetupTextFile( File file ) { if( file != null ) { String name = file.getName(); for( String format : new String[] { "txt", "rtf", "rtfd", "html" } ) { if( name.toLowerCase().endsWith( "." + format ) ) { return file; } } System.err.println( "The provided file must be of type: txt, rtf, rtfd or html. File was: " + name ); } return null; } /** * Call hdiutil to create a temporary image. */ private void createTempImage() { ArrayList<String> command = new ArrayList<>(); command.add( "/usr/bin/hdiutil" ); command.add( "create" ); command.add( "-srcfolder" ); command.add( imageSourceRoot ); command.add( "-format" ); command.add( "UDRW" ); command.add( "-volname" ); command.add( applicationName ); command.add( setup.getDestinationDir() + "/pack.temp.dmg" ); exec( command ); } /** * Call hdiutil to mount temporary image * * @throws IOException in case of errors */ private void attach() throws IOException { ArrayList<String> command = new ArrayList<>(); command.add( "/usr/bin/hdiutil" ); command.add( "attach" ); command.add( "-readwrite" ); command.add( "-noverify" ); command.add( "-noautoopen" ); command.add( setup.getDestinationDir() + "/pack.temp.dmg" ); command.add( "-mountroot" ); command.add( TempPath.get() ); exec( command ); } /** * Call hdiutil to detach temporary image */ private void detach() { ArrayList<String> command = new ArrayList<>(); command.add( "/usr/bin/hdiutil" ); command.add( "detach" ); command.add( TempPath.get() + "/" + applicationName ); exec( command ); } /** * Call SetFile to set the volume icon. * * @throws IOException IOException */ private void setVolumeIcon() throws IOException { // Copy Icon as file icon into attached container File iconDestination = TempPath.getTempFile( applicationName, ".VolumeIcon.icns" ); File icons = setup.getIconForType( buildDir, "icns" ); if( icons == null ) { throw new IllegalArgumentException( "You have to specify a valid icon file" ); } Files.copy( icons.toPath(), iconDestination.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING ); ArrayList<String> command = new ArrayList<>(); command.add( "SetFile" ); command.add( "-a" ); command.add( "C" ); command.add( iconDestination.getParent() ); exec( command, null, null, true ); if( task.getBackgroundImage() != null ) { String name = task.getBackgroundImage().getName(); File backgroundDestination = TempPath.getTempFile( applicationName, "/.resources/background" + name.substring( name.lastIndexOf( '.' ) ) ); Files.createDirectories( backgroundDestination.getParentFile().toPath(), new FileAttribute[0] ); Files.copy( task.getBackgroundImage().toPath(), backgroundDestination.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING ); BufferedImage image = ImageIO.read( backgroundDestination ); // Override the values to use the acutal image size task.setWindowWidth( image.getWidth() ); task.setWindowHeight( image.getHeight() ); } } /** * Run an apple script using the applescript.txt template * This will set up the layout of the DMG window * * @throws IOException in case of errors */ private void applescript() throws IOException { Template applescript = new Template( "dmg/template/applescript.txt" ); applescript.setPlaceholder( "displayName", setup.getApplication() ); applescript.setPlaceholder( "executable", setup.getApplication() ); applescript.setPlaceholder( "windowWidth", task.getWindowWidth().toString() ); applescript.setPlaceholder( "windowHeight", task.getWindowHeight().toString() ); applescript.setPlaceholder( "iconSize", task.getIconSize().toString() ); applescript.setPlaceholder( "fontSize", task.getFontSize().toString() ); if( task.getBackgroundImage() != null ) { String name = task.getBackgroundImage().getName(); applescript.setPlaceholder( "backgroundExt", name.substring( name.lastIndexOf( '.' ) ) ); } ArrayList<String> command = new ArrayList<>(); command.add( "/usr/bin/osascript" ); System.out.println( "Setting DMG display options." ); System.out.println( applescript ); exec( command, new ByteArrayInputStream( applescript.toString().getBytes( StandardCharsets.UTF_8 ) ), null, true ); } /** * run a Script for the Package. * * @throws IOException exception */ private void packageApplescript() throws IOException { Template applescript = new Template( "dmg/template/package.applescript.txt" ); applescript.setPlaceholder( "icon", ImageFactory.getImageFile( task.getProject(), task.getSetupIcon(), buildDir, "icns" ).getAbsolutePath() ); applescript.setPlaceholder( "package", new File( imageSourceRoot, applicationName + ".pkg" ).getAbsolutePath() ); ArrayList<String> command = new ArrayList<>(); command.add( "/usr/bin/osascript" ); System.out.println( "Setting display options for package." ); System.out.println( applescript ); exec( command, new ByteArrayInputStream( applescript.toString().getBytes( StandardCharsets.UTF_8 ) ), null, true ); } /** * convert to final image */ private void finalImage() { ArrayList<String> command = new ArrayList<>(); command.add( "/usr/bin/hdiutil" ); command.add( "convert" ); command.add( setup.getDestinationDir() + "/pack.temp.dmg" ); command.add( "-format" ); command.add( "UDZO" ); command.add( "-imagekey" ); command.add( "zlib-level=9" ); command.add( "-o" ); command.add( task.getSetupFile().toString() ); exec( command ); } }