返回 DeepSeek-Reasonix
launch.go
根目录 / internal / browser / cdp / launch.go
1 package cdp
2
3 import (
4 "bytes"
5 "context"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "sync"
13 "time"
14
15 "reasonix/internal/proc"
16 )
17
18 // activePortFile is where Chrome records the port it actually bound, which is
19 // the only reliable answer when the launcher asks for port 0.
20 const activePortFile = "DevToolsActivePort"
21
22 // launched is one Chrome this package started and therefore must reap.
23 type launched struct {
24 cmd *exec.Cmd
25 handle uintptr
26 dir string
27 owned bool
28
29 once sync.Once
30 }
31
32 // baseArgs keeps a launched Chrome out of the user's profile and away from the
33 // background services that make a first run slow and chatty.
34 func baseArgs(userDataDir string, headless bool) []string {
35 args := []string{
36 "--remote-debugging-port=0",
37 "--user-data-dir=" + userDataDir,
38 "--no-first-run",
39 "--no-default-browser-check",
40 "--disable-background-networking",
41 "--disable-backgrounding-occluded-windows",
42 "--disable-renderer-backgrounding",
43 "--disable-features=Translate,MediaRouter",
44 "--password-store=basic",
45 "--window-size=1280,900",
46 }
47 if runtime.GOOS == "darwin" {
48 args = append(args, "--use-mock-keychain")
49 }
50 if headless {
51 args = append(args, "--headless=new", "--hide-scrollbars")
52 }
53 return args
54 }
55
56 // launchChrome starts a browser and returns it with its DevTools socket URL.
57 func launchChrome(ctx context.Context, opts Options) (*launched, string, error) {
58 bin, err := chromePath(opts.ChromePath)
59 if err != nil {
60 return nil, "", err
61 }
62 dir, owned, err := userDataDir(opts.UserDataDir)
63 if err != nil {
64 return nil, "", err
65 }
66 args := append(baseArgs(dir, opts.Headless), opts.ChromeArgs...)
67 cmd := proc.CommandContext(ctx, bin, append(args, "about:blank")...)
68 var errBuf bytes.Buffer
69 cmd.Stderr = &boundedWriter{buf: &errBuf, limit: 8 << 10}
70 handle, err := proc.StartTracked(cmd)
71 if err != nil {
72 cleanupDir(dir, owned)
73 return nil, "", fmt.Errorf("cdp: start %s: %w", bin, err)
74 }
75 l := &launched{cmd: cmd, handle: handle, dir: dir, owned: owned}
76 wsURL, err := readActivePort(ctx, dir, opts.LaunchTimeout)
77 if err != nil {
78 l.stop()
79 if detail := strings.TrimSpace(errBuf.String()); detail != "" {
80 return nil, "", fmt.Errorf("%w: %s", err, lastLine(detail))
81 }
82 return nil, "", err
83 }
84 return l, wsURL, nil
85 }
86
87 // stop kills the browser tree and drops a user-data directory this package
88 // created. A directory the caller supplied is left alone: it is their profile.
89 func (l *launched) stop() {
90 l.once.Do(func() {
91 proc.KillTracked(l.cmd, l.handle)
92 _ = l.cmd.Wait()
93 proc.FinishTracked(l.handle)
94 cleanupDir(l.dir, l.owned)
95 })
96 }
97
98 func cleanupDir(dir string, owned bool) {
99 if owned && dir != "" {
100 _ = os.RemoveAll(dir)
101 }
102 }
103
104 func userDataDir(configured string) (string, bool, error) {
105 if dir := strings.TrimSpace(configured); dir != "" {
106 if err := os.MkdirAll(dir, 0o700); err != nil {
107 return "", false, fmt.Errorf("cdp: user data dir %s: %w", dir, err)
108 }
109 return dir, false, nil
110 }
111 dir, err := os.MkdirTemp("", "reasonix-chrome-")
112 if err != nil {
113 return "", false, fmt.Errorf("cdp: temporary user data dir: %w", err)
114 }
115 return dir, true, nil
116 }
117
118 // readActivePort waits for Chrome to publish its bound port and socket path.
119 func readActivePort(ctx context.Context, dir string, timeout time.Duration) (string, error) {
120 if timeout <= 0 {
121 timeout = 30 * time.Second
122 }
123 deadline, cancel := context.WithTimeout(ctx, timeout)
124 defer cancel()
125 path := filepath.Join(dir, activePortFile)
126 for {
127 data, err := os.ReadFile(path)
128 if err == nil {
129 lines := strings.Split(strings.TrimSpace(string(data)), "\n")
130 if len(lines) >= 2 && strings.TrimSpace(lines[0]) != "" {
131 return fmt.Sprintf("ws://127.0.0.1:%s%s", strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1])), nil
132 }
133 }
134 select {
135 case <-deadline.Done():
136 return "", fmt.Errorf("cdp: browser did not write %s within %s", activePortFile, timeout)
137 case <-time.After(50 * time.Millisecond):
138 }
139 }
140 }
141
142 // chromePath resolves the browser binary: the configured path first, then the
143 // usual install locations for Chrome, Chromium, and Chromium-based Edge.
144 func chromePath(configured string) (string, error) {
145 if p := strings.TrimSpace(configured); p != "" {
146 if _, err := os.Stat(p); err != nil {
147 return "", fmt.Errorf("cdp: chrome_path %s: %w", p, err)
148 }
149 return p, nil
150 }
151 for _, env := range []string{"REASONIX_CHROME", "CHROME_PATH"} {
152 if p := strings.TrimSpace(os.Getenv(env)); p != "" {
153 if _, err := os.Stat(p); err == nil {
154 return p, nil
155 }
156 }
157 }
158 for _, candidate := range chromeCandidates() {
159 if candidate == "" {
160 continue
161 }
162 if strings.ContainsRune(candidate, os.PathSeparator) {
163 if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
164 return candidate, nil
165 }
166 continue
167 }
168 if p, err := exec.LookPath(candidate); err == nil {
169 return p, nil
170 }
171 }
172 return "", fmt.Errorf("cdp: no Chrome, Chromium, or Edge binary found; set browser.chrome_path or REASONIX_CHROME")
173 }
174
175 func chromeCandidates() []string {
176 switch runtime.GOOS {
177 case "darwin":
178 home, _ := os.UserHomeDir()
179 return []string{
180 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
181 filepath.Join(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
182 "/Applications/Chromium.app/Contents/MacOS/Chromium",
183 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
184 }
185 case "windows":
186 var out []string
187 for _, env := range []string{"ProgramFiles", "ProgramFiles(x86)", "LocalAppData"} {
188 root := os.Getenv(env)
189 if root == "" {
190 continue
191 }
192 out = append(out,
193 filepath.Join(root, `Google\Chrome\Application\chrome.exe`),
194 filepath.Join(root, `Microsoft\Edge\Application\msedge.exe`))
195 }
196 return out
197 default:
198 return []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"}
199 }
200 }
201
202 // boundedWriter keeps only the first limit bytes of a child's stderr so a
203 // noisy browser cannot grow the parent's memory.
204 type boundedWriter struct {
205 buf *bytes.Buffer
206 limit int
207 }
208
209 func (w *boundedWriter) Write(p []byte) (int, error) {
210 if room := w.limit - w.buf.Len(); room > 0 {
211 if len(p) < room {
212 room = len(p)
213 }
214 w.buf.Write(p[:room])
215 }
216 return len(p), nil
217 }
218
219 func lastLine(s string) string {
220 lines := strings.Split(strings.TrimSpace(s), "\n")
221 return strings.TrimSpace(lines[len(lines)-1])
222 }
223
223 lines GO