1package desktop
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 neturl "net/url"
11 "os"
12 "path/filepath"
13 "sync"
14 "syscall"
15 "time"
16
17 "github.com/bnema/dumber/internal/application/port"
18 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
19 "github.com/bnema/dumber/internal/logging"
20)
21
22const browserLaunchSocketName = "browser-launch.sock"
23
24// Local browser-launch handoff needs enough headroom for a busy browser
25// process to accept and acknowledge the request before callers classify the
26// handoff as ambiguous.
27const browserLaunchIOTimeout = 250 * time.Millisecond
28
29const browserLaunchDirPerm = 0o700
30
31// ErrBrowserLaunchRelayUnconfirmed reports that the relay accepted a launch
32// request but the caller did not receive a confirmation response in time.
33var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery")
34
35type browserLaunchRelay struct {
36 ipc runtimeprofile.IPCPaths
37}
38
39type browserLaunchRequest struct {
40 URL string `json:"url"`
41}
42
43type browserLaunchResponse struct {
44 Error string `json:"error,omitempty"`
45}
46
47type browserLaunchRelayListener struct {
48 listener *net.UnixListener
49 socketPath string
50 once sync.Once
51 err error
52}
53
54func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
55 return &browserLaunchRelay{ipc: ipc}
56}
57
58func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
59 socketPath, err := r.socketPath()
60 if err != nil {
61 return false, err
62 }
63
64 conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
65 if err != nil {
66 if isMissingRelayListener(err) {
67 return false, nil
68 }
69 return false, err
70 }
71 defer func() { _ = conn.Close() }()
72
73 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
74 return false, err
75 }
76 if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil {
77 return false, err
78 }
79
80 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
81 return false, err
82 }
83
84 var response browserLaunchResponse
85 decoder := json.NewDecoder(conn)
86 for {
87 if decodeErr := decoder.Decode(&response); decodeErr != nil {
88 if isBrowserLaunchReadTimeout(decodeErr) {
89 if ctxErr := ctx.Err(); ctxErr != nil {
90 return false, ctxErr
91 }
92 if _, ok := ctx.Deadline(); !ok {
93 logging.FromContext(ctx).Warn().
94 Str("url_host", safeURLHost(url)).
95 Dur("timeout", browserLaunchIOTimeout).
96 Msg("browser launch relay response timed out without caller deadline; delivery is unconfirmed")
97 return true, ErrBrowserLaunchRelayUnconfirmed
98 }
99 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
100 return false, deadlineErr
101 }
102 continue
103 }
104 return false, decodeErr
105 }
106 break
107 }
108 if response.Error != "" {
109 return true, errors.New(response.Error)
110 }
111
112 return true, nil
113}
114
115func isMissingRelayListener(err error) bool {
116 return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
117}
118
119func safeURLHost(raw string) string {
120 parsed, err := neturl.Parse(raw)
121 if err != nil {
122 return ""
123 }
124 return parsed.Host
125}
126
127func isBrowserLaunchReadTimeout(err error) bool {
128 var netErr net.Error
129 return errors.As(err, &netErr) && netErr.Timeout()
130}
131
132func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) {
133 conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout)
134 if err == nil {
135 _ = conn.Close()
136 return true, nil
137 }
138
139 if isMissingRelayListener(err) {
140 return false, nil
141 }
142
143 return false, fmt.Errorf("check browser launch socket: %w", err)
144}
145
146func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
147 deadline := time.Now().Add(browserLaunchIOTimeout)
148 if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
149 deadline = ctxDeadline
150 }
151
152 return conn.SetDeadline(deadline)
153}
154
155func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
156 socketPath, err := r.socketPath()
157 if err != nil {
158 return nil, err
159 }
160
161 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
162 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
163 }
164 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
165 if err != nil {
166 if !errors.Is(err, syscall.EADDRINUSE) {
167 return nil, fmt.Errorf("listen browser launch socket: %w", err)
168 }
169
170 live, liveErr := browserLaunchSocketHasLiveListener(socketPath)
171 if liveErr != nil {
172 return nil, liveErr
173 }
174 if live {
175 return nil, errors.New("browser launch relay already running")
176 }
177
178 if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
179 return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
180 }
181
182 listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
183 if err != nil {
184 return nil, fmt.Errorf("listen browser launch socket: %w", err)
185 }
186 }
187
188 relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
189 go relayListener.serve(ctx, opener)
190
191 return relayListener, nil
192}
193
194func (r *browserLaunchRelay) socketPath() (string, error) {
195 if r == nil {
196 return "", errors.New("browser launch relay missing IPC paths")
197 }
198 if r.ipc.BrowserLaunchSocket == "" {
199 return "", errors.New("browser launch relay missing browser launch socket path")
200 }
201 return r.ipc.BrowserLaunchSocket, nil
202}
203
204func (l *browserLaunchRelayListener) Close() error {
205 l.once.Do(func() {
206 if l.listener != nil {
207 l.err = l.listener.Close()
208 }
209 _ = os.Remove(l.socketPath)
210 })
211
212 return l.err
213}
214
215func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) {
216 defer func() { _ = l.Close() }()
217
218 for {
219 if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
220 return
221 }
222 conn, err := l.listener.AcceptUnix()
223 if err != nil {
224 if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
225 return
226 }
227 var netErr net.Error
228 if errors.As(err, &netErr) && netErr.Timeout() {
229 if ctx.Err() != nil {
230 return
231 }
232 continue
233 }
234 continue
235 }
236
237 go l.handleConnection(ctx, conn, opener)
238 }
239}
240
241func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
242 defer func() { _ = conn.Close() }()
243 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
244 return
245 }
246
247 var request browserLaunchRequest
248 if err := json.NewDecoder(conn).Decode(&request); err != nil {
249 return
250 }
251
252 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
253 return
254 }
255 if err := json.NewEncoder(conn).Encode(browserLaunchResponse{}); err != nil {
256 log := logging.FromContext(ctx)
257 log.Warn().Err(err).Str("url", request.URL).Msg("failed to encode browser launch response")
258 return
259 }
260
261 go func() {
262 if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
263 logging.FromContext(ctx).Warn().Err(err).Str("url", request.URL).Msg("failed to open fresh browser window")
264 }
265 }()
266}
267
268var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)