cmd/gomobile: support macOS and Catalyst

Add support for macOS (non-Catalyst) and Catalyst targets.

The compiled library is packaged into a "fat" XCFramework file (as
opposed to a Framework), which includes binaries for iOS, macOS,
MacCatalyst (iOS on macOS), and iOS Simulator targets, for amd64 and
arm64 architectures.

The generated XCFramework file is suitable for distribution as a binary
Swift Package Manager package:
https://developer.apple.com/documentation/swift_packages/distributing_binary_frameworks_as_swift_packages

This change is based on earlier work:
https://github.com/golang/mobile/pull/45
https://github.com/golang/mobile/pull/63

Fixes golang/go#36856

Change-Id: Iabe535183c7215c68838d6c8f31618d8bceefdcf
GitHub-Last-Rev: 623f8f38653c856d2cd07e721f0932e515b50d02
GitHub-Pull-Request: golang/mobile#65
Reviewed-on: https://go-review.googlesource.com/c/mobile/+/310949
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Hajime Hoshi <hajimehoshi@gmail.com>
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Hajime Hoshi <hajimehoshi@gmail.com>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: Go Bot <gobot@golang.org>
diff --git a/cmd/gomobile/bind_iosapp.go b/cmd/gomobile/bind_iosapp.go
index 846a63f..83d9e3f 100644
--- a/cmd/gomobile/bind_iosapp.go
+++ b/cmd/gomobile/bind_iosapp.go
@@ -40,146 +40,188 @@
 
 	var name string
 	var title string
+
 	if buildO == "" {
 		name = pkgs[0].Name
 		title = strings.Title(name)
-		buildO = title + ".framework"
+		buildO = title + ".xcframework"
 	} else {
-		if !strings.HasSuffix(buildO, ".framework") {
-			return fmt.Errorf("static framework name %q missing .framework suffix", buildO)
+		if !strings.HasSuffix(buildO, ".xcframework") {
+			return fmt.Errorf("static framework name %q missing .xcframework suffix", buildO)
 		}
 		base := filepath.Base(buildO)
-		name = base[:len(base)-len(".framework")]
+		name = base[:len(base)-len(".xcframework")]
 		title = strings.Title(name)
 	}
 
-	fileBases := make([]string, len(pkgs)+1)
-	for i, pkg := range pkgs {
-		fileBases[i] = bindPrefix + strings.Title(pkg.Name)
+	if err := removeAll(buildO); err != nil {
+		return err
 	}
-	fileBases[len(fileBases)-1] = "Universe"
-
-	cmd = exec.Command("xcrun", "lipo", "-create")
 
 	modulesUsed, err := areGoModulesUsed()
 	if err != nil {
 		return err
 	}
 
-	for _, arch := range archs {
-		if err := writeGoMod("darwin", arch); err != nil {
-			return err
-		}
+	// create separate framework for ios,simulator and catalyst
+	// every target has at least one arch (arm64 and x86_64)
+	var frameworkDirs []string
+	for _, target := range iOSTargets {
+		frameworkDir := filepath.Join(tmpdir, target, title+".framework")
+		frameworkDirs = append(frameworkDirs, frameworkDir)
 
-		env := darwinEnv[arch]
-		// Add the generated packages to GOPATH for reverse bindings.
-		gopath := fmt.Sprintf("GOPATH=%s%c%s", tmpdir, filepath.ListSeparator, goEnv("GOPATH"))
-		env = append(env, gopath)
+		for index, arch := range iOSTargetArchs(target) {
+			fileBases := make([]string, len(pkgs)+1)
+			for i, pkg := range pkgs {
+				fileBases[i] = bindPrefix + strings.Title(pkg.Name)
+			}
+			fileBases[len(fileBases)-1] = "Universe"
 
-		// Run `go mod tidy` to force to create go.sum.
-		// Without go.sum, `go build` fails as of Go 1.16.
-		if modulesUsed {
-			if err := goModTidyAt(filepath.Join(tmpdir, "src"), env); err != nil {
+			env := darwinEnv[target+"_"+arch]
+
+			if err := writeGoMod("darwin", getenv(env, "GOARCH")); err != nil {
 				return err
 			}
-		}
 
-		path, err := goIOSBindArchive(name, env, filepath.Join(tmpdir, "src"))
-		if err != nil {
-			return fmt.Errorf("darwin-%s: %v", arch, err)
-		}
-		cmd.Args = append(cmd.Args, "-arch", archClang(arch), path)
-	}
+			// Add the generated packages to GOPATH for reverse bindings.
+			gopath := fmt.Sprintf("GOPATH=%s%c%s", tmpdir, filepath.ListSeparator, goEnv("GOPATH"))
+			env = append(env, gopath)
 
-	// Build static framework output directory.
-	if err := removeAll(buildO); err != nil {
-		return err
-	}
-	headers := buildO + "/Versions/A/Headers"
-	if err := mkdir(headers); err != nil {
-		return err
-	}
-	if err := symlink("A", buildO+"/Versions/Current"); err != nil {
-		return err
-	}
-	if err := symlink("Versions/Current/Headers", buildO+"/Headers"); err != nil {
-		return err
-	}
-	if err := symlink("Versions/Current/"+title, buildO+"/"+title); err != nil {
-		return err
-	}
+			// Run `go mod tidy` to force to create go.sum.
+			// Without go.sum, `go build` fails as of Go 1.16.
+			if modulesUsed {
+				if err := goModTidyAt(filepath.Join(tmpdir, "src"), env); err != nil {
+					return err
+				}
+			}
 
-	cmd.Args = append(cmd.Args, "-o", buildO+"/Versions/A/"+title)
-	if err := runCmd(cmd); err != nil {
-		return err
-	}
+			path, err := goIOSBindArchive(name, env, filepath.Join(tmpdir, "src"))
+			if err != nil {
+				return fmt.Errorf("darwin-%s: %v", arch, err)
+			}
 
-	// Copy header file next to output archive.
-	headerFiles := make([]string, len(fileBases))
-	if len(fileBases) == 1 {
-		headerFiles[0] = title + ".h"
-		err := copyFile(
-			headers+"/"+title+".h",
-			srcDir+"/"+bindPrefix+title+".objc.h",
-		)
-		if err != nil {
-			return err
-		}
-	} else {
-		for i, fileBase := range fileBases {
-			headerFiles[i] = fileBase + ".objc.h"
-			err := copyFile(
-				headers+"/"+fileBase+".objc.h",
-				srcDir+"/"+fileBase+".objc.h")
+			versionsDir := filepath.Join(frameworkDir, "Versions")
+			versionsADir := filepath.Join(versionsDir, "A")
+			titlePath := filepath.Join(versionsADir, title)
+			if index > 0 {
+				// not the first static lib, attach to a fat library and skip create headers
+				fatCmd := exec.Command(
+					"xcrun",
+					"lipo", "-create", "-output", titlePath, titlePath, path,
+				)
+				if err := runCmd(fatCmd); err != nil {
+					return err
+				}
+				continue
+			}
+
+			versionsAHeadersDir := filepath.Join(versionsADir, "Headers")
+			if err := mkdir(versionsAHeadersDir); err != nil {
+				return err
+			}
+			if err := symlink("A", filepath.Join(versionsDir, "Current")); err != nil {
+				return err
+			}
+			if err := symlink("Versions/Current/Headers", filepath.Join(frameworkDir, "Headers")); err != nil {
+				return err
+			}
+			if err := symlink(filepath.Join("Versions/Current", title), filepath.Join(frameworkDir, title)); err != nil {
+				return err
+			}
+
+			lipoCmd := exec.Command(
+				"xcrun",
+				"lipo", "-create", "-arch", archClang(arch), path, "-o", titlePath,
+			)
+			if err := runCmd(lipoCmd); err != nil {
+				return err
+			}
+
+			// Copy header file next to output archive.
+			var headerFiles []string
+			if len(fileBases) == 1 {
+				headerFiles = append(headerFiles, title+".h")
+				err := copyFile(
+					filepath.Join(versionsAHeadersDir, title+".h"),
+					filepath.Join(srcDir, bindPrefix+title+".objc.h"),
+				)
+				if err != nil {
+					return err
+				}
+			} else {
+				for _, fileBase := range fileBases {
+					headerFiles = append(headerFiles, fileBase+".objc.h")
+					err := copyFile(
+						filepath.Join(versionsAHeadersDir, fileBase+".objc.h"),
+						filepath.Join(srcDir, fileBase+".objc.h"),
+					)
+					if err != nil {
+						return err
+					}
+				}
+				err := copyFile(
+					filepath.Join(versionsAHeadersDir, "ref.h"),
+					filepath.Join(srcDir, "ref.h"),
+				)
+				if err != nil {
+					return err
+				}
+				headerFiles = append(headerFiles, title+".h")
+				err = writeFile(filepath.Join(versionsAHeadersDir, title+".h"), func(w io.Writer) error {
+					return iosBindHeaderTmpl.Execute(w, map[string]interface{}{
+						"pkgs": pkgs, "title": title, "bases": fileBases,
+					})
+				})
+				if err != nil {
+					return err
+				}
+			}
+
+			if err := mkdir(filepath.Join(versionsADir, "Resources")); err != nil {
+				return err
+			}
+			if err := symlink("Versions/Current/Resources", filepath.Join(frameworkDir, "Resources")); err != nil {
+				return err
+			}
+			err = writeFile(filepath.Join(frameworkDir, "Resources", "Info.plist"), func(w io.Writer) error {
+				_, err := w.Write([]byte(iosBindInfoPlist))
+				return err
+			})
+			if err != nil {
+				return err
+			}
+
+			var mmVals = struct {
+				Module  string
+				Headers []string
+			}{
+				Module:  title,
+				Headers: headerFiles,
+			}
+			err = writeFile(filepath.Join(versionsADir, "Modules", "module.modulemap"), func(w io.Writer) error {
+				return iosModuleMapTmpl.Execute(w, mmVals)
+			})
+			if err != nil {
+				return err
+			}
+			err = symlink(filepath.Join("Versions/Current/Modules"), filepath.Join(frameworkDir, "Modules"))
 			if err != nil {
 				return err
 			}
 		}
-		err := copyFile(
-			headers+"/ref.h",
-			srcDir+"/ref.h")
-		if err != nil {
-			return err
-		}
-		headerFiles = append(headerFiles, title+".h")
-		err = writeFile(headers+"/"+title+".h", func(w io.Writer) error {
-			return iosBindHeaderTmpl.Execute(w, map[string]interface{}{
-				"pkgs": pkgs, "title": title, "bases": fileBases,
-			})
-		})
-		if err != nil {
-			return err
-		}
 	}
 
-	resources := buildO + "/Versions/A/Resources"
-	if err := mkdir(resources); err != nil {
-		return err
-	}
-	if err := symlink("Versions/Current/Resources", buildO+"/Resources"); err != nil {
-		return err
-	}
-	if err := writeFile(buildO+"/Resources/Info.plist", func(w io.Writer) error {
-		_, err := w.Write([]byte(iosBindInfoPlist))
-		return err
-	}); err != nil {
-		return err
+	// Finally combine all frameworks to an XCFramework
+	xcframeworkArgs := []string{"-create-xcframework"}
+
+	for _, dir := range frameworkDirs {
+		xcframeworkArgs = append(xcframeworkArgs, "-framework", dir)
 	}
 
-	var mmVals = struct {
-		Module  string
-		Headers []string
-	}{
-		Module:  title,
-		Headers: headerFiles,
-	}
-	err = writeFile(buildO+"/Versions/A/Modules/module.modulemap", func(w io.Writer) error {
-		return iosModuleMapTmpl.Execute(w, mmVals)
-	})
-	if err != nil {
-		return err
-	}
-	return symlink("Versions/Current/Modules", buildO+"/Modules")
+	xcframeworkArgs = append(xcframeworkArgs, "-output", buildO)
+	cmd = exec.Command("xcodebuild", xcframeworkArgs...)
+	err = runCmd(cmd)
+	return err
 }
 
 const iosBindInfoPlist = `<?xml version="1.0" encoding="UTF-8"?>
diff --git a/cmd/gomobile/bind_test.go b/cmd/gomobile/bind_test.go
index 9a5ffa8..fed8142 100644
--- a/cmd/gomobile/bind_test.go
+++ b/cmd/gomobile/bind_test.go
@@ -112,7 +112,7 @@
 	}()
 	buildN = true
 	buildX = true
-	buildO = "Asset.framework"
+	buildO = "Asset.xcframework"
 	buildTarget = "ios/arm64"
 
 	tests := []struct {
@@ -126,7 +126,7 @@
 			prefix: "Foo",
 		},
 		{
-			out: "Abcde.framework",
+			out: "Abcde.xcframework",
 		},
 	}
 	for _, tc := range tests {
@@ -160,7 +160,7 @@
 			BitcodeEnabled bool
 		}{
 			outputData:     output,
-			Output:         buildO[:len(buildO)-len(".framework")],
+			Output:         buildO[:len(buildO)-len(".xcframework")],
 			Prefix:         tc.prefix,
 			BitcodeEnabled: bitcodeEnabled,
 		}
@@ -195,26 +195,28 @@
 var bindIOSTmpl = template.Must(template.New("output").Parse(`GOMOBILE={{.GOPATH}}/pkg/gomobile
 WORK=$WORK
 GOOS=darwin CGO_ENABLED=1 gobind -lang=go,objc -outdir=$WORK -tags=ios{{if .Prefix}} -prefix={{.Prefix}}{{end}} golang.org/x/mobile/asset
+rm -r -f "{{.Output}}.xcframework"
 mkdir -p $WORK/src
 PWD=$WORK/src GOOS=darwin GOARCH=arm64 CC=iphoneos-clang CXX=iphoneos-clang++ CGO_CFLAGS=-isysroot=iphoneos -miphoneos-version-min=7.0 {{if .BitcodeEnabled}}-fembed-bitcode {{end}}-arch arm64 CGO_CXXFLAGS=-isysroot=iphoneos -miphoneos-version-min=7.0 {{if .BitcodeEnabled}}-fembed-bitcode {{end}}-arch arm64 CGO_LDFLAGS=-isysroot=iphoneos -miphoneos-version-min=7.0 {{if .BitcodeEnabled}}-fembed-bitcode {{end}}-arch arm64 CGO_ENABLED=1 GOPATH=$WORK:$GOPATH go build -tags ios -x -buildmode=c-archive -o $WORK/{{.Output}}-arm64.a ./gobind
-rm -r -f "{{.Output}}.framework"
-mkdir -p {{.Output}}.framework/Versions/A/Headers
-ln -s A {{.Output}}.framework/Versions/Current
-ln -s Versions/Current/Headers {{.Output}}.framework/Headers
-ln -s Versions/Current/{{.Output}} {{.Output}}.framework/{{.Output}}
-xcrun lipo -create -arch arm64 $WORK/{{.Output}}-arm64.a -o {{.Output}}.framework/Versions/A/{{.Output}}
-cp $WORK/src/gobind/{{.Prefix}}Asset.objc.h {{.Output}}.framework/Versions/A/Headers/{{.Prefix}}Asset.objc.h
-mkdir -p {{.Output}}.framework/Versions/A/Headers
-cp $WORK/src/gobind/Universe.objc.h {{.Output}}.framework/Versions/A/Headers/Universe.objc.h
-mkdir -p {{.Output}}.framework/Versions/A/Headers
-cp $WORK/src/gobind/ref.h {{.Output}}.framework/Versions/A/Headers/ref.h
-mkdir -p {{.Output}}.framework/Versions/A/Headers
-mkdir -p {{.Output}}.framework/Versions/A/Headers
-mkdir -p {{.Output}}.framework/Versions/A/Resources
-ln -s Versions/Current/Resources {{.Output}}.framework/Resources
-mkdir -p {{.Output}}.framework/Resources
-mkdir -p {{.Output}}.framework/Versions/A/Modules
-ln -s Versions/Current/Modules {{.Output}}.framework/Modules
+mkdir -p $WORK/arm64/{{.Output}}.framework/Versions/A/Headers
+ln -s A $WORK/arm64/{{.Output}}.framework/Versions/Current
+ln -s Versions/Current/Headers $WORK/arm64/{{.Output}}.framework/Headers
+ln -s Versions/Current/{{.Output}} $WORK/arm64/{{.Output}}.framework/{{.Output}}
+xcrun lipo -create -arch arm64 $WORK/{{.Output}}-arm64.a -o $WORK/arm64/{{.Output}}.framework/Versions/A/{{.Output}}
+cp $WORK/src/gobind/{{.Prefix}}Asset.objc.h $WORK/arm64/{{.Output}}.framework/Versions/A/Headers/{{.Prefix}}Asset.objc.h
+mkdir -p $WORK/arm64/{{.Output}}.framework/Versions/A/Headers
+cp $WORK/src/gobind/Universe.objc.h $WORK/arm64/{{.Output}}.framework/Versions/A/Headers/Universe.objc.h
+mkdir -p $WORK/arm64/{{.Output}}.framework/Versions/A/Headers
+cp $WORK/src/gobind/ref.h $WORK/arm64/{{.Output}}.framework/Versions/A/Headers/ref.h
+mkdir -p $WORK/arm64/{{.Output}}.framework/Versions/A/Headers
+mkdir -p $WORK/arm64/{{.Output}}.framework/Versions/A/Headers
+mkdir -p $WORK/arm64/{{.Output}}.framework/Versions/A/Resources
+ln -s Versions/Current/Resources $WORK/arm64/{{.Output}}.framework/Resources
+mkdir -p $WORK/arm64/{{.Output}}.framework/Resources
+mkdir -p $WORK/arm64/{{.Output}}.framework/Versions/A/Modules
+ln -s Versions/Current/Modules $WORK/arm64/{{.Output}}.framework/Modules
+xcrun lipo $WORK/arm64/{{.Output}}.framework/Versions/A/{{.Output}} -thin arm64 -output $WORK/arm64/{{.Output}}.framework/Versions/A/{{.Output}}
+xcodebuild -create-xcframework -framework $WORK/arm64/{{.Output}}.framework -framework $WORK/amd64/{{.Output}}.framework -framework $WORK/catalyst/{{.Output}}.framework -output {{.Output}}.xcframework
 `))
 
 func TestBindIOSAll(t *testing.T) {
@@ -231,7 +233,7 @@
 	}()
 	buildN = true
 	buildX = true
-	buildO = "Asset.framework"
+	buildO = "Asset.xcframework"
 	buildTarget = "ios"
 
 	buf := new(bytes.Buffer)
@@ -291,7 +293,7 @@
 			case "android":
 				out = filepath.Join(dir, "cgopkg.aar")
 			case "ios":
-				out = filepath.Join(dir, "Cgopkg.framework")
+				out = filepath.Join(dir, "Cgopkg.xcframework")
 			}
 
 			tests := []struct {
diff --git a/cmd/gomobile/env.go b/cmd/gomobile/env.go
index 6dacc63..b342f3e 100644
--- a/cmd/gomobile/env.go
+++ b/cmd/gomobile/env.go
@@ -36,6 +36,27 @@
 	}
 }
 
+// iOSTargets lists Apple platforms as individual sub-targets.
+// The gomobile "ios" target actually builds for multiple Apple platforms:
+// iOS, iPadOS, MacCatalyst (iOS on macOS), and macOS.
+// TODO: support watchOS and tvOS?
+var iOSTargets = []string{"simulator", "ios", "catalyst", "macos"}
+
+func iOSTargetArchs(target string) []string {
+	switch target {
+	case "simulator":
+		return []string{"arm64", "amd64"}
+	case "ios":
+		return []string{"arm64"}
+	case "catalyst":
+		return []string{"arm64", "amd64"}
+	case "macos":
+		return []string{"arm64", "amd64"}
+	default:
+		panic(fmt.Sprintf("unexpected iOS target: %s", target))
+	}
+}
+
 func buildEnvInit() (cleanup func(), err error) {
 	// Find gomobilepath.
 	gopath := goEnv("GOPATH")
@@ -141,38 +162,52 @@
 
 	darwinArmNM = "nm"
 	darwinEnv = make(map[string][]string)
-	for _, arch := range allArchs("ios") {
-		var env []string
-		var err error
-		var clang, cflags string
-		switch arch {
-		case "arm64":
-			clang, cflags, err = envClang("iphoneos")
-			cflags += " -miphoneos-version-min=" + buildIOSVersion
-		case "amd64":
-			clang, cflags, err = envClang("iphonesimulator")
-			cflags += " -mios-simulator-version-min=" + buildIOSVersion
-		default:
-			panic(fmt.Errorf("unknown GOARCH: %q", arch))
-		}
-		if err != nil {
-			return err
-		}
+	for _, target := range iOSTargets {
+		for _, arch := range iOSTargetArchs(target) {
+			var env []string
+			var err error
+			var clang, cflags string
+			switch target {
+			case "ios":
+				clang, cflags, err = envClang("iphoneos")
+				cflags += " -miphoneos-version-min=" + buildIOSVersion
+			case "simulator":
+				clang, cflags, err = envClang("iphonesimulator")
+				cflags += " -mios-simulator-version-min=" + buildIOSVersion
+			case "catalyst":
+				clang, cflags, err = envClang("macosx")
+				switch arch {
+				case "amd64":
+					cflags += " -target x86_64-apple-ios13.0-macabi"
+				case "arm64":
+					cflags += " -target arm64-apple-ios13.0-macabi"
+				}
+			case "macos":
+				// Note: the SDK is called "macosx", not "macos"
+				clang, cflags, err = envClang("macosx")
+			default:
+				panic(fmt.Errorf("unknown ios target: %q", arch))
+			}
 
-		if bitcodeEnabled {
-			cflags += " -fembed-bitcode"
+			if err != nil {
+				return err
+			}
+
+			if bitcodeEnabled {
+				cflags += " -fembed-bitcode"
+			}
+			env = append(env,
+				"GOOS=darwin",
+				"GOARCH="+arch,
+				"CC="+clang,
+				"CXX="+clang+"++",
+				"CGO_CFLAGS="+cflags+" -arch "+archClang(arch),
+				"CGO_CXXFLAGS="+cflags+" -arch "+archClang(arch),
+				"CGO_LDFLAGS="+cflags+" -arch "+archClang(arch),
+				"CGO_ENABLED=1",
+			)
+			darwinEnv[target+"_"+arch] = env
 		}
-		env = append(env,
-			"GOOS=darwin",
-			"GOARCH="+arch,
-			"CC="+clang,
-			"CXX="+clang+"++",
-			"CGO_CFLAGS="+cflags+" -arch "+archClang(arch),
-			"CGO_CXXFLAGS="+cflags+" -arch "+archClang(arch),
-			"CGO_LDFLAGS="+cflags+" -arch "+archClang(arch),
-			"CGO_ENABLED=1",
-		)
-		darwinEnv[arch] = env
 	}
 
 	return nil