iosbuild.go raw

   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  	</array>
 345  	<key>CFBundleSupportedPlatforms</key>
 346  	<array>
 347  		<string>%s</string>
 348  	</array>
 349  	<key>UISupportedInterfaceOrientations</key>
 350  	<array>
 351  		<string>UIInterfaceOrientationPortrait</string>
 352  		<string>UIInterfaceOrientationLandscapeLeft</string>
 353  		<string>UIInterfaceOrientationLandscapeRight</string>
 354  	</array>
 355  	<key>DTCompiler</key>
 356  	<string>com.apple.compilers.llvm.clang.1_0</string>
 357  	<key>DTPlatformBuild</key>
 358  	<string>16G73</string>
 359  	<key>DTSDKBuild</key>
 360  	<string>16G73</string>
 361  	<key>DTSDKName</key>
 362  	<string>%s12.4</string>
 363  	<key>DTXcode</key>
 364  	<string>1030</string>
 365  	<key>DTXcodeBuild</key>
 366  	<string>10G8</string>
 367  </dict>
 368  </plist>`, appName, bi.appID, appName, bi.version, bi.version, platform, minIOSVersion, supportPlatform, platform)
 369  }
 370  
 371  func iosPlatformFor(target string) string {
 372  	switch target {
 373  	case "ios":
 374  		return "iphoneos"
 375  	case "tvos":
 376  		return "appletvos"
 377  	default:
 378  		panic("invalid platform " + target)
 379  	}
 380  }
 381  
 382  func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
 383  	framework := filepath.Base(frameworkRoot)
 384  	const suf = ".framework"
 385  	if !strings.HasSuffix(framework, suf) {
 386  		return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
 387  	}
 388  	framework = framework[:len(framework)-len(suf)]
 389  	if err := os.RemoveAll(frameworkRoot); err != nil {
 390  		return err
 391  	}
 392  	frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
 393  	for _, dir := range []string{"Headers", "Modules"} {
 394  		p := filepath.Join(frameworkDir, dir)
 395  		if err := os.MkdirAll(p, 0755); err != nil {
 396  			return err
 397  		}
 398  	}
 399  	symlinks := [][2]string{
 400  		{"Versions/Current/Headers", "Headers"},
 401  		{"Versions/Current/Modules", "Modules"},
 402  		{"Versions/Current/" + framework, framework},
 403  		{"A", filepath.Join("Versions", "Current")},
 404  	}
 405  	for _, l := range symlinks {
 406  		if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
 407  			return err
 408  		}
 409  	}
 410  	exe := filepath.Join(frameworkDir, framework)
 411  	lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
 412  	var builds errgroup.Group
 413  	tags := bi.tags
 414  	goos := "ios"
 415  	supportsIOS, err := supportsGOOS("ios")
 416  	if err != nil {
 417  		return err
 418  	}
 419  	if !supportsIOS {
 420  		// Go 1.15 and earlier target iOS with GOOS=darwin, tags=ios.
 421  		goos = "darwin"
 422  		tags = "ios " + tags
 423  	}
 424  	for _, a := range bi.archs {
 425  		clang, cflags, err := iosCompilerFor(target, a)
 426  		if err != nil {
 427  			return err
 428  		}
 429  		lib := filepath.Join(tmpDir, "gio-"+a)
 430  		cmd := exec.Command(
 431  			"go",
 432  			"build",
 433  			"-ldflags=-s -w "+bi.ldflags,
 434  			"-buildmode=c-archive",
 435  			"-o", lib,
 436  			"-tags", tags,
 437  			bi.pkgPath,
 438  		)
 439  		lipo.Args = append(lipo.Args, lib)
 440  		cflagsLine := strings.Join(cflags, " ")
 441  		cmd.Env = append(
 442  			os.Environ(),
 443  			"GOOS="+goos,
 444  			"GOARCH="+a,
 445  			"CGO_ENABLED=1",
 446  			"CC="+clang,
 447  			"CGO_CFLAGS="+cflagsLine,
 448  			"CGO_LDFLAGS="+cflagsLine,
 449  		)
 450  		builds.Go(func() error {
 451  			_, err := runCmd(cmd)
 452  			return err
 453  		})
 454  	}
 455  	if err := builds.Wait(); err != nil {
 456  		return err
 457  	}
 458  	if _, err := runCmd(lipo); err != nil {
 459  		return err
 460  	}
 461  	appDir, err := runCmd(exec.Command("go", "list", "-f", "{{.Dir}}", "github.com/p9c/p9/pkg/gel/gio/app/internal/wm"))
 462  	if err != nil {
 463  		return err
 464  	}
 465  	headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
 466  	headerSrc := filepath.Join(appDir, "framework_ios.h")
 467  	if err := copyFile(headerDst, headerSrc); err != nil {
 468  		return err
 469  	}
 470  	module := fmt.Sprintf(`framework module "%s" {
 471      header "%[1]s.h"
 472  
 473      export *
 474  }`, framework)
 475  	moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
 476  	return ioutil.WriteFile(moduleFile, []byte(module), 0644)
 477  }
 478  
 479  func supportsGOOS(wantGoos string) (bool, error) {
 480  	geese, err := runCmd(exec.Command("go", "tool", "dist", "list"))
 481  	if err != nil {
 482  		return false, err
 483  	}
 484  	for _, pair := range strings.Split(geese, "\n") {
 485  		s := strings.SplitN(pair, "/", 2)
 486  		if len(s) != 2 {
 487  			return false, fmt.Errorf("go tool dist list: invalid GOOS/GOARCH pair: %s", pair)
 488  		}
 489  		goos := s[0]
 490  		if goos == wantGoos {
 491  			return true, nil
 492  		}
 493  	}
 494  	return false, nil
 495  }
 496  
 497  func iosCompilerFor(target, arch string) (string, []string, error) {
 498  	var platformSDK string
 499  	var platformOS string
 500  	switch target {
 501  	case "ios":
 502  		platformOS = "ios"
 503  		platformSDK = "iphone"
 504  	case "tvos":
 505  		platformOS = "tvos"
 506  		platformSDK = "appletv"
 507  	}
 508  	switch arch {
 509  	case "arm", "arm64":
 510  		platformSDK += "os"
 511  	case "386", "amd64":
 512  		platformOS += "-simulator"
 513  		platformSDK += "simulator"
 514  	default:
 515  		return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
 516  	}
 517  	sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
 518  	if err != nil {
 519  		return "", nil, err
 520  	}
 521  	clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
 522  	if err != nil {
 523  		return "", nil, err
 524  	}
 525  	cflags := []string{
 526  		"-fembed-bitcode",
 527  		"-arch", allArchs[arch].iosArch,
 528  		"-isysroot", sdkPath,
 529  		"-m" + platformOS + "-version-min=" + minIOSVersion,
 530  	}
 531  	return clang, cflags, nil
 532  }
 533  
 534  func zipDir(dst, base, dir string) (err error) {
 535  	f, err := os.Create(dst)
 536  	if err != nil {
 537  		return err
 538  	}
 539  	defer func() {
 540  		if cerr := f.Close(); err == nil {
 541  			err = cerr
 542  		}
 543  	}()
 544  	zipf := zip.NewWriter(f)
 545  	err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
 546  		if err != nil {
 547  			return err
 548  		}
 549  		if f.IsDir() {
 550  			return nil
 551  		}
 552  		rel := filepath.ToSlash(path[len(base)+1:])
 553  		entry, err := zipf.Create(rel)
 554  		if err != nil {
 555  			return err
 556  		}
 557  		src, err := os.Open(path)
 558  		if err != nil {
 559  			return err
 560  		}
 561  		defer src.Close()
 562  		_, err = io.Copy(entry, src)
 563  		return err
 564  	})
 565  	if err != nil {
 566  		return err
 567  	}
 568  	return zipf.Close()
 569  }
 570