Browse Source

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
handoff-20260429-1057
jiajia 3 months ago
parent
commit
b0dff18dd4
  1. 98
      src/app/api/browse-directory/route.ts

98
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<boolean> {
try {
await execFileAsync("sh", ["-lc", `command -v ${command}`]);
return true;
} catch {
return false;
}
}
async function findPowerShellCommand(): Promise<string | null> {
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.");
}
}

Loading…
Cancel
Save