108 lines
4.0 KiB
TypeScript
108 lines
4.0 KiB
TypeScript
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<string | null> {
|
|
if (process.platform === "win32") return pickerWindows(startPath);
|
|
if (process.platform === "darwin") return pickerMacOS(startPath);
|
|
return pickerLinux(startPath);
|
|
}
|
|
|
|
function pickerWindows(startPath: string): Promise<string | null> {
|
|
// 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<string | null> {
|
|
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<string | null> {
|
|
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<string, string>,
|
|
): Promise<string | null> {
|
|
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);
|
|
});
|
|
});
|
|
}
|