Merge pull request #245 from bnema/feat/cef-file-dialog-support
Support CEF file dialogs with GTK
5 files · +802 -7
M go.mod +1 -1
| @@ -6,7 +6,7 @@ require ( | |||
| 6 | 6 | github.com/a-h/templ v0.3.1001 | |
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 9 | - | github.com/bnema/purego-cef v0.12.0 | |
| 9 | + | github.com/bnema/purego-cef v0.13.0 | |
| 10 | 10 | github.com/bnema/purego-cef2gtk v0.1.0 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.2 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.2 | |
| @@ -6,7 +6,7 @@ require ( | |||||
| 6 | github.com/a-h/templ v0.3.1001 | 6 | github.com/a-h/templ v0.3.1001 | ||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | - | github.com/bnema/purego-cef v0.12.0 | 9 | + | github.com/bnema/purego-cef v0.13.0 |
| 10 | github.com/bnema/purego-cef2gtk v0.1.0 | 10 | github.com/bnema/purego-cef2gtk v0.1.0 | ||
| 11 | github.com/bnema/purego-pipewire v0.1.2 | 11 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 12 | github.com/bnema/purego-sqlite v0.1.2 | ||
M go.sum +2 -2
| @@ -12,8 +12,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v | |||
| 12 | 12 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | |
| 13 | 13 | github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj+/Ve4o= | |
| 14 | 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | |
| 15 | - | github.com/bnema/purego-cef v0.12.0 h1:zw45+1e5WUBJdRooNIXmf2u/Zc8XFR5Xb0qwG2krpiM= | |
| 16 | - | github.com/bnema/purego-cef v0.12.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | |
| 15 | + | github.com/bnema/purego-cef v0.13.0 h1:TNPY19mBOqlJeFi5CZCLJIDfAgTEMp9tviQEyZgnvJI= | |
| 16 | + | github.com/bnema/purego-cef v0.13.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | |
| 17 | 17 | github.com/bnema/purego-cef2gtk v0.1.0 h1:8EFmT+WjtWrPEbo5ZJ+r7eOuf2ipA3pheWkSkcr8jhg= | |
| 18 | 18 | github.com/bnema/purego-cef2gtk v0.1.0/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | |
| 19 | 19 | github.com/bnema/purego-pipewire v0.1.2 h1:U057iOlpQatwlis6nfrZQJVrT3aGDFdagRLUz+ll7Ik= | |
| @@ -12,8 +12,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v | |||||
| 12 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | 12 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | ||
| 13 | github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj+/Ve4o= | 13 | github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj+/Ve4o= | ||
| 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | ||
| 15 | - | github.com/bnema/purego-cef v0.12.0 h1:zw45+1e5WUBJdRooNIXmf2u/Zc8XFR5Xb0qwG2krpiM= | 15 | + | github.com/bnema/purego-cef v0.13.0 h1:TNPY19mBOqlJeFi5CZCLJIDfAgTEMp9tviQEyZgnvJI= |
| 16 | - | github.com/bnema/purego-cef v0.12.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | 16 | + | github.com/bnema/purego-cef v0.13.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= |
| 17 | github.com/bnema/purego-cef2gtk v0.1.0 h1:8EFmT+WjtWrPEbo5ZJ+r7eOuf2ipA3pheWkSkcr8jhg= | 17 | github.com/bnema/purego-cef2gtk v0.1.0 h1:8EFmT+WjtWrPEbo5ZJ+r7eOuf2ipA3pheWkSkcr8jhg= | ||
| 18 | github.com/bnema/purego-cef2gtk v0.1.0/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | 18 | github.com/bnema/purego-cef2gtk v0.1.0/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | ||
| 19 | github.com/bnema/purego-pipewire v0.1.2 h1:U057iOlpQatwlis6nfrZQJVrT3aGDFdagRLUz+ll7Ik= | 19 | github.com/bnema/purego-pipewire v0.1.2 h1:U057iOlpQatwlis6nfrZQJVrT3aGDFdagRLUz+ll7Ik= | ||
A internal/infrastructure/cef/file_dialog.go +567 -0
| @@ -0,0 +1,567 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 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 | + | ||
| 21 | + | type cefFileDialogRequest struct { | |
| 22 | + | Mode purecef.FileDialogMode | |
| 23 | + | Title string | |
| 24 | + | DefaultFilePath string | |
| 25 | + | AcceptFilters []string | |
| 26 | + | AcceptExtensions []string | |
| 27 | + | AcceptDescriptions []string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback) | |
| 31 | + | ||
| 32 | + | var maxExpandedFolderUploadFiles = 5000 | |
| 33 | + | ||
| 34 | + | var ( | |
| 35 | + | decodeCEFStringList = purecef.StringListToSlice | |
| 36 | + | newCEFStringList = purecef.NewStringList | |
| 37 | + | freeCEFStringList = purecef.FreeStringList | |
| 38 | + | continueCEFFileDialog = continueCEFFileDialogWithLogs | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | func (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 | + | ||
| 80 | + | func 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 | + | ||
| 128 | + | func 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 | + | ||
| 156 | + | func 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 | + | ||
| 169 | + | func 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. | |
| 189 | + | type 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. | |
| 198 | + | func 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 | + | ||
| 233 | + | func 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 | + | ||
| 255 | + | func 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 | + | ||
| 289 | + | func 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 | + | ||
| 298 | + | func dialogFilterExtensions(req cefFileDialogRequest, index int) string { | |
| 299 | + | if index >= len(req.AcceptExtensions) { | |
| 300 | + | return "" | |
| 301 | + | } | |
| 302 | + | return req.AcceptExtensions[index] | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func 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 | + | ||
| 319 | + | func 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 | + | ||
| 342 | + | func 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 | + | ||
| 374 | + | func 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 | + | ||
| 434 | + | func gioFileDialogPath(file *gio.FileBase) string { | |
| 435 | + | if file == nil { | |
| 436 | + | return "" | |
| 437 | + | } | |
| 438 | + | return strings.TrimSpace(file.GetPath()) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func unrefGObjectPointer(ptr uintptr) { | |
| 442 | + | if ptr == 0 { | |
| 443 | + | return | |
| 444 | + | } | |
| 445 | + | obj := &gobject.Object{} | |
| 446 | + | obj.SetGoPointer(ptr) | |
| 447 | + | obj.Unref() | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func normalizeGTKResponseID(responseID int) int { | |
| 451 | + | return int(int32(uint32(responseID))) | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func 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 | + | ||
| 485 | + | func 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 | + | ||
| 540 | + | func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger { | |
| 541 | + | if logger != nil { | |
| 542 | + | return logger | |
| 543 | + | } | |
| 544 | + | return logging.FromContext(context.Background()) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | type cefRawPointerHolder interface { | |
| 548 | + | RawPointer() unsafe.Pointer | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | func 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 | + | ||
| 562 | + | func 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 | + | } | |
| @@ -0,0 +1,567 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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 | + | ||||
| 21 | + | type cefFileDialogRequest struct { | |||
| 22 | + | Mode purecef.FileDialogMode | |||
| 23 | + | Title string | |||
| 24 | + | DefaultFilePath string | |||
| 25 | + | AcceptFilters []string | |||
| 26 | + | AcceptExtensions []string | |||
| 27 | + | AcceptDescriptions []string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback) | |||
| 31 | + | ||||
| 32 | + | var maxExpandedFolderUploadFiles = 5000 | |||
| 33 | + | ||||
| 34 | + | var ( | |||
| 35 | + | decodeCEFStringList = purecef.StringListToSlice | |||
| 36 | + | newCEFStringList = purecef.NewStringList | |||
| 37 | + | freeCEFStringList = purecef.FreeStringList | |||
| 38 | + | continueCEFFileDialog = continueCEFFileDialogWithLogs | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | func (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 | + | ||||
| 80 | + | func 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 | + | ||||
| 128 | + | func 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 | + | ||||
| 156 | + | func 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 | + | ||||
| 169 | + | func 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. | |||
| 189 | + | type 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. | |||
| 198 | + | func 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 | + | ||||
| 233 | + | func 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 | + | ||||
| 255 | + | func 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 | + | ||||
| 289 | + | func 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 | + | ||||
| 298 | + | func dialogFilterExtensions(req cefFileDialogRequest, index int) string { | |||
| 299 | + | if index >= len(req.AcceptExtensions) { | |||
| 300 | + | return "" | |||
| 301 | + | } | |||
| 302 | + | return req.AcceptExtensions[index] | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func 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 | + | ||||
| 319 | + | func 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 | + | ||||
| 342 | + | func 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 | + | ||||
| 374 | + | func 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 | + | ||||
| 434 | + | func gioFileDialogPath(file *gio.FileBase) string { | |||
| 435 | + | if file == nil { | |||
| 436 | + | return "" | |||
| 437 | + | } | |||
| 438 | + | return strings.TrimSpace(file.GetPath()) | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | func unrefGObjectPointer(ptr uintptr) { | |||
| 442 | + | if ptr == 0 { | |||
| 443 | + | return | |||
| 444 | + | } | |||
| 445 | + | obj := &gobject.Object{} | |||
| 446 | + | obj.SetGoPointer(ptr) | |||
| 447 | + | obj.Unref() | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func normalizeGTKResponseID(responseID int) int { | |||
| 451 | + | return int(int32(uint32(responseID))) | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func 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 | + | ||||
| 485 | + | func 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 | + | ||||
| 540 | + | func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger { | |||
| 541 | + | if logger != nil { | |||
| 542 | + | return logger | |||
| 543 | + | } | |||
| 544 | + | return logging.FromContext(context.Background()) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | type cefRawPointerHolder interface { | |||
| 548 | + | RawPointer() unsafe.Pointer | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | func 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 | + | ||||
| 562 | + | func 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 | + | } | |||
A internal/infrastructure/cef/file_dialog_test.go +226 -0
| @@ -0,0 +1,226 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 11 | + | "github.com/rs/zerolog" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type stubFileDialogCallback struct { | |
| 16 | + | list purecef.StringList | |
| 17 | + | paths []string | |
| 18 | + | canceled bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (s *stubFileDialogCallback) Cont(filePaths purecef.StringList) { | |
| 22 | + | s.list = filePaths | |
| 23 | + | s.paths = decodeCEFStringList(filePaths) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (s *stubFileDialogCallback) Cancel() { | |
| 27 | + | s.canceled = true | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestGetDialogHandlerEnabled(t *testing.T) { | |
| 31 | + | h := &handlerSet{} | |
| 32 | + | require.Same(t, h, h.GetDialogHandler()) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestOnFileDialogRejectsNilWebViewOrCallback(t *testing.T) { | |
| 36 | + | require.Zero(t, (*handlerSet)(nil).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{})) | |
| 37 | + | require.Zero(t, (&handlerSet{}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{})) | |
| 38 | + | require.Zero(t, (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, nil)) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestNormalizeGTKResponseID(t *testing.T) { | |
| 42 | + | require.Equal(t, -3, normalizeGTKResponseID(4294967293)) | |
| 43 | + | require.Equal(t, -6, normalizeGTKResponseID(4294967290)) | |
| 44 | + | require.Equal(t, 0, normalizeGTKResponseID(0)) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestOnFileDialogDelegatesDecodedRequestToPresenter(t *testing.T) { | |
| 48 | + | prevDecode := decodeCEFStringList | |
| 49 | + | decodeCEFStringList = func(list purecef.StringList) []string { | |
| 50 | + | switch list { | |
| 51 | + | case purecef.StringList(11): | |
| 52 | + | return []string{"image/*", ".txt"} | |
| 53 | + | case purecef.StringList(12): | |
| 54 | + | return []string{".png;.jpg", ".txt"} | |
| 55 | + | case purecef.StringList(13): | |
| 56 | + | return []string{"Image Files", "Text Files"} | |
| 57 | + | default: | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | defer func() { decodeCEFStringList = prevDecode }() | |
| 62 | + | ||
| 63 | + | var gotReq cefFileDialogRequest | |
| 64 | + | var gotCalls int | |
| 65 | + | h := &handlerSet{ | |
| 66 | + | wv: &WebView{ctx: context.Background()}, | |
| 67 | + | fileDialogPresenter: func(_ *WebView, req cefFileDialogRequest, _ purecef.FileDialogCallback) { | |
| 68 | + | gotReq = req | |
| 69 | + | gotCalls++ | |
| 70 | + | }, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogOpenMultiple, "Pick files", "/tmp/start.txt", purecef.StringList(11), purecef.StringList(12), purecef.StringList(13), &stubFileDialogCallback{}) | |
| 74 | + | ||
| 75 | + | require.Equal(t, int32(1), handled) | |
| 76 | + | require.Equal(t, 1, gotCalls) | |
| 77 | + | require.Equal(t, purecef.FileDialogModeFileDialogOpenMultiple, gotReq.Mode) | |
| 78 | + | require.Equal(t, "Pick files", gotReq.Title) | |
| 79 | + | require.Equal(t, "/tmp/start.txt", gotReq.DefaultFilePath) | |
| 80 | + | require.Equal(t, []string{"image/*", ".txt"}, gotReq.AcceptFilters) | |
| 81 | + | require.Equal(t, []string{".png;.jpg", ".txt"}, gotReq.AcceptExtensions) | |
| 82 | + | require.Equal(t, []string{"Image Files", "Text Files"}, gotReq.AcceptDescriptions) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestFolderUploadPathsExpandsDirectoryFiles(t *testing.T) { | |
| 86 | + | dir := t.TempDir() | |
| 87 | + | first := filepath.Join(dir, "first.txt") | |
| 88 | + | second := filepath.Join(dir, "nested", "second.txt") | |
| 89 | + | require.NoError(t, os.MkdirAll(filepath.Dir(second), 0o755)) | |
| 90 | + | require.NoError(t, os.WriteFile(first, []byte("first"), 0o644)) | |
| 91 | + | require.NoError(t, os.WriteFile(second, []byte("second"), 0o644)) | |
| 92 | + | ||
| 93 | + | got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |
| 94 | + | ||
| 95 | + | require.ElementsMatch(t, []string{first, second}, got) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestFolderUploadPathsKeepsEmptyDirectoryPath(t *testing.T) { | |
| 99 | + | dir := t.TempDir() | |
| 100 | + | ||
| 101 | + | got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |
| 102 | + | ||
| 103 | + | require.Equal(t, []string{dir}, got) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestParseDefaultDialogPathSaveModeWithExistingDirectory(t *testing.T) { | |
| 107 | + | dir := t.TempDir() | |
| 108 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, dir) | |
| 109 | + | require.Equal(t, dir, info.setFolder) | |
| 110 | + | require.Empty(t, info.setName) | |
| 111 | + | require.Empty(t, info.setFile) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestParseDefaultDialogPathSaveModeWithFilePath(t *testing.T) { | |
| 115 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "/tmp/foo/bar.txt") | |
| 116 | + | require.Equal(t, "/tmp/foo", info.setFolder) | |
| 117 | + | require.Equal(t, "bar.txt", info.setName) | |
| 118 | + | require.Empty(t, info.setFile) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestParseDefaultDialogPathSaveModeWithRelativeFilePath(t *testing.T) { | |
| 122 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "bar.txt") | |
| 123 | + | require.Empty(t, info.setFolder) | |
| 124 | + | require.Equal(t, "bar.txt", info.setName) | |
| 125 | + | require.Empty(t, info.setFile) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestParseDefaultDialogPathOpenModeWithDirectory(t *testing.T) { | |
| 129 | + | dir := t.TempDir() | |
| 130 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, dir) | |
| 131 | + | require.Equal(t, dir, info.setFolder) | |
| 132 | + | require.Empty(t, info.setFile) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestParseDefaultDialogPathOpenModeWithFilePath(t *testing.T) { | |
| 136 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, "/tmp/foo/bar.txt") | |
| 137 | + | require.Equal(t, "/tmp/foo", info.setFolder) | |
| 138 | + | require.Equal(t, "/tmp/foo/bar.txt", info.setFile) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestParseDefaultDialogPathOpenFolderMode(t *testing.T) { | |
| 142 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpenFolder, "/tmp/foo/bar.txt") | |
| 143 | + | require.Equal(t, "/tmp/foo", info.setFolder) | |
| 144 | + | require.Empty(t, info.setFile) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestParseDefaultDialogPathEmptyPath(t *testing.T) { | |
| 148 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "") | |
| 149 | + | require.Empty(t, info.setFolder) | |
| 150 | + | require.Empty(t, info.setName) | |
| 151 | + | require.Empty(t, info.setFile) | |
| 152 | + | ||
| 153 | + | info = parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, " ") | |
| 154 | + | require.Empty(t, info.setFolder) | |
| 155 | + | require.Empty(t, info.setName) | |
| 156 | + | require.Empty(t, info.setFile) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestFolderUploadPathsTruncationCancels(t *testing.T) { | |
| 160 | + | prev := maxExpandedFolderUploadFiles | |
| 161 | + | maxExpandedFolderUploadFiles = 2 | |
| 162 | + | defer func() { maxExpandedFolderUploadFiles = prev }() | |
| 163 | + | ||
| 164 | + | dir := t.TempDir() | |
| 165 | + | for i := 0; i < 5; i++ { | |
| 166 | + | p := filepath.Join(dir, fmt.Sprintf("file_%d.txt", i)) | |
| 167 | + | require.NoError(t, os.WriteFile(p, []byte("data"), 0o644)) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |
| 171 | + | require.Nil(t, got) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestDispatchFileDialogResultCancelsWhenEngineMissing(t *testing.T) { | |
| 175 | + | callback := &stubFileDialogCallback{} | |
| 176 | + | dispatchFileDialogResult(&WebView{ctx: context.Background()}, callback, []string{"/tmp/test.txt"}) | |
| 177 | + | require.True(t, callback.canceled) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestDispatchFileDialogResultCancelsWhenCefPostTaskFails(t *testing.T) { | |
| 181 | + | prevNewTask := cefNewTask | |
| 182 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 183 | + | defer func() { cefNewTask = prevNewTask }() | |
| 184 | + | ||
| 185 | + | prevPost := cefPostTask | |
| 186 | + | cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 { | |
| 187 | + | return 0 | |
| 188 | + | } | |
| 189 | + | defer func() { cefPostTask = prevPost }() | |
| 190 | + | ||
| 191 | + | callback := &stubFileDialogCallback{} | |
| 192 | + | wv := &WebView{ | |
| 193 | + | ctx: context.Background(), | |
| 194 | + | engine: &Engine{}, | |
| 195 | + | } | |
| 196 | + | dispatchFileDialogResult(wv, callback, []string{"/tmp/test.txt"}) | |
| 197 | + | require.True(t, callback.canceled) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) { | |
| 201 | + | prevDecode := decodeCEFStringList | |
| 202 | + | decodeCEFStringList = func(purecef.StringList) []string { return nil } | |
| 203 | + | defer func() { decodeCEFStringList = prevDecode }() | |
| 204 | + | ||
| 205 | + | prevContinue := continueCEFFileDialog | |
| 206 | + | continueCEFFileDialog = func(_ *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) { | |
| 207 | + | recorder, ok := callback.(*stubFileDialogCallback) | |
| 208 | + | require.True(t, ok) | |
| 209 | + | recorder.paths = append([]string(nil), filePaths...) | |
| 210 | + | } | |
| 211 | + | defer func() { continueCEFFileDialog = prevContinue }() | |
| 212 | + | ||
| 213 | + | callback := &stubFileDialogCallback{} | |
| 214 | + | h := &handlerSet{ | |
| 215 | + | wv: &WebView{ctx: context.Background()}, | |
| 216 | + | fileDialogPresenter: func(_ *WebView, _ cefFileDialogRequest, cb purecef.FileDialogCallback) { | |
| 217 | + | continueCEFFileDialog(nil, cb, "/tmp/report.txt") | |
| 218 | + | }, | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogSave, "", "", 0, 0, 0, callback) | |
| 222 | + | ||
| 223 | + | require.Equal(t, int32(1), handled) | |
| 224 | + | require.False(t, callback.canceled) | |
| 225 | + | require.Equal(t, []string{"/tmp/report.txt"}, callback.paths) | |
| 226 | + | } | |
| @@ -0,0 +1,226 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 11 | + | "github.com/rs/zerolog" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type stubFileDialogCallback struct { | |||
| 16 | + | list purecef.StringList | |||
| 17 | + | paths []string | |||
| 18 | + | canceled bool | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (s *stubFileDialogCallback) Cont(filePaths purecef.StringList) { | |||
| 22 | + | s.list = filePaths | |||
| 23 | + | s.paths = decodeCEFStringList(filePaths) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (s *stubFileDialogCallback) Cancel() { | |||
| 27 | + | s.canceled = true | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestGetDialogHandlerEnabled(t *testing.T) { | |||
| 31 | + | h := &handlerSet{} | |||
| 32 | + | require.Same(t, h, h.GetDialogHandler()) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestOnFileDialogRejectsNilWebViewOrCallback(t *testing.T) { | |||
| 36 | + | require.Zero(t, (*handlerSet)(nil).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{})) | |||
| 37 | + | require.Zero(t, (&handlerSet{}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{})) | |||
| 38 | + | require.Zero(t, (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, nil)) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestNormalizeGTKResponseID(t *testing.T) { | |||
| 42 | + | require.Equal(t, -3, normalizeGTKResponseID(4294967293)) | |||
| 43 | + | require.Equal(t, -6, normalizeGTKResponseID(4294967290)) | |||
| 44 | + | require.Equal(t, 0, normalizeGTKResponseID(0)) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestOnFileDialogDelegatesDecodedRequestToPresenter(t *testing.T) { | |||
| 48 | + | prevDecode := decodeCEFStringList | |||
| 49 | + | decodeCEFStringList = func(list purecef.StringList) []string { | |||
| 50 | + | switch list { | |||
| 51 | + | case purecef.StringList(11): | |||
| 52 | + | return []string{"image/*", ".txt"} | |||
| 53 | + | case purecef.StringList(12): | |||
| 54 | + | return []string{".png;.jpg", ".txt"} | |||
| 55 | + | case purecef.StringList(13): | |||
| 56 | + | return []string{"Image Files", "Text Files"} | |||
| 57 | + | default: | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | defer func() { decodeCEFStringList = prevDecode }() | |||
| 62 | + | ||||
| 63 | + | var gotReq cefFileDialogRequest | |||
| 64 | + | var gotCalls int | |||
| 65 | + | h := &handlerSet{ | |||
| 66 | + | wv: &WebView{ctx: context.Background()}, | |||
| 67 | + | fileDialogPresenter: func(_ *WebView, req cefFileDialogRequest, _ purecef.FileDialogCallback) { | |||
| 68 | + | gotReq = req | |||
| 69 | + | gotCalls++ | |||
| 70 | + | }, | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogOpenMultiple, "Pick files", "/tmp/start.txt", purecef.StringList(11), purecef.StringList(12), purecef.StringList(13), &stubFileDialogCallback{}) | |||
| 74 | + | ||||
| 75 | + | require.Equal(t, int32(1), handled) | |||
| 76 | + | require.Equal(t, 1, gotCalls) | |||
| 77 | + | require.Equal(t, purecef.FileDialogModeFileDialogOpenMultiple, gotReq.Mode) | |||
| 78 | + | require.Equal(t, "Pick files", gotReq.Title) | |||
| 79 | + | require.Equal(t, "/tmp/start.txt", gotReq.DefaultFilePath) | |||
| 80 | + | require.Equal(t, []string{"image/*", ".txt"}, gotReq.AcceptFilters) | |||
| 81 | + | require.Equal(t, []string{".png;.jpg", ".txt"}, gotReq.AcceptExtensions) | |||
| 82 | + | require.Equal(t, []string{"Image Files", "Text Files"}, gotReq.AcceptDescriptions) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestFolderUploadPathsExpandsDirectoryFiles(t *testing.T) { | |||
| 86 | + | dir := t.TempDir() | |||
| 87 | + | first := filepath.Join(dir, "first.txt") | |||
| 88 | + | second := filepath.Join(dir, "nested", "second.txt") | |||
| 89 | + | require.NoError(t, os.MkdirAll(filepath.Dir(second), 0o755)) | |||
| 90 | + | require.NoError(t, os.WriteFile(first, []byte("first"), 0o644)) | |||
| 91 | + | require.NoError(t, os.WriteFile(second, []byte("second"), 0o644)) | |||
| 92 | + | ||||
| 93 | + | got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |||
| 94 | + | ||||
| 95 | + | require.ElementsMatch(t, []string{first, second}, got) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestFolderUploadPathsKeepsEmptyDirectoryPath(t *testing.T) { | |||
| 99 | + | dir := t.TempDir() | |||
| 100 | + | ||||
| 101 | + | got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |||
| 102 | + | ||||
| 103 | + | require.Equal(t, []string{dir}, got) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestParseDefaultDialogPathSaveModeWithExistingDirectory(t *testing.T) { | |||
| 107 | + | dir := t.TempDir() | |||
| 108 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, dir) | |||
| 109 | + | require.Equal(t, dir, info.setFolder) | |||
| 110 | + | require.Empty(t, info.setName) | |||
| 111 | + | require.Empty(t, info.setFile) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestParseDefaultDialogPathSaveModeWithFilePath(t *testing.T) { | |||
| 115 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "/tmp/foo/bar.txt") | |||
| 116 | + | require.Equal(t, "/tmp/foo", info.setFolder) | |||
| 117 | + | require.Equal(t, "bar.txt", info.setName) | |||
| 118 | + | require.Empty(t, info.setFile) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestParseDefaultDialogPathSaveModeWithRelativeFilePath(t *testing.T) { | |||
| 122 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "bar.txt") | |||
| 123 | + | require.Empty(t, info.setFolder) | |||
| 124 | + | require.Equal(t, "bar.txt", info.setName) | |||
| 125 | + | require.Empty(t, info.setFile) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestParseDefaultDialogPathOpenModeWithDirectory(t *testing.T) { | |||
| 129 | + | dir := t.TempDir() | |||
| 130 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, dir) | |||
| 131 | + | require.Equal(t, dir, info.setFolder) | |||
| 132 | + | require.Empty(t, info.setFile) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestParseDefaultDialogPathOpenModeWithFilePath(t *testing.T) { | |||
| 136 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, "/tmp/foo/bar.txt") | |||
| 137 | + | require.Equal(t, "/tmp/foo", info.setFolder) | |||
| 138 | + | require.Equal(t, "/tmp/foo/bar.txt", info.setFile) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestParseDefaultDialogPathOpenFolderMode(t *testing.T) { | |||
| 142 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpenFolder, "/tmp/foo/bar.txt") | |||
| 143 | + | require.Equal(t, "/tmp/foo", info.setFolder) | |||
| 144 | + | require.Empty(t, info.setFile) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestParseDefaultDialogPathEmptyPath(t *testing.T) { | |||
| 148 | + | info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "") | |||
| 149 | + | require.Empty(t, info.setFolder) | |||
| 150 | + | require.Empty(t, info.setName) | |||
| 151 | + | require.Empty(t, info.setFile) | |||
| 152 | + | ||||
| 153 | + | info = parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, " ") | |||
| 154 | + | require.Empty(t, info.setFolder) | |||
| 155 | + | require.Empty(t, info.setName) | |||
| 156 | + | require.Empty(t, info.setFile) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestFolderUploadPathsTruncationCancels(t *testing.T) { | |||
| 160 | + | prev := maxExpandedFolderUploadFiles | |||
| 161 | + | maxExpandedFolderUploadFiles = 2 | |||
| 162 | + | defer func() { maxExpandedFolderUploadFiles = prev }() | |||
| 163 | + | ||||
| 164 | + | dir := t.TempDir() | |||
| 165 | + | for i := 0; i < 5; i++ { | |||
| 166 | + | p := filepath.Join(dir, fmt.Sprintf("file_%d.txt", i)) | |||
| 167 | + | require.NoError(t, os.WriteFile(p, []byte("data"), 0o644)) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |||
| 171 | + | require.Nil(t, got) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestDispatchFileDialogResultCancelsWhenEngineMissing(t *testing.T) { | |||
| 175 | + | callback := &stubFileDialogCallback{} | |||
| 176 | + | dispatchFileDialogResult(&WebView{ctx: context.Background()}, callback, []string{"/tmp/test.txt"}) | |||
| 177 | + | require.True(t, callback.canceled) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestDispatchFileDialogResultCancelsWhenCefPostTaskFails(t *testing.T) { | |||
| 181 | + | prevNewTask := cefNewTask | |||
| 182 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 183 | + | defer func() { cefNewTask = prevNewTask }() | |||
| 184 | + | ||||
| 185 | + | prevPost := cefPostTask | |||
| 186 | + | cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 { | |||
| 187 | + | return 0 | |||
| 188 | + | } | |||
| 189 | + | defer func() { cefPostTask = prevPost }() | |||
| 190 | + | ||||
| 191 | + | callback := &stubFileDialogCallback{} | |||
| 192 | + | wv := &WebView{ | |||
| 193 | + | ctx: context.Background(), | |||
| 194 | + | engine: &Engine{}, | |||
| 195 | + | } | |||
| 196 | + | dispatchFileDialogResult(wv, callback, []string{"/tmp/test.txt"}) | |||
| 197 | + | require.True(t, callback.canceled) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) { | |||
| 201 | + | prevDecode := decodeCEFStringList | |||
| 202 | + | decodeCEFStringList = func(purecef.StringList) []string { return nil } | |||
| 203 | + | defer func() { decodeCEFStringList = prevDecode }() | |||
| 204 | + | ||||
| 205 | + | prevContinue := continueCEFFileDialog | |||
| 206 | + | continueCEFFileDialog = func(_ *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) { | |||
| 207 | + | recorder, ok := callback.(*stubFileDialogCallback) | |||
| 208 | + | require.True(t, ok) | |||
| 209 | + | recorder.paths = append([]string(nil), filePaths...) | |||
| 210 | + | } | |||
| 211 | + | defer func() { continueCEFFileDialog = prevContinue }() | |||
| 212 | + | ||||
| 213 | + | callback := &stubFileDialogCallback{} | |||
| 214 | + | h := &handlerSet{ | |||
| 215 | + | wv: &WebView{ctx: context.Background()}, | |||
| 216 | + | fileDialogPresenter: func(_ *WebView, _ cefFileDialogRequest, cb purecef.FileDialogCallback) { | |||
| 217 | + | continueCEFFileDialog(nil, cb, "/tmp/report.txt") | |||
| 218 | + | }, | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogSave, "", "", 0, 0, 0, callback) | |||
| 222 | + | ||||
| 223 | + | require.Equal(t, int32(1), handled) | |||
| 224 | + | require.False(t, callback.canceled) | |||
| 225 | + | require.Equal(t, []string{"/tmp/report.txt"}, callback.paths) | |||
| 226 | + | } | |||
M internal/infrastructure/cef/webview_handlers.go +6 -4
| @@ -23,9 +23,10 @@ const ( | |||
| 23 | 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 24 | 24 | // methods can return the same receiver, avoiding extra allocations. | |
| 25 | 25 | type handlerSet struct { | |
| 26 | - | wv *WebView | |
| 27 | - | renderHandlerOnce sync.Once | |
| 28 | - | renderHandler purecef.RenderHandler | |
| 26 | + | wv *WebView | |
| 27 | + | fileDialogPresenter fileDialogPresenter | |
| 28 | + | renderHandlerOnce sync.Once | |
| 29 | + | renderHandler purecef.RenderHandler | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // Compile-time interface checks. | |
| @@ -37,6 +38,7 @@ var ( | |||
| 37 | 38 | _ purecef.RequestHandler = (*handlerSet)(nil) | |
| 38 | 39 | _ purecef.AudioHandler = (*handlerSet)(nil) | |
| 39 | 40 | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | |
| 41 | + | _ purecef.DialogHandler = (*handlerSet)(nil) | |
| 40 | 42 | _ purecef.DownloadHandler = (*handlerSet)(nil) | |
| 41 | 43 | _ purecef.FindHandler = (*handlerSet)(nil) | |
| 42 | 44 | ) | |
| @@ -54,7 +56,7 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | |||
| 54 | 56 | } | |
| 55 | 57 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | |
| 56 | 58 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | |
| 57 | - | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | |
| 59 | + | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return h } | |
| 58 | 60 | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | |
| 59 | 61 | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { | |
| 60 | 62 | if h.downloadHandler() != nil { | |
| @@ -23,9 +23,10 @@ const ( | |||||
| 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | ||
| 24 | // methods can return the same receiver, avoiding extra allocations. | 24 | // methods can return the same receiver, avoiding extra allocations. | ||
| 25 | type handlerSet struct { | 25 | type handlerSet struct { | ||
| 26 | - | wv *WebView | 26 | + | wv *WebView |
| 27 | - | renderHandlerOnce sync.Once | 27 | + | fileDialogPresenter fileDialogPresenter |
| 28 | - | renderHandler purecef.RenderHandler | 28 | + | renderHandlerOnce sync.Once |
| 29 | + | renderHandler purecef.RenderHandler | |||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | // Compile-time interface checks. | 32 | // Compile-time interface checks. | ||
| @@ -37,6 +38,7 @@ var ( | |||||
| 37 | _ purecef.RequestHandler = (*handlerSet)(nil) | 38 | _ purecef.RequestHandler = (*handlerSet)(nil) | ||
| 38 | _ purecef.AudioHandler = (*handlerSet)(nil) | 39 | _ purecef.AudioHandler = (*handlerSet)(nil) | ||
| 39 | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | 40 | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | ||
| 41 | + | _ purecef.DialogHandler = (*handlerSet)(nil) | |||
| 40 | _ purecef.DownloadHandler = (*handlerSet)(nil) | 42 | _ purecef.DownloadHandler = (*handlerSet)(nil) | ||
| 41 | _ purecef.FindHandler = (*handlerSet)(nil) | 43 | _ purecef.FindHandler = (*handlerSet)(nil) | ||
| 42 | ) | 44 | ) | ||
| @@ -54,7 +56,7 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | |||||
| 54 | } | 56 | } | ||
| 55 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | 57 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | ||
| 56 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | 58 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | ||
| 57 | - | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | 59 | + | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return h } |
| 58 | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | 60 | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | ||
| 59 | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { | 61 | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { | ||
| 60 | if h.downloadHandler() != nil { | 62 | if h.downloadHandler() != nil { | ||