From a6857443883a5a1fbfd65958e427f272bafe1471 Mon Sep 17 00:00:00 2001 From: amemya Date: Sun, 17 May 2026 14:57:56 +0900 Subject: [PATCH 1/6] hotfix: resolve macOS Sandbox export failure by writing directly to target file without tmp --- handler.go | 62 +++++++++++++++++++++++++----------------------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/handler.go b/handler.go index 5f1ae40..25e2c35 100644 --- a/handler.go +++ b/handler.go @@ -7,7 +7,6 @@ import ( "mime" "net/http" "os" - "path/filepath" "sync" "time" ) @@ -158,49 +157,46 @@ func (h *ImageHandler) handleSave(w http.ResponseWriter, r *http.Request) { return } - // Write to a temporary file first to avoid destroying existing files if the upload fails. - tmpFile, err := os.CreateTemp(filepath.Dir(savePath), "exifframe-save-*.tmp") - if err != nil { - http.Error(w, "Failed to create temp file: "+err.Error(), http.StatusInternalServerError) + // Security: Verify the actual file content matches the expected MIME type. + // We read the first 512 bytes directly from the request body to validate it + // before opening the target file. This avoids writing invalid files to the system + // and works securely within Mac Sandboxing which prevents creating temp files in + // the target directory. + header := make([]byte, 512) + n, err := io.ReadFull(r.Body, header) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + http.Error(w, "Failed to read body: "+err.Error(), http.StatusInternalServerError) return } - tmpPath := tmpFile.Name() - defer os.Remove(tmpPath) // Automatically clean up temp file if not renamed - // Stream body directly to temp file without buffering entirely in memory. - if _, err := io.Copy(tmpFile, r.Body); err != nil { - tmpFile.Close() - http.Error(w, "Failed to write file: "+err.Error(), http.StatusInternalServerError) - return + if expectedMime != "" && n > 0 { + actualMime := http.DetectContentType(header[:n]) + if actualMime != expectedMime { + http.Error(w, "Security Error: saved file content does not match expected type", http.StatusBadRequest) + return + } } - if err := tmpFile.Close(); err != nil { - http.Error(w, "Failed to close temp file: "+err.Error(), http.StatusInternalServerError) + + // Validation passed. Open the target file directly. + // The app has explicit permission to write to this path granted by the native Save Dialog. + saveFile, err := os.Create(savePath) + if err != nil { + http.Error(w, "Failed to create target file: "+err.Error(), http.StatusInternalServerError) return } + defer saveFile.Close() - // Security: Verify the actual file content matches the expected MIME type. - // Read only the first 512 bytes (enough for http.DetectContentType) to avoid - // loading the entire file back into memory. - if expectedMime != "" { - verifyFile, err := os.Open(tmpPath) - if err != nil { - http.Error(w, "Failed to verify saved file: "+err.Error(), http.StatusInternalServerError) - return - } - header := make([]byte, 512) - n, _ := verifyFile.Read(header) - verifyFile.Close() - - actualMime := http.DetectContentType(header[:n]) - if actualMime != expectedMime { - http.Error(w, "Security Error: saved file content does not match expected type", http.StatusBadRequest) + // Write the validated header bytes first + if n > 0 { + if _, err := saveFile.Write(header[:n]); err != nil { + http.Error(w, "Failed to write to target file: "+err.Error(), http.StatusInternalServerError) return } } - // Everything succeeded and is validated. Atomically move the temp file to the final destination. - if err := os.Rename(tmpPath, savePath); err != nil { - http.Error(w, "Failed to finalize save: "+err.Error(), http.StatusInternalServerError) + // Stream the remainder of the payload directly + if _, err := io.Copy(saveFile, r.Body); err != nil { + http.Error(w, "Failed to stream remainder to target file: "+err.Error(), http.StatusInternalServerError) return } From a812365df8522d4b78c76c2708e3f7acd912faf9 Mon Sep 17 00:00:00 2001 From: amemya Date: Sun, 17 May 2026 15:28:55 +0900 Subject: [PATCH 2/6] hotfix: resolve 0kb export payload by converting Blob to ArrayBuffer for WebKit fetch compatibility --- frontend/src/App.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e0ff897..f278baa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -256,11 +256,14 @@ function App() { ); }); - // Step 3: Send binary directly to Go HTTP handler with save token + // Step 3: Send binary directly to Go HTTP handler with save token. + // WARNING: On macOS WebKit, using a Blob body with a custom URL scheme (wails://) + // often results in an empty payload (0kb file). We MUST convert it to an ArrayBuffer first. + const arrayBuffer = await blob.arrayBuffer(); const response = await fetch(`/api/save?token=${encodeURIComponent(result.saveToken)}`, { method: 'POST', headers: { 'Content-Type': targetMime }, - body: blob, + body: arrayBuffer, }); if (!response.ok) { From e76ca8b0883449c907f79de9d474c252d41b5d40 Mon Sep 17 00:00:00 2001 From: amemya Date: Sun, 17 May 2026 15:33:05 +0900 Subject: [PATCH 3/6] wails auto gen files --- frontend/wailsjs/runtime/package.json | 0 frontend/wailsjs/runtime/runtime.d.ts | 0 frontend/wailsjs/runtime/runtime.js | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 frontend/wailsjs/runtime/package.json mode change 100755 => 100644 frontend/wailsjs/runtime/runtime.d.ts mode change 100755 => 100644 frontend/wailsjs/runtime/runtime.js diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100755 new mode 100644 From 2556bfd6fb90731f7ff37d2a8e4e7ec9efbd12d2 Mon Sep 17 00:00:00 2001 From: amemya Date: Sun, 17 May 2026 15:38:23 +0900 Subject: [PATCH 4/6] refactor: robust HTTP save flow using safe system temp file, 0-byte rejection, and EXDEV fallback --- handler.go | 91 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/handler.go b/handler.go index 25e2c35..07ab0e0 100644 --- a/handler.go +++ b/handler.go @@ -157,19 +157,48 @@ func (h *ImageHandler) handleSave(w http.ResponseWriter, r *http.Request) { return } - // Security: Verify the actual file content matches the expected MIME type. - // We read the first 512 bytes directly from the request body to validate it - // before opening the target file. This avoids writing invalid files to the system - // and works securely within Mac Sandboxing which prevents creating temp files in - // the target directory. - header := make([]byte, 512) - n, err := io.ReadFull(r.Body, header) - if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { - http.Error(w, "Failed to read body: "+err.Error(), http.StatusInternalServerError) + // Write to a temporary file in the system temp directory (os.TempDir). + // This avoids macOS Sandbox permission issues which restrict writing to + // the target's parent directory, and ensures existing files are not truncated + // until the entire upload is successful. + tmpFile, err := os.CreateTemp("", "exifframe-save-*.tmp") + if err != nil { + http.Error(w, "Failed to create temp file: "+err.Error(), http.StatusInternalServerError) + return + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) // Automatically clean up temp file if not renamed + + // Stream body directly to the system temp file. + written, err := io.Copy(tmpFile, r.Body) + if err != nil { + tmpFile.Close() + http.Error(w, "Failed to stream upload: "+err.Error(), http.StatusInternalServerError) + return + } + tmpFile.Close() + + if written == 0 { + http.Error(w, "Empty image payload received", http.StatusBadRequest) return } - if expectedMime != "" && n > 0 { + // Security: Verify the actual file content matches the expected MIME type. + if expectedMime != "" { + verifyFile, err := os.Open(tmpPath) + if err != nil { + http.Error(w, "Failed to verify saved file: "+err.Error(), http.StatusInternalServerError) + return + } + header := make([]byte, 512) + n, _ := verifyFile.Read(header) + verifyFile.Close() + + if n == 0 { + http.Error(w, "Security Error: Unable to read saved file", http.StatusInternalServerError) + return + } + actualMime := http.DetectContentType(header[:n]) if actualMime != expectedMime { http.Error(w, "Security Error: saved file content does not match expected type", http.StatusBadRequest) @@ -177,27 +206,35 @@ func (h *ImageHandler) handleSave(w http.ResponseWriter, r *http.Request) { } } - // Validation passed. Open the target file directly. - // The app has explicit permission to write to this path granted by the native Save Dialog. - saveFile, err := os.Create(savePath) - if err != nil { - http.Error(w, "Failed to create target file: "+err.Error(), http.StatusInternalServerError) - return - } - defer saveFile.Close() + // Everything succeeded and is validated. Move the temp file to the final destination. + // We attempt an atomic os.Rename first. If it fails (e.g., EXDEV cross-device link), + // we fallback to io.Copy. + if err := os.Rename(tmpPath, savePath); err != nil { + // Fallback: Copy the file manually since os.Rename across volumes is not allowed + in, err := os.Open(tmpPath) + if err != nil { + http.Error(w, "Failed to open temp file for copying: "+err.Error(), http.StatusInternalServerError) + return + } + defer in.Close() - // Write the validated header bytes first - if n > 0 { - if _, err := saveFile.Write(header[:n]); err != nil { - http.Error(w, "Failed to write to target file: "+err.Error(), http.StatusInternalServerError) + out, err := os.Create(savePath) + if err != nil { + http.Error(w, "Failed to create final file: "+err.Error(), http.StatusInternalServerError) return } - } + defer out.Close() - // Stream the remainder of the payload directly - if _, err := io.Copy(saveFile, r.Body); err != nil { - http.Error(w, "Failed to stream remainder to target file: "+err.Error(), http.StatusInternalServerError) - return + if _, err := io.Copy(out, in); err != nil { + http.Error(w, "Failed to copy to final destination: "+err.Error(), http.StatusInternalServerError) + return + } + + // Ensure it's fully written + if err := out.Sync(); err != nil { + http.Error(w, "Failed to sync final destination: "+err.Error(), http.StatusInternalServerError) + return + } } w.WriteHeader(http.StatusOK) From 106feb70411fc51a9df8ad4908059d719cb1c9b0 Mon Sep 17 00:00:00 2001 From: amemya Date: Sun, 17 May 2026 16:45:14 +0900 Subject: [PATCH 5/6] hotfix: remove partial file artifact on fallback copy failure --- handler.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/handler.go b/handler.go index 07ab0e0..881b98b 100644 --- a/handler.go +++ b/handler.go @@ -226,12 +226,16 @@ func (h *ImageHandler) handleSave(w http.ResponseWriter, r *http.Request) { defer out.Close() if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(savePath) http.Error(w, "Failed to copy to final destination: "+err.Error(), http.StatusInternalServerError) return } // Ensure it's fully written if err := out.Sync(); err != nil { + out.Close() + os.Remove(savePath) http.Error(w, "Failed to sync final destination: "+err.Error(), http.StatusInternalServerError) return } From 7cb948abdcba9d71336c7d61a56a0d160065c837 Mon Sep 17 00:00:00 2001 From: amemya Date: Sun, 17 May 2026 16:57:32 +0900 Subject: [PATCH 6/6] hotfix: check Close error on temp file to catch flush/sync failures --- handler.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/handler.go b/handler.go index 881b98b..33db2a9 100644 --- a/handler.go +++ b/handler.go @@ -176,7 +176,10 @@ func (h *ImageHandler) handleSave(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to stream upload: "+err.Error(), http.StatusInternalServerError) return } - tmpFile.Close() + if err := tmpFile.Close(); err != nil { + http.Error(w, "Failed to close and flush temp file: "+err.Error(), http.StatusInternalServerError) + return + } if written == 0 { http.Error(w, "Empty image payload received", http.StatusBadRequest)