diff --git a/cmd/gio/gio.go b/cmd/gio/gio.go index 7fc47eef..9ec96a3c 100644 --- a/cmd/gio/gio.go +++ b/cmd/gio/gio.go @@ -10,10 +10,7 @@ import ( "io/ioutil" "os" "os/exec" - "path/filepath" "strings" - - "golang.org/x/sync/errgroup" ) var ( @@ -21,6 +18,7 @@ var ( archNames = flag.String("arch", "", "specify architecture(s) to include") buildMode = flag.String("buildmode", "archive", "specify buildmode: archive or exe") destPath = flag.String("o", "", "output file (Android .aar or .apk file) or directory (iOS/tvOS .framework)") + appID = flag.String("appid", "org.gioui.app", "app identifier (for -buildmode=exe)") verbose = flag.Bool("v", false, "verbose output") ) @@ -92,7 +90,7 @@ func build(bi *buildInfo) error { defer os.RemoveAll(tmpDir) switch *target { case "ios", "tvos": - return archiveIOS(tmpDir, *target, bi) + return buildIOS(tmpDir, *target, bi) case "android": return buildAndroid(tmpDir, bi) default: @@ -100,140 +98,28 @@ func build(bi *buildInfo) error { } } -func archiveIOS(tmpDir, target string, bi *buildInfo) error { - frameworkRoot := *destPath - if frameworkRoot == "" { - appName := filepath.Base(bi.pkg) - frameworkRoot = fmt.Sprintf("%s.framework", strings.Title(appName)) - } - framework := filepath.Base(frameworkRoot) - 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, 0755); 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 - for _, a := range bi.archs { - arch := allArchs[a] - var platformSDK string - var platformOS string - switch target { - case "ios": - platformOS = "ios" - platformSDK = "iphone" - case "tvos": - platformOS = "tvos" - platformSDK = "appletv" - } - switch a { - case "arm", "arm64": - platformSDK += "os" - case "386", "amd64": - platformOS += "-simulator" - platformSDK += "simulator" - default: - return fmt.Errorf("unsupported -arch: %s", a) - } - sdkPathOut, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path")) - if err != nil { - return err - } - sdkPath := string(bytes.TrimSpace(sdkPathOut)) - clangOut, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang")) - if err != nil { - return err - } - clang := string(bytes.TrimSpace(clangOut)) - cflags := fmt.Sprintf("-fmodules -fobjc-arc -fembed-bitcode -Werror -arch %s -isysroot %s -m%s-version-min=9.0", arch.iosArch, sdkPath, platformOS) - lib := filepath.Join(tmpDir, "gio-"+a) - cmd := exec.Command( - "go", - "build", - "-ldflags=-s -w "+bi.ldflags, - "-buildmode=c-archive", - "-o", lib, - "-tags", "ios", - bi.pkg, - ) - lipo.Args = append(lipo.Args, lib) - cmd.Env = append( - os.Environ(), - "GOOS=darwin", - "GOARCH="+a, - "CGO_ENABLED=1", - "CC="+clang, - "CGO_CFLAGS="+cflags, - "CGO_LDFLAGS="+cflags, - ) - 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 := appDir() - 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 ioutil.WriteFile(moduleFile, []byte(module), 0644) -} - func errorf(format string, args ...interface{}) { fmt.Fprintf(os.Stderr, format+"\n", args...) os.Exit(2) } -func runCmd(cmd *exec.Cmd) (string, error) { +func runCmdRaw(cmd *exec.Cmd) ([]byte, error) { if *verbose { fmt.Printf("%s\n", strings.Join(cmd.Args, " ")) } out, err := cmd.Output() if err == nil { - return string(bytes.TrimSpace(out)), nil + return out, nil } if err, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr) + return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr) } - return "", err + return nil, err +} + +func runCmd(cmd *exec.Cmd) (string, error) { + out, err := runCmdRaw(cmd) + return string(bytes.TrimSpace(out)), err } func copyFile(dst, src string) (err error) { diff --git a/cmd/gio/iosbuild.go b/cmd/gio/iosbuild.go new file mode 100644 index 00000000..95982a03 --- /dev/null +++ b/cmd/gio/iosbuild.go @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package main + +import ( + "archive/zip" + "crypto/x509" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "golang.org/x/sync/errgroup" +) + +func buildIOS(tmpDir, target string, bi *buildInfo) error { + appName := filepath.Base(bi.pkg) + switch *buildMode { + case "archive": + framework := *destPath + if framework == "" { + framework = fmt.Sprintf("%s.framework", strings.Title(appName)) + } + return archiveIOS(tmpDir, target, framework, bi) + case "exe": + tmpFramework := filepath.Join(tmpDir, "Gio.framework") + if err := archiveIOS(tmpDir, target, tmpFramework, bi); err != nil { + return err + } + out := *destPath + if out == "" { + out = appName + ".ipa" + } + isIPA := strings.HasSuffix(out, ".ipa") + if !isIPA && !strings.HasSuffix(out, ".app") { + return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out) + } + if !isIPA { + return exeIOS(tmpDir, target, out, bi) + } + payload := filepath.Join(tmpDir, "Payload") + appDir := filepath.Join(payload, "gio.app") + if err := os.MkdirAll(appDir, 0755); err != nil { + return err + } + if err := exeIOS(tmpDir, target, appDir, bi); err != nil { + return err + } + if err := signIOS(tmpDir, appDir, out); err != nil { + return err + } + return zipDir(out, tmpDir, "Payload") + default: + panic("unreachable") + } +} + +func signIOS(tmpDir, app, ipa string) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision") + provisions, err := filepath.Glob(provPattern) + if err != nil { + return err + } + provInfo := filepath.Join(tmpDir, "provision.plist") + 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 + } + provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo)) + if err != nil { + return err + } + expAppID := fmt.Sprintf("%s.%s", appIDPrefix, *appID) + if expAppID != provAppID { + continue + } + 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] + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return fmt.Errorf("sign: failed to parse developer certificate from %q: %v", prov, err) + } + 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 := ioutil.WriteFile(entFile, []byte(entitlements), 0600); err != nil { + return err + } + signIdentity := cert.Subject.CommonName + _, err = runCmd(exec.Command("codesign", "-s", signIdentity, "--entitlements", entFile, app)) + return err + } + return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q", *appID) +} + +func exeIOS(tmpDir, target, app string, bi *buildInfo) error { + if *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, 0755); err != nil { + return err + } + mainm := filepath.Join(tmpDir, "main.m") + const mainmSrc = `@import UIKit; +@import Gio; + +int main(int argc, char * argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([GioAppDelegate class])); + } +}` + if err := ioutil.WriteFile(mainm, []byte(mainmSrc), 0600); err != nil { + return err + } + exe := filepath.Join(app, "app") + lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create") + var builds errgroup.Group + for _, a := range bi.archs { + clang, cflags, err := iosCompilerFor(target, a) + if err != nil { + return err + } + exeSlice := filepath.Join(tmpDir, "app-"+a) + lipo.Args = append(lipo.Args, exeSlice) + compile := exec.Command(clang, cflags...) + compile.Args = append(compile.Args, + "-F", tmpDir, + "-o", exeSlice, + mainm, + ) + 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 + } + infoPlistSrc := fmt.Sprintf(` + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + app + CFBundleIdentifier + %s + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Gio + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchStoryboardName + LaunchScreen + +`, *appID) + infoPlist := filepath.Join(app, "Info.plist") + if err := ioutil.WriteFile(infoPlist, []byte(infoPlistSrc), 0600); err != nil { + return err + } + if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", infoPlist)); err != nil { + return err + } + return nil +} + +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, 0755); 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 + for _, a := range bi.archs { + clang, cflags, err := iosCompilerFor(target, a) + 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", "ios", + bi.pkg, + ) + lipo.Args = append(lipo.Args, lib) + cflagsLine := strings.Join(cflags, " ") + cmd.Env = append( + os.Environ(), + "GOOS=darwin", + "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 := appDir() + 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 ioutil.WriteFile(moduleFile, []byte(module), 0644) +} + +func iosCompilerFor(target, arch string) (string, []string, error) { + var platformSDK string + var platformOS string + switch target { + case "ios": + platformOS = "ios" + platformSDK = "iphone" + case "tvos": + platformOS = "tvos" + platformSDK = "appletv" + } + switch arch { + case "arm", "arm64": + platformSDK += "os" + case "386", "amd64": + platformOS += "-simulator" + platformSDK += "simulator" + 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{ + "-fmodules", + "-fobjc-arc", + "-fembed-bitcode", + "-Werror", + "-arch", allArchs[arch].iosArch, + "-isysroot", sdkPath, + "-m" + platformOS + "-version-min=9.0", + } + 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() +}