From 72958cf08606b7e1de3a228b5f1893de040992f0 Mon Sep 17 00:00:00 2001 From: gitsomebit <> Date: Wed, 6 May 2026 17:22:22 +0200 Subject: [PATCH 1/4] feat(file-dialogue): rewrite + folder select + custom fields + restrict nav --- docs/editor/native_dialog_demo_windows.md | 132 ++ src/platform/filesystem/directory.go | 68 + src/platform/filesystem/directory.win32.h | 1365 ++++++++++++++++- src/platform/filesystem/directory_android.go | 6 + src/platform/filesystem/directory_darwin.go | 23 + .../filesystem/directory_dialog_stub.go | 13 + src/platform/filesystem/directory_linux.go | 6 + src/platform/filesystem/directory_windows.go | 541 ++++++- .../filesystem/directory_windows_test.go | 345 +++++ src/platform/windowing/window.go | 18 + 10 files changed, 2405 insertions(+), 112 deletions(-) create mode 100644 docs/editor/native_dialog_demo_windows.md create mode 100644 src/platform/filesystem/directory_dialog_stub.go create mode 100644 src/platform/filesystem/directory_windows_test.go diff --git a/docs/editor/native_dialog_demo_windows.md b/docs/editor/native_dialog_demo_windows.md new file mode 100644 index 000000000..9fbf2f2fa --- /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. + +## 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/src/platform/filesystem/directory.go b/src/platform/filesystem/directory.go index 960d2ff49..da10e292c 100644 --- a/src/platform/filesystem/directory.go +++ b/src/platform/filesystem/directory.go @@ -54,6 +54,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) @@ -203,6 +252,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..6f38fb511 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 *pfdc, dialog_option_control_t *controls, int count) { + if (pfdc == 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 = pfdc->lpVtbl->StartVisualGroup(pfdc, ctl->group_id, L""); + if (FAILED(hr)) return hr; + hr = pfdc->lpVtbl->AddCheckButton(pfdc, ctl->control_id, ctl->name, ctl->default_value ? TRUE : FALSE); + if (FAILED(hr)) { + pfdc->lpVtbl->EndVisualGroup(pfdc); + return hr; + } + hr = pfdc->lpVtbl->SetControlState(pfdc, ctl->control_id, CDCS_VISIBLE | CDCS_ENABLED); + if (FAILED(hr)) { + pfdc->lpVtbl->EndVisualGroup(pfdc); + return hr; + } + hr = pfdc->lpVtbl->EndVisualGroup(pfdc); + if (FAILED(hr)) return hr; + } else { + HRESULT hr = pfdc->lpVtbl->StartVisualGroup(pfdc, ctl->group_id, ctl->name); + if (FAILED(hr)) return hr; + hr = pfdc->lpVtbl->AddComboBox(pfdc, ctl->control_id); + if (FAILED(hr)) { + pfdc->lpVtbl->EndVisualGroup(pfdc); + return hr; + } + for (int j = 0; j < ctl->item_count; j++) { + hr = pfdc->lpVtbl->AddControlItem(pfdc, ctl->control_id, (DWORD)j, ctl->items[j]); + if (FAILED(hr)) { + pfdc->lpVtbl->EndVisualGroup(pfdc); + return hr; + } + } + int idx = ctl->default_value; + if (idx < 0) idx = 0; + if (idx >= ctl->item_count) idx = ctl->item_count - 1; + hr = pfdc->lpVtbl->SetSelectedControlItem(pfdc, ctl->control_id, (DWORD)idx); + if (FAILED(hr)) { + pfdc->lpVtbl->EndVisualGroup(pfdc); + return hr; + } + hr = pfdc->lpVtbl->SetControlState(pfdc, ctl->control_id, CDCS_VISIBLE | CDCS_ENABLED); + if (FAILED(hr)) { + pfdc->lpVtbl->EndVisualGroup(pfdc); + return hr; + } + hr = pfdc->lpVtbl->EndVisualGroup(pfdc); + 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 *pfdc, dialog_option_control_t *controls, int count) { + if (pfdc == 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(pfdc->lpVtbl->GetCheckButtonState(pfdc, 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(pfdc->lpVtbl->GetSelectedControlItem(pfdc, 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 *pfd) { + (void)This; + (void)pfd; + return S_OK; +} + +static HRESULT STDMETHODCALLTYPE dialog_event_on_folder_changing(IFileDialogEvents *This, IFileDialog *pfd, IShellItem *psiFolder) { + (void)pfd; + 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 *pfd) { + (void)This; + (void)pfd; + return S_OK; +} + +static HRESULT STDMETHODCALLTYPE dialog_event_on_selection_change(IFileDialogEvents *This, IFileDialog *pfd) { + (void)This; + (void)pfd; + 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 *pfd, IShellItem *psi, FDE_SHAREVIOLATION_RESPONSE *pResponse) { + (void)This; + (void)pfd; + (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 *pfd) { + (void)This; + (void)pfd; + return S_OK; +} + +static HRESULT STDMETHODCALLTYPE dialog_event_on_overwrite(IFileDialogEvents *This, IFileDialog *pfd, IShellItem *psi, FDE_OVERWRITE_RESPONSE *pResponse) { + (void)This; + (void)pfd; + (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 *pfd = NULL; + if (req->mode == DIALOG_MODE_SAVE_FILE) { + hr = CoCreateInstance(&CLSID_FileSaveDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileSaveDialog, (void **)&pfd); } else { - return ""; + hr = CoCreateInstance(&CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void **)&pfd); + } + if (FAILED(hr) || pfd == 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"); + pfd->lpVtbl->Release(pfd); + 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"); + pfd->lpVtbl->Release(pfd); + 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 = pfd->lpVtbl->GetOptions(pfd, &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 = pfd->lpVtbl->SetOptions(pfd, 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 = pfd->lpVtbl->SetTitle(pfd, 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 = pfd->lpVtbl->SetFileName(pfd, 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 = pfd->lpVtbl->SetFileTypes(pfd, (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 = pfd->lpVtbl->SetFileTypeIndex(pfd, 1); + 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 = pfd->lpVtbl->Advise(pfd, (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 *folder_ref = resolve_dialog_start_folder_alloc(current_dir, enforced_root); + if (folder_ref != NULL) { + /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-shcreateitemfromparsingname */ + hr = SHCreateItemFromParsingName(folder_ref, 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 = pfd->lpVtbl->SetDefaultFolder(pfd, 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(folder_ref); + goto cleanup; + }*/ + /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialog-setfolder */ + hr = pfd->lpVtbl->SetFolder(pfd, 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(folder_ref); + 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(folder_ref); + goto cleanup; + } + free(folder_ref); + } + + IFileDialogCustomize *pfdc = NULL; + if (control_count > 0) { + hr = pfd->lpVtbl->QueryInterface(pfd, &IID_IFileDialogCustomize, (void **)&pfdc); + if (SUCCEEDED(hr) && pfdc != NULL) { + hr = add_option_controls(pfdc, controls, control_count); + } + if (FAILED(hr) || pfdc == 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 (pfdc) { + pfdc->lpVtbl->Release(pfdc); + pfdc = NULL; + } + goto cleanup; + } + } + + hr = pfd->lpVtbl->Show(pfd, 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(pfd->lpVtbl->GetFileTypeIndex(pfd, &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 (pfdc != NULL && control_count > 0) { + res.selected_options_utf8 = collect_selected_options_utf8(pfdc, controls, control_count); + } + + if (req->mode == DIALOG_MODE_OPEN_FILES) { + IShellItemArray *results = NULL; + hr = ((IFileOpenDialog *)pfd)->lpVtbl->GetResults((IFileOpenDialog *)pfd, &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 = pfd->lpVtbl->GetResult(pfd, &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) { + pfd->lpVtbl->Unadvise(pfd, event_cookie); + } + if (pfdc != NULL) pfdc->lpVtbl->Release(pfdc); + pfd->lpVtbl->Release(pfd); + 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 c41bd64c2..3671d985a 100644 --- a/src/platform/filesystem/directory_android.go +++ b/src/platform/filesystem/directory_android.go @@ -97,3 +97,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 2c478ab33..649b3987c 100644 --- a/src/platform/filesystem/directory_darwin.go +++ b/src/platform/filesystem/directory_darwin.go @@ -163,3 +163,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 46076d752..472030463 100644 --- a/src/platform/filesystem/directory_linux.go +++ b/src/platform/filesystem/directory_linux.go @@ -107,3 +107,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 051e5a9ea..d204d88a4 100644 --- a/src/platform/filesystem/directory_windows.go +++ b/src/platform/filesystem/directory_windows.go @@ -39,19 +39,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" @@ -59,6 +65,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, @@ -118,47 +140,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_test.go b/src/platform/filesystem/directory_windows_test.go new file mode 100644 index 000000000..1a7552ce3 --- /dev/null +++ b/src/platform/filesystem/directory_windows_test.go @@ -0,0 +1,345 @@ +//go:build windows + +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 df8bc13a4..9d2740dd4 100644 --- a/src/platform/windowing/window.go +++ b/src/platform/windowing/window.go @@ -237,6 +237,7 @@ func (w *Window) Poll() { } w.isCrashed = w.isCrashed || w.fatalFromNativeAPI w.Cursor.Poll() + filesystem.ProcessDialogCallbacks() } func (w *Window) EndUpdate() { @@ -365,6 +366,9 @@ func (w *Window) Destroy() { } else { destroyWindow(w.handle) } + if len(activeWindows) == 0 { + filesystem.ShutdownNativeDialogs() + } close(w.windowSync) } @@ -457,6 +461,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() } From 43dbbf2d152072a56bc0889b94bd2efe534460dc Mon Sep 17 00:00:00 2001 From: gitsomebit <> Date: Wed, 6 May 2026 17:40:03 +0200 Subject: [PATCH 2/4] add temporary demo for testing --- src/editor/editor_game_interface.go | 117 ++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/editor/editor_game_interface.go b/src/editor/editor_game_interface.go index 00a71a07b..ec216c81f 100644 --- a/src/editor/editor_game_interface.go +++ b/src/editor/editor_game_interface.go @@ -41,11 +41,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" ) @@ -100,4 +103,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) + } } From daceb8691ec317dba8a11980c13ecc9bda7a37ca Mon Sep 17 00:00:00 2001 From: gitsomebit <> Date: Wed, 6 May 2026 17:43:14 +0200 Subject: [PATCH 3/4] feat(file-dialogue): make file-dialogue optional with build tag --- .run/Build & Run Debug.run.xml | 4 +- .vscode/launch.json | 6 +- .vscode/tasks.json | 2 +- AGENTS.md | 6 +- README.md | 2 +- docs/editor/native_dialog_demo_windows.md | 2 +- docs/engine/build_from_source.md | 6 +- docs/engine/build_tags.md | 2 + docs/getting_started/start_without_editor.md | 2 +- src/build/tag_generator/tag_generator.go | 2 +- src/build/zfiledialog.go | 43 +++++++++ src/build/zfiledialog_not.go | 43 +++++++++ src/platform/filesystem/directory_windows.go | 2 +- .../filesystem/directory_windows_nodialog.go | 90 +++++++++++++++++++ .../filesystem/directory_windows_test.go | 2 +- 15 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 src/build/zfiledialog.go create mode 100644 src/build/zfiledialog_not.go create mode 100644 src/platform/filesystem/directory_windows_nodialog.go 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 e5a049a75..2fd2087d2 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 cccddc557..a5c257e4f 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`) ## Project Structure @@ -922,13 +922,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 3102a0d27..de405018f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ git clone --recurse-submodules https://github.com/KaijuEngine/kaiju.git If you have Go, C build tools, platform libs, and Vulkan setup, you can start by running: ```sh cd src -go build -tags="debug,editor,filedrop" -o ../ ./ +go build -tags="debug,editor,filedrop,filedialog" -o ../ ./ ``` *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 index 9fbf2f2fa..447ac8fb7 100644 --- a/docs/editor/native_dialog_demo_windows.md +++ b/docs/editor/native_dialog_demo_windows.md @@ -1,6 +1,6 @@ # Windows Native Dialog -The editor currently includes Windows-only native dialog. +The editor currently includes Windows-only native dialog when built with the `filedialog` tag (for example: `-tags="editor,filedialog"`). ## Summary diff --git a/docs/engine/build_from_source.md b/docs/engine/build_from_source.md index f61f7ee0e..a0bfb3782 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 713d5d659..bf0082f2d 100644 --- a/src/build/tag_generator/tag_generator.go +++ b/src/build/tag_generator/tag_generator.go @@ -45,7 +45,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/platform/filesystem/directory_windows.go b/src/platform/filesystem/directory_windows.go index d204d88a4..183f11140 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 */ 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 index 1a7552ce3..d02938a16 100644 --- a/src/platform/filesystem/directory_windows_test.go +++ b/src/platform/filesystem/directory_windows_test.go @@ -1,4 +1,4 @@ -//go:build windows +//go:build windows && filedialog package filesystem From 8d1484af0997f840efd359287348d7699bea224c Mon Sep 17 00:00:00 2001 From: gitsomebit <> Date: Wed, 6 May 2026 18:27:18 +0200 Subject: [PATCH 4/4] fix: improve var naming --- src/platform/filesystem/directory.win32.h | 150 +++++++++++----------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/src/platform/filesystem/directory.win32.h b/src/platform/filesystem/directory.win32.h index 6f38fb511..86d27754c 100644 --- a/src/platform/filesystem/directory.win32.h +++ b/src/platform/filesystem/directory.win32.h @@ -613,56 +613,56 @@ static bool build_option_controls(const char *options_utf8, dialog_option_contro * 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 *pfdc, dialog_option_control_t *controls, int count) { - if (pfdc == NULL || controls == NULL || count == 0) { +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 = pfdc->lpVtbl->StartVisualGroup(pfdc, ctl->group_id, L""); + HRESULT hr = customize->lpVtbl->StartVisualGroup(customize, ctl->group_id, L""); if (FAILED(hr)) return hr; - hr = pfdc->lpVtbl->AddCheckButton(pfdc, ctl->control_id, ctl->name, ctl->default_value ? TRUE : FALSE); + hr = customize->lpVtbl->AddCheckButton(customize, ctl->control_id, ctl->name, ctl->default_value ? TRUE : FALSE); if (FAILED(hr)) { - pfdc->lpVtbl->EndVisualGroup(pfdc); + customize->lpVtbl->EndVisualGroup(customize); return hr; } - hr = pfdc->lpVtbl->SetControlState(pfdc, ctl->control_id, CDCS_VISIBLE | CDCS_ENABLED); + hr = customize->lpVtbl->SetControlState(customize, ctl->control_id, CDCS_VISIBLE | CDCS_ENABLED); if (FAILED(hr)) { - pfdc->lpVtbl->EndVisualGroup(pfdc); + customize->lpVtbl->EndVisualGroup(customize); return hr; } - hr = pfdc->lpVtbl->EndVisualGroup(pfdc); + hr = customize->lpVtbl->EndVisualGroup(customize); if (FAILED(hr)) return hr; } else { - HRESULT hr = pfdc->lpVtbl->StartVisualGroup(pfdc, ctl->group_id, ctl->name); + HRESULT hr = customize->lpVtbl->StartVisualGroup(customize, ctl->group_id, ctl->name); if (FAILED(hr)) return hr; - hr = pfdc->lpVtbl->AddComboBox(pfdc, ctl->control_id); + hr = customize->lpVtbl->AddComboBox(customize, ctl->control_id); if (FAILED(hr)) { - pfdc->lpVtbl->EndVisualGroup(pfdc); + customize->lpVtbl->EndVisualGroup(customize); return hr; } for (int j = 0; j < ctl->item_count; j++) { - hr = pfdc->lpVtbl->AddControlItem(pfdc, ctl->control_id, (DWORD)j, ctl->items[j]); + hr = customize->lpVtbl->AddControlItem(customize, ctl->control_id, (DWORD)j, ctl->items[j]); if (FAILED(hr)) { - pfdc->lpVtbl->EndVisualGroup(pfdc); + 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 = pfdc->lpVtbl->SetSelectedControlItem(pfdc, ctl->control_id, (DWORD)idx); + hr = customize->lpVtbl->SetSelectedControlItem(customize, ctl->control_id, (DWORD)idx); if (FAILED(hr)) { - pfdc->lpVtbl->EndVisualGroup(pfdc); + customize->lpVtbl->EndVisualGroup(customize); return hr; } - hr = pfdc->lpVtbl->SetControlState(pfdc, ctl->control_id, CDCS_VISIBLE | CDCS_ENABLED); + hr = customize->lpVtbl->SetControlState(customize, ctl->control_id, CDCS_VISIBLE | CDCS_ENABLED); if (FAILED(hr)) { - pfdc->lpVtbl->EndVisualGroup(pfdc); + customize->lpVtbl->EndVisualGroup(customize); return hr; } - hr = pfdc->lpVtbl->EndVisualGroup(pfdc); + hr = customize->lpVtbl->EndVisualGroup(customize); if (FAILED(hr)) return hr; } } @@ -670,8 +670,8 @@ static HRESULT add_option_controls(IFileDialogCustomize *pfdc, dialog_option_con } /* serializes selections as "name|b|0/1" or "name|i|index", one control per line */ -static char *collect_selected_options_utf8(IFileDialogCustomize *pfdc, dialog_option_control_t *controls, int count) { - if (pfdc == NULL || controls == NULL || count == 0) { +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 */ @@ -705,7 +705,7 @@ static char *collect_selected_options_utf8(IFileDialogCustomize *pfdc, dialog_op if (ctl->is_checkbox) { BOOL checked = FALSE; - if (FAILED(pfdc->lpVtbl->GetCheckButtonState(pfdc, ctl->control_id, &checked))) { + 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")) { @@ -714,7 +714,7 @@ static char *collect_selected_options_utf8(IFileDialogCustomize *pfdc, dialog_op } } else { DWORD selected = (DWORD)ctl->default_value; - if (FAILED(pfdc->lpVtbl->GetSelectedControlItem(pfdc, ctl->control_id, &selected))) { + if (FAILED(customize->lpVtbl->GetSelectedControlItem(customize, ctl->control_id, &selected))) { selected = (DWORD)ctl->default_value; } char tmp[32]; @@ -760,14 +760,14 @@ static ULONG STDMETHODCALLTYPE dialog_event_release(IFileDialogEvents *This) { return (ULONG)ref; } -static HRESULT STDMETHODCALLTYPE dialog_event_on_file_ok(IFileDialogEvents *This, IFileDialog *pfd) { +static HRESULT STDMETHODCALLTYPE dialog_event_on_file_ok(IFileDialogEvents *This, IFileDialog *dialog) { (void)This; - (void)pfd; + (void)dialog; return S_OK; } -static HRESULT STDMETHODCALLTYPE dialog_event_on_folder_changing(IFileDialogEvents *This, IFileDialog *pfd, IShellItem *psiFolder) { - (void)pfd; +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; @@ -797,15 +797,15 @@ static HRESULT STDMETHODCALLTYPE dialog_event_on_folder_changing(IFileDialogEven return ok ? S_OK : HRESULT_FROM_WIN32(ERROR_ACCESS_DENIED); } -static HRESULT STDMETHODCALLTYPE dialog_event_on_folder_change(IFileDialogEvents *This, IFileDialog *pfd) { +static HRESULT STDMETHODCALLTYPE dialog_event_on_folder_change(IFileDialogEvents *This, IFileDialog *dialog) { (void)This; - (void)pfd; + (void)dialog; return S_OK; } -static HRESULT STDMETHODCALLTYPE dialog_event_on_selection_change(IFileDialogEvents *This, IFileDialog *pfd) { +static HRESULT STDMETHODCALLTYPE dialog_event_on_selection_change(IFileDialogEvents *This, IFileDialog *dialog) { (void)This; - (void)pfd; + (void)dialog; return S_OK; } @@ -813,9 +813,9 @@ static HRESULT STDMETHODCALLTYPE dialog_event_on_selection_change(IFileDialogEve * 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 *pfd, IShellItem *psi, FDE_SHAREVIOLATION_RESPONSE *pResponse) { +static HRESULT STDMETHODCALLTYPE dialog_event_on_share_violation(IFileDialogEvents *This, IFileDialog *dialog, IShellItem *psi, FDE_SHAREVIOLATION_RESPONSE *pResponse) { (void)This; - (void)pfd; + (void)dialog; (void)psi; /* windows is supposed to provide a valid pointer -> check anyway */ if (pResponse == NULL) { @@ -827,15 +827,15 @@ static HRESULT STDMETHODCALLTYPE dialog_event_on_share_violation(IFileDialogEven return S_OK; } -static HRESULT STDMETHODCALLTYPE dialog_event_on_type_change(IFileDialogEvents *This, IFileDialog *pfd) { +static HRESULT STDMETHODCALLTYPE dialog_event_on_type_change(IFileDialogEvents *This, IFileDialog *dialog) { (void)This; - (void)pfd; + (void)dialog; return S_OK; } -static HRESULT STDMETHODCALLTYPE dialog_event_on_overwrite(IFileDialogEvents *This, IFileDialog *pfd, IShellItem *psi, FDE_OVERWRITE_RESPONSE *pResponse) { +static HRESULT STDMETHODCALLTYPE dialog_event_on_overwrite(IFileDialogEvents *This, IFileDialog *dialog, IShellItem *psi, FDE_OVERWRITE_RESPONSE *pResponse) { (void)This; - (void)pfd; + (void)dialog; (void)psi; /* Windows is supposed to provide a valid pointer -> check anyway */ if (pResponse == NULL) { @@ -928,13 +928,13 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { return res; } - IFileDialog *pfd = NULL; + IFileDialog *dialog = NULL; if (req->mode == DIALOG_MODE_SAVE_FILE) { - hr = CoCreateInstance(&CLSID_FileSaveDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileSaveDialog, (void **)&pfd); + hr = CoCreateInstance(&CLSID_FileSaveDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileSaveDialog, (void **)&dialog); } else { - hr = CoCreateInstance(&CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void **)&pfd); + hr = CoCreateInstance(&CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void **)&dialog); } - if (FAILED(hr) || pfd == NULL) { + if (FAILED(hr) || dialog == NULL) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; set_error_message(&res, "failed to create native file dialog"); @@ -962,7 +962,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { 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"); - pfd->lpVtbl->Release(pfd); + dialog->lpVtbl->Release(dialog); if (title) free(title); if (current_dir) free(current_dir); if (filename) free(filename); @@ -978,7 +978,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { 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"); - pfd->lpVtbl->Release(pfd); + dialog->lpVtbl->Release(dialog); if (title) free(title); if (current_dir) free(current_dir); if (filename) free(filename); @@ -990,7 +990,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { } DWORD opts = 0; - hr = pfd->lpVtbl->GetOptions(pfd, &opts); + hr = dialog->lpVtbl->GetOptions(dialog, &opts); if (FAILED(hr)) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; @@ -1006,7 +1006,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { if ((current_dir != NULL && current_dir[0] != L'\0') || (root != NULL && root[0] != L'\0')) { opts |= FOS_PATHMUSTEXIST; } - hr = pfd->lpVtbl->SetOptions(pfd, opts); + hr = dialog->lpVtbl->SetOptions(dialog, opts); if (FAILED(hr)) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; @@ -1015,7 +1015,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { } if (title != NULL) { - hr = pfd->lpVtbl->SetTitle(pfd, title); + hr = dialog->lpVtbl->SetTitle(dialog, title); if (FAILED(hr)) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; @@ -1024,7 +1024,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { } } if (filename != NULL && req->mode == DIALOG_MODE_SAVE_FILE) { - hr = pfd->lpVtbl->SetFileName(pfd, filename); + hr = dialog->lpVtbl->SetFileName(dialog, filename); if (FAILED(hr)) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; @@ -1033,14 +1033,14 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { } } if (filter_count > 0 && req->mode != DIALOG_MODE_OPEN_FOLDER) { - hr = pfd->lpVtbl->SetFileTypes(pfd, (UINT)filter_count, filter_specs); + 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 = pfd->lpVtbl->SetFileTypeIndex(pfd, 1); + hr = dialog->lpVtbl->SetFileTypeIndex(dialog, 1); /* uses 1-based indexing */ if (FAILED(hr)) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; @@ -1060,7 +1060,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { * 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 = pfd->lpVtbl->Advise(pfd, (IFileDialogEvents *)&event_handler, &event_cookie); + hr = dialog->lpVtbl->Advise(dialog, (IFileDialogEvents *)&event_handler, &event_cookie); if (SUCCEEDED(hr)) { event_advised = true; } else { @@ -1072,31 +1072,31 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { } IShellItem *folder_item = NULL; - wchar_t *folder_ref = resolve_dialog_start_folder_alloc(current_dir, enforced_root); - if (folder_ref != 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(folder_ref, NULL, &IID_IShellItem, (void **)&folder_item); + 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 = pfd->lpVtbl->SetDefaultFolder(pfd, folder_item); + /*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(folder_ref); + free(start_folder_path); goto cleanup; }*/ /* Docs: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialog-setfolder */ - hr = pfd->lpVtbl->SetFolder(pfd, folder_item); + 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(folder_ref); + free(start_folder_path); goto cleanup; } folder_item->lpVtbl->Release(folder_item); @@ -1104,31 +1104,31 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; set_error_message(&res, "failed to create native dialog root folder item"); - free(folder_ref); + free(start_folder_path); goto cleanup; } - free(folder_ref); + free(start_folder_path); } - IFileDialogCustomize *pfdc = NULL; + IFileDialogCustomize *customize = NULL; if (control_count > 0) { - hr = pfd->lpVtbl->QueryInterface(pfd, &IID_IFileDialogCustomize, (void **)&pfdc); - if (SUCCEEDED(hr) && pfdc != NULL) { - hr = add_option_controls(pfdc, controls, control_count); + 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) || pfdc == NULL) { + 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 (pfdc) { - pfdc->lpVtbl->Release(pfdc); - pfdc = NULL; + if (customize) { + customize->lpVtbl->Release(customize); + customize = NULL; } goto cleanup; } } - hr = pfd->lpVtbl->Show(pfd, owner); + hr = dialog->lpVtbl->Show(dialog, owner); res.hresult = (int)hr; if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { res.status = DIALOG_STATUS_CANCEL; @@ -1138,7 +1138,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { } else { res.status = DIALOG_STATUS_OK; UINT idx = 1; - if (SUCCEEDED(pfd->lpVtbl->GetFileTypeIndex(pfd, &idx))) { + if (SUCCEEDED(dialog->lpVtbl->GetFileTypeIndex(dialog, &idx))) { if (idx > 0) { idx -= 1; } @@ -1148,13 +1148,13 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { /* 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 (pfdc != NULL && control_count > 0) { - res.selected_options_utf8 = collect_selected_options_utf8(pfdc, controls, control_count); + 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 *)pfd)->lpVtbl->GetResults((IFileOpenDialog *)pfd, &results); + hr = ((IFileOpenDialog *)dialog)->lpVtbl->GetResults((IFileOpenDialog *)dialog, &results); if (FAILED(hr) || results == NULL) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; @@ -1229,7 +1229,7 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { } } else { IShellItem *item = NULL; - hr = pfd->lpVtbl->GetResult(pfd, &item); + hr = dialog->lpVtbl->GetResult(dialog, &item); if (FAILED(hr) || item == NULL) { res.status = DIALOG_STATUS_ERROR; res.hresult = (int)hr; @@ -1272,10 +1272,10 @@ static dialog_result_t run_native_file_dialog(const dialog_request_t *req) { SetForegroundWindow(owner); } if (event_advised) { - pfd->lpVtbl->Unadvise(pfd, event_cookie); + dialog->lpVtbl->Unadvise(dialog, event_cookie); } - if (pfdc != NULL) pfdc->lpVtbl->Release(pfdc); - pfd->lpVtbl->Release(pfd); + 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);