v0.30.0 eb3068f

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