1// Copyright 2024 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_ios_$GOARCH_exec by the Go tool. It executes
6// binaries on the iOS Simulator using the XCode toolchain.
7package main
8
9import (
10	"fmt"
11	"go/build"
12	"log"
13	"os"
14	"os/exec"
15	"path/filepath"
16	"runtime"
17	"strings"
18	"syscall"
19)
20
21const debug = false
22
23var tmpdir string
24
25var (
26	devID    string
27	appID    string
28	teamID   string
29	bundleID string
30	deviceID string
31)
32
33// lock is a file lock to serialize iOS runs. It is global to avoid the
34// garbage collector finalizing it, closing the file and releasing the
35// lock prematurely.
36var lock *os.File
37
38func main() {
39	log.SetFlags(0)
40	log.SetPrefix("go_ios_exec: ")
41	if debug {
42		log.Println(strings.Join(os.Args, " "))
43	}
44	if len(os.Args) < 2 {
45		log.Fatal("usage: go_ios_exec a.out")
46	}
47
48	// For compatibility with the old builders, use a fallback bundle ID
49	bundleID = "golang.gotest"
50
51	exitCode, err := runMain()
52	if err != nil {
53		log.Fatalf("%v\n", err)
54	}
55	os.Exit(exitCode)
56}
57
58func runMain() (int, error) {
59	var err error
60	tmpdir, err = os.MkdirTemp("", "go_ios_exec_")
61	if err != nil {
62		return 1, err
63	}
64	if !debug {
65		defer os.RemoveAll(tmpdir)
66	}
67
68	appdir := filepath.Join(tmpdir, "gotest.app")
69	os.RemoveAll(appdir)
70
71	if err := assembleApp(appdir, os.Args[1]); err != nil {
72		return 1, err
73	}
74
75	// This wrapper uses complicated machinery to run iOS binaries. It
76	// works, but only when running one binary at a time.
77	// Use a file lock to make sure only one wrapper is running at a time.
78	//
79	// The lock file is never deleted, to avoid concurrent locks on distinct
80	// files with the same path.
81	lockName := filepath.Join(os.TempDir(), "go_ios_exec-"+deviceID+".lock")
82	lock, err = os.OpenFile(lockName, os.O_CREATE|os.O_RDONLY, 0666)
83	if err != nil {
84		return 1, err
85	}
86	if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
87		return 1, err
88	}
89
90	err = runOnSimulator(appdir)
91	if err != nil {
92		return 1, err
93	}
94	return 0, nil
95}
96
97func runOnSimulator(appdir string) error {
98	if err := installSimulator(appdir); err != nil {
99		return err
100	}
101
102	return runSimulator(appdir, bundleID, os.Args[2:])
103}
104
105func assembleApp(appdir, bin string) error {
106	if err := os.MkdirAll(appdir, 0755); err != nil {
107		return err
108	}
109
110	if err := cp(filepath.Join(appdir, "gotest"), bin); err != nil {
111		return err
112	}
113
114	pkgpath, err := copyLocalData(appdir)
115	if err != nil {
116		return err
117	}
118
119	entitlementsPath := filepath.Join(tmpdir, "Entitlements.plist")
120	if err := os.WriteFile(entitlementsPath, []byte(entitlementsPlist()), 0744); err != nil {
121		return err
122	}
123	if err := os.WriteFile(filepath.Join(appdir, "Info.plist"), []byte(infoPlist(pkgpath)), 0744); err != nil {
124		return err
125	}
126	if err := os.WriteFile(filepath.Join(appdir, "ResourceRules.plist"), []byte(resourceRules), 0744); err != nil {
127		return err
128	}
129	return nil
130}
131
132func installSimulator(appdir string) error {
133	cmd := exec.Command(
134		"xcrun", "simctl", "install",
135		"booted", // Install to the booted simulator.
136		appdir,
137	)
138	if out, err := cmd.CombinedOutput(); err != nil {
139		os.Stderr.Write(out)
140		return fmt.Errorf("xcrun simctl install booted %q: %v", appdir, err)
141	}
142	return nil
143}
144
145func runSimulator(appdir, bundleID string, args []string) error {
146	xcrunArgs := []string{"simctl", "spawn",
147		"booted",
148		appdir + "/gotest",
149	}
150	xcrunArgs = append(xcrunArgs, args...)
151	cmd := exec.Command("xcrun", xcrunArgs...)
152	cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
153	err := cmd.Run()
154	if err != nil {
155		return fmt.Errorf("xcrun simctl launch booted %q: %v", bundleID, err)
156	}
157
158	return nil
159}
160
161func copyLocalDir(dst, src string) error {
162	if err := os.Mkdir(dst, 0755); err != nil {
163		return err
164	}
165
166	d, err := os.Open(src)
167	if err != nil {
168		return err
169	}
170	defer d.Close()
171	fi, err := d.Readdir(-1)
172	if err != nil {
173		return err
174	}
175
176	for _, f := range fi {
177		if f.IsDir() {
178			if f.Name() == "testdata" {
179				if err := cp(dst, filepath.Join(src, f.Name())); err != nil {
180					return err
181				}
182			}
183			continue
184		}
185		if err := cp(dst, filepath.Join(src, f.Name())); err != nil {
186			return err
187		}
188	}
189	return nil
190}
191
192func cp(dst, src string) error {
193	out, err := exec.Command("cp", "-a", src, dst).CombinedOutput()
194	if err != nil {
195		os.Stderr.Write(out)
196	}
197	return err
198}
199
200func copyLocalData(dstbase string) (pkgpath string, err error) {
201	cwd, err := os.Getwd()
202	if err != nil {
203		return "", err
204	}
205
206	finalPkgpath, underGoRoot, err := subdir()
207	if err != nil {
208		return "", err
209	}
210	cwd = strings.TrimSuffix(cwd, finalPkgpath)
211
212	// Copy all immediate files and testdata directories between
213	// the package being tested and the source root.
214	pkgpath = ""
215	for _, element := range strings.Split(finalPkgpath, string(filepath.Separator)) {
216		if debug {
217			log.Printf("copying %s", pkgpath)
218		}
219		pkgpath = filepath.Join(pkgpath, element)
220		dst := filepath.Join(dstbase, pkgpath)
221		src := filepath.Join(cwd, pkgpath)
222		if err := copyLocalDir(dst, src); err != nil {
223			return "", err
224		}
225	}
226
227	if underGoRoot {
228		// Copy timezone file.
229		//
230		// Typical apps have the zoneinfo.zip in the root of their app bundle,
231		// read by the time package as the working directory at initialization.
232		// As we move the working directory to the GOROOT pkg directory, we
233		// install the zoneinfo.zip file in the pkgpath.
234		err := cp(
235			filepath.Join(dstbase, pkgpath),
236			filepath.Join(cwd, "lib", "time", "zoneinfo.zip"),
237		)
238		if err != nil {
239			return "", err
240		}
241		// Copy src/runtime/textflag.h for (at least) Test386EndToEnd in
242		// cmd/asm/internal/asm.
243		runtimePath := filepath.Join(dstbase, "src", "runtime")
244		if err := os.MkdirAll(runtimePath, 0755); err != nil {
245			return "", err
246		}
247		err = cp(
248			filepath.Join(runtimePath, "textflag.h"),
249			filepath.Join(cwd, "src", "runtime", "textflag.h"),
250		)
251		if err != nil {
252			return "", err
253		}
254	}
255
256	return finalPkgpath, nil
257}
258
259// subdir determines the package based on the current working directory,
260// and returns the path to the package source relative to $GOROOT (or $GOPATH).
261func subdir() (pkgpath string, underGoRoot bool, err error) {
262	cwd, err := os.Getwd()
263	if err != nil {
264		return "", false, err
265	}
266	cwd, err = filepath.EvalSymlinks(cwd)
267	if err != nil {
268		log.Fatal(err)
269	}
270	goroot, err := filepath.EvalSymlinks(runtime.GOROOT())
271	if err != nil {
272		return "", false, err
273	}
274	if strings.HasPrefix(cwd, goroot) {
275		subdir, err := filepath.Rel(goroot, cwd)
276		if err != nil {
277			return "", false, err
278		}
279		return subdir, true, nil
280	}
281
282	for _, p := range filepath.SplitList(build.Default.GOPATH) {
283		pabs, err := filepath.EvalSymlinks(p)
284		if err != nil {
285			return "", false, err
286		}
287		if !strings.HasPrefix(cwd, pabs) {
288			continue
289		}
290		subdir, err := filepath.Rel(pabs, cwd)
291		if err == nil {
292			return subdir, false, nil
293		}
294	}
295	return "", false, fmt.Errorf(
296		"working directory %q is not in either GOROOT(%q) or GOPATH(%q)",
297		cwd,
298		runtime.GOROOT(),
299		build.Default.GOPATH,
300	)
301}
302
303func infoPlist(pkgpath string) string {
304	return `<?xml version="1.0" encoding="UTF-8"?>
305<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
306<plist version="1.0">
307<dict>
308<key>CFBundleName</key><string>golang.gotest</string>
309<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
310<key>CFBundleExecutable</key><string>gotest</string>
311<key>CFBundleVersion</key><string>1.0</string>
312<key>CFBundleShortVersionString</key><string>1.0</string>
313<key>CFBundleIdentifier</key><string>` + bundleID + `</string>
314<key>CFBundleResourceSpecification</key><string>ResourceRules.plist</string>
315<key>LSRequiresIPhoneOS</key><true/>
316<key>CFBundleDisplayName</key><string>gotest</string>
317<key>GoExecWrapperWorkingDirectory</key><string>` + pkgpath + `</string>
318</dict>
319</plist>
320`
321}
322
323func entitlementsPlist() string {
324	return `<?xml version="1.0" encoding="UTF-8"?>
325<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
326<plist version="1.0">
327<dict>
328	<key>keychain-access-groups</key>
329	<array><string>` + appID + `</string></array>
330	<key>get-task-allow</key>
331	<true/>
332	<key>application-identifier</key>
333	<string>` + appID + `</string>
334	<key>com.apple.developer.team-identifier</key>
335	<string>` + teamID + `</string>
336</dict>
337</plist>
338`
339}
340
341const resourceRules = `<?xml version="1.0" encoding="UTF-8"?>
342<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
343<plist version="1.0">
344<dict>
345	<key>rules</key>
346	<dict>
347		<key>.*</key>
348		<true/>
349		<key>Info.plist</key>
350		<dict>
351			<key>omit</key>
352			<true/>
353			<key>weight</key>
354			<integer>10</integer>
355		</dict>
356		<key>ResourceRules.plist</key>
357		<dict>
358			<key>omit</key>
359			<true/>
360			<key>weight</key>
361			<integer>100</integer>
362		</dict>
363	</dict>
364</dict>
365</plist>
366`
367