v0.29.0 21b3150

browser_launch_relay.go RawHistoryFolder

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)

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