From b0dff18dd4427176655897805418aa026064af54 Mon Sep 17 00:00:00 2001 From: jiajia Date: Mon, 27 Apr 2026 10:28:39 +0800 Subject: [PATCH] Keep directory browsing usable without native pickers Remote and server-hosted deployments cannot rely on the browser user's desktop tools being available inside the Node server process. Directory browsing now checks for native picker commands first and falls back to a configurable server-side workflows directory instead of surfacing command-not-found errors. Constraint: The browse endpoint runs on the server, not on the Windows client browser Constraint: Test servers may not have osascript, zenity, kdialog, PowerShell, or an interactive desktop session Rejected: Keep propagating native picker failures to the modal | successful server deployments without desktop tools showed red command errors Confidence: high Scope-risk: narrow Directive: For true client-local folder selection, use a separate File System Access API flow instead of this server filesystem endpoint Tested: npx vitest run src/app/api/browse-directory/__tests__/route.test.ts src/components/__tests__/ProjectSetupModal.test.tsx Tested: npm run build Not-tested: Interactive Windows Server desktop picker session --- src/app/api/browse-directory/route.ts | 98 +++++++++++++++++++-------- 1 file changed, 68 insertions(+), 30 deletions(-) diff --git a/src/app/api/browse-directory/route.ts b/src/app/api/browse-directory/route.ts index 1f3df3b2..14777aa7 100644 --- a/src/app/api/browse-directory/route.ts +++ b/src/app/api/browse-directory/route.ts @@ -1,11 +1,47 @@ import { NextResponse } from "next/server"; -import { exec } from "child_process"; +import { exec, execFile } from "child_process"; import { promisify } from "util"; import { writeFile, unlink } from "fs/promises"; -import { tmpdir } from "os"; +import { homedir, tmpdir } from "os"; import { join } from "path"; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +async function commandExists(command: string): Promise { + try { + await execFileAsync("sh", ["-lc", `command -v ${command}`]); + return true; + } catch { + return false; + } +} + +async function findPowerShellCommand(): Promise { + for (const command of ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]) { + try { + await execFileAsync(command, ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"]); + return command; + } catch { + // Try the next PowerShell executable name. + } + } + return null; +} + +function getFallbackDirectory(): string { + return process.env.POPIART_WORKFLOWS_DIR || join(homedir(), "Documents"); +} + +function fallbackDirectoryResponse(reason: string) { + return NextResponse.json({ + success: true, + cancelled: false, + fallback: true, + path: getFallbackDirectory(), + warning: reason, + }); +} /** * Normalize a path returned by native directory pickers. @@ -43,11 +79,20 @@ export async function GET() { if (platform === "darwin") { // macOS: Use osascript to open folder picker + if (!(await commandExists("osascript"))) { + return fallbackDirectoryResponse("Native folder picker is unavailable in this environment."); + } + const { stdout } = await execAsync( `osascript -e 'set folderPath to POSIX path of (choose folder with prompt "Select a folder to save workflows")' -e 'return folderPath'` ); selectedPath = stdout.trim(); } else if (platform === "win32") { + const powershellCommand = await findPowerShellCommand(); + if (!powershellCommand) { + return fallbackDirectoryResponse("Windows folder picker is unavailable in this environment."); + } + // Windows: Use the modern IFileOpenDialog (Vista+) for a nice folder picker // Write script to temp file to avoid escaping issues const psScript = ` @@ -98,39 +143,39 @@ if ($result) { Write-Output $result } const tempFile = join(tmpdir(), `browse-folder-${Date.now()}.ps1`); try { await writeFile(tempFile, psScript, "utf-8"); - const { stdout } = await execAsync( - `powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`, + const { stdout } = await execFileAsync( + powershellCommand, + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tempFile], { timeout: 120000 } ); selectedPath = stdout.trim(); + } catch (error) { + if ( + error instanceof Error && + (error.message.includes("User canceled") || error.message.includes("-128")) + ) { + selectedPath = null; + } else { + return fallbackDirectoryResponse("Windows folder picker failed in this environment."); + } } finally { // Clean up temp file try { await unlink(tempFile); } catch { /* ignore */ } } } else if (platform === "linux") { // Linux: Try zenity (common on GNOME) or kdialog (KDE) - try { + if (await commandExists("zenity")) { const { stdout } = await execAsync( `zenity --file-selection --directory --title="Select a folder to save workflows" 2>/dev/null` ); selectedPath = stdout.trim(); - } catch { - // Try kdialog as fallback - try { - const { stdout } = await execAsync( - `kdialog --getexistingdirectory ~ --title "Select a folder to save workflows"` - ); - selectedPath = stdout.trim(); - } catch { - return NextResponse.json( - { - success: false, - error: - "No supported dialog tool found. Please install zenity or kdialog.", - }, - { status: 500 } - ); - } + } else if (await commandExists("kdialog")) { + const { stdout } = await execAsync( + `kdialog --getexistingdirectory ~ --title "Select a folder to save workflows"` + ); + selectedPath = stdout.trim(); + } else { + return fallbackDirectoryResponse("No supported folder picker was found. Using the default documents folder."); } } else { return NextResponse.json( @@ -169,13 +214,6 @@ if ($result) { Write-Output $result } }); } - return NextResponse.json( - { - success: false, - error: - error instanceof Error ? error.message : "Failed to open dialog", - }, - { status: 500 } - ); + return fallbackDirectoryResponse("Native folder picker failed in this environment."); } }