// SPDX-License-Identifier: Unlicense OR MIT package main import ( "archive/zip" "bytes" "crypto/sha1" "encoding/hex" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" "text/template" "time" "golang.org/x/sync/errgroup" ) const ( minIOSVersion = 10 // Some Metal features require tvOS 11 minTVOSVersion = 11 // Metal is available from iOS 8 on devices, yet from version 13 on the // simulator. minSimulatorVersion = 13 ) func buildIOS(tmpDir, target string, bi *buildInfo) error { appName := bi.name switch *buildMode { case "archive": framework := *destPath if framework == "" { framework = fmt.Sprintf("%s.framework", UppercaseName(appName)) } return archiveIOS(tmpDir, target, framework, bi) case "exe": out := *destPath if out == "" { out = appName + ".ipa" } forDevice := strings.HasSuffix(out, ".ipa") // Filter out unsupported architectures. for i := len(bi.archs) - 1; i >= 0; i-- { switch bi.archs[i] { case "arm", "arm64": if forDevice { continue } case "386", "amd64": if !forDevice { continue } } bi.archs = slices.Delete(bi.archs, i, i+1) } if !forDevice && !strings.HasSuffix(out, ".app") { return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out) } if !forDevice { return exeIOS(tmpDir, target, out, bi) } payload := filepath.Join(tmpDir, "Payload") appDir := filepath.Join(payload, appName+".app") if err := os.MkdirAll(appDir, 0o755); err != nil { return err } if err := exeIOS(tmpDir, target, appDir, bi); err != nil { return err } embedded := filepath.Join(appDir, "embedded.mobileprovision") var provisions []string if bi.key != "" { if ext := filepath.Ext(bi.key); ext != ".mobileprovision" && ext != ".provisionprofile" { return fmt.Errorf("sign: -signkey specifies an Apple provisioning profile, but %q does not end in .mobileprovision or .provisionprofile", bi.key) } provisions = []string{bi.key} } else { home, err := os.UserHomeDir() if err != nil { return err } p, err := filepath.Glob(filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")) if err != nil { return err } provisions = p } if err := signApple(bi.appID, tmpDir, embedded, appDir, provisions); err != nil { return err } return zipDir(out, tmpDir, "Payload") default: panic("unreachable") } } // signApple is shared between iOS and macOS. func signApple(appID, tmpDir, embedded, app string, provisions []string) error { provInfo := filepath.Join(tmpDir, "provision.plist") var avail []string for _, prov := range provisions { // Decode the provision file to a plist. _, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo)) if err != nil { return err } expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo)) if err != nil { return err } exp, err := time.Parse(time.UnixDate, expUnix) if err != nil { return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err) } if exp.Before(time.Now()) { continue } appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo)) if err != nil { return err } // iOS/macOS Catalyst provAppIDSearchKey := "Print:Entitlements:application-identifier" if filepath.Ext(prov) == ".provisionprofile" { // macOS provAppIDSearchKey = "Print:Entitlements:com.apple.application-identifier" } provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", provAppIDSearchKey, provInfo)) if err != nil { return err } expAppID := fmt.Sprintf("%s.%s", appIDPrefix, appID) avail = append(avail, provAppID) if expAppID != provAppID { continue } // Copy provisioning file. if err := copyFile(embedded, prov); err != nil { return err } certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo)) if err != nil { return err } // Omit trailing newline. certDER = certDER[:len(certDER)-1] entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo)) if err != nil { return err } entFile := filepath.Join(tmpDir, "entitlements.plist") if err := os.WriteFile(entFile, []byte(entitlements), 0o660); err != nil { return err } identity := sha1.Sum(certDER) idHex := hex.EncodeToString(identity[:]) _, err = runCmd(exec.Command( "codesign", "--sign", idHex, "--deep", "--force", "--options", "runtime", "--entitlements", entFile, app)) return err } return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", appID, avail) } func exeIOS(tmpDir, target, app string, bi *buildInfo) error { if bi.appID == "" { return errors.New("app id is empty; use -appid to set it") } if err := os.RemoveAll(app); err != nil { return err } if err := os.Mkdir(app, 0o755); err != nil { return err } appName := UppercaseName(bi.name) exe := filepath.Join(app, appName) lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create") var builds errgroup.Group for _, a := range bi.archs { clang, cflags, err := iosCompilerFor(target, a, bi.minsdk) if err != nil { return err } cflags = append(cflags, "-fobjc-arc", fmt.Sprintf("-miphoneos-version-min=%d.0", bi.minsdk), ) cflagsLine := strings.Join(cflags, " ") exeSlice := filepath.Join(tmpDir, "app-"+a) lipo.Args = append(lipo.Args, exeSlice) compile := exec.Command( "go", "build", "-ldflags=-s -w "+bi.ldflags, "-o", exeSlice, "-tags", bi.tags, bi.pkgPath, ) compile.Env = append( os.Environ(), "GOOS=ios", "GOARCH="+a, "CGO_ENABLED=1", "CC="+clang, "CXX="+clang+"++", "CGO_CFLAGS="+cflagsLine, "CGO_CXXFLAGS="+cflagsLine, "CGO_LDFLAGS=-lresolv "+cflagsLine, ) builds.Go(func() error { _, err := runCmd(compile) return err }) } if err := builds.Wait(); err != nil { return err } if _, err := runCmd(lipo); err != nil { return err } infoPlist := buildInfoPlist(bi) plistFile := filepath.Join(app, "Info.plist") if err := os.WriteFile(plistFile, []byte(infoPlist), 0o660); err != nil { return err } if _, err := os.Stat(bi.iconPath); err == nil { assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath) if err != nil { return err } // Merge assets plist with Info.plist cmd := exec.Command( "/usr/libexec/PlistBuddy", "-c", "Merge "+assetPlist, plistFile, ) if _, err := runCmd(cmd); err != nil { return err } } if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil { return err } return nil } // iosIcons builds an asset catalog and compile it with the Xcode command actool. // iosIcons returns the asset plist file to be merged into Info.plist. func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) { assets := filepath.Join(tmpDir, "Assets.xcassets") if err := os.Mkdir(assets, 0o700); err != nil { return "", err } appIcon := filepath.Join(assets, "AppIcon.appiconset") err := buildIcons(appIcon, icon, []iconVariant{ {path: "ios_2x.png", size: 120}, {path: "ios_3x.png", size: 180}, {path: "ipad_1x.png", size: 76}, {path: "ipad_2x.png", size: 152}, {path: "ipad_4x.png", size: 228}, // The App Store icon is not allowed to contain // transparent pixels. {path: "ios_store.png", size: 1024, fill: true}, }) if err != nil { return "", err } contentJson := `{ "images": [ { "size": "60x60", "idiom": "iphone", "filename": "ios_2x.png", "scale": "2x" }, { "size": "60x60", "idiom": "iphone", "filename": "ios_3x.png", "scale": "3x" }, { "size": "76x76", "idiom": "ipad", "filename": "ipad_1x.png", "scale": "1x" }, { "size": "76x76", "idiom": "ipad", "filename": "ipad_2x.png", "scale": "2x" }, { "size": "152x152", "idiom": "ipad", "filename": "ipad_4x.png", "scale": "2x" }, { "size": "1024x1024", "idiom": "ios-marketing", "filename": "ios_store.png", "scale": "1x" } ] }` contentFile := filepath.Join(appIcon, "Contents.json") if err := os.WriteFile(contentFile, []byte(contentJson), 0o600); err != nil { return "", err } assetPlist := filepath.Join(tmpDir, "assets.plist") minsdk := bi.minsdk if minsdk == 0 { minsdk = minIOSVersion } compile := exec.Command( "actool", "--compile", appDir, "--platform", iosPlatformFor(bi.target), "--minimum-deployment-target", strconv.Itoa(minsdk), "--app-icon", "AppIcon", "--output-partial-info-plist", assetPlist, assets) _, err = runCmd(compile) return assetPlist, err } func buildInfoPlist(bi *buildInfo) string { appName := UppercaseName(bi.name) platform := iosPlatformFor(bi.target) var supportPlatform string switch bi.target { case "ios": supportPlatform = "iPhoneOS" case "tvos": supportPlatform = "AppleTVOS" } manifestSrc := struct { AppName string AppID string Version string VersionCode uint32 Platform string MinVersion int SupportPlatform string Schemes []string }{ AppName: appName, AppID: bi.appID, Version: bi.version.StringCompact(), VersionCode: bi.version.VersionCode, Platform: platform, MinVersion: bi.minsdk, SupportPlatform: supportPlatform, Schemes: bi.schemes, } tmpl, err := template.New("manifest").Parse(` CFBundleDevelopmentRegion en CFBundleExecutable {{.AppName}} CFBundleIdentifier {{.AppID}} CFBundleInfoDictionaryVersion 6.0 CFBundleName {{.AppName}} CFBundlePackageType APPL CFBundleShortVersionString {{.Version}} CFBundleVersion {{.VersionCode}} UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities arm64 DTPlatformName {{.Platform}} DTPlatformVersion 12.4 MinimumOSVersion {{.MinVersion}}.0 UIDeviceFamily 1 2 CFBundleSupportedPlatforms {{.SupportPlatform}} UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIRequiresFullScreen DTCompiler com.apple.compilers.llvm.clang.1_0 DTPlatformBuild 16G73 DTSDKBuild 16G73 DTSDKName {{.Platform}}12.4 DTXcode 1030 DTXcodeBuild 10G8 UILaunchScreen {{if .Schemes}} CFBundleURLTypes {{range .Schemes}} CFBundleURLSchemes {{.}} {{end}} {{end}} `) if err != nil { panic(err) } var manifestBuffer bytes.Buffer if err := tmpl.Execute(&manifestBuffer, manifestSrc); err != nil { panic(err) } return manifestBuffer.String() } func iosPlatformFor(target string) string { switch target { case "ios": return "iphoneos" case "tvos": return "appletvos" default: panic("invalid platform " + target) } } func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error { framework := filepath.Base(frameworkRoot) const suf = ".framework" if !strings.HasSuffix(framework, suf) { return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot) } framework = framework[:len(framework)-len(suf)] if err := os.RemoveAll(frameworkRoot); err != nil { return err } frameworkDir := filepath.Join(frameworkRoot, "Versions", "A") for _, dir := range []string{"Headers", "Modules"} { p := filepath.Join(frameworkDir, dir) if err := os.MkdirAll(p, 0o755); err != nil { return err } } symlinks := [][2]string{ {"Versions/Current/Headers", "Headers"}, {"Versions/Current/Modules", "Modules"}, {"Versions/Current/" + framework, framework}, {"A", filepath.Join("Versions", "Current")}, } for _, l := range symlinks { if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) { return err } } exe := filepath.Join(frameworkDir, framework) lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create") var builds errgroup.Group tags := bi.tags for _, a := range bi.archs { clang, cflags, err := iosCompilerFor(target, a, bi.minsdk) if err != nil { return err } lib := filepath.Join(tmpDir, "gio-"+a) cmd := exec.Command( "go", "build", "-ldflags=-s -w "+bi.ldflags, "-buildmode=c-archive", "-o", lib, "-tags", tags, bi.pkgPath, ) lipo.Args = append(lipo.Args, lib) cflagsLine := strings.Join(cflags, " ") cmd.Env = append( os.Environ(), "GOOS=ios", "GOARCH="+a, "CGO_ENABLED=1", "CC="+clang, "CGO_CFLAGS="+cflagsLine, "CGO_LDFLAGS="+cflagsLine, ) builds.Go(func() error { _, err := runCmd(cmd) return err }) } if err := builds.Wait(); err != nil { return err } if _, err := runCmd(lipo); err != nil { return err } appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/")) if err != nil { return err } headerDst := filepath.Join(frameworkDir, "Headers", framework+".h") headerSrc := filepath.Join(appDir, "framework_ios.h") if err := copyFile(headerDst, headerSrc); err != nil { return err } module := fmt.Sprintf(`framework module "%s" { header "%[1]s.h" export * }`, framework) moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap") return os.WriteFile(moduleFile, []byte(module), 0o644) } func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) { var ( platformSDK string platformOS string ) switch target { case "ios": platformOS = "ios" platformSDK = "iphone" case "tvos": platformOS = "tvos" platformSDK = "appletv" } switch arch { case "arm", "arm64": platformSDK += "os" if minsdk == 0 { minsdk = minIOSVersion if target == "tvos" { minsdk = minTVOSVersion } } case "386", "amd64": platformOS += "-simulator" platformSDK += "simulator" if minsdk == 0 { minsdk = minSimulatorVersion } default: return "", nil, fmt.Errorf("unsupported -arch: %s", arch) } sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path")) if err != nil { return "", nil, err } clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang")) if err != nil { return "", nil, err } cflags := []string{ "-arch", allArchs[arch].iosArch, "-isysroot", sdkPath, "-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk), } return clang, cflags, nil } func zipDir(dst, base, dir string) (err error) { f, err := os.Create(dst) if err != nil { return err } defer func() { if cerr := f.Close(); err == nil { err = cerr } }() zipf := zip.NewWriter(f) err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error { if err != nil { return err } if f.IsDir() { return nil } rel := filepath.ToSlash(path[len(base)+1:]) entry, err := zipf.Create(rel) if err != nil { return err } src, err := os.Open(path) if err != nil { return err } defer src.Close() _, err = io.Copy(entry, src) return err }) if err != nil { return err } return zipf.Close() }