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