v0.31.0 bf0756b

browser_launch_relay.go RawHistoryFolder

1package desktop
2
3import (
4	"context"
5	"crypto/rand"
6	"encoding/hex"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"io"
11	"net"
12	neturl "net/url"
13	"os"
14	"path/filepath"
15	"sync"
16	"syscall"
17	"time"
18
19	"github.com/bnema/dumber/internal/application/port"
20	"github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
21	"github.com/bnema/dumber/internal/logging"
22)
23
24const browserLaunchSocketName = "browser-launch.sock"
25
26// Local browser-launch handoff needs enough headroom for a busy browser
27// process to accept and acknowledge the request before callers classify the
28// handoff as ambiguous.
29const browserLaunchIOTimeout = 250 * time.Millisecond
30
31const browserLaunchDirPerm = 0o700
32
33// ErrBrowserLaunchRelayUnconfirmed reports that the relay accepted a launch
34// request but the caller did not receive a confirmation response in time.
35var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery")
36
37type browserLaunchRelay struct {
38	ipc runtimeprofile.IPCPaths
39}
40
41type browserLaunchRequest struct {
42	RequestID string              `json:"request_id,omitempty"`
43	URL       string              `json:"url"`
44	Action    browserLaunchAction `json:"action,omitempty"`
45}
46
47type browserLaunchAction string
48
49const (
50	browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"
51	browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"
52)
53
54type browserLaunchResponse struct {
55	RequestID string `json:"request_id,omitempty"`
56	Accepted  bool   `json:"accepted,omitempty"`
57	Error     string `json:"error,omitempty"`
58}
59
60type browserLaunchRelayListener struct {
61	listener   *net.UnixListener
62	socketPath string
63	once       sync.Once
64	err        error
65}
66
67var newBrowserLaunchRequestID = func() string {
68	var buf [8]byte
69	if _, err := rand.Read(buf[:]); err == nil {
70		return "blr-" + hex.EncodeToString(buf[:])
71	}
72	return fmt.Sprintf("blr-%d", time.Now().UnixNano())
73}
74
75func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
76	return &browserLaunchRelay{ipc: ipc}
77}
78
79func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
80	return r.deliver(ctx, url, "")
81}
82
83func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
84	return r.deliver(ctx, url, browserLaunchActionOpenFreshWindow)
85}
86
87func (r *browserLaunchRelay) deliver(ctx context.Context, url string, action browserLaunchAction) (bool, error) {
88	socketPath, err := r.socketPath()
89	if err != nil {
90		return false, err
91	}
92
93	conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
94	if err != nil {
95		if isMissingRelayListener(err) {
96			return false, nil
97		}
98		return false, err
99	}
100	defer func() { _ = conn.Close() }()
101
102	requestID := newBrowserLaunchRequestID()
103	log := logging.FromContext(ctx)
104	log.Debug().
105		Str("request_id", requestID).
106		Str("url_host", safeURLHost(url)).
107		Msg("browser launch relay delivery started")
108
109	if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
110		return false, err
111	}
112	if err := json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: requestID, URL: url, Action: action}); err != nil {
113		return false, err
114	}
115
116	if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
117		return false, err
118	}
119
120	var response browserLaunchResponse
121	decoder := json.NewDecoder(conn)
122	for {
123		if decodeErr := decoder.Decode(&response); decodeErr != nil {
124			if isBrowserLaunchReadTimeout(decodeErr) {
125				if ctxErr := ctx.Err(); ctxErr != nil {
126					return false, ctxErr
127				}
128				if _, ok := ctx.Deadline(); !ok {
129					log.Warn().
130						Str("request_id", requestID).
131						Str("url_host", safeURLHost(url)).
132						Dur("timeout", browserLaunchIOTimeout).
133						Msg("browser launch relay response timed out without caller deadline; delivery is unconfirmed")
134					return true, ErrBrowserLaunchRelayUnconfirmed
135				}
136				if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
137					return false, deadlineErr
138				}
139				continue
140			}
141			return false, decodeErr
142		}
143		break
144	}
145	if response.RequestID != "" && response.RequestID != requestID {
146		return true, fmt.Errorf("mismatched browser launch relay response request id: got %q, want %q", response.RequestID, requestID)
147	}
148	if response.Error != "" {
149		log.Warn().
150			Str("request_id", requestID).
151			Str("url_host", safeURLHost(url)).
152			Str("relay_error", response.Error).
153			Msg("browser launch relay delivery rejected")
154		return true, errors.New(response.Error)
155	}
156
157	log.Debug().
158		Str("request_id", requestID).
159		Str("url_host", safeURLHost(url)).
160		Bool("accepted", response.Accepted || response.RequestID == "").
161		Msg("browser launch relay delivery acknowledged")
162	return true, nil
163}
164
165func isMissingRelayListener(err error) bool {
166	return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
167}
168
169func safeURLHost(raw string) string {
170	parsed, err := neturl.Parse(raw)
171	if err != nil {
172		return ""
173	}
174	return parsed.Host
175}
176
177func isBrowserLaunchReadTimeout(err error) bool {
178	var netErr net.Error
179	return errors.As(err, &netErr) && netErr.Timeout()
180}
181
182func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) {
183	conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout)
184	if err == nil {
185		_ = conn.Close()
186		return true, nil
187	}
188
189	if isMissingRelayListener(err) {
190		return false, nil
191	}
192
193	return false, fmt.Errorf("check browser launch socket: %w", err)
194}
195
196func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
197	deadline := time.Now().Add(browserLaunchIOTimeout)
198	if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
199		deadline = ctxDeadline
200	}
201
202	return conn.SetDeadline(deadline)
203}
204
205func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) error {
206	socketDir := filepath.Dir(socketPath)
207	for dir := socketDir; ; dir = filepath.Dir(dir) {
208		info, err := os.Stat(dir)
209		if err != nil {
210			return fmt.Errorf("stat browser launch dir: %w", err)
211		}
212		stat, ok := info.Sys().(*syscall.Stat_t)
213		if !ok {
214			return nil
215		}
216
217		isSocketDir := dir == socketDir
218		if stat.Uid != expectedUID {
219			if isSocketDir || stat.Uid != 0 {
220				return fmt.Errorf("browser launch dir owner mismatch: %s owned by uid %d, want uid %d", dir, stat.Uid, expectedUID)
221			}
222		}
223		if info.Mode().Perm()&0o022 != 0 && (isSocketDir || info.Mode()&os.ModeSticky == 0) {
224			return fmt.Errorf("browser launch dir permissions too broad: %s has mode %04o", dir, info.Mode().Perm())
225		}
226		if parent := filepath.Dir(dir); parent == dir {
227			return nil
228		}
229	}
230}
231
232func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
233	socketPath, err := r.socketPath()
234	if err != nil {
235		return nil, err
236	}
237
238	if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
239		return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
240	}
241	if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
242		return nil, ownerErr
243	}
244	listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
245	if err != nil {
246		if !errors.Is(err, syscall.EADDRINUSE) {
247			return nil, fmt.Errorf("listen browser launch socket: %w", err)
248		}
249
250		live, liveErr := browserLaunchSocketHasLiveListener(socketPath)
251		if liveErr != nil {
252			return nil, liveErr
253		}
254		if live {
255			return nil, errors.New("browser launch relay already running")
256		}
257
258		if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil {
259			return nil, ownerErr
260		}
261
262		if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
263			return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
264		}
265
266		listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
267		if err != nil {
268			return nil, fmt.Errorf("listen browser launch socket: %w", err)
269		}
270	}
271
272	relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
273	go relayListener.serve(ctx, opener)
274
275	return relayListener, nil
276}
277
278func (r *browserLaunchRelay) socketPath() (string, error) {
279	if r == nil {
280		return "", errors.New("browser launch relay missing IPC paths")
281	}
282	if r.ipc.BrowserLaunchSocket == "" {
283		return "", errors.New("browser launch relay missing browser launch socket path")
284	}
285	return r.ipc.BrowserLaunchSocket, nil
286}
287
288func (l *browserLaunchRelayListener) Close() error {
289	l.once.Do(func() {
290		if l.listener != nil {
291			l.err = l.listener.Close()
292		}
293		_ = os.Remove(l.socketPath)
294	})
295
296	return l.err
297}
298
299func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) {
300	defer func() { _ = l.Close() }()
301
302	for {
303		if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
304			return
305		}
306		conn, err := l.listener.AcceptUnix()
307		if err != nil {
308			if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
309				return
310			}
311			var netErr net.Error
312			if errors.As(err, &netErr) && netErr.Timeout() {
313				if ctx.Err() != nil {
314					return
315				}
316				continue
317			}
318			continue
319		}
320
321		go l.handleConnection(ctx, conn, opener)
322	}
323}
324
325func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
326	defer func() { _ = conn.Close() }()
327	log := logging.FromContext(ctx)
328	if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
329		return
330	}
331
332	var request browserLaunchRequest
333	if err := json.NewDecoder(conn).Decode(&request); err != nil {
334		return
335	}
336	requestID := request.RequestID
337	if requestID == "" {
338		requestID = newBrowserLaunchRequestID()
339	}
340	log.Debug().
341		Str("request_id", requestID).
342		Str("url_host", safeURLHost(request.URL)).
343		Msg("browser launch relay request received")
344
345	if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
346		return
347	}
348	if err := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}); err != nil {
349		log.Warn().Err(err).
350			Str("request_id", requestID).
351			Str("url_host", safeURLHost(request.URL)).
352			Msg("failed to encode browser launch response")
353		return
354	}
355	log.Debug().
356		Str("request_id", requestID).
357		Str("url_host", safeURLHost(request.URL)).
358		Msg("browser launch relay request accepted")
359
360	go func() {
361		if opener == nil {
362			log.Warn().
363				Str("request_id", requestID).
364				Str("url_host", safeURLHost(request.URL)).
365				Msg("browser launch relay accepted request without opener")
366			return
367		}
368		started := time.Now()
369		log.Debug().
370			Str("request_id", requestID).
371			Str("url_host", safeURLHost(request.URL)).
372			Msg("browser launch relay calling browser URL opener")
373		var err error
374		switch request.Action {
375		case "", browserLaunchActionOpenExternalURL:
376			err = opener.OpenExternalURL(ctx, request.URL)
377		case browserLaunchActionOpenFreshWindow:
378			err = opener.OpenFreshWindow(ctx, request.URL)
379		default:
380			log.Warn().
381				Str("request_id", requestID).
382				Str("url_host", safeURLHost(request.URL)).
383				Str("action", string(request.Action)).
384				Msg("browser launch relay rejected unknown action")
385			return
386		}
387		if err != nil {
388			log.Warn().Err(err).
389				Str("request_id", requestID).
390				Str("url_host", safeURLHost(request.URL)).
391				Dur("elapsed", time.Since(started)).
392				Msg("browser launch relay browser URL opener failed")
393			return
394		}
395		log.Debug().
396			Str("request_id", requestID).
397			Str("url_host", safeURLHost(request.URL)).
398			Dur("elapsed", time.Since(started)).
399			Msg("browser launch relay browser URL opener returned success")
400	}()
401}
402
403var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close