mirror of
https://github.com/golang/go
synced 2024-11-23 07:20:06 -07:00
6e54fe47ce
The "lldb start" phase often times out on the iOS builder. Increase the timeout and see if that helps. Change-Id: I92fd67cbfa90659600e713198d6b2c5c78dde20f Reviewed-on: https://go-review.googlesource.com/41863 Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org> Run-TryBot: Elias Naur <elias.naur@gmail.com> TryBot-Result: Gobot Gobot <gobot@golang.org>
647 lines
16 KiB
Go
647 lines
16 KiB
Go
// Copyright 2015 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
// This program can be used as go_darwin_arm_exec by the Go tool.
|
|
// It executes binaries on an iOS device using the XCode toolchain
|
|
// and the ios-deploy program: https://github.com/phonegap/ios-deploy
|
|
//
|
|
// This script supports an extra flag, -lldb, that pauses execution
|
|
// just before the main program begins and allows the user to control
|
|
// the remote lldb session. This flag is appended to the end of the
|
|
// script's arguments and is not passed through to the underlying
|
|
// binary.
|
|
//
|
|
// This script requires that three environment variables be set:
|
|
// GOIOS_DEV_ID: The codesigning developer id or certificate identifier
|
|
// GOIOS_APP_ID: The provisioning app id prefix. Must support wildcard app ids.
|
|
// GOIOS_TEAM_ID: The team id that owns the app id prefix.
|
|
// $GOROOT/misc/ios contains a script, detect.go, that attempts to autodetect these.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"go/build"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const debug = false
|
|
|
|
var errRetry = errors.New("failed to start test harness (retry attempted)")
|
|
|
|
var tmpdir string
|
|
|
|
var (
|
|
devID string
|
|
appID string
|
|
teamID string
|
|
bundleID string
|
|
)
|
|
|
|
// lock is a file lock to serialize iOS runs. It is global to avoid the
|
|
// garbage collector finalizing it, closing the file and releasing the
|
|
// lock prematurely.
|
|
var lock *os.File
|
|
|
|
func main() {
|
|
log.SetFlags(0)
|
|
log.SetPrefix("go_darwin_arm_exec: ")
|
|
if debug {
|
|
log.Println(strings.Join(os.Args, " "))
|
|
}
|
|
if len(os.Args) < 2 {
|
|
log.Fatal("usage: go_darwin_arm_exec a.out")
|
|
}
|
|
|
|
// e.g. B393DDEB490947F5A463FD074299B6C0AXXXXXXX
|
|
devID = getenv("GOIOS_DEV_ID")
|
|
|
|
// e.g. Z8B3JBXXXX.org.golang.sample, Z8B3JBXXXX prefix is available at
|
|
// https://developer.apple.com/membercenter/index.action#accountSummary as Team ID.
|
|
appID = getenv("GOIOS_APP_ID")
|
|
|
|
// e.g. Z8B3JBXXXX, available at
|
|
// https://developer.apple.com/membercenter/index.action#accountSummary as Team ID.
|
|
teamID = getenv("GOIOS_TEAM_ID")
|
|
|
|
parts := strings.SplitN(appID, ".", 2)
|
|
// For compatibility with the old builders, use a fallback bundle ID
|
|
bundleID = "golang.gotest"
|
|
if len(parts) == 2 {
|
|
bundleID = parts[1]
|
|
}
|
|
|
|
var err error
|
|
tmpdir, err = ioutil.TempDir("", "go_darwin_arm_exec_")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// This wrapper uses complicated machinery to run iOS binaries. It
|
|
// works, but only when running one binary at a time.
|
|
// Use a file lock to make sure only one wrapper is running at a time.
|
|
//
|
|
// The lock file is never deleted, to avoid concurrent locks on distinct
|
|
// files with the same path.
|
|
lockName := filepath.Join(os.TempDir(), "go_darwin_arm_exec.lock")
|
|
lock, err = os.OpenFile(lockName, os.O_CREATE|os.O_RDONLY, 0666)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
// Approximately 1 in a 100 binaries fail to start. If it happens,
|
|
// try again. These failures happen for several reasons beyond
|
|
// our control, but all of them are safe to retry as they happen
|
|
// before lldb encounters the initial getwd breakpoint. As we
|
|
// know the tests haven't started, we are not hiding flaky tests
|
|
// with this retry.
|
|
for i := 0; i < 5; i++ {
|
|
if i > 0 {
|
|
fmt.Fprintln(os.Stderr, "start timeout, trying again")
|
|
}
|
|
err = run(os.Args[1], os.Args[2:])
|
|
if err == nil || err != errRetry {
|
|
break
|
|
}
|
|
}
|
|
if !debug {
|
|
os.RemoveAll(tmpdir)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "go_darwin_arm_exec: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func getenv(envvar string) string {
|
|
s := os.Getenv(envvar)
|
|
if s == "" {
|
|
log.Fatalf("%s not set\nrun $GOROOT/misc/ios/detect.go to attempt to autodetect", envvar)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func run(bin string, args []string) (err error) {
|
|
appdir := filepath.Join(tmpdir, "gotest.app")
|
|
os.RemoveAll(appdir)
|
|
if err := os.MkdirAll(appdir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := cp(filepath.Join(appdir, "gotest"), bin); err != nil {
|
|
return err
|
|
}
|
|
|
|
pkgpath, err := copyLocalData(appdir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
entitlementsPath := filepath.Join(tmpdir, "Entitlements.plist")
|
|
if err := ioutil.WriteFile(entitlementsPath, []byte(entitlementsPlist()), 0744); err != nil {
|
|
return err
|
|
}
|
|
if err := ioutil.WriteFile(filepath.Join(appdir, "Info.plist"), []byte(infoPlist(pkgpath)), 0744); err != nil {
|
|
return err
|
|
}
|
|
if err := ioutil.WriteFile(filepath.Join(appdir, "ResourceRules.plist"), []byte(resourceRules), 0744); err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd := exec.Command(
|
|
"codesign",
|
|
"-f",
|
|
"-s", devID,
|
|
"--entitlements", entitlementsPath,
|
|
appdir,
|
|
)
|
|
if debug {
|
|
log.Println(strings.Join(cmd.Args, " "))
|
|
}
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("codesign: %v", err)
|
|
}
|
|
|
|
oldwd, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chdir(filepath.Join(appdir, "..")); err != nil {
|
|
return err
|
|
}
|
|
defer os.Chdir(oldwd)
|
|
|
|
// Setting up lldb is flaky. The test binary itself runs when
|
|
// started is set to true. Everything before that is considered
|
|
// part of the setup and is retried.
|
|
started := false
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
if w, ok := r.(waitPanic); ok {
|
|
err = w.err
|
|
if !started {
|
|
fmt.Printf("lldb setup error: %v\n", err)
|
|
err = errRetry
|
|
}
|
|
return
|
|
}
|
|
panic(r)
|
|
}
|
|
}()
|
|
|
|
defer exec.Command("killall", "ios-deploy").Run() // cleanup
|
|
exec.Command("killall", "ios-deploy").Run()
|
|
|
|
var opts options
|
|
opts, args = parseArgs(args)
|
|
|
|
// ios-deploy invokes lldb to give us a shell session with the app.
|
|
s, err := newSession(appdir, args, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
b := s.out.Bytes()
|
|
if err == nil && !debug {
|
|
i := bytes.Index(b, []byte("(lldb) process continue"))
|
|
if i > 0 {
|
|
b = b[i:]
|
|
}
|
|
}
|
|
os.Stdout.Write(b)
|
|
}()
|
|
|
|
// Script LLDB. Oh dear.
|
|
s.do(`process handle SIGHUP --stop false --pass true --notify false`)
|
|
s.do(`process handle SIGPIPE --stop false --pass true --notify false`)
|
|
s.do(`process handle SIGUSR1 --stop false --pass true --notify false`)
|
|
s.do(`process handle SIGCONT --stop false --pass true --notify false`)
|
|
s.do(`process handle SIGSEGV --stop false --pass true --notify false`) // does not work
|
|
s.do(`process handle SIGBUS --stop false --pass true --notify false`) // does not work
|
|
|
|
if opts.lldb {
|
|
_, err := io.Copy(s.in, os.Stdin)
|
|
if err != io.EOF {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
started = true
|
|
|
|
s.doCmd("run", "stop reason = signal SIGINT", 20*time.Second)
|
|
|
|
startTestsLen := s.out.Len()
|
|
fmt.Fprintln(s.in, `process continue`)
|
|
|
|
passed := func(out *buf) bool {
|
|
// Just to make things fun, lldb sometimes translates \n into \r\n.
|
|
return s.out.LastIndex([]byte("\nPASS\n")) > startTestsLen ||
|
|
s.out.LastIndex([]byte("\nPASS\r")) > startTestsLen ||
|
|
s.out.LastIndex([]byte("\n(lldb) PASS\n")) > startTestsLen ||
|
|
s.out.LastIndex([]byte("\n(lldb) PASS\r")) > startTestsLen ||
|
|
s.out.LastIndex([]byte("exited with status = 0 (0x00000000) \n")) > startTestsLen ||
|
|
s.out.LastIndex([]byte("exited with status = 0 (0x00000000) \r")) > startTestsLen
|
|
}
|
|
err = s.wait("test completion", passed, opts.timeout)
|
|
if passed(s.out) {
|
|
// The returned lldb error code is usually non-zero.
|
|
// We check for test success by scanning for the final
|
|
// PASS returned by the test harness, assuming the worst
|
|
// in its absence.
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
type lldbSession struct {
|
|
cmd *exec.Cmd
|
|
in *os.File
|
|
out *buf
|
|
timedout chan struct{}
|
|
exited chan error
|
|
}
|
|
|
|
func newSession(appdir string, args []string, opts options) (*lldbSession, error) {
|
|
lldbr, in, err := os.Pipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &lldbSession{
|
|
in: in,
|
|
out: new(buf),
|
|
exited: make(chan error),
|
|
}
|
|
|
|
iosdPath, err := exec.LookPath("ios-deploy")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.cmd = exec.Command(
|
|
// lldb tries to be clever with terminals.
|
|
// So we wrap it in script(1) and be clever
|
|
// right back at it.
|
|
"script",
|
|
"-q", "-t", "0",
|
|
"/dev/null",
|
|
|
|
iosdPath,
|
|
"--debug",
|
|
"-u",
|
|
"-r",
|
|
"-n",
|
|
`--args=`+strings.Join(args, " ")+``,
|
|
"--bundle", appdir,
|
|
)
|
|
if debug {
|
|
log.Println(strings.Join(s.cmd.Args, " "))
|
|
}
|
|
|
|
var out io.Writer = s.out
|
|
if opts.lldb {
|
|
out = io.MultiWriter(out, os.Stderr)
|
|
}
|
|
s.cmd.Stdout = out
|
|
s.cmd.Stderr = out // everything of interest is on stderr
|
|
s.cmd.Stdin = lldbr
|
|
|
|
if err := s.cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("ios-deploy failed to start: %v", err)
|
|
}
|
|
|
|
// Manage the -test.timeout here, outside of the test. There is a lot
|
|
// of moving parts in an iOS test harness (notably lldb) that can
|
|
// swallow useful stdio or cause its own ruckus.
|
|
if opts.timeout > 1*time.Second {
|
|
s.timedout = make(chan struct{})
|
|
time.AfterFunc(opts.timeout-1*time.Second, func() {
|
|
close(s.timedout)
|
|
})
|
|
}
|
|
|
|
go func() {
|
|
s.exited <- s.cmd.Wait()
|
|
}()
|
|
|
|
cond := func(out *buf) bool {
|
|
i0 := s.out.LastIndex([]byte("(lldb)"))
|
|
i1 := s.out.LastIndex([]byte("fruitstrap"))
|
|
i2 := s.out.LastIndex([]byte(" connect"))
|
|
return i0 > 0 && i1 > 0 && i2 > 0
|
|
}
|
|
if err := s.wait("lldb start", cond, 15*time.Second); err != nil {
|
|
panic(waitPanic{err})
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *lldbSession) do(cmd string) { s.doCmd(cmd, "(lldb)", 0) }
|
|
|
|
func (s *lldbSession) doCmd(cmd string, waitFor string, extraTimeout time.Duration) {
|
|
startLen := s.out.Len()
|
|
fmt.Fprintln(s.in, cmd)
|
|
cond := func(out *buf) bool {
|
|
i := s.out.LastIndex([]byte(waitFor))
|
|
return i > startLen
|
|
}
|
|
if err := s.wait(fmt.Sprintf("running cmd %q", cmd), cond, extraTimeout); err != nil {
|
|
panic(waitPanic{err})
|
|
}
|
|
}
|
|
|
|
func (s *lldbSession) wait(reason string, cond func(out *buf) bool, extraTimeout time.Duration) error {
|
|
doTimeout := 2*time.Second + extraTimeout
|
|
doTimedout := time.After(doTimeout)
|
|
for {
|
|
select {
|
|
case <-s.timedout:
|
|
if p := s.cmd.Process; p != nil {
|
|
p.Kill()
|
|
}
|
|
return fmt.Errorf("test timeout (%s)", reason)
|
|
case <-doTimedout:
|
|
return fmt.Errorf("command timeout (%s for %v)", reason, doTimeout)
|
|
case err := <-s.exited:
|
|
return fmt.Errorf("exited (%s: %v)", reason, err)
|
|
default:
|
|
if cond(s.out) {
|
|
return nil
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
|
|
type buf struct {
|
|
mu sync.Mutex
|
|
buf []byte
|
|
}
|
|
|
|
func (w *buf) Write(in []byte) (n int, err error) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.buf = append(w.buf, in...)
|
|
return len(in), nil
|
|
}
|
|
|
|
func (w *buf) LastIndex(sep []byte) int {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return bytes.LastIndex(w.buf, sep)
|
|
}
|
|
|
|
func (w *buf) Bytes() []byte {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
b := make([]byte, len(w.buf))
|
|
copy(b, w.buf)
|
|
return b
|
|
}
|
|
|
|
func (w *buf) Len() int {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return len(w.buf)
|
|
}
|
|
|
|
type waitPanic struct {
|
|
err error
|
|
}
|
|
|
|
type options struct {
|
|
timeout time.Duration
|
|
lldb bool
|
|
}
|
|
|
|
func parseArgs(binArgs []string) (opts options, remainingArgs []string) {
|
|
var flagArgs []string
|
|
for _, arg := range binArgs {
|
|
if strings.Contains(arg, "-test.timeout") {
|
|
flagArgs = append(flagArgs, arg)
|
|
}
|
|
if strings.Contains(arg, "-lldb") {
|
|
flagArgs = append(flagArgs, arg)
|
|
continue
|
|
}
|
|
remainingArgs = append(remainingArgs, arg)
|
|
}
|
|
f := flag.NewFlagSet("", flag.ContinueOnError)
|
|
f.DurationVar(&opts.timeout, "test.timeout", 10*time.Minute, "")
|
|
f.BoolVar(&opts.lldb, "lldb", false, "")
|
|
f.Parse(flagArgs)
|
|
return opts, remainingArgs
|
|
|
|
}
|
|
|
|
func copyLocalDir(dst, src string) error {
|
|
if err := os.Mkdir(dst, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
d, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer d.Close()
|
|
fi, err := d.Readdir(-1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, f := range fi {
|
|
if f.IsDir() {
|
|
if f.Name() == "testdata" {
|
|
if err := cp(dst, filepath.Join(src, f.Name())); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if err := cp(dst, filepath.Join(src, f.Name())); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cp(dst, src string) error {
|
|
out, err := exec.Command("cp", "-a", src, dst).CombinedOutput()
|
|
if err != nil {
|
|
os.Stderr.Write(out)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func copyLocalData(dstbase string) (pkgpath string, err error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
finalPkgpath, underGoRoot, err := subdir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
cwd = strings.TrimSuffix(cwd, finalPkgpath)
|
|
|
|
// Copy all immediate files and testdata directories between
|
|
// the package being tested and the source root.
|
|
pkgpath = ""
|
|
for _, element := range strings.Split(finalPkgpath, string(filepath.Separator)) {
|
|
if debug {
|
|
log.Printf("copying %s", pkgpath)
|
|
}
|
|
pkgpath = filepath.Join(pkgpath, element)
|
|
dst := filepath.Join(dstbase, pkgpath)
|
|
src := filepath.Join(cwd, pkgpath)
|
|
if err := copyLocalDir(dst, src); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
if underGoRoot {
|
|
// Copy timezone file.
|
|
//
|
|
// Typical apps have the zoneinfo.zip in the root of their app bundle,
|
|
// read by the time package as the working directory at initialization.
|
|
// As we move the working directory to the GOROOT pkg directory, we
|
|
// install the zoneinfo.zip file in the pkgpath.
|
|
err := cp(
|
|
filepath.Join(dstbase, pkgpath),
|
|
filepath.Join(cwd, "lib", "time", "zoneinfo.zip"),
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Copy src/runtime/textflag.h for (at least) Test386EndToEnd in
|
|
// cmd/asm/internal/asm.
|
|
runtimePath := filepath.Join(dstbase, "src", "runtime")
|
|
if err := os.MkdirAll(runtimePath, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
err = cp(
|
|
filepath.Join(runtimePath, "textflag.h"),
|
|
filepath.Join(cwd, "src", "runtime", "textflag.h"),
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
return finalPkgpath, nil
|
|
}
|
|
|
|
// subdir determines the package based on the current working directory,
|
|
// and returns the path to the package source relative to $GOROOT (or $GOPATH).
|
|
func subdir() (pkgpath string, underGoRoot bool, err error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if root := runtime.GOROOT(); strings.HasPrefix(cwd, root) {
|
|
subdir, err := filepath.Rel(root, cwd)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return subdir, true, nil
|
|
}
|
|
|
|
for _, p := range filepath.SplitList(build.Default.GOPATH) {
|
|
if !strings.HasPrefix(cwd, p) {
|
|
continue
|
|
}
|
|
subdir, err := filepath.Rel(p, cwd)
|
|
if err == nil {
|
|
return subdir, false, nil
|
|
}
|
|
}
|
|
return "", false, fmt.Errorf(
|
|
"working directory %q is not in either GOROOT(%q) or GOPATH(%q)",
|
|
cwd,
|
|
runtime.GOROOT(),
|
|
build.Default.GOPATH,
|
|
)
|
|
}
|
|
|
|
func infoPlist(pkgpath string) string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleName</key><string>golang.gotest</string>
|
|
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
|
|
<key>CFBundleExecutable</key><string>gotest</string>
|
|
<key>CFBundleVersion</key><string>1.0</string>
|
|
<key>CFBundleIdentifier</key><string>` + bundleID + `</string>
|
|
<key>CFBundleResourceSpecification</key><string>ResourceRules.plist</string>
|
|
<key>LSRequiresIPhoneOS</key><true/>
|
|
<key>CFBundleDisplayName</key><string>gotest</string>
|
|
<key>GoExecWrapperWorkingDirectory</key><string>` + pkgpath + `</string>
|
|
</dict>
|
|
</plist>
|
|
`
|
|
}
|
|
|
|
func entitlementsPlist() string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>keychain-access-groups</key>
|
|
<array><string>` + appID + `</string></array>
|
|
<key>get-task-allow</key>
|
|
<true/>
|
|
<key>application-identifier</key>
|
|
<string>` + appID + `</string>
|
|
<key>com.apple.developer.team-identifier</key>
|
|
<string>` + teamID + `</string>
|
|
</dict>
|
|
</plist>
|
|
`
|
|
}
|
|
|
|
const resourceRules = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>rules</key>
|
|
<dict>
|
|
<key>.*</key>
|
|
<true/>
|
|
<key>Info.plist</key>
|
|
<dict>
|
|
<key>omit</key>
|
|
<true/>
|
|
<key>weight</key>
|
|
<integer>10</integer>
|
|
</dict>
|
|
<key>ResourceRules.plist</key>
|
|
<dict>
|
|
<key>omit</key>
|
|
<true/>
|
|
<key>weight</key>
|
|
<integer>100</integer>
|
|
</dict>
|
|
</dict>
|
|
</dict>
|
|
</plist>
|
|
`
|