import { NextRequest, NextResponse } from "next/server"; import { spawn } from "node:child_process"; import { assertLocalRequest } from "@/lib/api/localOnly"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** * Open a native OS folder-picker dialog and return the absolute path the * user selected. Works because Pinkudex is local-only — the Next.js * server has the same desktop session as the browser. Returns * `{ path: null }` if the user cancels. * * Windows: PowerShell + WinForms FolderBrowserDialog. * macOS: osascript "choose folder". * Linux: zenity (must be installed). */ export async function POST(req: NextRequest) { const blocked = assertLocalRequest(req); if (blocked) return blocked; const body = await req.json().catch(() => ({})); const startPath = typeof body.start === "string" ? body.start : ""; try { const path = await runPicker(startPath); return NextResponse.json({ path }); } catch (e) { return NextResponse.json({ error: (e as Error).message, path: null }, { status: 500 }); } } function runPicker(startPath: string): Promise { if (process.platform === "win32") return pickerWindows(startPath); if (process.platform === "darwin") return pickerMacOS(startPath); return pickerLinux(startPath); } function pickerWindows(startPath: string): Promise { // STA threading is required for WinForms dialogs in PowerShell. // -Sta keeps it; -NoProfile avoids whatever the user's profile prints. // startPath is passed via env var so PowerShell never parses it as code. const script = ` Add-Type -AssemblyName System.Windows.Forms | Out-Null $dlg = New-Object System.Windows.Forms.FolderBrowserDialog $dlg.Description = 'Pinkudex — pick a folder' $dlg.ShowNewFolderButton = $false if ($env:PINKUDEX_PICK_START) { try { $dlg.SelectedPath = $env:PINKUDEX_PICK_START } catch {} } $owner = New-Object System.Windows.Forms.Form $owner.TopMost = $true $owner.Opacity = 0 $owner.ShowInTaskbar = $false $result = $dlg.ShowDialog($owner) if ($result -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $dlg.SelectedPath } `.trim(); return runProcess("powershell.exe", ["-NoProfile", "-Sta", "-Command", script], { PINKUDEX_PICK_START: startPath, }); } function pickerMacOS(startPath: string): Promise { const startClause = startPath ? ` default location (POSIX file "${startPath.replace(/"/g, '\\"')}")` : ""; const script = `try set f to choose folder with prompt "Pinkudex — pick a folder"${startClause} return POSIX path of f on error number -128 return "" end try`; return runProcess("osascript", ["-e", script]); } function pickerLinux(startPath: string): Promise { const args = ["--file-selection", "--directory", "--title=Pinkudex — pick a folder"]; if (startPath) args.push(`--filename=${startPath.endsWith("/") ? startPath : startPath + "/"}`); return runProcess("zenity", args).catch((e) => { throw new Error(`Linux folder pickers require zenity to be installed (${(e as Error).message})`); }); } function runProcess( cmd: string, args: string[], extraEnv?: Record, ): Promise { return new Promise((resolve, reject) => { const env = extraEnv ? { ...process.env, ...extraEnv } : process.env; const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env }); let out = ""; let err = ""; child.stdout.on("data", (b) => { out += b.toString(); }); child.stderr.on("data", (b) => { err += b.toString(); }); child.on("error", (e) => reject(e)); child.on("close", (code) => { // Cancel paths return non-zero (zenity) or empty stdout — treat as null. if (code !== 0 && code !== 1 && code !== null) { reject(new Error(err.trim() || `picker exited with code ${code}`)); return; } const trimmed = out.trim().replace(/\r/g, ""); resolve(trimmed || null); }); }); }