import os, sys, json, subprocess, time as _time from fastapi import APIRouter, HTTPException, UploadFile, File, Form from pydantic import BaseModel from typing import Optional router = APIRouter() # ── Native "Save As" dialog (fix bug: export MIDI/WAV không hỏi nơi lưu) ── # WebView2 standalone chặn anchor-download → không thấy save dialog. Giải pháp: # frontend gọi /save-dialog → engine mở NATIVE SaveFileDialog (Windows Explorer) # → trả đường dẫn user chọn → frontend gửi bytes qua /write → ghi file thật. SAVE_DIALOG_TIMEOUT = 120 # fallback native (PowerShell) — user có thể để dialog mở lâu # Bridge timeout phải LỚN HƠN Rust wait (300s trong lib.rs) — nếu không, user # chọn path sau 300s thì engine đã bỏ cuộc → save_file.response bị bỏ lại, file # không được ghi dù user đã chọn nơi lưu (bug "hỏi nơi lưu nhưng không có file"). SAVE_BRIDGE_TIMEOUT = 360 def _save_file_via_tauri_bridge(default_name: str) -> Optional[str]: """Tauri shell (Rust watcher trong lib.rs) mở NATIVE SaveFileDialog qua file IPC: engine ghi save_file.request (chứa tên mặc định) → Rust mở dialog → ghi save_file.response. Trả None nếu bridge không tồn tại (standalone) → caller fallback native. Trả "" nếu bridge ĐÃ dùng nhưng user hủy / hết hạn → KHÔNG fallback (tránh 2 dialog cùng lúc + tránh ghi đè path user đã chọn).""" root = os.environ.get("APPDATA") or os.path.expanduser("~") ipc = os.path.join(root, "SonicForgeDAW", "ipc") if not os.path.isdir(ipc): return None # Bridge chi dung khi engine do Tauri spawn (co SF_PARENT_PID). Marker co # the con sot tu lan Tauri chay truoc → chay standalone se cho response # khong bao gio den (treo 120s). Check nay loai bo truong hop do. if not os.environ.get("SF_PARENT_PID"): return None if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")): return None req = os.path.join(ipc, "save_file.request") resp = os.path.join(ipc, "save_file.response") try: for f in (req, resp): if os.path.exists(f): os.remove(f) with open(req, "w", encoding="utf-8") as fh: fh.write(default_name or "untitled") deadline = _time.time() + SAVE_BRIDGE_TIMEOUT while _time.time() < deadline: if os.path.exists(resp): try: with open(resp, "r", encoding="utf-8") as fh: val = fh.read().strip() finally: os.remove(resp) return val # "" khi user hủy — KHÔNG fallback PowerShell (tránh 2 dialog) _time.sleep(0.1) except Exception: return "" return "" # bridge đã dùng nhưng hết hạn — KHÔNG fallback (dialog Rust vẫn có thể đang mở) def _save_file_native_engine(default_name: str, filter_name: str, filter_ext: str) -> Optional[str]: """Fallback khi không có Tauri bridge: PowerShell SaveFileDialog (Windows), osascript (macOS), zenity/kdialog (Linux).""" if os.name == "nt": # FileName mặc định + Filter — single-quoted trong PS, filename không # chứa nháy đơn (SonicForge tự sinh tên). ps = ( "Add-Type -AssemblyName System.Windows.Forms; " f"$f = New-Object System.Windows.Forms.SaveFileDialog; " f"$f.FileName = '{default_name}'; " f"$f.Filter = '{filter_name} (*.{filter_ext})|*.{filter_ext}|Tất cả các tệp (*.*)|*.*'; " "$f.Title = 'Chọn nơi lưu tệp xuất'; " "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.FileName }" ) try: r = subprocess.run( ["powershell", "-NoProfile", "-STA", "-Command", ps], capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT, creationflags=subprocess.CREATE_NO_WINDOW, ) return r.stdout.strip() or None except Exception: return None if sys.platform == "darwin": try: r = subprocess.run( ["osascript", "-e", 'set f to choose file name with prompt "Chọn nơi lưu tệp xuất" default name "' + (default_name or "untitled") + '"'], capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT, ) return r.stdout.strip() or None except Exception: return None for cmd in (["zenity", "--file-selection", "--save", "--filename=" + (default_name or "untitled")], ["kdialog", "--getsavefilename", os.path.join(os.path.expanduser("~"), default_name or "untitled")]): try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT) if r.returncode == 0: p = r.stdout.strip() if p: return p except Exception: continue return None class SaveDialogRequest(BaseModel): default_name: str = "untitled" filter_name: str = "File" filter_ext: str = "bin" @router.post("/save-dialog") def save_file_dialog(req: SaveDialogRequest): """Mở NATIVE Save As dialog. Không cần auth (desktop local, chỉ mở dialog). Trả {"path": "<đường dẫn user chọn>"} hoặc {"path": None} (hủy/không có dialog). def (sync) → FastAPI chạy threadpool — không block loop.""" path = _save_file_via_tauri_bridge(req.default_name) if path is None: # không có bridge (standalone thuần) → fallback native path = _save_file_native_engine(req.default_name, req.filter_name, req.filter_ext) return {"path": path or None} @router.post("/write") async def write_export_file(path: str = Form(...), file: UploadFile = File(...)): """Ghi bytes đã export vào đường dẫn tuyệt đối user chọn qua save dialog. Trả {"path": ..., "bytes": N} — frontend toast kèm đường dẫn thật.""" try: data = await file.read() dirname = os.path.dirname(path) if dirname: os.makedirs(dirname, exist_ok=True) with open(path, "wb") as f: f.write(data) return {"path": path, "bytes": len(data)} except Exception as e: raise HTTPException(status_code=400, detail=f"Không thể ghi file: {str(e)}")