Harden CEF file dialog edge cases
- commit
- 9dcc0d9957106550f1c85a81aaabe13ba7bea6f5
- author
- brice · · 2026-05-08 05:26 UTC
- parent
- 163e508
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +163 -43
M internal/infrastructure/cef/file_dialog.go +68 -43
| @@ -29,7 +29,7 @@ type cefFileDialogRequest struct { | |||
| 29 | 29 | ||
| 30 | 30 | type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback) | |
| 31 | 31 | ||
| 32 | - | const maxExpandedFolderUploadFiles = 5000 | |
| 32 | + | var maxExpandedFolderUploadFiles = 5000 | |
| 33 | 33 | ||
| 34 | 34 | var ( | |
| 35 | 35 | decodeCEFStringList = purecef.StringListToSlice | |
| @@ -68,7 +68,7 @@ func (h *handlerSet) OnFileDialog( | |||
| 68 | 68 | logCEFFileDialog(h.wv).Debug(). | |
| 69 | 69 | Int32("mode", mode). | |
| 70 | 70 | Str("title", title). | |
| 71 | - | Str("default_file_path", defaultFilePath). | |
| 71 | + | Bool("has_default_file_path", strings.TrimSpace(defaultFilePath) != ""). | |
| 72 | 72 | Int("accept_filter_count", len(req.AcceptFilters)). | |
| 73 | 73 | Int("accept_extension_count", len(req.AcceptExtensions)). | |
| 74 | 74 | Msg("cef: file dialog requested") | |
| @@ -86,7 +86,7 @@ func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purece | |||
| 86 | 86 | logCEFFileDialog(wv).Debug(). | |
| 87 | 87 | Int32("mode", req.Mode). | |
| 88 | 88 | Str("title", req.Title). | |
| 89 | - | Str("default_file_path", req.DefaultFilePath). | |
| 89 | + | Bool("has_default_file_path", strings.TrimSpace(req.DefaultFilePath) != ""). | |
| 90 | 90 | Msg("cef: presenting GTK file dialog") | |
| 91 | 91 | dialog, ok := newNativeFileDialog(wv, req) | |
| 92 | 92 | if !ok || dialog == nil { | |
| @@ -116,7 +116,6 @@ func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purece | |||
| 116 | 116 | paths := selectedFileDialogPaths(dialog, req.Mode) | |
| 117 | 117 | logCEFFileDialog(wv).Debug(). | |
| 118 | 118 | Int("path_count", len(paths)). | |
| 119 | - | Strs("paths", paths). | |
| 120 | 119 | Msg("cef: file dialog selection captured") | |
| 121 | 120 | paths = folderUploadPaths(wv, req.Mode, paths) | |
| 122 | 121 | dispatchFileDialogResult(wv, callback, paths) | |
| @@ -184,44 +183,69 @@ func fileDialogParentWindow(wv *WebView) *gtk.Window { | |||
| 184 | 183 | return gtk.WindowNewFromInternalPtr(ancestor.GoPointer()) | |
| 185 | 184 | } | |
| 186 | 185 | ||
| 187 | - | func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) { | |
| 188 | - | if dialog == nil { | |
| 189 | - | return | |
| 190 | - | } | |
| 191 | - | path := strings.TrimSpace(req.DefaultFilePath) | |
| 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) | |
| 192 | 200 | if path == "" { | |
| 193 | - | return | |
| 201 | + | return defaultDialogPathInfo{} | |
| 194 | 202 | } | |
| 195 | 203 | ||
| 196 | - | if req.Mode == purecef.FileDialogModeFileDialogSave { | |
| 197 | - | if dir := filepath.Dir(path); dir != "." && dir != "" { | |
| 198 | - | if folder := gio.FileNewForPath(dir); folder != nil { | |
| 199 | - | defer unrefGObjectPointer(folder.GoPointer()) | |
| 200 | - | _, _ = dialog.SetCurrentFolder(folder) | |
| 201 | - | } | |
| 204 | + | if mode == purecef.FileDialogModeFileDialogSave { | |
| 205 | + | if info, err := os.Stat(path); err == nil && info.IsDir() { | |
| 206 | + | return defaultDialogPathInfo{setFolder: path} | |
| 202 | 207 | } | |
| 203 | - | if name := filepath.Base(path); name != "." && name != string(filepath.Separator) { | |
| 204 | - | dialog.SetCurrentName(name) | |
| 208 | + | name := filepath.Base(path) | |
| 209 | + | if name == "." || name == string(filepath.Separator) { | |
| 210 | + | name = "" | |
| 205 | 211 | } | |
| 206 | - | return | |
| 212 | + | info := defaultDialogPathInfo{setName: name} | |
| 213 | + | if dir := filepath.Dir(path); dir != "." && dir != "" { | |
| 214 | + | info.setFolder = dir | |
| 215 | + | } | |
| 216 | + | return info | |
| 207 | 217 | } | |
| 208 | 218 | ||
| 209 | 219 | if info, err := os.Stat(path); err == nil && info.IsDir() { | |
| 210 | - | if folder := gio.FileNewForPath(path); folder != nil { | |
| 211 | - | defer unrefGObjectPointer(folder.GoPointer()) | |
| 212 | - | _, _ = dialog.SetCurrentFolder(folder) | |
| 213 | - | } | |
| 214 | - | return | |
| 220 | + | return defaultDialogPathInfo{setFolder: path} | |
| 215 | 221 | } | |
| 216 | 222 | ||
| 223 | + | info := defaultDialogPathInfo{} | |
| 217 | 224 | if dir := filepath.Dir(path); dir != "." && dir != "" { | |
| 218 | - | if folder := gio.FileNewForPath(dir); folder != nil { | |
| 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 { | |
| 219 | 240 | defer unrefGObjectPointer(folder.GoPointer()) | |
| 220 | 241 | _, _ = dialog.SetCurrentFolder(folder) | |
| 221 | 242 | } | |
| 222 | 243 | } | |
| 223 | - | if req.Mode != purecef.FileDialogModeFileDialogOpenFolder { | |
| 224 | - | if file := gio.FileNewForPath(path); file != nil { | |
| 244 | + | if dpi.setName != "" { | |
| 245 | + | dialog.SetCurrentName(dpi.setName) | |
| 246 | + | } | |
| 247 | + | if dpi.setFile != "" { | |
| 248 | + | if file := gio.FileNewForPath(dpi.setFile); file != nil { | |
| 225 | 249 | defer unrefGObjectPointer(file.GoPointer()) | |
| 226 | 250 | _, _ = dialog.SetFile(file) | |
| 227 | 251 | } | |
| @@ -358,8 +382,6 @@ func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) | |||
| 358 | 382 | info, err := os.Stat(folder) | |
| 359 | 383 | if err != nil || !info.IsDir() { | |
| 360 | 384 | logCEFFileDialog(wv).Warn(). | |
| 361 | - | Err(err). | |
| 362 | - | Str("path", folder). | |
| 363 | 385 | Msg("cef: folder upload path expansion skipped; selected path is not a directory") | |
| 364 | 386 | return paths | |
| 365 | 387 | } | |
| @@ -368,7 +390,7 @@ func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) | |||
| 368 | 390 | truncated := false | |
| 369 | 391 | walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error { | |
| 370 | 392 | if err != nil { | |
| 371 | - | logCEFFileDialog(wv).Debug().Err(err).Str("path", path).Msg("cef: folder upload path expansion skipped unreadable entry") | |
| 393 | + | logCEFFileDialog(wv).Debug().Msg("cef: folder upload path expansion skipped unreadable entry") | |
| 372 | 394 | return nil | |
| 373 | 395 | } | |
| 374 | 396 | if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { | |
| @@ -387,22 +409,24 @@ func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) | |||
| 387 | 409 | }) | |
| 388 | 410 | if walkErr != nil { | |
| 389 | 411 | logCEFFileDialog(wv).Warn(). | |
| 390 | - | Err(walkErr). | |
| 391 | - | Str("folder", folder). | |
| 392 | 412 | Msg("cef: folder upload descendant enumeration failed; using selected directory path") | |
| 393 | 413 | return paths | |
| 394 | 414 | } | |
| 395 | 415 | if len(files) == 0 { | |
| 396 | 416 | logCEFFileDialog(wv).Warn(). | |
| 397 | - | Str("folder", folder). | |
| 398 | 417 | Msg("cef: folder upload descendant enumeration found no regular files; using selected directory path") | |
| 399 | 418 | return paths | |
| 400 | 419 | } | |
| 401 | 420 | ||
| 402 | - | logCEFFileDialog(wv).Warn(). | |
| 403 | - | Str("folder", folder). | |
| 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(). | |
| 404 | 429 | Int("file_count", len(files)). | |
| 405 | - | Bool("truncated", truncated). | |
| 406 | 430 | Msg("cef: folder upload expanded selected directory to descendant file paths") | |
| 407 | 431 | return files | |
| 408 | 432 | } | |
| @@ -431,27 +455,30 @@ func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, | |||
| 431 | 455 | if callback == nil { | |
| 432 | 456 | return | |
| 433 | 457 | } | |
| 458 | + | logger := logCEFFileDialog(wv) | |
| 434 | 459 | if wv == nil || wv.engine == nil { | |
| 460 | + | logger.Warn().Msg("cef: webview engine unavailable; canceling file dialog selection") | |
| 461 | + | continueCEFFileDialog(logger, callback) | |
| 435 | 462 | return | |
| 436 | 463 | } | |
| 437 | 464 | ||
| 438 | 465 | task := cefNewTask(cefTaskFunc(func() { | |
| 439 | - | logger := logCEFFileDialog(wv) | |
| 440 | 466 | logger.Debug(). | |
| 441 | 467 | Int("path_count", len(paths)). | |
| 442 | - | Strs("paths", paths). | |
| 443 | 468 | Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)). | |
| 444 | 469 | Msg("cef: file dialog continuation task executing") | |
| 445 | 470 | continueCEFFileDialog(logger, callback, paths...) | |
| 446 | 471 | logger.Debug(). | |
| 447 | 472 | Int("path_count", len(paths)). | |
| 448 | - | Strs("paths", paths). | |
| 449 | 473 | Msg("cef: file dialog continuation task finished") | |
| 450 | 474 | })) | |
| 451 | 475 | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | |
| 452 | - | logCEFFileDialog(wv).Warn().Int32("post_result", result).Msg("cef: failed to post file dialog continuation to CEF UI thread") | |
| 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) | |
| 453 | 480 | } else { | |
| 454 | - | logCEFFileDialog(wv).Debug().Int("path_count", len(paths)).Strs("paths", paths).Msg("cef: file dialog continuation task posted") | |
| 481 | + | logger.Debug().Int("path_count", len(paths)).Msg("cef: file dialog continuation task posted") | |
| 455 | 482 | } | |
| 456 | 483 | } | |
| 457 | 484 | ||
| @@ -474,7 +501,6 @@ func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.File | |||
| 474 | 501 | logger.Debug(). | |
| 475 | 502 | Uint64("callback_ptr", uint64(callbackPtr)). | |
| 476 | 503 | Int("path_count", len(filePaths)). | |
| 477 | - | Strs("paths", filePaths). | |
| 478 | 504 | Msg("cef: allocating file dialog CEF string list") | |
| 479 | 505 | list := newCEFStringList(filePaths...) | |
| 480 | 506 | if list == 0 { | |
| @@ -498,7 +524,6 @@ func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.File | |||
| 498 | 524 | Uint64("callback_ptr", uint64(callbackPtr)). | |
| 499 | 525 | Uint64("string_list", listValue). | |
| 500 | 526 | Int("path_count", len(filePaths)). | |
| 501 | - | Strs("paths", filePaths). | |
| 502 | 527 | Msg("cef: invoking file dialog callback continuation") | |
| 503 | 528 | callback.Cont(list) | |
| 504 | 529 | logger.Debug(). | |
| @@ -29,7 +29,7 @@ type cefFileDialogRequest struct { | |||||
| 29 | 29 | ||||
| 30 | type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback) | 30 | type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback) | ||
| 31 | 31 | ||||
| 32 | - | const maxExpandedFolderUploadFiles = 5000 | 32 | + | var maxExpandedFolderUploadFiles = 5000 |
| 33 | 33 | ||||
| 34 | var ( | 34 | var ( | ||
| 35 | decodeCEFStringList = purecef.StringListToSlice | 35 | decodeCEFStringList = purecef.StringListToSlice | ||
| @@ -68,7 +68,7 @@ func (h *handlerSet) OnFileDialog( | |||||
| 68 | logCEFFileDialog(h.wv).Debug(). | 68 | logCEFFileDialog(h.wv).Debug(). | ||
| 69 | Int32("mode", mode). | 69 | Int32("mode", mode). | ||
| 70 | Str("title", title). | 70 | Str("title", title). | ||
| 71 | - | Str("default_file_path", defaultFilePath). | 71 | + | Bool("has_default_file_path", strings.TrimSpace(defaultFilePath) != ""). |
| 72 | Int("accept_filter_count", len(req.AcceptFilters)). | 72 | Int("accept_filter_count", len(req.AcceptFilters)). | ||
| 73 | Int("accept_extension_count", len(req.AcceptExtensions)). | 73 | Int("accept_extension_count", len(req.AcceptExtensions)). | ||
| 74 | Msg("cef: file dialog requested") | 74 | Msg("cef: file dialog requested") | ||
| @@ -86,7 +86,7 @@ func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purece | |||||
| 86 | logCEFFileDialog(wv).Debug(). | 86 | logCEFFileDialog(wv).Debug(). | ||
| 87 | Int32("mode", req.Mode). | 87 | Int32("mode", req.Mode). | ||
| 88 | Str("title", req.Title). | 88 | Str("title", req.Title). | ||
| 89 | - | Str("default_file_path", req.DefaultFilePath). | 89 | + | Bool("has_default_file_path", strings.TrimSpace(req.DefaultFilePath) != ""). |
| 90 | Msg("cef: presenting GTK file dialog") | 90 | Msg("cef: presenting GTK file dialog") | ||
| 91 | dialog, ok := newNativeFileDialog(wv, req) | 91 | dialog, ok := newNativeFileDialog(wv, req) | ||
| 92 | if !ok || dialog == nil { | 92 | if !ok || dialog == nil { | ||
| @@ -116,7 +116,6 @@ func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purece | |||||
| 116 | paths := selectedFileDialogPaths(dialog, req.Mode) | 116 | paths := selectedFileDialogPaths(dialog, req.Mode) | ||
| 117 | logCEFFileDialog(wv).Debug(). | 117 | logCEFFileDialog(wv).Debug(). | ||
| 118 | Int("path_count", len(paths)). | 118 | Int("path_count", len(paths)). | ||
| 119 | - | Strs("paths", paths). | |||
| 120 | Msg("cef: file dialog selection captured") | 119 | Msg("cef: file dialog selection captured") | ||
| 121 | paths = folderUploadPaths(wv, req.Mode, paths) | 120 | paths = folderUploadPaths(wv, req.Mode, paths) | ||
| 122 | dispatchFileDialogResult(wv, callback, paths) | 121 | dispatchFileDialogResult(wv, callback, paths) | ||
| @@ -184,44 +183,69 @@ func fileDialogParentWindow(wv *WebView) *gtk.Window { | |||||
| 184 | return gtk.WindowNewFromInternalPtr(ancestor.GoPointer()) | 183 | return gtk.WindowNewFromInternalPtr(ancestor.GoPointer()) | ||
| 185 | } | 184 | } | ||
| 186 | 185 | ||||
| 187 | - | func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) { | 186 | + | // defaultDialogPathInfo holds the result of parsing DefaultFilePath for use by |
| 188 | - | if dialog == nil { | 187 | + | // applyDefaultDialogPath. It is a pure value type to remain unit-testable without |
| 189 | - | return | 188 | + | // GTK objects. |
| 190 | - | } | 189 | + | type defaultDialogPathInfo struct { |
| 191 | - | path := strings.TrimSpace(req.DefaultFilePath) | 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) | |||
| 192 | if path == "" { | 200 | if path == "" { | ||
| 193 | - | return | 201 | + | return defaultDialogPathInfo{} |
| 194 | } | 202 | } | ||
| 195 | 203 | ||||
| 196 | - | if req.Mode == purecef.FileDialogModeFileDialogSave { | 204 | + | if mode == purecef.FileDialogModeFileDialogSave { |
| 197 | - | if dir := filepath.Dir(path); dir != "." && dir != "" { | 205 | + | if info, err := os.Stat(path); err == nil && info.IsDir() { |
| 198 | - | if folder := gio.FileNewForPath(dir); folder != nil { | 206 | + | return defaultDialogPathInfo{setFolder: path} |
| 199 | - | defer unrefGObjectPointer(folder.GoPointer()) | |||
| 200 | - | _, _ = dialog.SetCurrentFolder(folder) | |||
| 201 | - | } | |||
| 202 | } | 207 | } | ||
| 203 | - | if name := filepath.Base(path); name != "." && name != string(filepath.Separator) { | 208 | + | name := filepath.Base(path) |
| 204 | - | dialog.SetCurrentName(name) | 209 | + | if name == "." || name == string(filepath.Separator) { |
| 210 | + | name = "" | |||
| 205 | } | 211 | } | ||
| 206 | - | return | 212 | + | info := defaultDialogPathInfo{setName: name} |
| 213 | + | if dir := filepath.Dir(path); dir != "." && dir != "" { | |||
| 214 | + | info.setFolder = dir | |||
| 215 | + | } | |||
| 216 | + | return info | |||
| 207 | } | 217 | } | ||
| 208 | 218 | ||||
| 209 | if info, err := os.Stat(path); err == nil && info.IsDir() { | 219 | if info, err := os.Stat(path); err == nil && info.IsDir() { | ||
| 210 | - | if folder := gio.FileNewForPath(path); folder != nil { | 220 | + | return defaultDialogPathInfo{setFolder: path} |
| 211 | - | defer unrefGObjectPointer(folder.GoPointer()) | |||
| 212 | - | _, _ = dialog.SetCurrentFolder(folder) | |||
| 213 | - | } | |||
| 214 | - | return | |||
| 215 | } | 221 | } | ||
| 216 | 222 | ||||
| 223 | + | info := defaultDialogPathInfo{} | |||
| 217 | if dir := filepath.Dir(path); dir != "." && dir != "" { | 224 | if dir := filepath.Dir(path); dir != "." && dir != "" { | ||
| 218 | - | if folder := gio.FileNewForPath(dir); folder != nil { | 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 { | |||
| 219 | defer unrefGObjectPointer(folder.GoPointer()) | 240 | defer unrefGObjectPointer(folder.GoPointer()) | ||
| 220 | _, _ = dialog.SetCurrentFolder(folder) | 241 | _, _ = dialog.SetCurrentFolder(folder) | ||
| 221 | } | 242 | } | ||
| 222 | } | 243 | } | ||
| 223 | - | if req.Mode != purecef.FileDialogModeFileDialogOpenFolder { | 244 | + | if dpi.setName != "" { |
| 224 | - | if file := gio.FileNewForPath(path); file != nil { | 245 | + | dialog.SetCurrentName(dpi.setName) |
| 246 | + | } | |||
| 247 | + | if dpi.setFile != "" { | |||
| 248 | + | if file := gio.FileNewForPath(dpi.setFile); file != nil { | |||
| 225 | defer unrefGObjectPointer(file.GoPointer()) | 249 | defer unrefGObjectPointer(file.GoPointer()) | ||
| 226 | _, _ = dialog.SetFile(file) | 250 | _, _ = dialog.SetFile(file) | ||
| 227 | } | 251 | } | ||
| @@ -358,8 +382,6 @@ func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) | |||||
| 358 | info, err := os.Stat(folder) | 382 | info, err := os.Stat(folder) | ||
| 359 | if err != nil || !info.IsDir() { | 383 | if err != nil || !info.IsDir() { | ||
| 360 | logCEFFileDialog(wv).Warn(). | 384 | logCEFFileDialog(wv).Warn(). | ||
| 361 | - | Err(err). | |||
| 362 | - | Str("path", folder). | |||
| 363 | Msg("cef: folder upload path expansion skipped; selected path is not a directory") | 385 | Msg("cef: folder upload path expansion skipped; selected path is not a directory") | ||
| 364 | return paths | 386 | return paths | ||
| 365 | } | 387 | } | ||
| @@ -368,7 +390,7 @@ func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) | |||||
| 368 | truncated := false | 390 | truncated := false | ||
| 369 | walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error { | 391 | walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error { | ||
| 370 | if err != nil { | 392 | if err != nil { | ||
| 371 | - | logCEFFileDialog(wv).Debug().Err(err).Str("path", path).Msg("cef: folder upload path expansion skipped unreadable entry") | 393 | + | logCEFFileDialog(wv).Debug().Msg("cef: folder upload path expansion skipped unreadable entry") |
| 372 | return nil | 394 | return nil | ||
| 373 | } | 395 | } | ||
| 374 | if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { | 396 | if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { | ||
| @@ -387,22 +409,24 @@ func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) | |||||
| 387 | }) | 409 | }) | ||
| 388 | if walkErr != nil { | 410 | if walkErr != nil { | ||
| 389 | logCEFFileDialog(wv).Warn(). | 411 | logCEFFileDialog(wv).Warn(). | ||
| 390 | - | Err(walkErr). | |||
| 391 | - | Str("folder", folder). | |||
| 392 | Msg("cef: folder upload descendant enumeration failed; using selected directory path") | 412 | Msg("cef: folder upload descendant enumeration failed; using selected directory path") | ||
| 393 | return paths | 413 | return paths | ||
| 394 | } | 414 | } | ||
| 395 | if len(files) == 0 { | 415 | if len(files) == 0 { | ||
| 396 | logCEFFileDialog(wv).Warn(). | 416 | logCEFFileDialog(wv).Warn(). | ||
| 397 | - | Str("folder", folder). | |||
| 398 | Msg("cef: folder upload descendant enumeration found no regular files; using selected directory path") | 417 | Msg("cef: folder upload descendant enumeration found no regular files; using selected directory path") | ||
| 399 | return paths | 418 | return paths | ||
| 400 | } | 419 | } | ||
| 401 | 420 | ||||
| 402 | - | logCEFFileDialog(wv).Warn(). | 421 | + | if truncated { |
| 403 | - | Str("folder", folder). | 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(). | |||
| 404 | Int("file_count", len(files)). | 429 | Int("file_count", len(files)). | ||
| 405 | - | Bool("truncated", truncated). | |||
| 406 | Msg("cef: folder upload expanded selected directory to descendant file paths") | 430 | Msg("cef: folder upload expanded selected directory to descendant file paths") | ||
| 407 | return files | 431 | return files | ||
| 408 | } | 432 | } | ||
| @@ -431,27 +455,30 @@ func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, | |||||
| 431 | if callback == nil { | 455 | if callback == nil { | ||
| 432 | return | 456 | return | ||
| 433 | } | 457 | } | ||
| 458 | + | logger := logCEFFileDialog(wv) | |||
| 434 | if wv == nil || wv.engine == nil { | 459 | if wv == nil || wv.engine == nil { | ||
| 460 | + | logger.Warn().Msg("cef: webview engine unavailable; canceling file dialog selection") | |||
| 461 | + | continueCEFFileDialog(logger, callback) | |||
| 435 | return | 462 | return | ||
| 436 | } | 463 | } | ||
| 437 | 464 | ||||
| 438 | task := cefNewTask(cefTaskFunc(func() { | 465 | task := cefNewTask(cefTaskFunc(func() { | ||
| 439 | - | logger := logCEFFileDialog(wv) | |||
| 440 | logger.Debug(). | 466 | logger.Debug(). | ||
| 441 | Int("path_count", len(paths)). | 467 | Int("path_count", len(paths)). | ||
| 442 | - | Strs("paths", paths). | |||
| 443 | Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)). | 468 | Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)). | ||
| 444 | Msg("cef: file dialog continuation task executing") | 469 | Msg("cef: file dialog continuation task executing") | ||
| 445 | continueCEFFileDialog(logger, callback, paths...) | 470 | continueCEFFileDialog(logger, callback, paths...) | ||
| 446 | logger.Debug(). | 471 | logger.Debug(). | ||
| 447 | Int("path_count", len(paths)). | 472 | Int("path_count", len(paths)). | ||
| 448 | - | Strs("paths", paths). | |||
| 449 | Msg("cef: file dialog continuation task finished") | 473 | Msg("cef: file dialog continuation task finished") | ||
| 450 | })) | 474 | })) | ||
| 451 | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | 475 | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | ||
| 452 | - | logCEFFileDialog(wv).Warn().Int32("post_result", result).Msg("cef: failed to post file dialog continuation to CEF UI thread") | 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) | |||
| 453 | } else { | 480 | } else { | ||
| 454 | - | logCEFFileDialog(wv).Debug().Int("path_count", len(paths)).Strs("paths", paths).Msg("cef: file dialog continuation task posted") | 481 | + | logger.Debug().Int("path_count", len(paths)).Msg("cef: file dialog continuation task posted") |
| 455 | } | 482 | } | ||
| 456 | } | 483 | } | ||
| 457 | 484 | ||||
| @@ -474,7 +501,6 @@ func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.File | |||||
| 474 | logger.Debug(). | 501 | logger.Debug(). | ||
| 475 | Uint64("callback_ptr", uint64(callbackPtr)). | 502 | Uint64("callback_ptr", uint64(callbackPtr)). | ||
| 476 | Int("path_count", len(filePaths)). | 503 | Int("path_count", len(filePaths)). | ||
| 477 | - | Strs("paths", filePaths). | |||
| 478 | Msg("cef: allocating file dialog CEF string list") | 504 | Msg("cef: allocating file dialog CEF string list") | ||
| 479 | list := newCEFStringList(filePaths...) | 505 | list := newCEFStringList(filePaths...) | ||
| 480 | if list == 0 { | 506 | if list == 0 { | ||
| @@ -498,7 +524,6 @@ func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.File | |||||
| 498 | Uint64("callback_ptr", uint64(callbackPtr)). | 524 | Uint64("callback_ptr", uint64(callbackPtr)). | ||
| 499 | Uint64("string_list", listValue). | 525 | Uint64("string_list", listValue). | ||
| 500 | Int("path_count", len(filePaths)). | 526 | Int("path_count", len(filePaths)). | ||
| 501 | - | Strs("paths", filePaths). | |||
| 502 | Msg("cef: invoking file dialog callback continuation") | 527 | Msg("cef: invoking file dialog callback continuation") | ||
| 503 | callback.Cont(list) | 528 | callback.Cont(list) | ||
| 504 | logger.Debug(). | 529 | logger.Debug(). | ||
M internal/infrastructure/cef/file_dialog_test.go +95 -0
| @@ -2,6 +2,7 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | 8 | "testing" | |
| @@ -102,6 +103,100 @@ func TestFolderUploadPathsKeepsEmptyDirectoryPath(t *testing.T) { | |||
| 102 | 103 | require.Equal(t, []string{dir}, got) | |
| 103 | 104 | } | |
| 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 | + | ||
| 105 | 200 | func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) { | |
| 106 | 201 | prevDecode := decodeCEFStringList | |
| 107 | 202 | decodeCEFStringList = func(purecef.StringList) []string { return nil } | |
| @@ -2,6 +2,7 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 7 | "testing" | 8 | "testing" | ||
| @@ -102,6 +103,100 @@ func TestFolderUploadPathsKeepsEmptyDirectoryPath(t *testing.T) { | |||||
| 102 | require.Equal(t, []string{dir}, got) | 103 | require.Equal(t, []string{dir}, got) | ||
| 103 | } | 104 | } | ||
| 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 | + | ||||
| 105 | func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) { | 200 | func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) { | ||
| 106 | prevDecode := decodeCEFStringList | 201 | prevDecode := decodeCEFStringList | ||
| 107 | decodeCEFStringList = func(purecef.StringList) []string { return nil } | 202 | decodeCEFStringList = func(purecef.StringList) []string { return nil } | ||