1package cef
2
3import (
4 "context"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "unsafe"
11
12 purecef "github.com/bnema/purego-cef/cef"
13 "github.com/bnema/puregotk/v4/gio"
14 "github.com/bnema/puregotk/v4/gobject"
15 "github.com/bnema/puregotk/v4/gtk"
16 "github.com/rs/zerolog"
17
18 "github.com/bnema/dumber/internal/logging"
19)
20
21type cefFileDialogRequest struct {
22 Mode purecef.FileDialogMode
23 Title string
24 DefaultFilePath string
25 AcceptFilters []string
26 AcceptExtensions []string
27 AcceptDescriptions []string
28}
29
30type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback)
31
32var maxExpandedFolderUploadFiles = 5000
33
34var (
35 decodeCEFStringList = purecef.StringListToSlice
36 newCEFStringList = purecef.NewStringList
37 freeCEFStringList = purecef.FreeStringList
38 continueCEFFileDialog = continueCEFFileDialogWithLogs
39)
40
41func (h *handlerSet) OnFileDialog(
42 _ purecef.Browser,
43 mode purecef.FileDialogMode,
44 title string,
45 defaultFilePath string,
46 acceptFilters purecef.StringList,
47 acceptExtensions purecef.StringList,
48 acceptDescriptions purecef.StringList,
49 callback purecef.FileDialogCallback,
50) int32 {
51 if h == nil || h.wv == nil || callback == nil {
52 return 0
53 }
54
55 presenter := h.fileDialogPresenter
56 if presenter == nil {
57 presenter = presentGTKFileDialog
58 }
59
60 req := cefFileDialogRequest{
61 Mode: mode,
62 Title: title,
63 DefaultFilePath: defaultFilePath,
64 AcceptFilters: decodeCEFStringList(acceptFilters),
65 AcceptExtensions: decodeCEFStringList(acceptExtensions),
66 AcceptDescriptions: decodeCEFStringList(acceptDescriptions),
67 }
68 logCEFFileDialog(h.wv).Debug().
69 Int32("mode", mode).
70 Str("title", title).
71 Bool("has_default_file_path", strings.TrimSpace(defaultFilePath) != "").
72 Int("accept_filter_count", len(req.AcceptFilters)).
73 Int("accept_extension_count", len(req.AcceptExtensions)).
74 Msg("cef: file dialog requested")
75
76 presenter(h.wv, req, callback)
77 return 1
78}
79
80func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purecef.FileDialogCallback) {
81 if callback == nil || wv == nil {
82 return
83 }
84
85 wv.runOnGTK(func() {
86 logCEFFileDialog(wv).Debug().
87 Int32("mode", req.Mode).
88 Str("title", req.Title).
89 Bool("has_default_file_path", strings.TrimSpace(req.DefaultFilePath) != "").
90 Msg("cef: presenting GTK file dialog")
91 dialog, ok := newNativeFileDialog(wv, req)
92 if !ok || dialog == nil {
93 dispatchFileDialogResult(wv, callback, nil)
94 return
95 }
96
97 onResponse := func(_ gtk.NativeDialog, responseID int) {
98 defer func() {
99 logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: releasing GTK file dialog")
100 dialog.Unref()
101 logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: released GTK file dialog")
102 }()
103
104 normalizedResponseID := normalizeGTKResponseID(responseID)
105 logCEFFileDialog(wv).Debug().
106 Int("response_id_raw", responseID).
107 Int("response_id_normalized", normalizedResponseID).
108 Int32("mode", req.Mode).
109 Msg("cef: GTK file dialog response received")
110 if normalizedResponseID != int(gtk.ResponseAcceptValue) {
111 logCEFFileDialog(wv).Debug().Msg("cef: file dialog canceled")
112 dispatchFileDialogResult(wv, callback, nil)
113 return
114 }
115
116 paths := selectedFileDialogPaths(dialog, req.Mode)
117 logCEFFileDialog(wv).Debug().
118 Int("path_count", len(paths)).
119 Msg("cef: file dialog selection captured")
120 paths = folderUploadPaths(wv, req.Mode, paths)
121 dispatchFileDialogResult(wv, callback, paths)
122 }
123 dialog.ConnectResponse(&onResponse)
124 dialog.Show()
125 })
126}
127
128func newNativeFileDialog(wv *WebView, req cefFileDialogRequest) (*gtk.FileChooserNative, bool) {
129 action, ok := fileChooserActionForMode(req.Mode)
130 if !ok {
131 logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: unsupported file dialog mode")
132 return nil, false
133 }
134
135 var titlePtr *string
136 title := strings.TrimSpace(req.Title)
137 if title != "" {
138 titlePtr = &title
139 }
140
141 dialog := gtk.NewFileChooserNative(titlePtr, fileDialogParentWindow(wv), action, nil, nil)
142 if dialog == nil {
143 logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: failed to create GTK file chooser dialog")
144 return nil, false
145 }
146
147 dialog.SetModal(true)
148 if req.Mode == purecef.FileDialogModeFileDialogOpenMultiple {
149 dialog.SetSelectMultiple(true)
150 }
151 applyDefaultDialogPath(dialog, req)
152 applyDialogFilters(dialog, req)
153 return dialog, true
154}
155
156func fileChooserActionForMode(mode purecef.FileDialogMode) (gtk.FileChooserAction, bool) {
157 switch mode {
158 case purecef.FileDialogModeFileDialogOpen, purecef.FileDialogModeFileDialogOpenMultiple:
159 return gtk.FileChooserActionOpenValue, true
160 case purecef.FileDialogModeFileDialogOpenFolder:
161 return gtk.FileChooserActionSelectFolderValue, true
162 case purecef.FileDialogModeFileDialogSave:
163 return gtk.FileChooserActionSaveValue, true
164 default:
165 return 0, false
166 }
167}
168
169func fileDialogParentWindow(wv *WebView) *gtk.Window {
170 if wv == nil || wv.viewBridge == nil {
171 return nil
172 }
173 widget := wv.viewBridge.Widget()
174 if widget == nil {
175 return nil
176 }
177 ancestor := widget.GetAncestor(gtk.WindowGLibType())
178 if ancestor == nil {
179 return nil
180 }
181 // puregotk's GetAncestor wrapper adds a reference before returning.
182 defer ancestor.Unref()
183 return gtk.WindowNewFromInternalPtr(ancestor.GoPointer())
184}
185
186// defaultDialogPathInfo holds the result of parsing DefaultFilePath for use by
187// applyDefaultDialogPath. It is a pure value type to remain unit-testable without
188// GTK objects.
189type defaultDialogPathInfo struct {
190 setFolder string // path to set as current folder (empty = don't set)
191 setName string // name to set as current name (empty = don't set)
192 setFile string // path to set as selected file (empty = don't set)
193}
194
195// parseDefaultDialogPath extracts folder/name/file settings from DefaultFilePath
196// without any GTK dependencies. For save mode, if the path points to an existing
197// directory, it sets that as the current folder without a current name.
198func parseDefaultDialogPath(mode purecef.FileDialogMode, defaultFilePath string) defaultDialogPathInfo {
199 path := strings.TrimSpace(defaultFilePath)
200 if path == "" {
201 return defaultDialogPathInfo{}
202 }
203
204 if mode == purecef.FileDialogModeFileDialogSave {
205 if info, err := os.Stat(path); err == nil && info.IsDir() {
206 return defaultDialogPathInfo{setFolder: path}
207 }
208 name := filepath.Base(path)
209 if name == "." || name == string(filepath.Separator) {
210 name = ""
211 }
212 info := defaultDialogPathInfo{setName: name}
213 if dir := filepath.Dir(path); dir != "." && dir != "" {
214 info.setFolder = dir
215 }
216 return info
217 }
218
219 if info, err := os.Stat(path); err == nil && info.IsDir() {
220 return defaultDialogPathInfo{setFolder: path}
221 }
222
223 info := defaultDialogPathInfo{}
224 if dir := filepath.Dir(path); dir != "." && dir != "" {
225 info.setFolder = dir
226 }
227 if mode != purecef.FileDialogModeFileDialogOpenFolder {
228 info.setFile = path
229 }
230 return info
231}
232
233func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
234 if dialog == nil {
235 return
236 }
237 dpi := parseDefaultDialogPath(req.Mode, req.DefaultFilePath)
238 if dpi.setFolder != "" {
239 if folder := gio.FileNewForPath(dpi.setFolder); folder != nil {
240 defer unrefGObjectPointer(folder.GoPointer())
241 _, _ = dialog.SetCurrentFolder(folder)
242 }
243 }
244 if dpi.setName != "" {
245 dialog.SetCurrentName(dpi.setName)
246 }
247 if dpi.setFile != "" {
248 if file := gio.FileNewForPath(dpi.setFile); file != nil {
249 defer unrefGObjectPointer(file.GoPointer())
250 _, _ = dialog.SetFile(file)
251 }
252 }
253}
254
255func applyDialogFilters(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
256 if dialog == nil {
257 return
258 }
259
260 appliedAny := false
261 for i, spec := range req.AcceptFilters {
262 filter := gtk.NewFileFilter()
263 if filter == nil {
264 continue
265 }
266
267 if name := dialogFilterName(req, i, spec); name != "" {
268 filter.SetName(&name)
269 }
270
271 added := false
272 for _, token := range dialogFilterTokens(spec, dialogFilterExtensions(req, i)) {
273 if addDialogFilterToken(filter, token) {
274 added = true
275 }
276 }
277 if !added {
278 continue
279 }
280
281 dialog.AddFilter(filter)
282 if !appliedAny {
283 dialog.SetFilter(filter)
284 appliedAny = true
285 }
286 }
287}
288
289func dialogFilterName(req cefFileDialogRequest, index int, fallback string) string {
290 if index < len(req.AcceptDescriptions) {
291 if name := strings.TrimSpace(req.AcceptDescriptions[index]); name != "" {
292 return name
293 }
294 }
295 return strings.TrimSpace(fallback)
296}
297
298func dialogFilterExtensions(req cefFileDialogRequest, index int) string {
299 if index >= len(req.AcceptExtensions) {
300 return ""
301 }
302 return req.AcceptExtensions[index]
303}
304
305func dialogFilterTokens(spec, extensions string) []string {
306 tokens := make([]string, 0, 4)
307 if spec = strings.TrimSpace(spec); spec != "" {
308 tokens = append(tokens, spec)
309 }
310 for _, part := range strings.Split(extensions, ";") {
311 part = strings.TrimSpace(part)
312 if part != "" {
313 tokens = append(tokens, part)
314 }
315 }
316 return tokens
317}
318
319func addDialogFilterToken(filter *gtk.FileFilter, token string) bool {
320 if filter == nil {
321 return false
322 }
323 token = strings.TrimSpace(token)
324 if token == "" {
325 return false
326 }
327 if strings.Contains(token, "/") && !strings.HasPrefix(token, ".") {
328 filter.AddMimeType(token)
329 return true
330 }
331 if strings.HasPrefix(token, ".") {
332 filter.AddPattern("*" + token)
333 return true
334 }
335 if strings.ContainsRune(token, '*') {
336 filter.AddPattern(token)
337 return true
338 }
339 return false
340}
341
342func selectedFileDialogPaths(dialog *gtk.FileChooserNative, mode purecef.FileDialogMode) []string {
343 if dialog == nil {
344 return nil
345 }
346 if mode == purecef.FileDialogModeFileDialogOpenMultiple {
347 model := dialog.GetFiles()
348 if model == nil {
349 return nil
350 }
351 defer unrefGObjectPointer(model.GoPointer())
352 paths := make([]string, 0, model.GetNItems())
353 for i := uint(0); i < model.GetNItems(); i++ {
354 obj := model.GetObject(i)
355 if obj == nil {
356 continue
357 }
358 file := &gio.FileBase{Ptr: obj.GoPointer()}
359 if path := gioFileDialogPath(file); path != "" {
360 paths = append(paths, path)
361 }
362 obj.Unref()
363 }
364 return paths
365 }
366 file := dialog.GetFile()
367 defer unrefGObjectPointer(file.GoPointer())
368 if path := gioFileDialogPath(file); path != "" {
369 return []string{path}
370 }
371 return nil
372}
373
374func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) []string {
375 if mode != purecef.FileDialogModeFileDialogOpenFolder || len(paths) != 1 {
376 return paths
377 }
378 folder := strings.TrimSpace(paths[0])
379 if folder == "" {
380 return paths
381 }
382 info, err := os.Stat(folder)
383 if err != nil || !info.IsDir() {
384 logCEFFileDialog(wv).Warn().
385 Msg("cef: folder upload path expansion skipped; selected path is not a directory")
386 return paths
387 }
388
389 files := make([]string, 0, 64)
390 truncated := false
391 walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error {
392 if err != nil {
393 logCEFFileDialog(wv).Debug().Msg("cef: folder upload path expansion skipped unreadable entry")
394 return nil
395 }
396 if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
397 return nil
398 }
399 info, err := entry.Info()
400 if err != nil || !info.Mode().IsRegular() {
401 return nil
402 }
403 files = append(files, path)
404 if len(files) >= maxExpandedFolderUploadFiles {
405 truncated = true
406 return filepath.SkipAll
407 }
408 return nil
409 })
410 if walkErr != nil {
411 logCEFFileDialog(wv).Warn().
412 Msg("cef: folder upload descendant enumeration failed; using selected directory path")
413 return paths
414 }
415 if len(files) == 0 {
416 logCEFFileDialog(wv).Warn().
417 Msg("cef: folder upload descendant enumeration found no regular files; using selected directory path")
418 return paths
419 }
420
421 if truncated {
422 logCEFFileDialog(wv).Warn().
423 Int("limit", maxExpandedFolderUploadFiles).
424 Msg("cef: folder upload expansion truncated; canceling selection")
425 return nil
426 }
427
428 logCEFFileDialog(wv).Debug().
429 Int("file_count", len(files)).
430 Msg("cef: folder upload expanded selected directory to descendant file paths")
431 return files
432}
433
434func gioFileDialogPath(file *gio.FileBase) string {
435 if file == nil {
436 return ""
437 }
438 return strings.TrimSpace(file.GetPath())
439}
440
441func unrefGObjectPointer(ptr uintptr) {
442 if ptr == 0 {
443 return
444 }
445 obj := &gobject.Object{}
446 obj.SetGoPointer(ptr)
447 obj.Unref()
448}
449
450func normalizeGTKResponseID(responseID int) int {
451 return int(int32(uint32(responseID)))
452}
453
454func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, paths []string) {
455 if callback == nil {
456 return
457 }
458 logger := logCEFFileDialog(wv)
459 if wv == nil || wv.engine == nil {
460 logger.Warn().Msg("cef: webview engine unavailable; canceling file dialog selection")
461 continueCEFFileDialog(logger, callback)
462 return
463 }
464
465 task := cefNewTask(cefTaskFunc(func() {
466 logger.Debug().
467 Int("path_count", len(paths)).
468 Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)).
469 Msg("cef: file dialog continuation task executing")
470 continueCEFFileDialog(logger, callback, paths...)
471 logger.Debug().
472 Int("path_count", len(paths)).
473 Msg("cef: file dialog continuation task finished")
474 }))
475 if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
476 logger.Warn().
477 Int32("post_result", result).
478 Msg("cef: failed to post file dialog continuation to CEF UI thread; canceling selection")
479 continueCEFFileDialog(logger, callback)
480 } else {
481 logger.Debug().Int("path_count", len(paths)).Msg("cef: file dialog continuation task posted")
482 }
483}
484
485func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
486 logger = nonNilCEFFileDialogLogger(logger)
487 if callback == nil {
488 logger.Debug().Msg("cef: file dialog callback continuation skipped; callback is nil")
489 return
490 }
491 callbackPtr := rawCEFPointer(callback)
492
493 if len(filePaths) == 0 {
494 logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: invoking file dialog callback cancel")
495 callback.Cancel()
496 runtime.KeepAlive(callback)
497 logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned")
498 return
499 }
500
501 logger.Debug().
502 Uint64("callback_ptr", uint64(callbackPtr)).
503 Int("path_count", len(filePaths)).
504 Msg("cef: allocating file dialog CEF string list")
505 list := newCEFStringList(filePaths...)
506 if list == 0 {
507 logger.Warn().
508 Uint64("callback_ptr", uint64(callbackPtr)).
509 Int("path_count", len(filePaths)).
510 Msg("cef: file dialog CEF string list allocation failed; canceling")
511 callback.Cancel()
512 runtime.KeepAlive(callback)
513 logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned after allocation failure")
514 return
515 }
516
517 listValue := uint64(list)
518 logger.Debug().
519 Uint64("callback_ptr", uint64(callbackPtr)).
520 Uint64("string_list", listValue).
521 Int("path_count", len(filePaths)).
522 Msg("cef: file dialog CEF string list allocated")
523 logger.Debug().
524 Uint64("callback_ptr", uint64(callbackPtr)).
525 Uint64("string_list", listValue).
526 Int("path_count", len(filePaths)).
527 Msg("cef: invoking file dialog callback continuation")
528 callback.Cont(list)
529 logger.Debug().
530 Uint64("callback_ptr", uint64(callbackPtr)).
531 Uint64("string_list", listValue).
532 Int("path_count", len(filePaths)).
533 Msg("cef: file dialog callback continuation returned")
534 logger.Debug().Uint64("string_list", listValue).Msg("cef: freeing file dialog CEF string list")
535 freeCEFStringList(list)
536 logger.Debug().Uint64("string_list", listValue).Msg("cef: freed file dialog CEF string list")
537 runtime.KeepAlive(callback)
538}
539
540func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger {
541 if logger != nil {
542 return logger
543 }
544 return logging.FromContext(context.Background())
545}
546
547type cefRawPointerHolder interface {
548 RawPointer() unsafe.Pointer
549}
550
551func rawCEFPointer(v any) uintptr {
552 if v == nil {
553 return 0
554 }
555 holder, ok := v.(cefRawPointerHolder)
556 if !ok {
557 return 0
558 }
559 return uintptr(holder.RawPointer())
560}
561
562func logCEFFileDialog(wv *WebView) *zerolog.Logger {
563 if wv == nil || wv.ctx == nil {
564 return logging.FromContext(context.Background())
565 }
566 return logging.FromContext(wv.ctx)
567}