github.com/cybriq/giocore@v0.0.7-0.20210703034601-cfb9cb5f3900/cmd/gogio/iosbuild.go (about)

     1  // SPDX-License-Identifier: Unlicense OR MIT
     2  
     3  package main
     4  
     5  import (
     6  	"archive/zip"
     7  	"crypto/sha1"
     8  	"encoding/hex"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"io/ioutil"
    13  	"os"
    14  	"os/exec"
    15  	"path/filepath"
    16  	"strings"
    17  	"time"
    18  
    19  	"golang.org/x/sync/errgroup"
    20  )
    21  
    22  const minIOSVersion = "9.0"
    23  
    24  func buildIOS(tmpDir, target string, bi *buildInfo) error {
    25  	appName := bi.name
    26  	switch *buildMode {
    27  	case "archive":
    28  		framework := *destPath
    29  		if framework == "" {
    30  			framework = fmt.Sprintf("%s.framework", strings.Title(appName))
    31  		}
    32  		return archiveIOS(tmpDir, target, framework, bi)
    33  	case "exe":
    34  		out := *destPath
    35  		if out == "" {
    36  			out = appName + ".ipa"
    37  		}
    38  		forDevice := strings.HasSuffix(out, ".ipa")
    39  		// Filter out unsupported architectures.
    40  		for i := len(bi.archs) - 1; i >= 0; i-- {
    41  			switch bi.archs[i] {
    42  			case "arm", "arm64":
    43  				if forDevice {
    44  					continue
    45  				}
    46  			case "386", "amd64":
    47  				if !forDevice {
    48  					continue
    49  				}
    50  			}
    51  
    52  			bi.archs = append(bi.archs[:i], bi.archs[i+1:]...)
    53  		}
    54  		tmpFramework := filepath.Join(tmpDir, "Gio.framework")
    55  		if err := archiveIOS(tmpDir, target, tmpFramework, bi); err != nil {
    56  			return err
    57  		}
    58  		if !forDevice && !strings.HasSuffix(out, ".app") {
    59  			return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out)
    60  		}
    61  		if !forDevice {
    62  			return exeIOS(tmpDir, target, out, bi)
    63  		}
    64  		payload := filepath.Join(tmpDir, "Payload")
    65  		appDir := filepath.Join(payload, appName+".app")
    66  		if err := os.MkdirAll(appDir, 0755); err != nil {
    67  			return err
    68  		}
    69  		if err := exeIOS(tmpDir, target, appDir, bi); err != nil {
    70  			return err
    71  		}
    72  		if err := signIOS(bi, tmpDir, appDir); err != nil {
    73  			return err
    74  		}
    75  		return zipDir(out, tmpDir, "Payload")
    76  	default:
    77  		panic("unreachable")
    78  	}
    79  }
    80  
    81  func signIOS(bi *buildInfo, tmpDir, app string) error {
    82  	home, err := os.UserHomeDir()
    83  	if err != nil {
    84  		return err
    85  	}
    86  	provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")
    87  	provisions, err := filepath.Glob(provPattern)
    88  	if err != nil {
    89  		return err
    90  	}
    91  	provInfo := filepath.Join(tmpDir, "provision.plist")
    92  	var avail []string
    93  	for _, prov := range provisions {
    94  		// Decode the provision file to a plist.
    95  		_, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo))
    96  		if err != nil {
    97  			return err
    98  		}
    99  		expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo))
   100  		if err != nil {
   101  			return err
   102  		}
   103  		exp, err := time.Parse(time.UnixDate, expUnix)
   104  		if err != nil {
   105  			return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err)
   106  		}
   107  		if exp.Before(time.Now()) {
   108  			continue
   109  		}
   110  		appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo))
   111  		if err != nil {
   112  			return err
   113  		}
   114  		provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo))
   115  		if err != nil {
   116  			return err
   117  		}
   118  		expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID)
   119  		avail = append(avail, provAppID)
   120  		if expAppID != provAppID {
   121  			continue
   122  		}
   123  		// Copy provisioning file.
   124  		embedded := filepath.Join(app, "embedded.mobileprovision")
   125  		if err := copyFile(embedded, prov); err != nil {
   126  			return err
   127  		}
   128  		certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo))
   129  		if err != nil {
   130  			return err
   131  		}
   132  		// Omit trailing newline.
   133  		certDER = certDER[:len(certDER)-1]
   134  		entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo))
   135  		if err != nil {
   136  			return err
   137  		}
   138  		entFile := filepath.Join(tmpDir, "entitlements.plist")
   139  		if err := ioutil.WriteFile(entFile, []byte(entitlements), 0660); err != nil {
   140  			return err
   141  		}
   142  		identity := sha1.Sum(certDER)
   143  		idHex := hex.EncodeToString(identity[:])
   144  		_, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app))
   145  		return err
   146  	}
   147  	return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail)
   148  }
   149  
   150  func exeIOS(tmpDir, target, app string, bi *buildInfo) error {
   151  	if bi.appID == "" {
   152  		return errors.New("app id is empty; use -appid to set it")
   153  	}
   154  	if err := os.RemoveAll(app); err != nil {
   155  		return err
   156  	}
   157  	if err := os.Mkdir(app, 0755); err != nil {
   158  		return err
   159  	}
   160  	mainm := filepath.Join(tmpDir, "main.m")
   161  	const mainmSrc = `@import UIKit;
   162  @import Gio;
   163  
   164  @interface GioAppDelegate : UIResponder <UIApplicationDelegate>
   165  @property (strong, nonatomic) UIWindow *window;
   166  @end
   167  
   168  @implementation GioAppDelegate
   169  - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
   170  	self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
   171  	GioViewController *controller = [[GioViewController alloc] initWithNibName:nil bundle:nil];
   172  	self.window.rootViewController = controller;
   173  	[self.window makeKeyAndVisible];
   174  	return YES;
   175  }
   176  @end
   177  
   178  int main(int argc, char * argv[]) {
   179  	@autoreleasepool {
   180  		return UIApplicationMain(argc, argv, nil, NSStringFromClass([GioAppDelegate class]));
   181  	}
   182  }`
   183  	if err := ioutil.WriteFile(mainm, []byte(mainmSrc), 0660); err != nil {
   184  		return err
   185  	}
   186  	appName := strings.Title(bi.name)
   187  	exe := filepath.Join(app, appName)
   188  	lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
   189  	var builds errgroup.Group
   190  	for _, a := range bi.archs {
   191  		clang, cflags, err := iosCompilerFor(target, a)
   192  		if err != nil {
   193  			return err
   194  		}
   195  		exeSlice := filepath.Join(tmpDir, "app-"+a)
   196  		lipo.Args = append(lipo.Args, exeSlice)
   197  		compile := exec.Command(clang, cflags...)
   198  		compile.Args = append(compile.Args,
   199  			"-Werror",
   200  			"-fmodules",
   201  			"-fobjc-arc",
   202  			"-x", "objective-c",
   203  			"-F", tmpDir,
   204  			"-o", exeSlice,
   205  			mainm,
   206  		)
   207  		builds.Go(func() error {
   208  			_, err := runCmd(compile)
   209  			return err
   210  		})
   211  	}
   212  	if err := builds.Wait(); err != nil {
   213  		return err
   214  	}
   215  	if _, err := runCmd(lipo); err != nil {
   216  		return err
   217  	}
   218  	infoPlist := buildInfoPlist(bi)
   219  	plistFile := filepath.Join(app, "Info.plist")
   220  	if err := ioutil.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
   221  		return err
   222  	}
   223  	if _, err := os.Stat(bi.iconPath); err == nil {
   224  		assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
   225  		if err != nil {
   226  			return err
   227  		}
   228  		// Merge assets plist with Info.plist
   229  		cmd := exec.Command(
   230  			"/usr/libexec/PlistBuddy",
   231  			"-c", "Merge "+assetPlist,
   232  			plistFile,
   233  		)
   234  		if _, err := runCmd(cmd); err != nil {
   235  			return err
   236  		}
   237  	}
   238  	if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil {
   239  		return err
   240  	}
   241  	return nil
   242  }
   243  
   244  // iosIcons builds an asset catalog and compile it with the Xcode command actool.
   245  // iosIcons returns the asset plist file to be merged into Info.plist.
   246  func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) {
   247  	assets := filepath.Join(tmpDir, "Assets.xcassets")
   248  	if err := os.Mkdir(assets, 0700); err != nil {
   249  		return "", err
   250  	}
   251  	appIcon := filepath.Join(assets, "AppIcon.appiconset")
   252  	err := buildIcons(appIcon, icon, []iconVariant{
   253  		{path: "ios_2x.png", size: 120},
   254  		{path: "ios_3x.png", size: 180},
   255  		// The App Store icon is not allowed to contain
   256  		// transparent pixels.
   257  		{path: "ios_store.png", size: 1024, fill: true},
   258  	})
   259  	if err != nil {
   260  		return "", err
   261  	}
   262  	contentJson := `{
   263  	"images" : [
   264  		{
   265  			"size" : "60x60",
   266  			"idiom" : "iphone",
   267  			"filename" : "ios_2x.png",
   268  			"scale" : "2x"
   269  		},
   270  		{
   271  			"size" : "60x60",
   272  			"idiom" : "iphone",
   273  			"filename" : "ios_3x.png",
   274  			"scale" : "3x"
   275  		},
   276  		{
   277  			"size" : "1024x1024",
   278  			"idiom" : "ios-marketing",
   279  			"filename" : "ios_store.png",
   280  			"scale" : "1x"
   281  		}
   282  	]
   283  }`
   284  	contentFile := filepath.Join(appIcon, "Contents.json")
   285  	if err := ioutil.WriteFile(contentFile, []byte(contentJson), 0600); err != nil {
   286  		return "", err
   287  	}
   288  	assetPlist := filepath.Join(tmpDir, "assets.plist")
   289  	compile := exec.Command(
   290  		"actool",
   291  		"--compile", appDir,
   292  		"--platform", iosPlatformFor(bi.target),
   293  		"--minimum-deployment-target", minIOSVersion,
   294  		"--app-icon", "AppIcon",
   295  		"--output-partial-info-plist", assetPlist,
   296  		assets)
   297  	_, err = runCmd(compile)
   298  	return assetPlist, err
   299  }
   300  
   301  func buildInfoPlist(bi *buildInfo) string {
   302  	appName := strings.Title(bi.name)
   303  	platform := iosPlatformFor(bi.target)
   304  	var supportPlatform string
   305  	switch bi.target {
   306  	case "ios":
   307  		supportPlatform = "iPhoneOS"
   308  	case "tvos":
   309  		supportPlatform = "AppleTVOS"
   310  	}
   311  	return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
   312  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
   313  <plist version="1.0">
   314  <dict>
   315  	<key>CFBundleDevelopmentRegion</key>
   316  	<string>en</string>
   317  	<key>CFBundleExecutable</key>
   318  	<string>%s</string>
   319  	<key>CFBundleIdentifier</key>
   320  	<string>%s</string>
   321  	<key>CFBundleInfoDictionaryVersion</key>
   322  	<string>6.0</string>
   323  	<key>CFBundleName</key>
   324  	<string>%s</string>
   325  	<key>CFBundlePackageType</key>
   326  	<string>APPL</string>
   327  	<key>CFBundleShortVersionString</key>
   328  	<string>1.0.%d</string>
   329  	<key>CFBundleVersion</key>
   330  	<string>%d</string>
   331  	<key>UILaunchStoryboardName</key>
   332  	<string>LaunchScreen</string>
   333  	<key>UIRequiredDeviceCapabilities</key>
   334  	<array><string>arm64</string></array>
   335  	<key>DTPlatformName</key>
   336  	<string>%s</string>
   337  	<key>DTPlatformVersion</key>
   338  	<string>12.4</string>
   339  	<key>MinimumOSVersion</key>
   340  	<string>%s</string>
   341  	<key>UIDeviceFamily</key>
   342  	<array>
   343  		<integer>1</integer>
   344  		<integer>2</integer>
   345  	</array>
   346  	<key>CFBundleSupportedPlatforms</key>
   347  	<array>
   348  		<string>%s</string>
   349  	</array>
   350  	<key>UISupportedInterfaceOrientations</key>
   351  	<array>
   352  		<string>UIInterfaceOrientationPortrait</string>
   353  		<string>UIInterfaceOrientationLandscapeLeft</string>
   354  		<string>UIInterfaceOrientationLandscapeRight</string>
   355  	</array>
   356  	<key>DTCompiler</key>
   357  	<string>com.apple.compilers.llvm.clang.1_0</string>
   358  	<key>DTPlatformBuild</key>
   359  	<string>16G73</string>
   360  	<key>DTSDKBuild</key>
   361  	<string>16G73</string>
   362  	<key>DTSDKName</key>
   363  	<string>%s12.4</string>
   364  	<key>DTXcode</key>
   365  	<string>1030</string>
   366  	<key>DTXcodeBuild</key>
   367  	<string>10G8</string>
   368  </dict>
   369  </plist>`, appName, bi.appID, appName, bi.version, bi.version, platform, minIOSVersion, supportPlatform, platform)
   370  }
   371  
   372  func iosPlatformFor(target string) string {
   373  	switch target {
   374  	case "ios":
   375  		return "iphoneos"
   376  	case "tvos":
   377  		return "appletvos"
   378  	default:
   379  		panic("invalid platform " + target)
   380  	}
   381  }
   382  
   383  func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
   384  	framework := filepath.Base(frameworkRoot)
   385  	const suf = ".framework"
   386  	if !strings.HasSuffix(framework, suf) {
   387  		return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
   388  	}
   389  	framework = framework[:len(framework)-len(suf)]
   390  	if err := os.RemoveAll(frameworkRoot); err != nil {
   391  		return err
   392  	}
   393  	frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
   394  	for _, dir := range []string{"Headers", "Modules"} {
   395  		p := filepath.Join(frameworkDir, dir)
   396  		if err := os.MkdirAll(p, 0755); err != nil {
   397  			return err
   398  		}
   399  	}
   400  	symlinks := [][2]string{
   401  		{"Versions/Current/Headers", "Headers"},
   402  		{"Versions/Current/Modules", "Modules"},
   403  		{"Versions/Current/" + framework, framework},
   404  		{"A", filepath.Join("Versions", "Current")},
   405  	}
   406  	for _, l := range symlinks {
   407  		if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
   408  			return err
   409  		}
   410  	}
   411  	exe := filepath.Join(frameworkDir, framework)
   412  	lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
   413  	var builds errgroup.Group
   414  	tags := bi.tags
   415  	goos := "ios"
   416  	supportsIOS, err := supportsGOOS("ios")
   417  	if err != nil {
   418  		return err
   419  	}
   420  	if !supportsIOS {
   421  		// Go 1.15 and earlier target iOS with GOOS=darwin, tags=ios.
   422  		goos = "darwin"
   423  		tags = "ios " + tags
   424  	}
   425  	for _, a := range bi.archs {
   426  		clang, cflags, err := iosCompilerFor(target, a)
   427  		if err != nil {
   428  			return err
   429  		}
   430  		lib := filepath.Join(tmpDir, "gio-"+a)
   431  		cmd := exec.Command(
   432  			"go",
   433  			"build",
   434  			"-ldflags=-s -w "+bi.ldflags,
   435  			"-buildmode=c-archive",
   436  			"-o", lib,
   437  			"-tags", tags,
   438  			bi.pkgPath,
   439  		)
   440  		lipo.Args = append(lipo.Args, lib)
   441  		cflagsLine := strings.Join(cflags, " ")
   442  		cmd.Env = append(
   443  			os.Environ(),
   444  			"GOOS="+goos,
   445  			"GOARCH="+a,
   446  			"CGO_ENABLED=1",
   447  			"CC="+clang,
   448  			"CGO_CFLAGS="+cflagsLine,
   449  			"CGO_LDFLAGS="+cflagsLine,
   450  		)
   451  		builds.Go(func() error {
   452  			_, err := runCmd(cmd)
   453  			return err
   454  		})
   455  	}
   456  	if err := builds.Wait(); err != nil {
   457  		return err
   458  	}
   459  	if _, err := runCmd(lipo); err != nil {
   460  		return err
   461  	}
   462  	appDir, err := runCmd(exec.Command("go", "list", "-f", "{{.Dir}}", "github.com/cybriq/giocore/app/internal/wm"))
   463  	if err != nil {
   464  		return err
   465  	}
   466  	headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
   467  	headerSrc := filepath.Join(appDir, "framework_ios.h")
   468  	if err := copyFile(headerDst, headerSrc); err != nil {
   469  		return err
   470  	}
   471  	module := fmt.Sprintf(`framework module "%s" {
   472      header "%[1]s.h"
   473  
   474      export *
   475  }`, framework)
   476  	moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
   477  	return ioutil.WriteFile(moduleFile, []byte(module), 0644)
   478  }
   479  
   480  func supportsGOOS(wantGoos string) (bool, error) {
   481  	geese, err := runCmd(exec.Command("go", "tool", "dist", "list"))
   482  	if err != nil {
   483  		return false, err
   484  	}
   485  	for _, pair := range strings.Split(geese, "\n") {
   486  		s := strings.SplitN(pair, "/", 2)
   487  		if len(s) != 2 {
   488  			return false, fmt.Errorf("go tool dist list: invalid GOOS/GOARCH pair: %s", pair)
   489  		}
   490  		goos := s[0]
   491  		if goos == wantGoos {
   492  			return true, nil
   493  		}
   494  	}
   495  	return false, nil
   496  }
   497  
   498  func iosCompilerFor(target, arch string) (string, []string, error) {
   499  	var platformSDK string
   500  	var platformOS string
   501  	switch target {
   502  	case "ios":
   503  		platformOS = "ios"
   504  		platformSDK = "iphone"
   505  	case "tvos":
   506  		platformOS = "tvos"
   507  		platformSDK = "appletv"
   508  	}
   509  	switch arch {
   510  	case "arm", "arm64":
   511  		platformSDK += "os"
   512  	case "386", "amd64":
   513  		platformOS += "-simulator"
   514  		platformSDK += "simulator"
   515  	default:
   516  		return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
   517  	}
   518  	sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
   519  	if err != nil {
   520  		return "", nil, err
   521  	}
   522  	clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
   523  	if err != nil {
   524  		return "", nil, err
   525  	}
   526  	cflags := []string{
   527  		"-fembed-bitcode",
   528  		"-arch", allArchs[arch].iosArch,
   529  		"-isysroot", sdkPath,
   530  		"-m" + platformOS + "-version-min=" + minIOSVersion,
   531  	}
   532  	return clang, cflags, nil
   533  }
   534  
   535  func zipDir(dst, base, dir string) (err error) {
   536  	f, err := os.Create(dst)
   537  	if err != nil {
   538  		return err
   539  	}
   540  	defer func() {
   541  		if cerr := f.Close(); err == nil {
   542  			err = cerr
   543  		}
   544  	}()
   545  	zipf := zip.NewWriter(f)
   546  	err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
   547  		if err != nil {
   548  			return err
   549  		}
   550  		if f.IsDir() {
   551  			return nil
   552  		}
   553  		rel := filepath.ToSlash(path[len(base)+1:])
   554  		entry, err := zipf.Create(rel)
   555  		if err != nil {
   556  			return err
   557  		}
   558  		src, err := os.Open(path)
   559  		if err != nil {
   560  			return err
   561  		}
   562  		defer src.Close()
   563  		_, err = io.Copy(entry, src)
   564  		return err
   565  	})
   566  	if err != nil {
   567  		return err
   568  	}
   569  	return zipf.Close()
   570  }