diff --git a/.run/Build & Run Debug.run.xml b/.run/Build & Run Debug.run.xml
index 795941ae0..422847237 100644
--- a/.run/Build & Run Debug.run.xml
+++ b/.run/Build & Run Debug.run.xml
@@ -2,7 +2,7 @@
-
+
@@ -11,4 +11,4 @@
-
\ No newline at end of file
+
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 536a05cd5..c2ebb8074 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -11,7 +11,7 @@
"mode": "auto",
"program": "${workspaceFolder}/src",
"cwd": "${workspaceFolder}",
- "buildFlags": "-tags=debug,editor,filedrop,rawsrc",
+ "buildFlags": "-tags=debug,editor,filedrop,filedialog,rawsrc",
"env": {
"CGO_ENABLED": "1",
}
@@ -23,7 +23,7 @@
"program": "${workspaceFolder}/src",
"cwd": "${workspaceFolder}",
"args": ["-trace"],
- "buildFlags": "-tags=debug,editor,filedrop,rawsrc",
+ "buildFlags": "-tags=debug,editor,filedrop,filedialog,rawsrc",
"env": {
"CGO_ENABLED": "1",
}
@@ -34,7 +34,7 @@
"mode": "auto",
"program": "${workspaceFolder}/src",
"cwd": "${workspaceFolder}",
- "buildFlags": "-tags=editor,filedrop",
+ "buildFlags": "-tags=editor,filedrop,filedialog",
"env": {
"CGO_ENABLED": "1",
}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index d3f32fb66..7c348bf29 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -19,7 +19,7 @@
"args": [
"-C",
"${workspaceFolder}/src",
- "-tags=editor,filedrop",
+ "-tags=editor,filedrop,filedialog",
"-o=../kaiju.exe",
"main.go"
],
diff --git a/AGENTS.md b/AGENTS.md
index 30c8e3166..e7d964706 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@ Kaiju Engine is a 2D/3D game engine written in Go (Golang) backed by Vulkan. It
- **Module**: `kaijuengine.com`
- **Go Version**: 1.25.0+
-- **Build Tags**: `debug`, `editor`, platform-specific (`.windows.go`, `.darwin.go`, `.linux.go`, `.android.go`)
+- **Build Tags**: `debug`, `editor`, optional `filedrop`, optional `filedialog`, platform-specific (`.windows.go`, `.darwin.go`, `.linux.go`, `.android.go`)
## Integration testing
If you want to test visuals or non-unit testable behavior, you can use the integration testing framework. In the `src/integration_testing` folder is where integration tests should be placed. In the `init` function of your integration testing file, you should register your test launch function to the `tests` package map. Review `integration_testing_helpers.go` to see what functions are already available, and also put any generic testing functions into this file.
@@ -994,13 +994,15 @@ uiCamera := host.Cameras.UI
```bash
cd src
-go build -tags="debug,editor,filedrop" -o ../ ./
+go build -tags="debug,editor,filedrop,filedialog" -o ../ ./
```
### Build Tags
- `debug`: Include debug information
- `editor`: Build with editor support
+- `filedrop`: Enable file drop integrations
+- `filedialog`: Enable native file dialog integrations
- Platform-specific: `.windows.go`, `.darwin.go`, `.linux.go`, `.android.go`
### Content
diff --git a/README.md b/README.md
index 9513709af..07ddd4303 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,7 @@ If you have Go, C build tools, platform libs, and Vulkan setup, you can start by
```sh
mkdir bin/
cd src
-go build -tags="debug,editor,filedrop" -o ../bin/ ./
+go build -tags="debug,editor,filedrop,filedialog" -o ../bin/ ./
```
*Or just open the repository in VSCode (or other IDE) and begin debugging it.*
diff --git a/docs/editor/native_dialog_demo_windows.md b/docs/editor/native_dialog_demo_windows.md
new file mode 100644
index 000000000..447ac8fb7
--- /dev/null
+++ b/docs/editor/native_dialog_demo_windows.md
@@ -0,0 +1,132 @@
+# Windows Native Dialog
+
+The editor currently includes Windows-only native dialog when built with the `filedialog` tag (for example: `-tags="editor,filedialog"`).
+
+## Summary
+
+- Dialog requests execute on a worker thread
+- Completed dialog results are queued and processed on the window polling thread
+- Callback processing is driven by `Window.Poll()` through `filesystem.ProcessDialogCallbacks()`
+- If `Root` is set, folder navigation is blocked outside that root and accepted selections are revalidated before results are returned
+- Start folder rule:
+`CurrentDirectory` is used only when it is inside `Root`; otherwise the dialog starts from `Root`
+
+## Usage Snippets
+
+Use these snippets from game/editor code where you already have `host *engine.Host`.
+
+### Open File Dialog
+
+`ok` callback is required for wrapper helpers; `cancel` is optional.
+
+```go
+import "kaijuengine.com/platform/filesystem"
+
+err := host.Window.OpenFileDialog(
+ "", // startPath: initial directory (empty lets OS choose a default)
+ []filesystem.DialogExtension{
+ {Name: "Go Files", Extension: ".go"},
+ {Name: "All Files", Extension: ".*"}, // maps to *.*
+ },
+ func(path string) { // required ok callback - called when user confirms selection
+ println("selected file:", path)
+ },
+ nil, // optional cancel callback
+)
+if err != nil {
+ // Immediate setup/launch failure (not a user cancel)
+ println("open file dialog failed:", err.Error())
+}
+```
+
+### Save File Dialog
+
+`ok` callback is required; `cancel` is optional.
+
+```go
+import "kaijuengine.com/platform/filesystem"
+
+err := host.Window.SaveFileDialog(
+ "", // startPath: initial directory
+ "output.txt", // default save file name
+ []filesystem.DialogExtension{
+ {Name: "Text Files", Extension: ".txt"},
+ {Name: "All Files", Extension: ".*"},
+ },
+ func(path string) { // required ok callback
+ println("save path:", path)
+ },
+ func() { // optional cancel callback
+ println("save canceled or failed")
+ },
+)
+if err != nil {
+ println("save file dialog failed:", err.Error())
+}
+```
+
+### Open Folder Dialog
+
+`ok` callback is required; `cancel` is optional.
+
+```go
+err := host.Window.OpenFolderDialog(
+ "", // startPath: initial folder
+ func(path string) { // required ok callback
+ println("selected folder:", path)
+ },
+ func() { // optional cancel callback
+ println("folder selection canceled or failed")
+ },
+)
+if err != nil {
+ println("open folder dialog failed:", err.Error())
+}
+```
+
+### Advanced Native Dialog Request
+
+```go
+import "kaijuengine.com/platform/filesystem"
+
+root, err := filesystem.GameDirectory()
+if err != nil {
+ // Fallback: no explicit root/current directory constraints
+ root = ""
+}
+
+req := filesystem.NativeDialogRequest{
+ Mode: filesystem.NativeDialogModeOpenFiles, // multi-select files
+ Title: "Import Assets",
+ CurrentDirectory: root,
+ Root: root, // navigation + final-selection constraint
+ ShowHidden: true,
+ Filters: []filesystem.DialogFilter{
+ {Name: "Go and Text Files", Patterns: []string{"*.go", "*.txt"}},
+ {Name: "Images", Patterns: []string{"*.png", "*.jpg", "*.jpeg"}},
+ {Name: "All Files", Patterns: []string{"*.*"}},
+ },
+ Options: []filesystem.DialogCustomOption{
+ {Name: "Recursive import", Default: 1}, // checkbox option
+ {Name: "Import mode", Values: []string{"Copy", "Reference", "Link"}, Default: 0}, // combo option
+ },
+ WindowHandle: host.Window.PlatformWindow(),
+}
+
+err = filesystem.OpenNativeDialogWindow(req, func(result filesystem.NativeDialogResult) {
+ switch result.Status {
+ case filesystem.NativeDialogStatusAccepted:
+ println("accepted files:", len(result.Paths))
+ println("selected filter index:", result.SelectedFilterIndex)
+ // If selected options are not returned by the native layer, Kaiju fills defaults from req.Options.
+ println("selected options keys:", len(result.SelectedOptions))
+ case filesystem.NativeDialogStatusCancel:
+ println("dialog canceled")
+ case filesystem.NativeDialogStatusFailed:
+ println("dialog failed")
+ }
+})
+if err != nil {
+ println("advanced dialog failed:", err.Error())
+}
+```
diff --git a/docs/engine/build_from_source.md b/docs/engine/build_from_source.md
index bb0990b9a..5eddb55c2 100644
--- a/docs/engine/build_from_source.md
+++ b/docs/engine/build_from_source.md
@@ -34,7 +34,7 @@ Expected output: amd64
- Pull the repository
- Go into src `cd src`
- To build the exe in debug mode, run:
- - `go build -tags="debug,editor,filedrop" -o ../ ./`
+ - `go build -tags="debug,editor,filedrop,filedialog" -o ../ ./`
- To build the exe, run:
- `go build -ldflags="-s -w" -tags="editor" -o ../ ./`
@@ -47,7 +47,7 @@ you'll need to install the [DirectX runtime from Microsoft](https://www.microsof
- Pull the repository
- Go into src `cd src`
- To build the exe in debug mode, run:
- - `go build -tags="debug,editor,filedrop" -o ../ ./`
+ - `go build -tags="debug,editor,filedrop,filedialog" -o ../ ./`
- To build the exe, run:
- `go build -ldflags="-s -w" -tags="editor" -o ../ ./`
@@ -62,7 +62,7 @@ you'll need to install the [DirectX runtime from Microsoft](https://www.microsof
- Pull the repository
- Go into src: `cd src`
- To build the editor in debug mode, run:
- - `CGO_ENABLED=1 CGO_CFLAGS="-I$VULKAN_SDK/include" CGO_LDFLAGS="-L$VULKAN_SDK/lib -lMoltenVK -Wl,-rpath,$VULKAN_SDK/lib" go build -tags="debug,editor,filedrop" -o ../bin/kaiju`
+ - `CGO_ENABLED=1 CGO_CFLAGS="-I$VULKAN_SDK/include" CGO_LDFLAGS="-L$VULKAN_SDK/lib -lMoltenVK -Wl,-rpath,$VULKAN_SDK/lib" go build -tags="debug,editor,filedrop,filedialog" -o ../bin/kaiju`
- To build the editor, run:
- `CGO_ENABLED=1 CGO_CFLAGS="-I$VULKAN_SDK/include" CGO_LDFLAGS="-L$VULKAN_SDK/lib -lMoltenVK -Wl,-rpath,$VULKAN_SDK/lib" go build -ldflags="-s -w" -tags="editor" -o ../bin/kaiju`
diff --git a/docs/engine/build_tags.md b/docs/engine/build_tags.md
index 316286f27..5b9c0489c 100644
--- a/docs/engine/build_tags.md
+++ b/docs/engine/build_tags.md
@@ -11,3 +11,5 @@ but others will do debugging/runtime changes.
| ------------------ | ----------- |
| `editor` | Used to build the editor, otherwise the runtime will be built |
| `debug` | Used to enable various debug systems for the editor/runtime |
+| `filedrop` | Enables file-drop handling integrations used by editor workflows |
+| `filedialog` | Enables native file dialog integrations (optional package) |
diff --git a/docs/getting_started/start_without_editor.md b/docs/getting_started/start_without_editor.md
index d4b03de6a..3094b9f7e 100644
--- a/docs/getting_started/start_without_editor.md
+++ b/docs/getting_started/start_without_editor.md
@@ -32,7 +32,7 @@ The file `src/main.test.go` contains the build constraint:
//go:build !editor
```
-This means it is compiled **only** when the `editor` tag is **absent**. The default VS Code tasks in this repository build with `-tags=editor,filedrop`. To build a binary that runs the pure-code path, invoke `go build` **without** those flags:
+This means it is compiled **only** when the `editor` tag is **absent**. The default VS Code tasks in this repository build with `-tags=editor,filedrop,filedialog`. To build a binary that runs the pure-code path, invoke `go build` **without** those flags:
```powershell
# Build from the src directory and output the executable one level up
diff --git a/src/build/tag_generator/tag_generator.go b/src/build/tag_generator/tag_generator.go
index 68d3a3200..a8646dafa 100644
--- a/src/build/tag_generator/tag_generator.go
+++ b/src/build/tag_generator/tag_generator.go
@@ -15,7 +15,7 @@ import (
)
var availableTags = []string{
- "editor", "debug", "filedrop",
+ "editor", "debug", "filedrop", "filedialog",
}
const tagSetFmt = `//go:build %s
diff --git a/src/build/zfiledialog.go b/src/build/zfiledialog.go
new file mode 100644
index 000000000..6c5a78b15
--- /dev/null
+++ b/src/build/zfiledialog.go
@@ -0,0 +1,43 @@
+//go:build filedialog
+
+/******************************************************************************/
+/* zfiledialog.go */
+/******************************************************************************/
+/* This file is part of */
+/* KAIJU ENGINE */
+/* https://kaijuengine.com/ */
+/******************************************************************************/
+/* MIT License */
+/* */
+/* Copyright (c) 2023-present Kaiju Engine authors (AUTHORS.md). */
+/* Copyright (c) 2015-present Brent Farris. */
+/* */
+/* May all those that this source may reach be blessed by the LORD and find */
+/* peace and joy in life. */
+/* Everyone who drinks of this water will be thirsty again; but whoever */
+/* drinks of the water that I will give him shall never thirst; John 4:13-14 */
+/* */
+/* Permission is hereby granted, free of charge, to any person obtaining a */
+/* copy of this software and associated documentation files (the "Software"), */
+/* to deal in the Software without restriction, including without limitation */
+/* the rights to use, copy, modify, merge, publish, distribute, sublicense, */
+/* and/or sell copies of the Software, and to permit persons to whom the */
+/* Software is furnished to do so, subject to the following conditions: */
+/* */
+/* The above copyright notice and this permission notice shall be included in */
+/* all copies or substantial portions of the Software. */
+/* */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS */
+/* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT */
+/* OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE */
+/* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
+/******************************************************************************/
+
+// Code generated by src/build/tag_generator; DO NOT EDIT.
+
+package build
+
+const Filedialog = true
diff --git a/src/build/zfiledialog_not.go b/src/build/zfiledialog_not.go
new file mode 100644
index 000000000..591caa2cc
--- /dev/null
+++ b/src/build/zfiledialog_not.go
@@ -0,0 +1,43 @@
+//go:build !filedialog
+
+/******************************************************************************/
+/* zfiledialog_not.go */
+/******************************************************************************/
+/* This file is part of */
+/* KAIJU ENGINE */
+/* https://kaijuengine.com/ */
+/******************************************************************************/
+/* MIT License */
+/* */
+/* Copyright (c) 2023-present Kaiju Engine authors (AUTHORS.md). */
+/* Copyright (c) 2015-present Brent Farris. */
+/* */
+/* May all those that this source may reach be blessed by the LORD and find */
+/* peace and joy in life. */
+/* Everyone who drinks of this water will be thirsty again; but whoever */
+/* drinks of the water that I will give him shall never thirst; John 4:13-14 */
+/* */
+/* Permission is hereby granted, free of charge, to any person obtaining a */
+/* copy of this software and associated documentation files (the "Software"), */
+/* to deal in the Software without restriction, including without limitation */
+/* the rights to use, copy, modify, merge, publish, distribute, sublicense, */
+/* and/or sell copies of the Software, and to permit persons to whom the */
+/* Software is furnished to do so, subject to the following conditions: */
+/* */
+/* The above copyright notice and this permission notice shall be included in */
+/* all copies or substantial portions of the Software. */
+/* */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS */
+/* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT */
+/* OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE */
+/* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
+/******************************************************************************/
+
+// Code generated by src/build/tag_generator; DO NOT EDIT.
+
+package build
+
+const Filedialog = false
diff --git a/src/editor/editor_game_interface.go b/src/editor/editor_game_interface.go
index 1ac976850..f9d255c29 100644
--- a/src/editor/editor_game_interface.go
+++ b/src/editor/editor_game_interface.go
@@ -11,11 +11,14 @@ import (
"image/png"
"log/slog"
"reflect"
+ "runtime"
+ "time"
"kaijuengine.com/build"
"kaijuengine.com/editor/editor_embedded_content"
"kaijuengine.com/engine"
"kaijuengine.com/engine/assets"
+ "kaijuengine.com/platform/filesystem"
"kaijuengine.com/platform/profiler/tracing"
)
@@ -70,4 +73,118 @@ func (EditorGame) Launch(host *engine.Host) {
ed.newProjectOverlay()
}
})
+ if runtime.GOOS == "windows" {
+ host.RunAfterTime(1*time.Second, func() {
+ runNativeDialogDemo(host)
+ })
+ }
+}
+
+func runNativeDialogDemo(host *engine.Host) {
+ defer tracing.NewRegion("runNativeDialogDemo").End()
+ slog.Info("native dialog demo: opening file dialog")
+ err := host.Window.OpenFileDialog("", []filesystem.DialogExtension{
+ {Name: "Go Files", Extension: ".go"},
+ {Name: "All Files", Extension: ".*"},
+ }, func(path string) {
+ slog.Info("native dialog demo: open file selected", "path", path)
+ runNativeSaveDialogDemo(host)
+ }, func() {
+ slog.Info("native dialog demo: open file canceled")
+ runNativeSaveDialogDemo(host)
+ })
+ if err != nil {
+ slog.Error("native dialog demo: failed to show open file dialog", "error", err)
+ runNativeSaveDialogDemo(host)
+ }
+}
+
+func runNativeSaveDialogDemo(host *engine.Host) {
+ defer tracing.NewRegion("runNativeSaveDialogDemo").End()
+ slog.Info("native dialog demo: opening save file dialog")
+ err := host.Window.SaveFileDialog("", "demo_output.txt", []filesystem.DialogExtension{
+ {Name: "Text Files", Extension: ".txt"},
+ {Name: "All Files", Extension: ".*"},
+ }, func(path string) {
+ slog.Info("native dialog demo: save file selected", "path", path)
+ runNativeFolderDialogDemo(host)
+ }, func() {
+ slog.Info("native dialog demo: save file canceled")
+ runNativeFolderDialogDemo(host)
+ })
+ if err != nil {
+ slog.Error("native dialog demo: failed to show save file dialog", "error", err)
+ runNativeFolderDialogDemo(host)
+ }
+}
+
+func runNativeFolderDialogDemo(host *engine.Host) {
+ defer tracing.NewRegion("runNativeFolderDialogDemo").End()
+ slog.Info("native dialog demo: opening folder dialog")
+ err := host.Window.OpenFolderDialog("", func(path string) {
+ slog.Info("native dialog demo: folder selected", "path", path)
+ runNativeAdvancedDialogDemo(host)
+ }, func() {
+ slog.Info("native dialog demo: folder dialog canceled")
+ runNativeAdvancedDialogDemo(host)
+ })
+ if err != nil {
+ slog.Error("native dialog demo: failed to show folder dialog", "error", err)
+ runNativeAdvancedDialogDemo(host)
+ }
+}
+
+func runNativeAdvancedDialogDemo(host *engine.Host) {
+ defer tracing.NewRegion("runNativeAdvancedDialogDemo").End()
+
+ root, err := filesystem.GameDirectory()
+ if err != nil {
+ slog.Error("native dialog demo: failed to resolve game directory for advanced dialog", "error", err)
+ root = ""
+ }
+
+ request := filesystem.NativeDialogRequest{
+ Mode: filesystem.NativeDialogModeOpenFiles,
+ Title: "Advanced Native Dialog Demo (multi-select + options)",
+ CurrentDirectory: root,
+ Root: root,
+ ShowHidden: true,
+ Filters: []filesystem.DialogFilter{
+ {Name: "Go and Text Files", Patterns: []string{"*.go", "*.txt"}},
+ {Name: "Images", Patterns: []string{"*.png", "*.jpg", "*.jpeg"}},
+ {Name: "All Files", Patterns: []string{"*.*"}},
+ },
+ Options: []filesystem.DialogCustomOption{
+ {Name: "Recursive import", Default: 1},
+ {Name: "Import mode", Values: []string{"Copy", "Reference", "Link"}, Default: 0},
+ },
+ WindowHandle: host.Window.PlatformWindow(),
+ }
+
+ slog.Info("native dialog demo: opening advanced dialog", "root", root)
+ host.Window.DisableRawMouseInput()
+ err = filesystem.OpenNativeDialogWindow(request, func(result filesystem.NativeDialogResult) {
+ host.Window.EnableRawMouseInput()
+ switch result.Status {
+ case filesystem.NativeDialogStatusAccepted:
+ slog.Info("native dialog demo: multi-select accepted",
+ "count", len(result.Paths),
+ "paths", result.Paths,
+ "selectedFilterIndex", result.SelectedFilterIndex,
+ "selectedOptions", result.SelectedOptions)
+ for i := range result.Paths {
+ slog.Info("native dialog demo: selected file", "index", i, "path", result.Paths[i])
+ }
+ case filesystem.NativeDialogStatusCancel:
+ slog.Info("native dialog demo: advanced dialog canceled")
+ case filesystem.NativeDialogStatusFailed:
+ slog.Error("native dialog demo: advanced dialog failed",
+ "error", result.Err,
+ "hresult", result.HResult)
+ }
+ })
+ if err != nil {
+ host.Window.EnableRawMouseInput()
+ slog.Error("native dialog demo: failed to open advanced dialog", "error", err)
+ }
}
diff --git a/src/platform/filesystem/directory.go b/src/platform/filesystem/directory.go
index e56bdc5a9..b10179d19 100644
--- a/src/platform/filesystem/directory.go
+++ b/src/platform/filesystem/directory.go
@@ -24,6 +24,55 @@ type DialogExtension struct {
Extension string
}
+type NativeDialogMode uint8
+
+const (
+ NativeDialogModeOpenFile NativeDialogMode = iota
+ NativeDialogModeOpenFiles
+ NativeDialogModeSaveFile
+ NativeDialogModeOpenFolder
+)
+
+type NativeDialogStatus uint8
+
+const (
+ NativeDialogStatusCancel NativeDialogStatus = iota
+ NativeDialogStatusAccepted
+ NativeDialogStatusFailed
+)
+
+type DialogFilter struct {
+ Name string
+ Patterns []string
+}
+
+type DialogCustomOption struct {
+ Name string
+ Values []string
+ Default int
+}
+
+type NativeDialogRequest struct {
+ Mode NativeDialogMode
+ Title string
+ CurrentDirectory string
+ FileName string
+ Root string
+ Filters []DialogFilter
+ ShowHidden bool
+ Options []DialogCustomOption
+ WindowHandle unsafe.Pointer
+}
+
+type NativeDialogResult struct {
+ Status NativeDialogStatus
+ Paths []string
+ SelectedFilterIndex int
+ SelectedOptions map[string]any
+ HResult int32
+ Err error
+}
+
// CreateDirectory creates a directory at the specified path with full permissions.
func CreateDirectory(path string) error {
return os.MkdirAll(path, os.ModePerm)
@@ -173,6 +222,25 @@ func OpenSaveFileDialogWindow(startPath string, fileName string, extensions []Di
return openSaveFileDialogWindow(startPath, fileName, extensions, ok, cancel, windowHandle)
}
+func OpenFolderDialogWindow(startPath string, ok func(path string), cancel func(), windowHandle unsafe.Pointer) error {
+ return openFolderDialogWindow(startPath, ok, cancel, windowHandle)
+}
+
+// OpenNativeDialogWindow shows a native OS file dialog using a richer request/response model.
+func OpenNativeDialogWindow(request NativeDialogRequest, callback func(NativeDialogResult)) error {
+ return openNativeDialogWindow(request, callback)
+}
+
+// ProcessDialogCallbacks executes completed native dialog callbacks on the caller thread.
+func ProcessDialogCallbacks() {
+ processDialogCallbacks()
+}
+
+// ShutdownNativeDialogs performs platform-specific dialog runtime cleanup.
+func ShutdownNativeDialogs() {
+ shutdownNativeDialogs()
+}
+
func Zip(srcDir, outFile string, skipFiles, skipFolders, skipExtensions []string) error {
out, err := os.Create(outFile)
if err != nil {
diff --git a/src/platform/filesystem/directory.win32.h b/src/platform/filesystem/directory.win32.h
index e28c9651c..86d27754c 100644
--- a/src/platform/filesystem/directory.win32.h
+++ b/src/platform/filesystem/directory.win32.h
@@ -1,83 +1,1304 @@
#ifndef DIRECTORY_WIN32_H
#define DIRECTORY_WIN32_H
-#include
#include
-#include
+#include
+#include
+#include
+#include
+#include
+#include
#include
+#include
+
+/* Dialog mode requested by Go */
+typedef enum dialog_mode_t {
+ DIALOG_MODE_OPEN_FILE = 0,
+ DIALOG_MODE_OPEN_FILES = 1,
+ DIALOG_MODE_SAVE_FILE = 2,
+ DIALOG_MODE_OPEN_FOLDER = 3,
+} dialog_mode_t;
+
+/* Result status returned to Go */
+typedef enum dialog_status_t {
+ DIALOG_STATUS_ERROR = -1,
+ DIALOG_STATUS_CANCEL = 0,
+ DIALOG_STATUS_OK = 1,
+} dialog_status_t;
+
+/*
+ * Input payload for run_native_file_dialog.
+ * All text is utf8. Optional fields may be NULL.
+ */
+typedef struct dialog_request_t {
+ int mode;
+ const char *title_utf8;
+ const char *current_directory_utf8;
+ const char *filename_utf8;
+ const char *root_utf8;
+ const char *filters_utf8; /* "Name|*.ext;*.alt\\nOther|*.*" */
+ const char *options_utf8; /* "Checkbox|1|\\nCombo|0|A;B;C" */
+ int show_hidden;
+ void *hwnd;
+} dialog_request_t;
+
+/*
+ * Output payload for run_native_file_dialog.
+ * Caller must release heap fields with free_native_file_dialog_result.
+ */
+typedef struct dialog_result_t {
+ int status;
+ int selected_filter_index;
+ int hresult;
+ int path_count;
+ char **paths_utf8;
+ char *selected_options_utf8;
+ char *error_utf8;
+} dialog_result_t;
+
+/* Internal representation for dialog custom controls (parsed from options payload) */
+typedef struct dialog_option_control_t {
+ DWORD group_id;
+ DWORD control_id;
+ int is_checkbox;
+ int default_value;
+ wchar_t *name;
+ wchar_t **items;
+ int item_count;
+} dialog_option_control_t;
+
+/*
+ * lightweight COM event sink used while dialog is shown.
+ * Used to deny out-of-root folder navigation in dialog_event_on_folder_changing
+ * so the UI does not allow browsing outside the configured root.
+ */
+typedef struct dialog_event_handler_t {
+ IFileDialogEvents iface;
+ LONG ref_count;
+ const wchar_t *root;
+} dialog_event_handler_t;
+
+/* convert API-facing utf8 strings to win32 wide strings with owned storage */
+static wchar_t *utf8_to_wide_alloc(const char *src) {
+ if (src == NULL || src[0] == '\0') {
+ return NULL;
+ }
+ int chars = MultiByteToWideChar(CP_UTF8, 0, src, -1, NULL, 0);
+ if (chars <= 0) {
+ return NULL;
+ }
+ wchar_t *dst = (wchar_t *)malloc((size_t)chars * sizeof(wchar_t));
+ if (dst == NULL) {
+ return NULL;
+ }
+ if (MultiByteToWideChar(CP_UTF8, 0, src, -1, dst, chars) <= 0) {
+ free(dst);
+ return NULL;
+ }
+ return dst;
+}
+
+/* convert Win32 wchar back to UTF-8 for Go-facing result payloads */
+static char *wide_to_utf8_alloc(const wchar_t *src) {
+ if (src == NULL || src[0] == L'\0') {
+ return NULL;
+ }
+ int bytes = WideCharToMultiByte(CP_UTF8, 0, src, -1, NULL, 0, NULL, NULL);
+ if (bytes <= 0) {
+ return NULL;
+ }
+ char *dst = (char *)malloc((size_t)bytes);
+ if (dst == NULL) {
+ return NULL;
+ }
+ if (WideCharToMultiByte(CP_UTF8, 0, src, -1, dst, bytes, NULL, NULL) <= 0) {
+ free(dst);
+ return NULL;
+ }
+ return dst;
+}
+
+static char *cstr_dup_alloc(const char *src) {
+ if (src == NULL) {
+ return NULL;
+ }
+ size_t len = strlen(src);
+ char *dst = (char *)malloc(len + 1);
+ if (dst == NULL) {
+ return NULL;
+ }
+ memcpy(dst, src, len + 1);
+ return dst;
+}
-const char* open_file_dialog(const char* startPath, const char* ext, void* hwnd) {
- bool valid = false;
- OPENFILENAMEA ofn = { 0 }; // common dialog box structure
- char szFile[260] = ""; // buffer for file name
- if (ext == NULL) {
- ext = "All Files\0*.*\0\0";
- }
- char filter[256] = "";
- snprintf(filter, sizeof(filter), "%s", ext);
- for (int i = 0; i < sizeof(filter) && filter[0] != 0; i++) {
- if (filter[i] == '\n') {
- filter[i] = '\0';
- }
- }
- // Initialize OPENFILENAME
- ZeroMemory(&ofn, sizeof(ofn));
- ofn.lStructSize = sizeof(ofn);
- ofn.hwndOwner = (HWND)hwnd;
- ofn.lpstrFile = (LPSTR)szFile;
- // Set lpstrFile[0] to '\0' so that GetOpenFileName does not
- // use the contents of szFile to initialize itself.
- ofn.lpstrFile[0] = '\0';
- ofn.nMaxFile = sizeof(szFile);
- ofn.lpstrFilter = filter;
- ofn.nFilterIndex = 1;
- ofn.Flags = OFN_PATHMUSTEXIST | OFN_OVERWRITEPROMPT | OFN_NOREADONLYRETURN;
- valid = GetOpenFileNameA(&ofn) == TRUE;
- if (valid) {
- return ofn.lpstrFile;
+/* in-place tokenizer: replaces delimiter with '\0' and advances cursor */
+static char *split_next_token_inplace(char **cursor, char delim) {
+ if (cursor == NULL || *cursor == NULL) {
+ return NULL;
+ }
+ char *start = *cursor;
+ if (start[0] == '\0') {
+ *cursor = NULL;
+ return NULL;
+ }
+ char *p = start;
+ while (*p != '\0' && *p != delim) {
+ p++;
+ }
+ if (*p == delim) {
+ *p = '\0';
+ *cursor = p + 1;
} else {
- return "";
- }
-}
-
-const char* save_file_dialog(const char* startPath, const char* fileName, const char* ext, void* hwnd) {
- bool valid = false;
- OPENFILENAMEA ofn = { 0 }; // common dialog box structure
- char szFile[260] = ""; // buffer for file name
- if (ext == NULL) {
- ext = "All Files\0*.*\0\0";
- }
- if (startPath == NULL) {
- startPath = "\0";
- }
- // Set lpstrFile[0] to '\0' so that GetOpenFileName does not
- // use the contents of szFile to initialize itself.
- if (fileName == NULL) {
- fileName = "\0";
- }
- snprintf(szFile, sizeof(szFile), "%s", fileName);
- char filter[256] = "";
- snprintf(filter, sizeof(filter), "%s", ext);
- for (int i = 0; i < sizeof(filter) && filter[0] != 0; i++) {
- if (filter[i] == '\n') {
- filter[i] = '\0';
- }
- }
- // Initialize OPENFILENAME
- ZeroMemory(&ofn, sizeof(ofn));
- ofn.lStructSize = sizeof(ofn);
- ofn.hwndOwner = (HWND)hwnd;
- ofn.lpstrFile = szFile;
- ofn.nMaxFile = sizeof(szFile);
- ofn.lpstrFilter = filter;
- ofn.nFilterIndex = 1;
- ofn.lpstrInitialDir = startPath;
- ofn.Flags = OFN_PATHMUSTEXIST | OFN_OVERWRITEPROMPT | OFN_NOREADONLYRETURN;
- valid = GetSaveFileNameA(&ofn) == TRUE;
- if (valid) {
- return ofn.lpstrFile;
+ *cursor = NULL;
+ }
+ return start;
+}
+
+static void set_error_message(dialog_result_t *res, const char *msg) {
+ if (res == NULL || msg == NULL) {
+ return;
+ }
+ if (res->error_utf8 != NULL) {
+ free(res->error_utf8);
+ }
+ res->error_utf8 = cstr_dup_alloc(msg);
+}
+
+static bool push_path(dialog_result_t *res, const wchar_t *path) {
+ if (res == NULL || path == NULL) {
+ return false;
+ }
+ char *utf8 = wide_to_utf8_alloc(path);
+ if (utf8 == NULL) {
+ return false;
+ }
+ int new_count = res->path_count + 1;
+ char **new_paths = (char **)realloc(res->paths_utf8, (size_t)new_count * sizeof(char *));
+ if (new_paths == NULL) {
+ free(utf8);
+ return false;
+ }
+ res->paths_utf8 = new_paths;
+ res->paths_utf8[res->path_count] = utf8;
+ res->path_count = new_count;
+ return true;
+}
+
+static bool append_text(char **buf, size_t *len, size_t *cap, const char *txt) {
+ if (txt == NULL) {
+ return true;
+ }
+ size_t n = strlen(txt);
+ if (*len + n + 1 > *cap) {
+ size_t next = (*cap == 0) ? 256 : *cap;
+ while (*len + n + 1 > next) {
+ next *= 2;
+ }
+ char *grown = (char *)realloc(*buf, next);
+ if (grown == NULL) {
+ return false;
+ }
+ *buf = grown;
+ *cap = next;
+ }
+ memcpy((*buf) + *len, txt, n);
+ *len += n;
+ (*buf)[*len] = '\0';
+ return true;
+}
+
+static bool append_char(char **buf, size_t *len, size_t *cap, char c) {
+ char tmp[2] = {c, '\0'};
+ return append_text(buf, len, cap, tmp);
+}
+
+static void clear_result_paths(dialog_result_t *res) {
+ if (res == NULL || res->paths_utf8 == NULL) {
+ if (res != NULL) {
+ res->path_count = 0;
+ }
+ return;
+ }
+ for (int i = 0; i < res->path_count; i++) {
+ if (res->paths_utf8[i] != NULL) {
+ free(res->paths_utf8[i]);
+ }
+ }
+ free(res->paths_utf8);
+ res->paths_utf8 = NULL;
+ res->path_count = 0;
+}
+
+static bool is_drive_root_path(const wchar_t *path) {
+ return path != NULL && wcslen(path) == 3 && path[1] == L':' &&
+ (path[2] == L'\\' || path[2] == L'/');
+}
+
+/* Normalize a path for root-prefix checks. */
+static wchar_t *normalize_full_path_alloc(const wchar_t *path) {
+ if (path == NULL || path[0] == L'\0') {
+ return NULL;
+ }
+ DWORD need = GetFullPathNameW(path, 0, NULL, NULL);
+ if (need == 0) {
+ return NULL;
+ }
+ wchar_t *full = (wchar_t *)malloc((size_t)(need + 2) * sizeof(wchar_t));
+ if (full == NULL) {
+ return NULL;
+ }
+ DWORD got = GetFullPathNameW(path, need + 1, full, NULL);
+ if (got == 0 || got > need) {
+ free(full);
+ return NULL;
+ }
+ while (got > 0 && (full[got - 1] == L'\\' || full[got - 1] == L'/')) {
+ /* keep drive roots like "C:\" intact while normalizing path suffixes */
+ if (got == 3 && full[1] == L':') {
+ break;
+ }
+ full[got - 1] = L'\0';
+ got--;
+ }
+ return full;
+}
+
+/* root is enforced after selection to avoid returning paths outside the requested scope. */
+static bool path_is_within_root(const wchar_t *root, const wchar_t *path) {
+ if (root == NULL || root[0] == L'\0') {
+ return true;
+ }
+ wchar_t *full_root = normalize_full_path_alloc(root);
+ wchar_t *full_path = normalize_full_path_alloc(path);
+ if (full_root == NULL || full_path == NULL) {
+ if (full_root != NULL) {
+ free(full_root);
+ }
+ if (full_path != NULL) {
+ free(full_path);
+ }
+ return false;
+ }
+ size_t root_len = wcslen(full_root);
+ bool ok = false;
+ if (_wcsnicmp(full_root, full_path, root_len) == 0) {
+ /* Drive roots include the separator (e.g. "C:\") so any child path is valid after prefix match. */
+ if (is_drive_root_path(full_root)) {
+ ok = true;
+ }
+ /* enforce a full path segment boundary so "C:\foo" does not match "C:\foobar" */
+ else if (full_path[root_len] == L'\0' || full_path[root_len] == L'\\' || full_path[root_len] == L'/') {
+ ok = true;
+ }
+ }
+ free(full_root);
+ free(full_path);
+ return ok;
+}
+
+static void free_filter_arrays(COMDLG_FILTERSPEC *specs, wchar_t **names, wchar_t **patterns, int count) {
+ if (names != NULL) {
+ for (int i = 0; i < count; i++) {
+ if (names[i] != NULL) {
+ free(names[i]);
+ }
+ }
+ free(names);
+ }
+ if (patterns != NULL) {
+ for (int i = 0; i < count; i++) {
+ if (patterns[i] != NULL) {
+ free(patterns[i]);
+ }
+ }
+ free(patterns);
+ }
+ if (specs != NULL) {
+ free(specs);
+ }
+}
+
+/* expected format: "Label|*.ext\\nLabel2|*.foo;*.bar". falls back to All Files */
+static bool build_filter_specs(const char *filters_utf8, COMDLG_FILTERSPEC **out_specs, int *out_count, wchar_t ***out_names, wchar_t ***out_patterns) {
+ *out_specs = NULL;
+ *out_count = 0;
+ *out_names = NULL;
+ *out_patterns = NULL;
+
+ if (filters_utf8 == NULL || filters_utf8[0] == '\0') {
+ COMDLG_FILTERSPEC *spec = (COMDLG_FILTERSPEC *)malloc(sizeof(COMDLG_FILTERSPEC));
+ wchar_t **names = (wchar_t **)malloc(sizeof(wchar_t *));
+ wchar_t **patterns = (wchar_t **)malloc(sizeof(wchar_t *));
+ if (spec == NULL || names == NULL || patterns == NULL) {
+ if (spec != NULL) free(spec);
+ if (names != NULL) free(names);
+ if (patterns != NULL) free(patterns);
+ return false;
+ }
+ names[0] = utf8_to_wide_alloc("All Files (*.*)");
+ patterns[0] = utf8_to_wide_alloc("*.*");
+ if (names[0] == NULL || patterns[0] == NULL) {
+ free_filter_arrays(spec, names, patterns, 1);
+ return false;
+ }
+ spec[0].pszName = names[0];
+ spec[0].pszSpec = patterns[0];
+ *out_specs = spec;
+ *out_count = 1;
+ *out_names = names;
+ *out_patterns = patterns;
+ return true;
+ }
+
+ char *work = cstr_dup_alloc(filters_utf8);
+ if (work == NULL) {
+ return false;
+ }
+
+ int cap = 8;
+ int count = 0;
+ COMDLG_FILTERSPEC *specs = (COMDLG_FILTERSPEC *)malloc((size_t)cap * sizeof(COMDLG_FILTERSPEC));
+ wchar_t **names = (wchar_t **)malloc((size_t)cap * sizeof(wchar_t *));
+ wchar_t **patterns = (wchar_t **)malloc((size_t)cap * sizeof(wchar_t *));
+ if (specs == NULL || names == NULL || patterns == NULL) {
+ free(work);
+ if (specs != NULL) free(specs);
+ if (names != NULL) free(names);
+ if (patterns != NULL) free(patterns);
+ return false;
+ }
+
+ char *line_cursor = work;
+ for (char *line = split_next_token_inplace(&line_cursor, '\n'); line != NULL; line = split_next_token_inplace(&line_cursor, '\n')) {
+ if (line[0] == '\0') {
+ continue;
+ }
+ char *sep = strchr(line, '|');
+ if (sep == NULL) {
+ continue;
+ }
+ *sep = '\0';
+ char *name_utf8 = line;
+ char *pattern_utf8 = sep + 1;
+ if (name_utf8[0] == '\0' || pattern_utf8[0] == '\0') {
+ continue;
+ }
+
+ if (count == cap) {
+ int next_cap = cap * 2;
+ /* grow all parallel arrays together to keep COMDLG_FILTERSPEC pointers aligned */
+ COMDLG_FILTERSPEC *next_specs = (COMDLG_FILTERSPEC *)malloc((size_t)next_cap * sizeof(COMDLG_FILTERSPEC));
+ wchar_t **next_names = (wchar_t **)malloc((size_t)next_cap * sizeof(wchar_t *));
+ wchar_t **next_patterns = (wchar_t **)malloc((size_t)next_cap * sizeof(wchar_t *));
+ if (next_specs == NULL || next_names == NULL || next_patterns == NULL) {
+ if (next_specs != NULL) free(next_specs);
+ if (next_names != NULL) free(next_names);
+ if (next_patterns != NULL) free(next_patterns);
+ free(work);
+ free_filter_arrays(specs, names, patterns, count);
+ return false;
+ }
+
+ memcpy(next_specs, specs, (size_t)count * sizeof(COMDLG_FILTERSPEC));
+ memcpy(next_names, names, (size_t)count * sizeof(wchar_t *));
+ memcpy(next_patterns, patterns, (size_t)count * sizeof(wchar_t *));
+ free(specs);
+ free(names);
+ free(patterns);
+ specs = next_specs;
+ names = next_names;
+ patterns = next_patterns;
+ cap = next_cap;
+ }
+
+ names[count] = utf8_to_wide_alloc(name_utf8);
+ patterns[count] = utf8_to_wide_alloc(pattern_utf8);
+ if (names[count] == NULL || patterns[count] == NULL) {
+ free(work);
+ free_filter_arrays(specs, names, patterns, count + 1);
+ return false;
+ }
+ specs[count].pszName = names[count];
+ specs[count].pszSpec = patterns[count];
+ count++;
+ }
+ free(work);
+
+ if (count == 0) {
+ free_filter_arrays(specs, names, patterns, 0);
+ return build_filter_specs(NULL, out_specs, out_count, out_names, out_patterns);
+ }
+
+ *out_specs = specs;
+ *out_count = count;
+ *out_names = names;
+ *out_patterns = patterns;
+ return true;
+}
+
+static void free_option_controls(dialog_option_control_t *controls, int count) {
+ if (controls == NULL) {
+ return;
+ }
+ for (int i = 0; i < count; i++) {
+ if (controls[i].name != NULL) {
+ free(controls[i].name);
+ }
+ if (controls[i].items != NULL) {
+ for (int j = 0; j < controls[i].item_count; j++) {
+ if (controls[i].items[j] != NULL) {
+ free(controls[i].items[j]);
+ }
+ }
+ free(controls[i].items);
+ }
+ }
+ free(controls);
+}
+
+static void free_wide_string_array(wchar_t **items, int count) {
+ if (items == NULL) {
+ return;
+ }
+ for (int i = 0; i < count; i++) {
+ if (items[i] != NULL) {
+ free(items[i]);
+ }
+ }
+ free(items);
+}
+
+/* expected format per line: "name|default|v1;v2;v3". Empty values -> checkbox */
+static bool build_option_controls(const char *options_utf8, dialog_option_control_t **out_controls, int *out_count) {
+ *out_controls = NULL;
+ *out_count = 0;
+ if (options_utf8 == NULL || options_utf8[0] == '\0') {
+ return true;
+ }
+
+ char *work = cstr_dup_alloc(options_utf8);
+ if (work == NULL) {
+ return false;
+ }
+
+ int cap = 8;
+ int count = 0;
+ dialog_option_control_t *controls = (dialog_option_control_t *)calloc((size_t)cap, sizeof(dialog_option_control_t));
+ if (controls == NULL) {
+ free(work);
+ return false;
+ }
+ DWORD next_id = 100;
+
+ char *line_cursor = work;
+ for (char *line = split_next_token_inplace(&line_cursor, '\n'); line != NULL; line = split_next_token_inplace(&line_cursor, '\n')) {
+ if (line[0] == '\0') {
+ continue;
+ }
+ char *sep1 = strchr(line, '|');
+ if (sep1 == NULL) {
+ continue;
+ }
+ *sep1 = '\0';
+ char *sep2 = strchr(sep1 + 1, '|');
+ if (sep2 == NULL) {
+ continue;
+ }
+ *sep2 = '\0';
+
+ char *name_utf8 = line;
+ char *default_utf8 = sep1 + 1;
+ char *values_utf8 = sep2 + 1;
+ if (name_utf8[0] == '\0') {
+ continue;
+ }
+
+ if (count == cap) {
+ int next_cap = cap * 2;
+ dialog_option_control_t *next_controls = (dialog_option_control_t *)realloc(controls, (size_t)next_cap * sizeof(dialog_option_control_t));
+ if (next_controls == NULL) {
+ free(work);
+ free_option_controls(controls, count);
+ return false;
+ }
+ memset(next_controls + cap, 0, (size_t)(next_cap - cap) * sizeof(dialog_option_control_t));
+ controls = next_controls;
+ cap = next_cap;
+ }
+
+ dialog_option_control_t *ctl = &controls[count];
+ ctl->group_id = next_id++;
+ ctl->control_id = next_id++;
+ ctl->name = utf8_to_wide_alloc(name_utf8);
+ if (ctl->name == NULL) {
+ free(work);
+ free_option_controls(controls, count + 1);
+ return false;
+ }
+ ctl->default_value = atoi(default_utf8);
+
+ if (values_utf8[0] == '\0') {
+ ctl->is_checkbox = 1;
+ ctl->items = NULL;
+ ctl->item_count = 0;
+ } else {
+ ctl->is_checkbox = 0;
+ int item_cap = 4;
+ int item_count = 0;
+ wchar_t **items = (wchar_t **)malloc((size_t)item_cap * sizeof(wchar_t *));
+ if (items == NULL) {
+ free(work);
+ free_option_controls(controls, count + 1);
+ return false;
+ }
+
+ char *values_copy = cstr_dup_alloc(values_utf8);
+ if (values_copy == NULL) {
+ free(items);
+ free(work);
+ free_option_controls(controls, count + 1);
+ return false;
+ }
+
+ char *value_cursor = values_copy;
+ for (char *value = split_next_token_inplace(&value_cursor, ';'); value != NULL; value = split_next_token_inplace(&value_cursor, ';')) {
+ if (value[0] == '\0') {
+ continue;
+ }
+ if (item_count == item_cap) {
+ int next_item_cap = item_cap * 2;
+ wchar_t **next_items = (wchar_t **)realloc(items, (size_t)next_item_cap * sizeof(wchar_t *));
+ if (next_items == NULL) {
+ free(values_copy);
+ free_wide_string_array(items, item_count);
+ free(work);
+ free_option_controls(controls, count + 1);
+ return false;
+ }
+ items = next_items;
+ item_cap = next_item_cap;
+ }
+ items[item_count] = utf8_to_wide_alloc(value);
+ if (items[item_count] == NULL) {
+ free(values_copy);
+ free_wide_string_array(items, item_count);
+ free(work);
+ free_option_controls(controls, count + 1);
+ return false;
+ }
+ item_count++;
+ }
+ free(values_copy);
+
+ if (item_count == 0) {
+ free(items);
+ ctl->is_checkbox = 1;
+ ctl->items = NULL;
+ ctl->item_count = 0;
+ } else {
+ ctl->items = items;
+ ctl->item_count = item_count;
+ }
+ }
+ count++;
+ }
+
+ free(work);
+ *out_controls = controls;
+ *out_count = count;
+ return true;
+}
+
+/*
+ * IFileDialogCustomize methods return HRESULTs; keep the first failure visible to Go.
+ * Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nn-shobjidl_core-ifiledialogcustomize
+ */
+static HRESULT add_option_controls(IFileDialogCustomize *customize, dialog_option_control_t *controls, int count) {
+ if (customize == NULL || controls == NULL || count == 0) {
+ return S_OK;
+ }
+ for (int i = 0; i < count; i++) {
+ dialog_option_control_t *ctl = &controls[i];
+ if (ctl->is_checkbox) {
+ HRESULT hr = customize->lpVtbl->StartVisualGroup(customize, ctl->group_id, L"");
+ if (FAILED(hr)) return hr;
+ hr = customize->lpVtbl->AddCheckButton(customize, ctl->control_id, ctl->name, ctl->default_value ? TRUE : FALSE);
+ if (FAILED(hr)) {
+ customize->lpVtbl->EndVisualGroup(customize);
+ return hr;
+ }
+ hr = customize->lpVtbl->SetControlState(customize, ctl->control_id, CDCS_VISIBLE | CDCS_ENABLED);
+ if (FAILED(hr)) {
+ customize->lpVtbl->EndVisualGroup(customize);
+ return hr;
+ }
+ hr = customize->lpVtbl->EndVisualGroup(customize);
+ if (FAILED(hr)) return hr;
+ } else {
+ HRESULT hr = customize->lpVtbl->StartVisualGroup(customize, ctl->group_id, ctl->name);
+ if (FAILED(hr)) return hr;
+ hr = customize->lpVtbl->AddComboBox(customize, ctl->control_id);
+ if (FAILED(hr)) {
+ customize->lpVtbl->EndVisualGroup(customize);
+ return hr;
+ }
+ for (int j = 0; j < ctl->item_count; j++) {
+ hr = customize->lpVtbl->AddControlItem(customize, ctl->control_id, (DWORD)j, ctl->items[j]);
+ if (FAILED(hr)) {
+ customize->lpVtbl->EndVisualGroup(customize);
+ return hr;
+ }
+ }
+ int idx = ctl->default_value;
+ if (idx < 0) idx = 0;
+ if (idx >= ctl->item_count) idx = ctl->item_count - 1;
+ hr = customize->lpVtbl->SetSelectedControlItem(customize, ctl->control_id, (DWORD)idx);
+ if (FAILED(hr)) {
+ customize->lpVtbl->EndVisualGroup(customize);
+ return hr;
+ }
+ hr = customize->lpVtbl->SetControlState(customize, ctl->control_id, CDCS_VISIBLE | CDCS_ENABLED);
+ if (FAILED(hr)) {
+ customize->lpVtbl->EndVisualGroup(customize);
+ return hr;
+ }
+ hr = customize->lpVtbl->EndVisualGroup(customize);
+ if (FAILED(hr)) return hr;
+ }
+ }
+ return S_OK;
+}
+
+/* serializes selections as "name|b|0/1" or "name|i|index", one control per line */
+static char *collect_selected_options_utf8(IFileDialogCustomize *customize, dialog_option_control_t *controls, int count) {
+ if (customize == NULL || controls == NULL || count == 0) {
+ return NULL;
+ }
+ /* values are queried after Show() returns to capture final control state */
+ char *buf = NULL;
+ size_t len = 0;
+ size_t cap = 0;
+ bool first = true;
+
+ for (int i = 0; i < count; i++) {
+ dialog_option_control_t *ctl = &controls[i];
+ char *name_utf8 = wide_to_utf8_alloc(ctl->name);
+ if (name_utf8 == NULL) {
+ continue;
+ }
+
+ if (!first) {
+ if (!append_char(&buf, &len, &cap, '\n')) {
+ free(name_utf8);
+ free(buf);
+ return NULL;
+ }
+ }
+ first = false;
+
+ if (!append_text(&buf, &len, &cap, name_utf8) || !append_char(&buf, &len, &cap, '|')) {
+ free(name_utf8);
+ free(buf);
+ return NULL;
+ }
+ free(name_utf8);
+
+ if (ctl->is_checkbox) {
+ BOOL checked = FALSE;
+ if (FAILED(customize->lpVtbl->GetCheckButtonState(customize, ctl->control_id, &checked))) {
+ checked = ctl->default_value ? TRUE : FALSE;
+ }
+ if (!append_text(&buf, &len, &cap, checked ? "b|1" : "b|0")) {
+ free(buf);
+ return NULL;
+ }
+ } else {
+ DWORD selected = (DWORD)ctl->default_value;
+ if (FAILED(customize->lpVtbl->GetSelectedControlItem(customize, ctl->control_id, &selected))) {
+ selected = (DWORD)ctl->default_value;
+ }
+ char tmp[32];
+ snprintf(tmp, sizeof(tmp), "i|%lu", (unsigned long)selected);
+ if (!append_text(&buf, &len, &cap, tmp)) {
+ free(buf);
+ return NULL;
+ }
+ }
+ }
+
+ return buf;
+}
+
+static HRESULT STDMETHODCALLTYPE dialog_event_query_interface(IFileDialogEvents *This, REFIID riid, void **ppvObject) {
+ if (ppvObject == NULL) {
+ return E_POINTER;
+ }
+ *ppvObject = NULL;
+ if (riid == NULL) {
+ return E_NOINTERFACE;
+ }
+ if (IsEqualIID(riid, &IID_IUnknown) || IsEqualIID(riid, &IID_IFileDialogEvents)) {
+ *ppvObject = This;
+ This->lpVtbl->AddRef(This);
+ return S_OK;
+ }
+ return E_NOINTERFACE;
+}
+
+static ULONG STDMETHODCALLTYPE dialog_event_add_ref(IFileDialogEvents *This) {
+ dialog_event_handler_t *self = (dialog_event_handler_t *)This;
+ return (ULONG)InterlockedIncrement(&self->ref_count);
+}
+
+static ULONG STDMETHODCALLTYPE dialog_event_release(IFileDialogEvents *This) {
+ dialog_event_handler_t *self = (dialog_event_handler_t *)This;
+ LONG ref = InterlockedDecrement(&self->ref_count);
+ if (ref < 0) {
+ ref = 0;
+ }
+ /* handler storage is stack-owned by run_native_file_dialog; do not free from Release */
+ return (ULONG)ref;
+}
+
+static HRESULT STDMETHODCALLTYPE dialog_event_on_file_ok(IFileDialogEvents *This, IFileDialog *dialog) {
+ (void)This;
+ (void)dialog;
+ return S_OK;
+}
+
+static HRESULT STDMETHODCALLTYPE dialog_event_on_folder_changing(IFileDialogEvents *This, IFileDialog *dialog, IShellItem *psiFolder) {
+ (void)dialog;
+ dialog_event_handler_t *self = (dialog_event_handler_t *)This;
+ if (self == NULL || self->root == NULL || self->root[0] == L'\0') {
+ return S_OK;
+ }
+ if (psiFolder == NULL) {
+ return HRESULT_FROM_WIN32(ERROR_ACCESS_DENIED);
+ }
+
+ PWSTR folder_path = NULL;
+ HRESULT hr = psiFolder->lpVtbl->GetDisplayName(psiFolder, SIGDN_FILESYSPATH, &folder_path);
+ if (FAILED(hr) || folder_path == NULL) {
+ if (folder_path != NULL) {
+ CoTaskMemFree(folder_path);
+ }
+ return FAILED(hr) ? hr : HRESULT_FROM_WIN32(ERROR_ACCESS_DENIED);
+ }
+
+ bool ok = path_is_within_root(self->root, folder_path);
+ CoTaskMemFree(folder_path);
+
+ /*
+ * returning a failure HRESULT denies navigation, keeping the picker constrained
+ * to the configured root. Final selected paths are validated again after Show()
+ * as a hard safety check.
+ * Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialogevents-onfolderchanging
+ */
+ return ok ? S_OK : HRESULT_FROM_WIN32(ERROR_ACCESS_DENIED);
+}
+
+static HRESULT STDMETHODCALLTYPE dialog_event_on_folder_change(IFileDialogEvents *This, IFileDialog *dialog) {
+ (void)This;
+ (void)dialog;
+ return S_OK;
+}
+
+static HRESULT STDMETHODCALLTYPE dialog_event_on_selection_change(IFileDialogEvents *This, IFileDialog *dialog) {
+ (void)This;
+ (void)dialog;
+ return S_OK;
+}
+
+/*
+ * if the selected file is locked or in use, let the
+ * Windows file dialog decide what message/action to show
+ */
+static HRESULT STDMETHODCALLTYPE dialog_event_on_share_violation(IFileDialogEvents *This, IFileDialog *dialog, IShellItem *psi, FDE_SHAREVIOLATION_RESPONSE *pResponse) {
+ (void)This;
+ (void)dialog;
+ (void)psi;
+ /* windows is supposed to provide a valid pointer -> check anyway */
+ if (pResponse == NULL) {
+ return E_POINTER;
+ }
+ /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialogevents-onshareviolation */
+ /* Enum docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/ne-shobjidl_core-fde_shareviolation_response */
+ *pResponse = FDESVR_DEFAULT;
+ return S_OK;
+}
+
+static HRESULT STDMETHODCALLTYPE dialog_event_on_type_change(IFileDialogEvents *This, IFileDialog *dialog) {
+ (void)This;
+ (void)dialog;
+ return S_OK;
+}
+
+static HRESULT STDMETHODCALLTYPE dialog_event_on_overwrite(IFileDialogEvents *This, IFileDialog *dialog, IShellItem *psi, FDE_OVERWRITE_RESPONSE *pResponse) {
+ (void)This;
+ (void)dialog;
+ (void)psi;
+ /* Windows is supposed to provide a valid pointer -> check anyway */
+ if (pResponse == NULL) {
+ return E_POINTER;
+ }
+ /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialogevents-onoverwrite */
+ /* Enum docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/ne-shobjidl_core-fde_overwrite_response */
+ *pResponse = FDEOR_DEFAULT; /* Use the normal built-in behavior -> standard overwrite confirmation */
+ return S_OK;
+}
+
+static IFileDialogEventsVtbl g_dialog_event_vtbl = {
+ dialog_event_query_interface,
+ dialog_event_add_ref,
+ dialog_event_release,
+ dialog_event_on_file_ok,
+ dialog_event_on_folder_changing,
+ dialog_event_on_folder_change,
+ dialog_event_on_selection_change,
+ dialog_event_on_share_violation,
+ dialog_event_on_type_change,
+ dialog_event_on_overwrite,
+};
+
+static void dialog_event_handler_init(dialog_event_handler_t *handler, const wchar_t *root) {
+ if (handler == NULL) {
+ return;
+ }
+ handler->iface.lpVtbl = &g_dialog_event_vtbl;
+ handler->ref_count = 1;
+ handler->root = root;
+}
+
+static wchar_t *resolve_dialog_start_folder_alloc(const wchar_t *current_dir, const wchar_t *root) {
+ /*
+ * Prefer starting at current_dir only when it is within root.
+ * If a root is configured and current_dir falls outside it, start from root.
+ */
+ if (root != NULL && root[0] != L'\0') {
+ if (current_dir != NULL && current_dir[0] != L'\0' && path_is_within_root(root, current_dir)) {
+ return normalize_full_path_alloc(current_dir);
+ }
+ return normalize_full_path_alloc(root);
+ }
+ if (current_dir != NULL && current_dir[0] != L'\0') {
+ return normalize_full_path_alloc(current_dir);
+ }
+ return NULL;
+}
+
+static dialog_result_t make_empty_result(void) {
+ dialog_result_t res;
+ res.status = DIALOG_STATUS_CANCEL;
+ res.selected_filter_index = 0;
+ res.hresult = 0;
+ res.path_count = 0;
+ res.paths_utf8 = NULL;
+ res.selected_options_utf8 = NULL;
+ res.error_utf8 = NULL;
+ return res;
+}
+
+static dialog_result_t run_native_file_dialog(const dialog_request_t *req) {
+ /* high-level flow: init COM -> configure dialog -> show -> collect results -> cleanup */
+ dialog_result_t res = make_empty_result();
+ if (req == NULL) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "dialog request was null");
+ return res;
+ }
+ if (req->mode < DIALOG_MODE_OPEN_FILE || req->mode > DIALOG_MODE_OPEN_FOLDER) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "invalid native file dialog mode");
+ return res;
+ }
+
+ HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
+ bool should_uninitialize = SUCCEEDED(hr);
+ if (hr == RPC_E_CHANGED_MODE) {
+ /* RPC_E_CHANGED_MODE means the thread has an incompatible COM apartment */
+ /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex */
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "COM is already initialized with an incompatible apartment model");
+ return res;
+ } else if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to initialize COM for file dialog");
+ return res;
+ }
+
+ IFileDialog *dialog = NULL;
+ if (req->mode == DIALOG_MODE_SAVE_FILE) {
+ hr = CoCreateInstance(&CLSID_FileSaveDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileSaveDialog, (void **)&dialog);
} else {
- return "";
+ hr = CoCreateInstance(&CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void **)&dialog);
+ }
+ if (FAILED(hr) || dialog == NULL) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to create native file dialog");
+ if (should_uninitialize) {
+ CoUninitialize();
+ }
+ return res;
+ }
+
+ wchar_t *title = utf8_to_wide_alloc(req->title_utf8);
+ wchar_t *current_dir = utf8_to_wide_alloc(req->current_directory_utf8);
+ wchar_t *filename = utf8_to_wide_alloc(req->filename_utf8);
+ wchar_t *root = utf8_to_wide_alloc(req->root_utf8);
+ wchar_t *root_normalized = normalize_full_path_alloc(root);
+ /* use root when possible to make prefix checks stable across equivalent paths */
+ const wchar_t *enforced_root = root_normalized;
+ if (enforced_root == NULL) {
+ enforced_root = root;
+ }
+
+ COMDLG_FILTERSPEC *filter_specs = NULL;
+ wchar_t **filter_names = NULL;
+ wchar_t **filter_patterns = NULL;
+ int filter_count = 0;
+ if (!build_filter_specs(req->filters_utf8, &filter_specs, &filter_count, &filter_names, &filter_patterns)) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "failed to build dialog file filters");
+ dialog->lpVtbl->Release(dialog);
+ if (title) free(title);
+ if (current_dir) free(current_dir);
+ if (filename) free(filename);
+ if (root) free(root);
+ if (root_normalized) free(root_normalized);
+ if (should_uninitialize) CoUninitialize();
+ return res;
+ }
+
+ dialog_option_control_t *controls = NULL;
+ int control_count = 0;
+ HWND owner = (HWND)req->hwnd;
+ if (!build_option_controls(req->options_utf8, &controls, &control_count)) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "failed to build dialog option controls");
+ dialog->lpVtbl->Release(dialog);
+ if (title) free(title);
+ if (current_dir) free(current_dir);
+ if (filename) free(filename);
+ if (root) free(root);
+ if (root_normalized) free(root_normalized);
+ free_filter_arrays(filter_specs, filter_names, filter_patterns, filter_count);
+ if (should_uninitialize) CoUninitialize();
+ return res;
+ }
+
+ DWORD opts = 0;
+ hr = dialog->lpVtbl->GetOptions(dialog, &opts);
+ if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to read native dialog options");
+ goto cleanup;
+ }
+ opts |= FOS_FORCEFILESYSTEM;
+ if (req->mode == DIALOG_MODE_OPEN_FILES) opts |= FOS_ALLOWMULTISELECT;
+ if (req->mode == DIALOG_MODE_OPEN_FOLDER) opts |= FOS_PICKFOLDERS;
+ if (req->show_hidden) opts |= FOS_FORCESHOWHIDDEN;
+ if (req->mode == DIALOG_MODE_OPEN_FILE || req->mode == DIALOG_MODE_OPEN_FILES) opts |= FOS_FILEMUSTEXIST;
+ if (req->mode == DIALOG_MODE_SAVE_FILE) opts |= FOS_OVERWRITEPROMPT;
+ if ((current_dir != NULL && current_dir[0] != L'\0') || (root != NULL && root[0] != L'\0')) {
+ opts |= FOS_PATHMUSTEXIST;
+ }
+ hr = dialog->lpVtbl->SetOptions(dialog, opts);
+ if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to apply native dialog options");
+ goto cleanup;
+ }
+
+ if (title != NULL) {
+ hr = dialog->lpVtbl->SetTitle(dialog, title);
+ if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to set native dialog title");
+ goto cleanup;
+ }
+ }
+ if (filename != NULL && req->mode == DIALOG_MODE_SAVE_FILE) {
+ hr = dialog->lpVtbl->SetFileName(dialog, filename);
+ if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to set native dialog filename");
+ goto cleanup;
+ }
+ }
+ if (filter_count > 0 && req->mode != DIALOG_MODE_OPEN_FOLDER) {
+ hr = dialog->lpVtbl->SetFileTypes(dialog, (UINT)filter_count, filter_specs);
+ if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to configure native dialog file filters");
+ goto cleanup;
+ }
+ hr = dialog->lpVtbl->SetFileTypeIndex(dialog, 1); /* uses 1-based indexing */
+ if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to select default native dialog filter");
+ goto cleanup;
+ }
+ }
+
+ dialog_event_handler_t event_handler;
+ DWORD event_cookie = 0;
+ bool event_advised = false;
+ if (enforced_root != NULL && enforced_root[0] != L'\0') {
+ dialog_event_handler_init(&event_handler, enforced_root);
+ /*
+ * Advise wires dialog_event_on_folder_changing so navigation can be
+ * blocked immediately.
+ * We still enforce root again against the final selected path(s) below.
+ * Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialog-advise
+ */
+ hr = dialog->lpVtbl->Advise(dialog, (IFileDialogEvents *)&event_handler, &event_cookie);
+ if (SUCCEEDED(hr)) {
+ event_advised = true;
+ } else {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to attach native dialog root guard");
+ goto cleanup;
+ }
+ }
+
+ IShellItem *folder_item = NULL;
+ wchar_t *start_folder_path = resolve_dialog_start_folder_alloc(current_dir, enforced_root);
+ if (start_folder_path != NULL) {
+ /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-shcreateitemfromparsingname */
+ hr = SHCreateItemFromParsingName(start_folder_path, NULL, &IID_IShellItem, (void **)&folder_item);
+ if (SUCCEEDED(hr) && folder_item != NULL) {
+ /* SetDefaultFolder respects MRU (most recently used) */
+ /* SetFolder forces the constrained starting folder */
+ /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialog-setdefaultfolder */
+ /*hr = dialog->lpVtbl->SetDefaultFolder(dialog, folder_item);
+ if (FAILED(hr) && enforced_root != NULL && enforced_root[0] != L'\0') {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to set native dialog default root folder");
+ folder_item->lpVtbl->Release(folder_item);
+ free(start_folder_path);
+ goto cleanup;
+ }*/
+ /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialog-setfolder */
+ hr = dialog->lpVtbl->SetFolder(dialog, folder_item);
+ if (FAILED(hr) && enforced_root != NULL && enforced_root[0] != L'\0') {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to set native dialog root folder");
+ folder_item->lpVtbl->Release(folder_item);
+ free(start_folder_path);
+ goto cleanup;
+ }
+ folder_item->lpVtbl->Release(folder_item);
+ } else if (enforced_root != NULL && enforced_root[0] != L'\0') {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to create native dialog root folder item");
+ free(start_folder_path);
+ goto cleanup;
+ }
+ free(start_folder_path);
+ }
+
+ IFileDialogCustomize *customize = NULL;
+ if (control_count > 0) {
+ hr = dialog->lpVtbl->QueryInterface(dialog, &IID_IFileDialogCustomize, (void **)&customize);
+ if (SUCCEEDED(hr) && customize != NULL) {
+ hr = add_option_controls(customize, controls, control_count);
+ }
+ if (FAILED(hr) || customize == NULL) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)(FAILED(hr) ? hr : E_NOINTERFACE);
+ set_error_message(&res, "failed to add custom controls to native dialog");
+ if (customize) {
+ customize->lpVtbl->Release(customize);
+ customize = NULL;
+ }
+ goto cleanup;
+ }
+ }
+
+ hr = dialog->lpVtbl->Show(dialog, owner);
+ res.hresult = (int)hr;
+ if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) {
+ res.status = DIALOG_STATUS_CANCEL;
+ } else if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "native file dialog failed to show");
+ } else {
+ res.status = DIALOG_STATUS_OK;
+ UINT idx = 1;
+ if (SUCCEEDED(dialog->lpVtbl->GetFileTypeIndex(dialog, &idx))) {
+ if (idx > 0) {
+ idx -= 1;
+ }
+ res.selected_filter_index = (int)idx;
+ }
+
+ /* Custom controls are read after Show() returns because that is when
+ * the final user choice is available. Go fills defaults if this is empty.
+ */
+ if (customize != NULL && control_count > 0) {
+ res.selected_options_utf8 = collect_selected_options_utf8(customize, controls, control_count);
+ }
+
+ if (req->mode == DIALOG_MODE_OPEN_FILES) {
+ IShellItemArray *results = NULL;
+ hr = ((IFileOpenDialog *)dialog)->lpVtbl->GetResults((IFileOpenDialog *)dialog, &results);
+ if (FAILED(hr) || results == NULL) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "native dialog accepted but failed to read selected files");
+ goto cleanup;
+ }
+ DWORD count = 0;
+ hr = results->lpVtbl->GetCount(results, &count);
+ if (FAILED(hr)) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to count selected files");
+ clear_result_paths(&res);
+ results->lpVtbl->Release(results);
+ goto cleanup;
+ }
+ for (DWORD i = 0; i < count; i++) {
+ IShellItem *item = NULL;
+ hr = results->lpVtbl->GetItemAt(results, i, &item);
+ if (FAILED(hr) || item == NULL) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to read selected file item");
+ clear_result_paths(&res);
+ results->lpVtbl->Release(results);
+ goto cleanup;
+ }
+
+ PWSTR file_path = NULL;
+ hr = item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, &file_path);
+ if (FAILED(hr) || file_path == NULL) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to read selected file path");
+ clear_result_paths(&res);
+ if (file_path != NULL) {
+ CoTaskMemFree(file_path);
+ }
+ item->lpVtbl->Release(item);
+ results->lpVtbl->Release(results);
+ goto cleanup;
+ }
+
+ /* keep this post-selection check even with events; callers rely on hard enforcement */
+ if (!path_is_within_root(enforced_root, file_path)) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "selected path is outside configured dialog root");
+ clear_result_paths(&res);
+ CoTaskMemFree(file_path);
+ item->lpVtbl->Release(item);
+ results->lpVtbl->Release(results);
+ goto cleanup;
+ }
+ if (!push_path(&res, file_path)) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "failed to store selected file path");
+ clear_result_paths(&res);
+ CoTaskMemFree(file_path);
+ item->lpVtbl->Release(item);
+ results->lpVtbl->Release(results);
+ goto cleanup;
+ }
+
+ CoTaskMemFree(file_path);
+ item->lpVtbl->Release(item);
+ }
+ results->lpVtbl->Release(results);
+ if (res.path_count == 0) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "native dialog accepted but returned no file paths");
+ goto cleanup;
+ }
+ } else {
+ IShellItem *item = NULL;
+ hr = dialog->lpVtbl->GetResult(dialog, &item);
+ if (FAILED(hr) || item == NULL) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "native dialog accepted but failed to read selected item");
+ goto cleanup;
+ }
+ PWSTR file_path = NULL;
+ hr = item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, &file_path);
+ if (FAILED(hr) || file_path == NULL) {
+ res.status = DIALOG_STATUS_ERROR;
+ res.hresult = (int)hr;
+ set_error_message(&res, "failed to read selected file path");
+ if (file_path != NULL) {
+ CoTaskMemFree(file_path);
+ }
+ item->lpVtbl->Release(item);
+ goto cleanup;
+ }
+ if (!path_is_within_root(enforced_root, file_path)) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "selected path is outside configured dialog root");
+ CoTaskMemFree(file_path);
+ item->lpVtbl->Release(item);
+ goto cleanup;
+ }
+ if (!push_path(&res, file_path)) {
+ res.status = DIALOG_STATUS_ERROR;
+ set_error_message(&res, "failed to store selected file path");
+ CoTaskMemFree(file_path);
+ item->lpVtbl->Release(item);
+ goto cleanup;
+ }
+ CoTaskMemFree(file_path);
+ item->lpVtbl->Release(item);
+ }
+ }
+
+cleanup:
+ if (owner != NULL) {
+ SetForegroundWindow(owner);
+ }
+ if (event_advised) {
+ dialog->lpVtbl->Unadvise(dialog, event_cookie);
+ }
+ if (customize != NULL) customize->lpVtbl->Release(customize);
+ dialog->lpVtbl->Release(dialog);
+ if (title) free(title);
+ if (current_dir) free(current_dir);
+ if (filename) free(filename);
+ if (root) free(root);
+ if (root_normalized) free(root_normalized);
+ free_filter_arrays(filter_specs, filter_names, filter_patterns, filter_count);
+ free_option_controls(controls, control_count);
+ if (should_uninitialize) CoUninitialize();
+ return res;
+}
+
+static void free_native_file_dialog_result(dialog_result_t *result) {
+ if (result == NULL) {
+ return;
+ }
+ clear_result_paths(result);
+ if (result->selected_options_utf8 != NULL) {
+ free(result->selected_options_utf8);
+ result->selected_options_utf8 = NULL;
+ }
+ if (result->error_utf8 != NULL) {
+ free(result->error_utf8);
+ result->error_utf8 = NULL;
}
}
diff --git a/src/platform/filesystem/directory_android.go b/src/platform/filesystem/directory_android.go
index 40f778123..44a6e76f8 100644
--- a/src/platform/filesystem/directory_android.go
+++ b/src/platform/filesystem/directory_android.go
@@ -67,3 +67,9 @@ func openSaveFileDialogWindow(startPath string, fileName string, extensions []Di
klib.NotYetImplemented(-1)
return nil
}
+
+func openFolderDialogWindow(startPath string, ok func(path string), cancel func(), windowHandle unsafe.Pointer) error {
+ // TODO: Eventually we'll create our own fully working file browser, instead of using current temp one
+ klib.NotYetImplemented(-1)
+ return nil
+}
diff --git a/src/platform/filesystem/directory_darwin.go b/src/platform/filesystem/directory_darwin.go
index 66a6a5ad9..848c5e782 100644
--- a/src/platform/filesystem/directory_darwin.go
+++ b/src/platform/filesystem/directory_darwin.go
@@ -133,3 +133,26 @@ func openSaveFileDialogWindow(startPath string, fileName string, extensions []Di
}
return nil
}
+
+func openFolderDialogWindow(startPath string, ok func(path string), cancel func(), windowHandle unsafe.Pointer) error {
+ script := `POSIX path of (choose folder`
+ if startPath != "" {
+ script += ` default location "` + startPath + `"`
+ }
+ script += `)`
+ cmd := exec.Command("osascript", "-e", script)
+ output, err := cmd.Output()
+ if err != nil {
+ if cancel != nil {
+ cancel()
+ }
+ return nil
+ }
+ path := strings.TrimSpace(string(output))
+ if path != "" {
+ ok(path)
+ } else if cancel != nil {
+ cancel()
+ }
+ return nil
+}
diff --git a/src/platform/filesystem/directory_dialog_stub.go b/src/platform/filesystem/directory_dialog_stub.go
new file mode 100644
index 000000000..d47487037
--- /dev/null
+++ b/src/platform/filesystem/directory_dialog_stub.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package filesystem
+
+import "errors"
+
+func openNativeDialogWindow(_ NativeDialogRequest, _ func(NativeDialogResult)) error {
+ return errors.New("native dialog request API is only implemented on windows")
+}
+
+func processDialogCallbacks() {}
+
+func shutdownNativeDialogs() {}
diff --git a/src/platform/filesystem/directory_linux.go b/src/platform/filesystem/directory_linux.go
index 6029100df..6d3cd13ab 100644
--- a/src/platform/filesystem/directory_linux.go
+++ b/src/platform/filesystem/directory_linux.go
@@ -77,3 +77,9 @@ func openSaveFileDialogWindow(startPath string, fileName string, extensions []Di
klib.NotYetImplemented(-1)
return nil
}
+
+func openFolderDialogWindow(startPath string, ok func(path string), cancel func(), windowHandle unsafe.Pointer) error {
+ // TODO: Eventually we'll create our own fully working file browser, instead of using current temp one
+ klib.NotYetImplemented(-1)
+ return nil
+}
diff --git a/src/platform/filesystem/directory_windows.go b/src/platform/filesystem/directory_windows.go
index 7cd00940d..b464a2e80 100644
--- a/src/platform/filesystem/directory_windows.go
+++ b/src/platform/filesystem/directory_windows.go
@@ -1,4 +1,4 @@
-//go:build windows
+//go:build windows && filedialog
/******************************************************************************/
/* directory_windows.go */
@@ -9,19 +9,25 @@
package filesystem
/*
-#cgo windows LDFLAGS: -lcomdlg32
-#cgo noescape open_file_dialog
-#cgo noescape save_file_dialog
+#cgo windows LDFLAGS: -lshell32 -lole32 -luuid
+#include
#include "directory.win32.h"
*/
import "C"
import (
+ "errors"
"fmt"
+ "log/slog"
"os"
"os/exec"
"path/filepath"
+ "runtime"
+ "strconv"
"strings"
+ "sync"
+ "sync/atomic"
+ "time"
"unsafe"
"kaijuengine.com/build"
@@ -29,6 +35,22 @@ import (
"golang.org/x/sys/windows"
)
+type queuedDialogCallback struct {
+ callback func(NativeDialogResult)
+ result NativeDialogResult
+}
+
+var windowsDialogRuntime = struct {
+ mu sync.Mutex
+ nextID atomic.Uint64
+ active map[uint64]struct{}
+ callbacks []queuedDialogCallback
+ closing bool
+ wg sync.WaitGroup
+}{
+ active: make(map[uint64]struct{}),
+}
+
func knownPaths() map[string]string {
folders := map[string]*windows.KNOWNFOLDERID{
"Desktop": windows.FOLDERID_Desktop,
@@ -88,47 +110,486 @@ func openFileBrowserSelectCommand(path string) *exec.Cmd {
}
func openFileDialogWindow(startPath string, extensions []DialogExtension, ok func(path string), cancel func(), windowHandle unsafe.Pointer) error {
- ext := strings.Builder{}
- for i := range extensions {
- e := &extensions[i]
- ext.WriteString(fmt.Sprintf("%s\n*%s\n", e.Name, e.Extension))
- }
- if len(extensions) == 0 {
- ext.WriteString("All Files\x00*.*\x00\x00")
- }
- cStartPath := C.CString(startPath)
- defer C.free(unsafe.Pointer(cStartPath))
- cExt := C.CString(ext.String())
- defer C.free(unsafe.Pointer(cExt))
- savePath := C.GoString(C.open_file_dialog(cStartPath, cExt, windowHandle))
- if savePath != "" {
- ok(savePath)
- } else if cancel != nil {
- cancel()
+ if ok == nil {
+ return errors.New("open file dialog ok callback cannot be nil")
}
- return nil
+ request := NativeDialogRequest{
+ Mode: NativeDialogModeOpenFile,
+ CurrentDirectory: startPath,
+ Filters: extensionsToDialogFilters(extensions),
+ WindowHandle: windowHandle,
+ }
+ return openNativeDialogWindow(request, makeSimpleDialogCallback(ok, cancel))
}
func openSaveFileDialogWindow(startPath string, fileName string, extensions []DialogExtension, ok func(path string), cancel func(), windowHandle unsafe.Pointer) error {
- ext := strings.Builder{}
- for i := range extensions {
- e := &extensions[i]
- ext.WriteString(fmt.Sprintf("%s\n*%s\n", e.Name, e.Extension))
- }
- if len(extensions) == 0 {
- ext.WriteString("All Files\x00*.*\x00\x00")
- }
- cStartPath := C.CString(startPath)
- defer C.free(unsafe.Pointer(cStartPath))
- cFileName := C.CString(fileName)
- defer C.free(unsafe.Pointer(cFileName))
- cExt := C.CString(ext.String())
- defer C.free(unsafe.Pointer(cExt))
- savePath := C.GoString(C.save_file_dialog(cStartPath, cFileName, cExt, windowHandle))
- if savePath != "" {
- ok(savePath)
- } else if cancel != nil {
- cancel()
+ if ok == nil {
+ return errors.New("save file dialog ok callback cannot be nil")
+ }
+ request := NativeDialogRequest{
+ Mode: NativeDialogModeSaveFile,
+ CurrentDirectory: startPath,
+ FileName: fileName,
+ Filters: extensionsToDialogFilters(extensions),
+ WindowHandle: windowHandle,
+ }
+ return openNativeDialogWindow(request, makeSimpleDialogCallback(ok, cancel))
+}
+
+func openFolderDialogWindow(startPath string, ok func(path string), cancel func(), windowHandle unsafe.Pointer) error {
+ if ok == nil {
+ return errors.New("open folder dialog ok callback cannot be nil")
+ }
+ request := NativeDialogRequest{
+ Mode: NativeDialogModeOpenFolder,
+ CurrentDirectory: startPath,
+ WindowHandle: windowHandle,
+ }
+ return openNativeDialogWindow(request, makeSimpleDialogCallback(ok, cancel))
+}
+
+func openNativeDialogWindow(request NativeDialogRequest, callback func(NativeDialogResult)) error {
+ if callback == nil {
+ return errors.New("native dialog callback cannot be nil")
+ }
+
+ if err := validateNativeDialogRequest(request); err != nil {
+ return err
+ }
+
+ windowsDialogRuntime.mu.Lock()
+ if windowsDialogRuntime.closing {
+ windowsDialogRuntime.mu.Unlock()
+ return errors.New("native dialog runtime is shutting down")
}
+ id := windowsDialogRuntime.nextID.Add(1)
+ windowsDialogRuntime.active[id] = struct{}{}
+ windowsDialogRuntime.wg.Add(1)
+ windowsDialogRuntime.mu.Unlock()
+
+ go runNativeDialogRequest(id, request, callback)
return nil
}
+
+func validateNativeDialogRequest(request NativeDialogRequest) error {
+ if request.Mode > NativeDialogModeOpenFolder {
+ return fmt.Errorf("invalid native dialog mode: %d", request.Mode)
+ }
+
+ // The C bridge encodes filters/options with raw |, ;, and newline delimiters
+ // Reject ambiguous fields here
+ for i := range request.Filters {
+ filter := request.Filters[i]
+ if strings.ContainsAny(filter.Name, "|\r\n") {
+ return fmt.Errorf("native dialog filter %d name contains an unsupported delimiter", i)
+ }
+ for j := range filter.Patterns {
+ if strings.ContainsAny(filter.Patterns[j], "|;\r\n") {
+ return fmt.Errorf("native dialog filter %d pattern %d contains an unsupported delimiter", i, j)
+ }
+ }
+ }
+ for i := range request.Options {
+ option := request.Options[i]
+ if strings.ContainsAny(option.Name, "|\r\n") {
+ return fmt.Errorf("native dialog option %d name contains an unsupported delimiter", i)
+ }
+ for j := range option.Values {
+ if strings.ContainsAny(option.Values[j], "|;\r\n") {
+ return fmt.Errorf("native dialog option %d value %d contains an unsupported delimiter", i, j)
+ }
+ }
+ }
+ return nil
+}
+
+func runNativeDialogRequest(id uint64, request NativeDialogRequest, callback func(NativeDialogResult)) {
+ defer windowsDialogRuntime.wg.Done()
+ defer func() {
+ windowsDialogRuntime.mu.Lock()
+ delete(windowsDialogRuntime.active, id)
+ windowsDialogRuntime.mu.Unlock()
+ }()
+
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ result := executeNativeDialog(request)
+ if len(result.SelectedOptions) == 0 {
+ result.SelectedOptions = defaultSelectedOptions(request.Options)
+ }
+
+ windowsDialogRuntime.mu.Lock()
+ if !windowsDialogRuntime.closing {
+ windowsDialogRuntime.callbacks = append(windowsDialogRuntime.callbacks, queuedDialogCallback{
+ callback: callback,
+ result: result,
+ })
+ }
+ windowsDialogRuntime.mu.Unlock()
+}
+
+func executeNativeDialog(request NativeDialogRequest) NativeDialogResult {
+ var cReq C.dialog_request_t
+
+ cReq.mode = C.int(request.Mode)
+ cReq.show_hidden = 0
+ if request.ShowHidden {
+ cReq.show_hidden = 1
+ }
+ cReq.hwnd = request.WindowHandle
+
+ cTitle := cStringOrNil(request.Title)
+ cCurrentDir := cStringOrNil(request.CurrentDirectory)
+ cFileName := cStringOrNil(request.FileName)
+ cRoot := cStringOrNil(request.Root)
+ cFilters := cStringOrNil(encodeDialogFilters(request.Filters))
+ cOptions := cStringOrNil(encodeDialogOptions(request.Options))
+
+ defer freeCString(cTitle)
+ defer freeCString(cCurrentDir)
+ defer freeCString(cFileName)
+ defer freeCString(cRoot)
+ defer freeCString(cFilters)
+ defer freeCString(cOptions)
+
+ cReq.title_utf8 = cTitle
+ cReq.current_directory_utf8 = cCurrentDir
+ cReq.filename_utf8 = cFileName
+ cReq.root_utf8 = cRoot
+ cReq.filters_utf8 = cFilters
+ cReq.options_utf8 = cOptions
+
+ cRes := C.run_native_file_dialog(&cReq)
+ defer C.free_native_file_dialog_result(&cRes)
+
+ result := NativeDialogResult{
+ SelectedFilterIndex: int(cRes.selected_filter_index),
+ HResult: int32(cRes.hresult),
+ Paths: make([]string, 0, int(cRes.path_count)),
+ }
+
+ switch int(cRes.status) {
+ case int(C.DIALOG_STATUS_OK):
+ result.Status = NativeDialogStatusAccepted
+ case int(C.DIALOG_STATUS_CANCEL):
+ result.Status = NativeDialogStatusCancel
+ default:
+ result.Status = NativeDialogStatusFailed
+ }
+
+ if cRes.error_utf8 != nil {
+ result.Err = errors.New(C.GoString(cRes.error_utf8))
+ }
+ if cRes.selected_options_utf8 != nil {
+ result.SelectedOptions = parseDialogSelectedOptions(C.GoString(cRes.selected_options_utf8))
+ }
+
+ if cRes.path_count > 0 && cRes.paths_utf8 != nil {
+ paths := unsafe.Slice((**C.char)(unsafe.Pointer(cRes.paths_utf8)), int(cRes.path_count))
+ for i := range paths {
+ if paths[i] != nil {
+ result.Paths = append(result.Paths, C.GoString(paths[i]))
+ }
+ }
+ }
+
+ applyNativeDialogResultGuards(&result, request.Root)
+ return result
+}
+
+func applyNativeDialogResultGuards(result *NativeDialogResult, root string) {
+ if result == nil {
+ return
+ }
+
+ if result.Status == NativeDialogStatusAccepted && strings.TrimSpace(root) != "" {
+ for i := range result.Paths {
+ if !isPathWithinDialogRoot(root, result.Paths[i]) {
+ result.Status = NativeDialogStatusFailed
+ result.Err = errors.New("selected path is outside configured dialog root")
+ result.Paths = nil
+ break
+ }
+ }
+ }
+
+ if result.Status == NativeDialogStatusAccepted && len(result.Paths) == 0 {
+ result.Status = NativeDialogStatusCancel
+ }
+
+ if result.Status == NativeDialogStatusFailed && len(result.Paths) > 0 {
+ // Error results should never expose partial selections to callers.
+ result.Paths = nil
+ }
+
+ if result.Status == NativeDialogStatusFailed && result.Err == nil {
+ result.Err = fmt.Errorf("native file dialog failed (HRESULT=0x%08X)", uint32(result.HResult))
+ }
+}
+
+func isPathWithinDialogRoot(root, path string) bool {
+ rootNorm, ok := normalizeDialogPathForContainment(root)
+ if !ok {
+ return false
+ }
+ pathNorm, ok := normalizeDialogPathForContainment(path)
+ if !ok {
+ return false
+ }
+ if !strings.HasPrefix(pathNorm, rootNorm) {
+ return false
+ }
+ // Drive roots include the separator (e.g. C:\), so a prefix match already implies containment.
+ if isDriveRootPath(rootNorm) {
+ return true
+ }
+ if len(pathNorm) == len(rootNorm) {
+ return true
+ }
+ next := pathNorm[len(rootNorm)]
+ return next == '\\' || next == '/'
+}
+
+func normalizeDialogPathForContainment(path string) (string, bool) {
+ p := strings.TrimSpace(path)
+ if p == "" {
+ return "", false
+ }
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ return "", false
+ }
+ norm := filepath.Clean(abs)
+ if !isDriveRootPath(norm) {
+ norm = strings.TrimRight(norm, `\/`)
+ }
+ return strings.ToLower(norm), true
+}
+
+func isDriveRootPath(path string) bool {
+ return len(path) == 3 && path[1] == ':' && (path[2] == '\\' || path[2] == '/')
+}
+
+func processDialogCallbacks() {
+ windowsDialogRuntime.mu.Lock()
+ pending := windowsDialogRuntime.callbacks
+ windowsDialogRuntime.callbacks = nil
+ windowsDialogRuntime.mu.Unlock()
+
+ for i := range pending {
+ func(item queuedDialogCallback) {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("panic while executing native dialog callback", "panic", r)
+ }
+ }()
+ item.callback(item.result)
+ }(pending[i])
+ }
+}
+
+func shutdownNativeDialogs() {
+ windowsDialogRuntime.mu.Lock()
+ if windowsDialogRuntime.closing {
+ windowsDialogRuntime.mu.Unlock()
+ return
+ }
+ windowsDialogRuntime.closing = true
+ windowsDialogRuntime.callbacks = nil
+ windowsDialogRuntime.mu.Unlock()
+
+ done := make(chan struct{})
+ go func() {
+ windowsDialogRuntime.wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ slog.Warn("timed out waiting for native dialog worker threads to finish")
+ }
+}
+
+func makeSimpleDialogCallback(ok func(path string), cancel func()) func(NativeDialogResult) {
+ return func(result NativeDialogResult) {
+ if ok != nil && result.Status == NativeDialogStatusAccepted && len(result.Paths) > 0 {
+ ok(result.Paths[0])
+ return
+ }
+ if result.Status == NativeDialogStatusFailed {
+ slog.Error("native dialog request failed", "error", result.Err, "hresult", fmt.Sprintf("0x%08X", uint32(result.HResult)))
+ }
+ if cancel != nil {
+ cancel()
+ }
+ }
+}
+
+func extensionsToDialogFilters(extensions []DialogExtension) []DialogFilter {
+ filters := make([]DialogFilter, 0, len(extensions)+1)
+ for i := range extensions {
+ e := extensions[i]
+ name := strings.TrimSpace(e.Name)
+ if name == "" {
+ name = "Files"
+ }
+ pattern := normalizeDialogPattern(e.Extension)
+ filters = append(filters, DialogFilter{Name: name, Patterns: []string{pattern}})
+ }
+ if len(filters) == 0 {
+ filters = append(filters, DialogFilter{Name: "All Files (*.*)", Patterns: []string{"*.*"}})
+ }
+ return filters
+}
+
+func normalizeDialogPattern(extension string) string {
+ ext := strings.TrimSpace(extension)
+ if ext == "" || ext == ".*" || ext == "*" || ext == "*.*" {
+ return "*.*"
+ }
+ if strings.Contains(ext, "*") {
+ return ext
+ }
+ if strings.HasPrefix(ext, ".") {
+ return "*" + ext
+ }
+ return "*." + ext
+}
+
+func encodeDialogFilters(filters []DialogFilter) string {
+ if len(filters) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ for i := range filters {
+ f := filters[i]
+ name := strings.TrimSpace(f.Name)
+ if name == "" {
+ name = "Files"
+ }
+ patterns := make([]string, 0, len(f.Patterns))
+ for j := range f.Patterns {
+ p := strings.TrimSpace(f.Patterns[j])
+ if p != "" {
+ patterns = append(patterns, p)
+ }
+ }
+ if len(patterns) == 0 {
+ patterns = []string{"*.*"}
+ }
+ b.WriteString(name)
+ b.WriteString("|")
+ b.WriteString(strings.Join(patterns, ";"))
+ if i != len(filters)-1 {
+ b.WriteString("\n")
+ }
+ }
+ return b.String()
+}
+
+func encodeDialogOptions(options []DialogCustomOption) string {
+ if len(options) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ first := true
+ for i := range options {
+ opt := options[i]
+ name := strings.TrimSpace(opt.Name)
+ if name == "" {
+ continue
+ }
+ if !first {
+ b.WriteString("\n")
+ }
+ first = false
+ b.WriteString(name)
+ b.WriteString("|")
+ b.WriteString(fmt.Sprintf("%d", opt.Default))
+ b.WriteString("|")
+ if len(opt.Values) > 0 {
+ values := make([]string, 0, len(opt.Values))
+ for j := range opt.Values {
+ v := strings.TrimSpace(opt.Values[j])
+ if v != "" {
+ values = append(values, v)
+ }
+ }
+ b.WriteString(strings.Join(values, ";"))
+ }
+ }
+ return b.String()
+}
+
+func parseDialogSelectedOptions(encoded string) map[string]any {
+ selected := map[string]any{}
+ if strings.TrimSpace(encoded) == "" {
+ return selected
+ }
+ lines := strings.Split(encoded, "\n")
+ for i := range lines {
+ line := strings.TrimSpace(lines[i])
+ if line == "" {
+ continue
+ }
+ parts := strings.Split(line, "|")
+ if len(parts) != 3 {
+ continue
+ }
+ name := strings.TrimSpace(parts[0])
+ kind := strings.TrimSpace(parts[1])
+ value := strings.TrimSpace(parts[2])
+ if name == "" {
+ continue
+ }
+ switch kind {
+ case "b":
+ selected[name] = value == "1"
+ case "i":
+ idx, err := strconv.Atoi(value)
+ if err == nil {
+ selected[name] = idx
+ }
+ }
+ }
+ return selected
+}
+
+func defaultSelectedOptions(options []DialogCustomOption) map[string]any {
+ selected := make(map[string]any, len(options))
+ for i := range options {
+ opt := options[i]
+ name := strings.TrimSpace(opt.Name)
+ if name == "" {
+ continue
+ }
+ if len(opt.Values) == 0 {
+ selected[name] = opt.Default != 0
+ continue
+ }
+ idx := opt.Default
+ if idx < 0 {
+ idx = 0
+ }
+ if idx >= len(opt.Values) {
+ idx = len(opt.Values) - 1
+ }
+ selected[name] = idx
+ }
+ return selected
+}
+
+func cStringOrNil(v string) *C.char {
+ if strings.TrimSpace(v) == "" {
+ return nil
+ }
+ return C.CString(v)
+}
+
+func freeCString(s *C.char) {
+ if s != nil {
+ C.free(unsafe.Pointer(s))
+ }
+}
diff --git a/src/platform/filesystem/directory_windows_nodialog.go b/src/platform/filesystem/directory_windows_nodialog.go
new file mode 100644
index 000000000..dba64c52a
--- /dev/null
+++ b/src/platform/filesystem/directory_windows_nodialog.go
@@ -0,0 +1,90 @@
+//go:build windows && !filedialog
+
+package filesystem
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+func knownPaths() map[string]string {
+ folders := map[string]*windows.KNOWNFOLDERID{
+ "Desktop": windows.FOLDERID_Desktop,
+ "Documents": windows.FOLDERID_Documents,
+ "Downloads": windows.FOLDERID_Downloads,
+ "Music": windows.FOLDERID_Music,
+ "Pictures": windows.FOLDERID_Pictures,
+ "Videos": windows.FOLDERID_Videos,
+ }
+ paths := make(map[string]string, len(folders))
+ for _, r := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
+ str := fmt.Sprintf("%c:\\", r)
+ s, err := os.Stat(str)
+ if err != nil || !s.IsDir() {
+ continue
+ }
+ paths[str] = str
+ }
+ for name, id := range folders {
+ path, err := windows.KnownFolderPath(id, 0)
+ if err != nil {
+ continue
+ }
+ paths[name] = path
+ }
+ return paths
+}
+
+func imageDirectory() (string, error) {
+ userFolder, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(userFolder, "Pictures"), nil
+}
+
+func gameDirectory() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, "Saved Games"), nil
+}
+
+func openFileInTextEditor(path string) *exec.Cmd {
+ return exec.Command("code", path)
+}
+
+func openFileBrowserCommand(path string) *exec.Cmd {
+ return exec.Command("explorer", path)
+}
+
+func openFileBrowserSelectCommand(path string) *exec.Cmd {
+ return exec.Command("explorer", "/select,", path)
+}
+
+func openFileDialogWindow(_ string, _ []DialogExtension, _ func(string), _ func(), _ unsafe.Pointer) error {
+ return errors.New("file dialog is disabled; rebuild with 'filedialog' tag")
+}
+
+func openSaveFileDialogWindow(_ string, _ string, _ []DialogExtension, _ func(string), _ func(), _ unsafe.Pointer) error {
+ return errors.New("file dialog is disabled; rebuild with 'filedialog' tag")
+}
+
+func openFolderDialogWindow(_ string, _ func(string), _ func(), _ unsafe.Pointer) error {
+ return errors.New("file dialog is disabled; rebuild with 'filedialog' tag")
+}
+
+func openNativeDialogWindow(_ NativeDialogRequest, _ func(NativeDialogResult)) error {
+ return errors.New("native dialog request API requires 'filedialog' build tag")
+}
+
+func processDialogCallbacks() {}
+
+func shutdownNativeDialogs() {}
diff --git a/src/platform/filesystem/directory_windows_test.go b/src/platform/filesystem/directory_windows_test.go
new file mode 100644
index 000000000..d02938a16
--- /dev/null
+++ b/src/platform/filesystem/directory_windows_test.go
@@ -0,0 +1,345 @@
+//go:build windows && filedialog
+
+package filesystem
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestNormalizeDialogPattern(t *testing.T) {
+ tests := []struct {
+ name string
+ extension string
+ want string
+ }{
+ {name: "Empty", extension: "", want: "*.*"},
+ {name: "Whitespace", extension: " ", want: "*.*"},
+ {name: "WildcardDot", extension: ".*", want: "*.*"},
+ {name: "WildcardAll", extension: "*", want: "*.*"},
+ {name: "WildcardAllDot", extension: "*.*", want: "*.*"},
+ {name: "DotExt", extension: ".go", want: "*.go"},
+ {name: "PlainExt", extension: "go", want: "*.go"},
+ {name: "TrimmedDotExt", extension: " .json ", want: "*.json"},
+ {name: "AlreadyPattern", extension: "*.png", want: "*.png"},
+ {name: "WildcardWithPrefix", extension: "file.*", want: "file.*"},
+ }
+ for i := range tests {
+ tc := tests[i]
+ t.Run(tc.name, func(t *testing.T) {
+ got := normalizeDialogPattern(tc.extension)
+ if got != tc.want {
+ t.Fatalf("normalizeDialogPattern(%q) = %q, want %q", tc.extension, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestExtensionsToDialogFilters(t *testing.T) {
+ t.Run("Default", func(t *testing.T) {
+ got := extensionsToDialogFilters(nil)
+ want := []DialogFilter{
+ {Name: "All Files (*.*)", Patterns: []string{"*.*"}},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("extensionsToDialogFilters(nil) = %#v, want %#v", got, want)
+ }
+ })
+
+ t.Run("MapsExtensions", func(t *testing.T) {
+ got := extensionsToDialogFilters([]DialogExtension{
+ {Name: "Go Files", Extension: ".go"},
+ {Name: "", Extension: "txt"},
+ })
+ want := []DialogFilter{
+ {Name: "Go Files", Patterns: []string{"*.go"}},
+ {Name: "Files", Patterns: []string{"*.txt"}},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("extensionsToDialogFilters(...) = %#v, want %#v", got, want)
+ }
+ })
+}
+
+func TestEncodeDialogFilters(t *testing.T) {
+ t.Run("EmptyFilters", func(t *testing.T) {
+ got := encodeDialogFilters(nil)
+ if got != "" {
+ t.Fatalf("encodeDialogFilters(nil) = %q, want empty string", got)
+ }
+ })
+
+ got := encodeDialogFilters([]DialogFilter{
+ {Name: "Go and Text", Patterns: []string{"*.go", "*.txt"}},
+ {Name: "", Patterns: []string{" ", "*.*"}},
+ {Name: "Whitespace Only", Patterns: []string{" ", "\t"}},
+ })
+ want := "Go and Text|*.go;*.txt\nFiles|*.*\nWhitespace Only|*.*"
+ if got != want {
+ t.Fatalf("encodeDialogFilters(...) = %q, want %q", got, want)
+ }
+}
+
+func TestEncodeAndParseDialogOptions(t *testing.T) {
+ encoded := encodeDialogOptions([]DialogCustomOption{
+ {Name: "Recursive import", Default: 1},
+ {Name: "Import mode", Values: []string{"Copy", "Reference", "Link"}, Default: 2},
+ {Name: " ", Values: []string{"ignored"}, Default: 0},
+ })
+
+ wantEncoded := "Recursive import|1|\nImport mode|2|Copy;Reference;Link"
+ if encoded != wantEncoded {
+ t.Fatalf("encodeDialogOptions(...) = %q, want %q", encoded, wantEncoded)
+ }
+
+ parsed := parseDialogSelectedOptions("Recursive import|b|1\nImport mode|i|2\nInvalid\nBad|x|10")
+ wantParsed := map[string]any{
+ "Recursive import": true,
+ "Import mode": 2,
+ }
+ if !reflect.DeepEqual(parsed, wantParsed) {
+ t.Fatalf("parseDialogSelectedOptions(...) = %#v, want %#v", parsed, wantParsed)
+ }
+}
+
+func TestParseDialogSelectedOptionsInvalidData(t *testing.T) {
+ got := parseDialogSelectedOptions("Flag|b|0\nMode|i|abc\nUnknown|x|1\n|b|1")
+ want := map[string]any{
+ "Flag": false,
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("parseDialogSelectedOptions(...) = %#v, want %#v", got, want)
+ }
+}
+
+func TestDefaultSelectedOptions(t *testing.T) {
+ got := defaultSelectedOptions([]DialogCustomOption{
+ {Name: "Recursive import", Default: 1},
+ {Name: "Import mode", Values: []string{"Copy", "Reference", "Link"}, Default: -2},
+ {Name: "Compression", Values: []string{"Off", "On"}, Default: 10},
+ {Name: " Trim Me ", Values: []string{"A", "B"}, Default: 1},
+ {Name: " ", Default: 1},
+ })
+
+ want := map[string]any{
+ "Recursive import": true,
+ "Import mode": 0,
+ "Compression": 1,
+ "Trim Me": 1,
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("defaultSelectedOptions(...) = %#v, want %#v", got, want)
+ }
+}
+
+func TestOpenNativeDialogWindowValidation(t *testing.T) {
+ err := openNativeDialogWindow(NativeDialogRequest{}, nil)
+ if err == nil {
+ t.Fatal("openNativeDialogWindow with nil callback should fail")
+ }
+
+ err = openNativeDialogWindow(NativeDialogRequest{
+ Mode: NativeDialogModeOpenFolder + 1,
+ }, func(NativeDialogResult) {})
+ if err == nil {
+ t.Fatal("openNativeDialogWindow with invalid mode should fail")
+ }
+}
+
+func TestValidateNativeDialogRequestRejectsDelimiters(t *testing.T) {
+ // These cases protect the raw delimiter wire format consumed by directory.win32.h.
+ tests := []struct {
+ name string
+ request NativeDialogRequest
+ }{
+ {
+ name: "FilterNamePipe",
+ request: NativeDialogRequest{
+ Filters: []DialogFilter{{Name: "Go|Files", Patterns: []string{"*.go"}}},
+ },
+ },
+ {
+ name: "FilterPatternSemicolon",
+ request: NativeDialogRequest{
+ Filters: []DialogFilter{{Name: "Files", Patterns: []string{"*.go;*.txt"}}},
+ },
+ },
+ {
+ name: "OptionNameNewline",
+ request: NativeDialogRequest{
+ Options: []DialogCustomOption{{Name: "Import\nMode"}},
+ },
+ },
+ {
+ name: "OptionValueSemicolon",
+ request: NativeDialogRequest{
+ Options: []DialogCustomOption{{Name: "Mode", Values: []string{"Copy;Link"}}},
+ },
+ },
+ }
+
+ for i := range tests {
+ tc := tests[i]
+ t.Run(tc.name, func(t *testing.T) {
+ if err := validateNativeDialogRequest(tc.request); err == nil {
+ t.Fatal("validateNativeDialogRequest should reject unsupported delimiters")
+ }
+ })
+ }
+}
+
+func TestOpenNativeDialogWindowWhenRuntimeClosing(t *testing.T) {
+ windowsDialogRuntime.mu.Lock()
+ prevClosing := windowsDialogRuntime.closing
+ windowsDialogRuntime.closing = true
+ windowsDialogRuntime.mu.Unlock()
+ t.Cleanup(func() {
+ windowsDialogRuntime.mu.Lock()
+ windowsDialogRuntime.closing = prevClosing
+ windowsDialogRuntime.mu.Unlock()
+ })
+
+ err := openNativeDialogWindow(NativeDialogRequest{
+ Mode: NativeDialogModeOpenFile,
+ }, func(NativeDialogResult) {})
+ if err == nil {
+ t.Fatal("openNativeDialogWindow should fail when runtime is shutting down")
+ }
+}
+
+func TestMakeSimpleDialogCallbackRouting(t *testing.T) {
+ var okPath string
+ cancelCalled := 0
+ callback := makeSimpleDialogCallback(func(path string) {
+ okPath = path
+ }, func() {
+ cancelCalled++
+ })
+
+ callback(NativeDialogResult{
+ Status: NativeDialogStatusAccepted,
+ Paths: []string{"C:/tmp/test.txt"},
+ })
+ if okPath != "C:/tmp/test.txt" {
+ t.Fatalf("ok callback path = %q, want %q", okPath, "C:/tmp/test.txt")
+ }
+ if cancelCalled != 0 {
+ t.Fatalf("cancel callback count = %d, want 0", cancelCalled)
+ }
+
+ callback(NativeDialogResult{
+ Status: NativeDialogStatusAccepted,
+ Paths: nil,
+ })
+ if cancelCalled != 1 {
+ t.Fatalf("cancel callback count after empty accepted result = %d, want 1", cancelCalled)
+ }
+}
+
+func TestMakeSimpleDialogCallbackAllowsNilCancel(t *testing.T) {
+ callback := makeSimpleDialogCallback(func(path string) {}, nil)
+ callback(NativeDialogResult{
+ Status: NativeDialogStatusFailed,
+ Err: errors.New("test failure"),
+ })
+}
+
+func TestMakeSimpleDialogCallbackAllowsNilOk(t *testing.T) {
+ cancelCalled := 0
+ callback := makeSimpleDialogCallback(nil, func() {
+ cancelCalled++
+ })
+ callback(NativeDialogResult{
+ Status: NativeDialogStatusAccepted,
+ Paths: []string{"C:/tmp/test.txt"},
+ })
+ if cancelCalled != 1 {
+ t.Fatalf("cancel callback count with nil ok callback = %d, want 1", cancelCalled)
+ }
+}
+
+func TestOpenDialogWindowRejectsNilOkCallbacks(t *testing.T) {
+ if err := openFileDialogWindow("", nil, nil, nil, nil); err == nil {
+ t.Fatal("openFileDialogWindow should fail with nil ok callback")
+ }
+ if err := openSaveFileDialogWindow("", "file.txt", nil, nil, nil, nil); err == nil {
+ t.Fatal("openSaveFileDialogWindow should fail with nil ok callback")
+ }
+ if err := openFolderDialogWindow("", nil, nil, nil); err == nil {
+ t.Fatal("openFolderDialogWindow should fail with nil ok callback")
+ }
+}
+
+func TestIsPathWithinDialogRoot(t *testing.T) {
+ tests := []struct {
+ name string
+ root string
+ path string
+ want bool
+ }{
+ {
+ name: "DriveRootContainsChild",
+ root: `C:\`,
+ path: `C:\a.txt`,
+ want: true,
+ },
+ {
+ name: "DriveRootRejectsOtherDrive",
+ root: `C:\`,
+ path: `D:\a.txt`,
+ want: false,
+ },
+ {
+ name: "BoundaryRejectsPrefixCollision",
+ root: `C:\foo`,
+ path: `C:\foobar\a.txt`,
+ want: false,
+ },
+ }
+
+ for i := range tests {
+ tc := tests[i]
+ t.Run(tc.name, func(t *testing.T) {
+ got := isPathWithinDialogRoot(tc.root, tc.path)
+ if got != tc.want {
+ t.Fatalf("isPathWithinDialogRoot(%q, %q) = %v, want %v", tc.root, tc.path, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestApplyNativeDialogResultGuardsClearsPathsOnFailure(t *testing.T) {
+ res := NativeDialogResult{
+ Status: NativeDialogStatusFailed,
+ Paths: []string{`C:\tmp\a.txt`},
+ }
+
+ applyNativeDialogResultGuards(&res, "")
+
+ if len(res.Paths) != 0 {
+ t.Fatalf("expected failed dialog result to clear paths, got %#v", res.Paths)
+ }
+ if res.Err == nil {
+ t.Fatal("expected failed dialog result without explicit error to get default error")
+ }
+}
+
+func TestApplyNativeDialogResultGuardsRootViolation(t *testing.T) {
+ res := NativeDialogResult{
+ Status: NativeDialogStatusAccepted,
+ Paths: []string{`D:\picked.txt`},
+ }
+
+ applyNativeDialogResultGuards(&res, `C:\`)
+
+ if res.Status != NativeDialogStatusFailed {
+ t.Fatalf("expected status failed after root violation, got %v", res.Status)
+ }
+ if len(res.Paths) != 0 {
+ t.Fatalf("expected paths to be cleared after root violation, got %#v", res.Paths)
+ }
+ if res.Err == nil {
+ t.Fatal("expected error after root violation")
+ }
+}
diff --git a/src/platform/windowing/window.go b/src/platform/windowing/window.go
index 49b40b1cf..5247510a0 100644
--- a/src/platform/windowing/window.go
+++ b/src/platform/windowing/window.go
@@ -207,6 +207,7 @@ func (w *Window) Poll() {
}
w.isCrashed = w.isCrashed || w.fatalFromNativeAPI
w.Cursor.Poll()
+ filesystem.ProcessDialogCallbacks()
}
func (w *Window) EndUpdate() {
@@ -335,6 +336,9 @@ func (w *Window) Destroy() {
} else {
destroyWindow(w.handle)
}
+ if len(activeWindows) == 0 {
+ filesystem.ShutdownNativeDialogs()
+ }
close(w.windowSync)
}
@@ -427,6 +431,20 @@ func (w *Window) SaveFileDialog(startPath string, fileName string, extensions []
}, w.handle)
}
+func (w *Window) OpenFolderDialog(startPath string, ok func(path string), cancel func()) error {
+ debug.Assert(ok != nil, "OpenFolderDialog requires a non-nil ok callback")
+ w.disableRawMouse()
+ return filesystem.OpenFolderDialogWindow(startPath, func(path string) {
+ w.enableRawMouse()
+ ok(path)
+ }, func() {
+ w.enableRawMouse()
+ if cancel != nil {
+ cancel()
+ }
+ }, w.handle)
+}
+
func (w *Window) EnableRawMouseInput() { w.enableRawMouse() }
func (w *Window) DisableRawMouseInput() { w.disableRawMouse() }