1package cef
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "sync"
12 "time"
13
14 cef2gtk "github.com/bnema/purego-cef2gtk"
15
16 "github.com/bnema/dumber/internal/logging"
17)
18
19const (
20 cef2gtkProfileEnv = "DUMBER_CEF2GTK_PROFILE"
21 cef2gtkProfileIntervalEnv = "DUMBER_CEF2GTK_PROFILE_INTERVAL"
22 cef2gtkProfileOutputEnv = "DUMBER_CEF2GTK_PROFILE_OUTPUT"
23)
24
25var errProfileWriterClosed = errors.New("cef2gtk profile writer is closed")
26
27type lockedProfileWriter struct {
28 mu sync.Mutex
29 file *os.File
30}
31
32func (w *lockedProfileWriter) Write(p []byte) (int, error) {
33 if w == nil {
34 return 0, errProfileWriterClosed
35 }
36 w.mu.Lock()
37 defer w.mu.Unlock()
38 if w.file == nil {
39 return 0, errProfileWriterClosed
40 }
41 return w.file.Write(p)
42}
43
44func (w *lockedProfileWriter) Close() error {
45 if w == nil {
46 return nil
47 }
48 w.mu.Lock()
49 defer w.mu.Unlock()
50 if w.file == nil {
51 return nil
52 }
53 err := w.file.Close()
54 w.file = nil
55 return err
56}
57
58func cef2gtkProfileEnabled() bool {
59 v := strings.TrimSpace(strings.ToLower(os.Getenv(cef2gtkProfileEnv)))
60 if v == "" {
61 return false
62 }
63 if b, err := strconv.ParseBool(v); err == nil {
64 return b
65 }
66 switch v {
67 case "1", "yes", "on", "enabled":
68 return true
69 default:
70 return false
71 }
72}
73
74func cef2gtkProfileInterval() time.Duration {
75 v := strings.TrimSpace(os.Getenv(cef2gtkProfileIntervalEnv))
76 if v == "" {
77 return time.Second
78 }
79 d, err := time.ParseDuration(v)
80 if err != nil || d <= 0 {
81 return time.Second
82 }
83 return d
84}
85
86type cef2gtkProfileRecord struct {
87 WebViewID uint64 `json:"webview_id"`
88 Snapshot cef2gtk.ProfileSnapshot `json:"snapshot"`
89}
90
91const (
92 profileLogDirPerm = 0o755
93 profileLogFilePerm = 0o644
94)
95
96func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions {
97 if e == nil || wv == nil || !cef2gtkProfileEnabled() {
98 return cef2gtk.ProfileOptions{}
99 }
100 path := e.cef2gtkProfilePath()
101 if path == "" {
102 return cef2gtk.ProfileOptions{}
103 }
104 if err := os.MkdirAll(filepath.Dir(path), profileLogDirPerm); err != nil {
105 logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to create profile log directory")
106 return cef2gtk.ProfileOptions{}
107 }
108 file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, profileLogFilePerm)
109 if err != nil {
110 logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to open profile log")
111 return cef2gtk.ProfileOptions{}
112 }
113 writer := &lockedProfileWriter{file: file}
114 wv.profileCleanup = func() { _ = writer.Close() }
115 logging.FromContext(e.ctx).Info().Str("profile_log", path).Uint64("webview_id", uint64(wv.id)).Msg("cef2gtk profiling enabled")
116 return cef2gtk.ProfileOptions{
117 Enabled: true,
118 Interval: cef2gtkProfileInterval(),
119 OnSnapshot: func(s cef2gtk.ProfileSnapshot) {
120 wv.recordCEF2GTKProfileSnapshot(s)
121 record := cef2gtkProfileRecord{WebViewID: uint64(wv.id), Snapshot: s}
122 if b, err := json.Marshal(record); err == nil {
123 _, _ = writer.Write(append(b, '\n'))
124 }
125 logging.FromContext(e.ctx).Debug().
126 Uint64("webview_id", uint64(wv.id)).
127 Uint64("frames_received", s.FramesReceived).
128 Uint64("frames_rendered", s.FramesRendered).
129 Uint32("gc_delta", s.GC.NumGCDelta).
130 Msg("cef2gtk profile snapshot")
131 },
132 }
133}
134
135func (wv *WebView) recordCEF2GTKProfileSnapshot(snapshot cef2gtk.ProfileSnapshot) {
136 if wv == nil {
137 return
138 }
139 wv.profileMu.Lock()
140 wv.previousProfileSnapshot = wv.latestProfileSnapshot
141 wv.latestProfileSnapshot = snapshot
142 wv.latestProfileSnapshotAt = snapshot.Time
143 wv.profileMu.Unlock()
144}
145
146func (wv *WebView) latestCEF2GTKProfileSnapshot(now time.Time) (cef2gtk.ProfileSnapshot, time.Duration, bool) {
147 if wv == nil {
148 return cef2gtk.ProfileSnapshot{}, 0, false
149 }
150 wv.profileMu.Lock()
151 snapshot := wv.latestProfileSnapshot
152 snapshotAt := wv.latestProfileSnapshotAt
153 wv.profileMu.Unlock()
154 if snapshotAt.IsZero() {
155 return cef2gtk.ProfileSnapshot{}, 0, false
156 }
157 age := max(now.Sub(snapshotAt), 0)
158 return snapshot, age, true
159}
160
161func (e *Engine) cef2gtkProfilePath() string {
162 if explicit := strings.TrimSpace(os.Getenv(cef2gtkProfileOutputEnv)); explicit != "" {
163 return explicit
164 }
165 metadata, ok := logging.SessionMetadataFromContext(e.ctx)
166 if !ok || metadata.ID == "" {
167 if e.profileLogDir == "" {
168 return ""
169 }
170 return filepath.Join(e.profileLogDir, "cef2gtk_profile.jsonl")
171 }
172 logDir := e.profileLogDir
173 if metadata.LogPath != "" {
174 logDir = filepath.Dir(metadata.LogPath)
175 }
176 if logDir == "" {
177 return ""
178 }
179 return filepath.Join(logDir, fmt.Sprintf("session_%s_cef2gtk_profile.jsonl", metadata.ID))
180}