Home | History | Annotate | Download | only in ios
      1 // Copyright 2015 The Go Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style
      3 // license that can be found in the LICENSE file.
      4 
      5 // This program can be used as go_darwin_arm_exec by the Go tool.
      6 // It executes binaries on an iOS device using the XCode toolchain
      7 // and the ios-deploy program: https://github.com/phonegap/ios-deploy
      8 //
      9 // This script supports an extra flag, -lldb, that pauses execution
     10 // just before the main program begins and allows the user to control
     11 // the remote lldb session. This flag is appended to the end of the
     12 // script's arguments and is not passed through to the underlying
     13 // binary.
     14 //
     15 // This script requires that three environment variables be set:
     16 // 	GOIOS_DEV_ID: The codesigning developer id or certificate identifier
     17 // 	GOIOS_APP_ID: The provisioning app id prefix. Must support wildcard app ids.
     18 // 	GOIOS_TEAM_ID: The team id that owns the app id prefix.
     19 // $GOROOT/misc/ios contains a script, detect.go, that attempts to autodetect these.
     20 package main
     21 
     22 import (
     23 	"bytes"
     24 	"errors"
     25 	"flag"
     26 	"fmt"
     27 	"go/build"
     28 	"io"
     29 	"io/ioutil"
     30 	"log"
     31 	"os"
     32 	"os/exec"
     33 	"path/filepath"
     34 	"runtime"
     35 	"strings"
     36 	"sync"
     37 	"syscall"
     38 	"time"
     39 )
     40 
     41 const debug = false
     42 
     43 var errRetry = errors.New("failed to start test harness (retry attempted)")
     44 
     45 var tmpdir string
     46 
     47 var (
     48 	devID    string
     49 	appID    string
     50 	teamID   string
     51 	bundleID string
     52 	deviceID string
     53 )
     54 
     55 // lock is a file lock to serialize iOS runs. It is global to avoid the
     56 // garbage collector finalizing it, closing the file and releasing the
     57 // lock prematurely.
     58 var lock *os.File
     59 
     60 func main() {
     61 	log.SetFlags(0)
     62 	log.SetPrefix("go_darwin_arm_exec: ")
     63 	if debug {
     64 		log.Println(strings.Join(os.Args, " "))
     65 	}
     66 	if len(os.Args) < 2 {
     67 		log.Fatal("usage: go_darwin_arm_exec a.out")
     68 	}
     69 
     70 	// e.g. B393DDEB490947F5A463FD074299B6C0AXXXXXXX
     71 	devID = getenv("GOIOS_DEV_ID")
     72 
     73 	// e.g. Z8B3JBXXXX.org.golang.sample, Z8B3JBXXXX prefix is available at
     74 	// https://developer.apple.com/membercenter/index.action#accountSummary as Team ID.
     75 	appID = getenv("GOIOS_APP_ID")
     76 
     77 	// e.g. Z8B3JBXXXX, available at
     78 	// https://developer.apple.com/membercenter/index.action#accountSummary as Team ID.
     79 	teamID = getenv("GOIOS_TEAM_ID")
     80 
     81 	// Device IDs as listed with ios-deploy -c.
     82 	deviceID = os.Getenv("GOIOS_DEVICE_ID")
     83 
     84 	parts := strings.SplitN(appID, ".", 2)
     85 	// For compatibility with the old builders, use a fallback bundle ID
     86 	bundleID = "golang.gotest"
     87 	if len(parts) == 2 {
     88 		bundleID = parts[1]
     89 	}
     90 
     91 	var err error
     92 	tmpdir, err = ioutil.TempDir("", "go_darwin_arm_exec_")
     93 	if err != nil {
     94 		log.Fatal(err)
     95 	}
     96 
     97 	// This wrapper uses complicated machinery to run iOS binaries. It
     98 	// works, but only when running one binary at a time.
     99 	// Use a file lock to make sure only one wrapper is running at a time.
    100 	//
    101 	// The lock file is never deleted, to avoid concurrent locks on distinct
    102 	// files with the same path.
    103 	lockName := filepath.Join(os.TempDir(), "go_darwin_arm_exec-"+deviceID+".lock")
    104 	lock, err = os.OpenFile(lockName, os.O_CREATE|os.O_RDONLY, 0666)
    105 	if err != nil {
    106 		log.Fatal(err)
    107 	}
    108 	if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
    109 		log.Fatal(err)
    110 	}
    111 	// Approximately 1 in a 100 binaries fail to start. If it happens,
    112 	// try again. These failures happen for several reasons beyond
    113 	// our control, but all of them are safe to retry as they happen
    114 	// before lldb encounters the initial getwd breakpoint. As we
    115 	// know the tests haven't started, we are not hiding flaky tests
    116 	// with this retry.
    117 	for i := 0; i < 5; i++ {
    118 		if i > 0 {
    119 			fmt.Fprintln(os.Stderr, "start timeout, trying again")
    120 		}
    121 		err = run(os.Args[1], os.Args[2:])
    122 		if err == nil || err != errRetry {
    123 			break
    124 		}
    125 	}
    126 	if !debug {
    127 		os.RemoveAll(tmpdir)
    128 	}
    129 	if err != nil {
    130 		fmt.Fprintf(os.Stderr, "go_darwin_arm_exec: %v\n", err)
    131 		os.Exit(1)
    132 	}
    133 }
    134 
    135 func getenv(envvar string) string {
    136 	s := os.Getenv(envvar)
    137 	if s == "" {
    138 		log.Fatalf("%s not set\nrun $GOROOT/misc/ios/detect.go to attempt to autodetect", envvar)
    139 	}
    140 	return s
    141 }
    142 
    143 func run(bin string, args []string) (err error) {
    144 	appdir := filepath.Join(tmpdir, "gotest.app")
    145 	os.RemoveAll(appdir)
    146 	if err := os.MkdirAll(appdir, 0755); err != nil {
    147 		return err
    148 	}
    149 
    150 	if err := cp(filepath.Join(appdir, "gotest"), bin); err != nil {
    151 		return err
    152 	}
    153 
    154 	pkgpath, err := copyLocalData(appdir)
    155 	if err != nil {
    156 		return err
    157 	}
    158 
    159 	entitlementsPath := filepath.Join(tmpdir, "Entitlements.plist")
    160 	if err := ioutil.WriteFile(entitlementsPath, []byte(entitlementsPlist()), 0744); err != nil {
    161 		return err
    162 	}
    163 	if err := ioutil.WriteFile(filepath.Join(appdir, "Info.plist"), []byte(infoPlist(pkgpath)), 0744); err != nil {
    164 		return err
    165 	}
    166 	if err := ioutil.WriteFile(filepath.Join(appdir, "ResourceRules.plist"), []byte(resourceRules), 0744); err != nil {
    167 		return err
    168 	}
    169 
    170 	cmd := exec.Command(
    171 		"codesign",
    172 		"-f",
    173 		"-s", devID,
    174 		"--entitlements", entitlementsPath,
    175 		appdir,
    176 	)
    177 	if debug {
    178 		log.Println(strings.Join(cmd.Args, " "))
    179 	}
    180 	cmd.Stdout = os.Stdout
    181 	cmd.Stderr = os.Stderr
    182 	if err := cmd.Run(); err != nil {
    183 		return fmt.Errorf("codesign: %v", err)
    184 	}
    185 
    186 	oldwd, err := os.Getwd()
    187 	if err != nil {
    188 		return err
    189 	}
    190 	if err := os.Chdir(filepath.Join(appdir, "..")); err != nil {
    191 		return err
    192 	}
    193 	defer os.Chdir(oldwd)
    194 
    195 	// Setting up lldb is flaky. The test binary itself runs when
    196 	// started is set to true. Everything before that is considered
    197 	// part of the setup and is retried.
    198 	started := false
    199 	defer func() {
    200 		if r := recover(); r != nil {
    201 			if w, ok := r.(waitPanic); ok {
    202 				err = w.err
    203 				if !started {
    204 					fmt.Printf("lldb setup error: %v\n", err)
    205 					err = errRetry
    206 				}
    207 				return
    208 			}
    209 			panic(r)
    210 		}
    211 	}()
    212 
    213 	defer exec.Command("killall", "ios-deploy").Run() // cleanup
    214 	exec.Command("killall", "ios-deploy").Run()
    215 
    216 	var opts options
    217 	opts, args = parseArgs(args)
    218 
    219 	// ios-deploy invokes lldb to give us a shell session with the app.
    220 	s, err := newSession(appdir, args, opts)
    221 	if err != nil {
    222 		return err
    223 	}
    224 	defer func() {
    225 		b := s.out.Bytes()
    226 		if err == nil && !debug {
    227 			i := bytes.Index(b, []byte("(lldb) process continue"))
    228 			if i > 0 {
    229 				b = b[i:]
    230 			}
    231 		}
    232 		os.Stdout.Write(b)
    233 	}()
    234 
    235 	cond := func(out *buf) bool {
    236 		i0 := s.out.LastIndex([]byte("(lldb)"))
    237 		i1 := s.out.LastIndex([]byte("fruitstrap"))
    238 		i2 := s.out.LastIndex([]byte(" connect"))
    239 		return i0 > 0 && i1 > 0 && i2 > 0
    240 	}
    241 	if err := s.wait("lldb start", cond, 15*time.Second); err != nil {
    242 		panic(waitPanic{err})
    243 	}
    244 
    245 	// Script LLDB. Oh dear.
    246 	s.do(`process handle SIGHUP  --stop false --pass true --notify false`)
    247 	s.do(`process handle SIGPIPE --stop false --pass true --notify false`)
    248 	s.do(`process handle SIGUSR1 --stop false --pass true --notify false`)
    249 	s.do(`process handle SIGCONT --stop false --pass true --notify false`)
    250 	s.do(`process handle SIGSEGV --stop false --pass true --notify false`) // does not work
    251 	s.do(`process handle SIGBUS  --stop false --pass true --notify false`) // does not work
    252 
    253 	if opts.lldb {
    254 		_, err := io.Copy(s.in, os.Stdin)
    255 		if err != io.EOF {
    256 			return err
    257 		}
    258 		return nil
    259 	}
    260 
    261 	started = true
    262 
    263 	s.doCmd("run", "stop reason = signal SIGINT", 20*time.Second)
    264 
    265 	startTestsLen := s.out.Len()
    266 	fmt.Fprintln(s.in, `process continue`)
    267 
    268 	passed := func(out *buf) bool {
    269 		// Just to make things fun, lldb sometimes translates \n into \r\n.
    270 		return s.out.LastIndex([]byte("\nPASS\n")) > startTestsLen ||
    271 			s.out.LastIndex([]byte("\nPASS\r")) > startTestsLen ||
    272 			s.out.LastIndex([]byte("\n(lldb) PASS\n")) > startTestsLen ||
    273 			s.out.LastIndex([]byte("\n(lldb) PASS\r")) > startTestsLen ||
    274 			s.out.LastIndex([]byte("exited with status = 0 (0x00000000) \n")) > startTestsLen ||
    275 			s.out.LastIndex([]byte("exited with status = 0 (0x00000000) \r")) > startTestsLen
    276 	}
    277 	err = s.wait("test completion", passed, opts.timeout)
    278 	if passed(s.out) {
    279 		// The returned lldb error code is usually non-zero.
    280 		// We check for test success by scanning for the final
    281 		// PASS returned by the test harness, assuming the worst
    282 		// in its absence.
    283 		return nil
    284 	}
    285 	return err
    286 }
    287 
    288 type lldbSession struct {
    289 	cmd      *exec.Cmd
    290 	in       *os.File
    291 	out      *buf
    292 	timedout chan struct{}
    293 	exited   chan error
    294 }
    295 
    296 func newSession(appdir string, args []string, opts options) (*lldbSession, error) {
    297 	lldbr, in, err := os.Pipe()
    298 	if err != nil {
    299 		return nil, err
    300 	}
    301 	s := &lldbSession{
    302 		in:     in,
    303 		out:    new(buf),
    304 		exited: make(chan error),
    305 	}
    306 
    307 	iosdPath, err := exec.LookPath("ios-deploy")
    308 	if err != nil {
    309 		return nil, err
    310 	}
    311 	cmdArgs := []string{
    312 		// lldb tries to be clever with terminals.
    313 		// So we wrap it in script(1) and be clever
    314 		// right back at it.
    315 		"script",
    316 		"-q", "-t", "0",
    317 		"/dev/null",
    318 
    319 		iosdPath,
    320 		"--debug",
    321 		"-u",
    322 		"-r",
    323 		"-n",
    324 		`--args=` + strings.Join(args, " ") + ``,
    325 		"--bundle", appdir,
    326 	}
    327 	if deviceID != "" {
    328 		cmdArgs = append(cmdArgs, "--id", deviceID)
    329 	}
    330 	s.cmd = exec.Command(cmdArgs[0], cmdArgs[1:]...)
    331 	if debug {
    332 		log.Println(strings.Join(s.cmd.Args, " "))
    333 	}
    334 
    335 	var out io.Writer = s.out
    336 	if opts.lldb {
    337 		out = io.MultiWriter(out, os.Stderr)
    338 	}
    339 	s.cmd.Stdout = out
    340 	s.cmd.Stderr = out // everything of interest is on stderr
    341 	s.cmd.Stdin = lldbr
    342 
    343 	if err := s.cmd.Start(); err != nil {
    344 		return nil, fmt.Errorf("ios-deploy failed to start: %v", err)
    345 	}
    346 
    347 	// Manage the -test.timeout here, outside of the test. There is a lot
    348 	// of moving parts in an iOS test harness (notably lldb) that can
    349 	// swallow useful stdio or cause its own ruckus.
    350 	if opts.timeout > 1*time.Second {
    351 		s.timedout = make(chan struct{})
    352 		time.AfterFunc(opts.timeout-1*time.Second, func() {
    353 			close(s.timedout)
    354 		})
    355 	}
    356 
    357 	go func() {
    358 		s.exited <- s.cmd.Wait()
    359 	}()
    360 
    361 	return s, nil
    362 }
    363 
    364 func (s *lldbSession) do(cmd string) { s.doCmd(cmd, "(lldb)", 0) }
    365 
    366 func (s *lldbSession) doCmd(cmd string, waitFor string, extraTimeout time.Duration) {
    367 	startLen := s.out.Len()
    368 	fmt.Fprintln(s.in, cmd)
    369 	cond := func(out *buf) bool {
    370 		i := s.out.LastIndex([]byte(waitFor))
    371 		return i > startLen
    372 	}
    373 	if err := s.wait(fmt.Sprintf("running cmd %q", cmd), cond, extraTimeout); err != nil {
    374 		panic(waitPanic{err})
    375 	}
    376 }
    377 
    378 func (s *lldbSession) wait(reason string, cond func(out *buf) bool, extraTimeout time.Duration) error {
    379 	doTimeout := 2*time.Second + extraTimeout
    380 	doTimedout := time.After(doTimeout)
    381 	for {
    382 		select {
    383 		case <-s.timedout:
    384 			if p := s.cmd.Process; p != nil {
    385 				p.Kill()
    386 			}
    387 			return fmt.Errorf("test timeout (%s)", reason)
    388 		case <-doTimedout:
    389 			if p := s.cmd.Process; p != nil {
    390 				p.Kill()
    391 			}
    392 			return fmt.Errorf("command timeout (%s for %v)", reason, doTimeout)
    393 		case err := <-s.exited:
    394 			return fmt.Errorf("exited (%s: %v)", reason, err)
    395 		default:
    396 			if cond(s.out) {
    397 				return nil
    398 			}
    399 			time.Sleep(20 * time.Millisecond)
    400 		}
    401 	}
    402 }
    403 
    404 type buf struct {
    405 	mu  sync.Mutex
    406 	buf []byte
    407 }
    408 
    409 func (w *buf) Write(in []byte) (n int, err error) {
    410 	w.mu.Lock()
    411 	defer w.mu.Unlock()
    412 	w.buf = append(w.buf, in...)
    413 	return len(in), nil
    414 }
    415 
    416 func (w *buf) LastIndex(sep []byte) int {
    417 	w.mu.Lock()
    418 	defer w.mu.Unlock()
    419 	return bytes.LastIndex(w.buf, sep)
    420 }
    421 
    422 func (w *buf) Bytes() []byte {
    423 	w.mu.Lock()
    424 	defer w.mu.Unlock()
    425 
    426 	b := make([]byte, len(w.buf))
    427 	copy(b, w.buf)
    428 	return b
    429 }
    430 
    431 func (w *buf) Len() int {
    432 	w.mu.Lock()
    433 	defer w.mu.Unlock()
    434 	return len(w.buf)
    435 }
    436 
    437 type waitPanic struct {
    438 	err error
    439 }
    440 
    441 type options struct {
    442 	timeout time.Duration
    443 	lldb    bool
    444 }
    445 
    446 func parseArgs(binArgs []string) (opts options, remainingArgs []string) {
    447 	var flagArgs []string
    448 	for _, arg := range binArgs {
    449 		if strings.Contains(arg, "-test.timeout") {
    450 			flagArgs = append(flagArgs, arg)
    451 		}
    452 		if strings.Contains(arg, "-lldb") {
    453 			flagArgs = append(flagArgs, arg)
    454 			continue
    455 		}
    456 		remainingArgs = append(remainingArgs, arg)
    457 	}
    458 	f := flag.NewFlagSet("", flag.ContinueOnError)
    459 	f.DurationVar(&opts.timeout, "test.timeout", 10*time.Minute, "")
    460 	f.BoolVar(&opts.lldb, "lldb", false, "")
    461 	f.Parse(flagArgs)
    462 	return opts, remainingArgs
    463 
    464 }
    465 
    466 func copyLocalDir(dst, src string) error {
    467 	if err := os.Mkdir(dst, 0755); err != nil {
    468 		return err
    469 	}
    470 
    471 	d, err := os.Open(src)
    472 	if err != nil {
    473 		return err
    474 	}
    475 	defer d.Close()
    476 	fi, err := d.Readdir(-1)
    477 	if err != nil {
    478 		return err
    479 	}
    480 
    481 	for _, f := range fi {
    482 		if f.IsDir() {
    483 			if f.Name() == "testdata" {
    484 				if err := cp(dst, filepath.Join(src, f.Name())); err != nil {
    485 					return err
    486 				}
    487 			}
    488 			continue
    489 		}
    490 		if err := cp(dst, filepath.Join(src, f.Name())); err != nil {
    491 			return err
    492 		}
    493 	}
    494 	return nil
    495 }
    496 
    497 func cp(dst, src string) error {
    498 	out, err := exec.Command("cp", "-a", src, dst).CombinedOutput()
    499 	if err != nil {
    500 		os.Stderr.Write(out)
    501 	}
    502 	return err
    503 }
    504 
    505 func copyLocalData(dstbase string) (pkgpath string, err error) {
    506 	cwd, err := os.Getwd()
    507 	if err != nil {
    508 		return "", err
    509 	}
    510 
    511 	finalPkgpath, underGoRoot, err := subdir()
    512 	if err != nil {
    513 		return "", err
    514 	}
    515 	cwd = strings.TrimSuffix(cwd, finalPkgpath)
    516 
    517 	// Copy all immediate files and testdata directories between
    518 	// the package being tested and the source root.
    519 	pkgpath = ""
    520 	for _, element := range strings.Split(finalPkgpath, string(filepath.Separator)) {
    521 		if debug {
    522 			log.Printf("copying %s", pkgpath)
    523 		}
    524 		pkgpath = filepath.Join(pkgpath, element)
    525 		dst := filepath.Join(dstbase, pkgpath)
    526 		src := filepath.Join(cwd, pkgpath)
    527 		if err := copyLocalDir(dst, src); err != nil {
    528 			return "", err
    529 		}
    530 	}
    531 
    532 	if underGoRoot {
    533 		// Copy timezone file.
    534 		//
    535 		// Typical apps have the zoneinfo.zip in the root of their app bundle,
    536 		// read by the time package as the working directory at initialization.
    537 		// As we move the working directory to the GOROOT pkg directory, we
    538 		// install the zoneinfo.zip file in the pkgpath.
    539 		err := cp(
    540 			filepath.Join(dstbase, pkgpath),
    541 			filepath.Join(cwd, "lib", "time", "zoneinfo.zip"),
    542 		)
    543 		if err != nil {
    544 			return "", err
    545 		}
    546 		// Copy src/runtime/textflag.h for (at least) Test386EndToEnd in
    547 		// cmd/asm/internal/asm.
    548 		runtimePath := filepath.Join(dstbase, "src", "runtime")
    549 		if err := os.MkdirAll(runtimePath, 0755); err != nil {
    550 			return "", err
    551 		}
    552 		err = cp(
    553 			filepath.Join(runtimePath, "textflag.h"),
    554 			filepath.Join(cwd, "src", "runtime", "textflag.h"),
    555 		)
    556 		if err != nil {
    557 			return "", err
    558 		}
    559 	}
    560 
    561 	return finalPkgpath, nil
    562 }
    563 
    564 // subdir determines the package based on the current working directory,
    565 // and returns the path to the package source relative to $GOROOT (or $GOPATH).
    566 func subdir() (pkgpath string, underGoRoot bool, err error) {
    567 	cwd, err := os.Getwd()
    568 	if err != nil {
    569 		return "", false, err
    570 	}
    571 	if root := runtime.GOROOT(); strings.HasPrefix(cwd, root) {
    572 		subdir, err := filepath.Rel(root, cwd)
    573 		if err != nil {
    574 			return "", false, err
    575 		}
    576 		return subdir, true, nil
    577 	}
    578 
    579 	for _, p := range filepath.SplitList(build.Default.GOPATH) {
    580 		if !strings.HasPrefix(cwd, p) {
    581 			continue
    582 		}
    583 		subdir, err := filepath.Rel(p, cwd)
    584 		if err == nil {
    585 			return subdir, false, nil
    586 		}
    587 	}
    588 	return "", false, fmt.Errorf(
    589 		"working directory %q is not in either GOROOT(%q) or GOPATH(%q)",
    590 		cwd,
    591 		runtime.GOROOT(),
    592 		build.Default.GOPATH,
    593 	)
    594 }
    595 
    596 func infoPlist(pkgpath string) string {
    597 	return `<?xml version="1.0" encoding="UTF-8"?>
    598 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    599 <plist version="1.0">
    600 <dict>
    601 <key>CFBundleName</key><string>golang.gotest</string>
    602 <key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
    603 <key>CFBundleExecutable</key><string>gotest</string>
    604 <key>CFBundleVersion</key><string>1.0</string>
    605 <key>CFBundleIdentifier</key><string>` + bundleID + `</string>
    606 <key>CFBundleResourceSpecification</key><string>ResourceRules.plist</string>
    607 <key>LSRequiresIPhoneOS</key><true/>
    608 <key>CFBundleDisplayName</key><string>gotest</string>
    609 <key>GoExecWrapperWorkingDirectory</key><string>` + pkgpath + `</string>
    610 </dict>
    611 </plist>
    612 `
    613 }
    614 
    615 func entitlementsPlist() string {
    616 	return `<?xml version="1.0" encoding="UTF-8"?>
    617 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    618 <plist version="1.0">
    619 <dict>
    620 	<key>keychain-access-groups</key>
    621 	<array><string>` + appID + `</string></array>
    622 	<key>get-task-allow</key>
    623 	<true/>
    624 	<key>application-identifier</key>
    625 	<string>` + appID + `</string>
    626 	<key>com.apple.developer.team-identifier</key>
    627 	<string>` + teamID + `</string>
    628 </dict>
    629 </plist>
    630 `
    631 }
    632 
    633 const resourceRules = `<?xml version="1.0" encoding="UTF-8"?>
    634 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    635 <plist version="1.0">
    636 <dict>
    637 	<key>rules</key>
    638 	<dict>
    639 		<key>.*</key>
    640 		<true/>
    641 		<key>Info.plist</key>
    642 		<dict>
    643 			<key>omit</key>
    644 			<true/>
    645 			<key>weight</key>
    646 			<integer>10</integer>
    647 		</dict>
    648 		<key>ResourceRules.plist</key>
    649 		<dict>
    650 			<key>omit</key>
    651 			<true/>
    652 			<key>weight</key>
    653 			<integer>100</integer>
    654 		</dict>
    655 	</dict>
    656 </dict>
    657 </plist>
    658 `
    659