Fix 3 bug standalone: save project lỗi schema, export MIDI/WAV không hỏi nơi lưu
- Bug 1: serializeTracksList bỏ section item không có sectionId (do insertSectionAtPlayhead tạo) — tránh source_data {} làm schema oneOf fail
- Bug 2: export MIDI qua save-dialog + write file, toast kèm đường dẫn
- Bug 3: export mix WAV/MP3/OGG qua save-dialog + write file, toast kèm đường dẫn
- Marker tauri_bridge_ready: xóa khi window destroy + guard SF_PARENT_PID — tránh standalone treo 120s
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
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 # user có thể để dialog mở lâu
|
||||
|
||||
|
||||
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)."""
|
||||
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_DIALOG_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 or None
|
||||
_time.sleep(0.1)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
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:
|
||||
path = _save_file_native_engine(req.default_name, req.filter_name, req.filter_ext)
|
||||
return {"path": path}
|
||||
|
||||
|
||||
@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)}")
|
||||
@@ -277,6 +277,11 @@ def _pick_dir_via_tauri_bridge() -> Optional[str]:
|
||||
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
|
||||
# Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
|
||||
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user