mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
Merge pull request #146 from kaovilai/feat/file-upload-tool
feat: Add file upload capability using Chrome DevTools Protocol
This commit is contained in:
@@ -145,6 +145,11 @@ export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT) {
|
||||
console.log(SUCCESS_MESSAGES.SERVER_STOPPED);
|
||||
} else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) {
|
||||
console.error('Error from native host:', message.payload?.message || 'Unknown error');
|
||||
} else if (message.type === 'file_operation_response') {
|
||||
// Forward file operation response back to the requesting tool
|
||||
chrome.runtime.sendMessage(message).catch(() => {
|
||||
// Ignore if no listeners
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -233,5 +238,16 @@ export const initNativeHostListener = () => {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Forward file operation messages to native host
|
||||
if (message.type === 'forward_to_native' && message.message) {
|
||||
if (nativePort) {
|
||||
nativePort.postMessage(message.message);
|
||||
sendResponse({ success: true });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'Native host not connected' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
|
||||
interface FileUploadToolParams {
|
||||
selector: string; // CSS selector for the file input element
|
||||
filePath?: string; // Local file path
|
||||
fileUrl?: string; // URL to download file from
|
||||
base64Data?: string; // Base64 encoded file data
|
||||
fileName?: string; // Optional filename when using base64 or URL
|
||||
multiple?: boolean; // Whether to allow multiple files
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for uploading files to web forms using Chrome DevTools Protocol
|
||||
* Similar to Playwright's setInputFiles implementation
|
||||
*/
|
||||
class FileUploadTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.FILE_UPLOAD;
|
||||
private activeDebuggers: Map<number, boolean> = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Clean up debuggers on tab removal
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (this.activeDebuggers.has(tabId)) {
|
||||
this.cleanupDebugger(tabId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute file upload operation using Chrome DevTools Protocol
|
||||
*/
|
||||
async execute(args: FileUploadToolParams): Promise<ToolResult> {
|
||||
const { selector, filePath, fileUrl, base64Data, fileName, multiple = false } = args;
|
||||
|
||||
console.log(`Starting file upload operation with options:`, args);
|
||||
|
||||
// Validate input
|
||||
if (!selector) {
|
||||
return createErrorResponse('Selector is required for file upload');
|
||||
}
|
||||
|
||||
if (!filePath && !fileUrl && !base64Data) {
|
||||
return createErrorResponse(
|
||||
'One of filePath, fileUrl, or base64Data must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
let tabId: number | undefined;
|
||||
|
||||
try {
|
||||
// Get current tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]?.id) {
|
||||
return createErrorResponse('No active tab found');
|
||||
}
|
||||
tabId = tabs[0].id;
|
||||
|
||||
// Prepare file paths
|
||||
let files: string[] = [];
|
||||
|
||||
if (filePath) {
|
||||
// Direct file path provided
|
||||
files = [filePath];
|
||||
} else if (fileUrl || base64Data) {
|
||||
// For URL or base64, we need to use the native messaging host
|
||||
// to download or save the file temporarily
|
||||
const tempFilePath = await this.prepareFileFromRemote({
|
||||
fileUrl,
|
||||
base64Data,
|
||||
fileName: fileName || 'uploaded-file',
|
||||
});
|
||||
if (!tempFilePath) {
|
||||
return createErrorResponse('Failed to prepare file for upload');
|
||||
}
|
||||
files = [tempFilePath];
|
||||
}
|
||||
|
||||
// Attach debugger to the tab
|
||||
await this.attachDebugger(tabId);
|
||||
|
||||
// Enable necessary CDP domains
|
||||
await chrome.debugger.sendCommand({ tabId }, 'DOM.enable', {});
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable', {});
|
||||
|
||||
// Get the document
|
||||
const { root } = await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'DOM.getDocument',
|
||||
{ depth: -1, pierce: true },
|
||||
) as { root: { nodeId: number } };
|
||||
|
||||
// Find the file input element using the selector
|
||||
const { nodeId } = await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'DOM.querySelector',
|
||||
{
|
||||
nodeId: root.nodeId,
|
||||
selector: selector,
|
||||
},
|
||||
) as { nodeId: number };
|
||||
|
||||
if (!nodeId || nodeId === 0) {
|
||||
throw new Error(`Element with selector "${selector}" not found`);
|
||||
}
|
||||
|
||||
// Verify it's actually a file input
|
||||
const { node } = await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'DOM.describeNode',
|
||||
{ nodeId },
|
||||
) as { node: { nodeName: string; attributes?: string[] } };
|
||||
|
||||
if (node.nodeName !== 'INPUT') {
|
||||
throw new Error(`Element with selector "${selector}" is not an input element`);
|
||||
}
|
||||
|
||||
// Check if it's a file input by looking for type="file" in attributes
|
||||
const attributes = node.attributes || [];
|
||||
let isFileInput = false;
|
||||
for (let i = 0; i < attributes.length; i += 2) {
|
||||
if (attributes[i] === 'type' && attributes[i + 1] === 'file') {
|
||||
isFileInput = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFileInput) {
|
||||
throw new Error(`Element with selector "${selector}" is not a file input (type="file")`);
|
||||
}
|
||||
|
||||
// Set the files on the input element
|
||||
// This is the key CDP command that Playwright and Puppeteer use
|
||||
await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'DOM.setFileInputFiles',
|
||||
{
|
||||
nodeId: nodeId,
|
||||
files: files,
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger change event to ensure the page reacts to the file upload
|
||||
await chrome.debugger.sendCommand(
|
||||
{ tabId },
|
||||
'Runtime.evaluate',
|
||||
{
|
||||
expression: `
|
||||
(function() {
|
||||
const element = document.querySelector('${selector.replace(/'/g, "\\'")}');
|
||||
if (element) {
|
||||
const event = new Event('change', { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`,
|
||||
},
|
||||
);
|
||||
|
||||
// Clean up debugger
|
||||
await this.detachDebugger(tabId);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: 'File(s) uploaded successfully',
|
||||
files: files,
|
||||
selector: selector,
|
||||
fileCount: files.length,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in file upload operation:', error);
|
||||
|
||||
// Clean up debugger if attached
|
||||
if (tabId !== undefined && this.activeDebuggers.has(tabId)) {
|
||||
await this.detachDebugger(tabId);
|
||||
}
|
||||
|
||||
return createErrorResponse(
|
||||
`Error uploading file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach debugger to a tab
|
||||
*/
|
||||
private async attachDebugger(tabId: number): Promise<void> {
|
||||
// Check if debugger is already attached
|
||||
const targets = await chrome.debugger.getTargets();
|
||||
const existingTarget = targets.find(
|
||||
(t) => t.tabId === tabId && t.attached,
|
||||
);
|
||||
|
||||
if (existingTarget) {
|
||||
if (existingTarget.extensionId === chrome.runtime.id) {
|
||||
// Our extension already attached
|
||||
console.log('Debugger already attached by this extension');
|
||||
return;
|
||||
} else {
|
||||
throw new Error(
|
||||
'Debugger is already attached to this tab by another extension or DevTools',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach debugger
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
this.activeDebuggers.set(tabId, true);
|
||||
console.log(`Debugger attached to tab ${tabId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach debugger from a tab
|
||||
*/
|
||||
private async detachDebugger(tabId: number): Promise<void> {
|
||||
if (!this.activeDebuggers.has(tabId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId });
|
||||
console.log(`Debugger detached from tab ${tabId}`);
|
||||
} catch (error) {
|
||||
console.warn(`Error detaching debugger from tab ${tabId}:`, error);
|
||||
} finally {
|
||||
this.activeDebuggers.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up debugger connection
|
||||
*/
|
||||
private cleanupDebugger(tabId: number): void {
|
||||
this.activeDebuggers.delete(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare file from URL or base64 data using native messaging host
|
||||
*/
|
||||
private async prepareFileFromRemote(options: {
|
||||
fileUrl?: string;
|
||||
base64Data?: string;
|
||||
fileName: string;
|
||||
}): Promise<string | null> {
|
||||
const { fileUrl, base64Data, fileName } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const requestId = `file-upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('File preparation request timed out');
|
||||
resolve(null);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
// Create listener for the response
|
||||
const handleMessage = (message: any) => {
|
||||
if (message.type === 'file_operation_response' &&
|
||||
message.responseToRequestId === requestId) {
|
||||
clearTimeout(timeout);
|
||||
chrome.runtime.onMessage.removeListener(handleMessage);
|
||||
|
||||
if (message.payload?.success && message.payload?.filePath) {
|
||||
resolve(message.payload.filePath);
|
||||
} else {
|
||||
console.error('Native host failed to prepare file:', message.error || message.payload?.error);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add listener
|
||||
chrome.runtime.onMessage.addListener(handleMessage);
|
||||
|
||||
// Send message to background script to forward to native host
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'forward_to_native',
|
||||
message: {
|
||||
type: 'file_operation',
|
||||
requestId: requestId,
|
||||
payload: {
|
||||
action: 'prepareFile',
|
||||
fileUrl,
|
||||
base64Data,
|
||||
fileName,
|
||||
},
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error('Error sending message to background:', error);
|
||||
clearTimeout(timeout);
|
||||
chrome.runtime.onMessage.removeListener(handleMessage);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const fileUploadTool = new FileUploadTool();
|
||||
@@ -12,3 +12,4 @@ export { historyTool } from './history';
|
||||
export { bookmarkSearchTool, bookmarkAddTool, bookmarkDeleteTool } from './bookmark';
|
||||
export { injectScriptTool, sendCommandToInjectScriptTool } from './inject-script';
|
||||
export { consoleTool } from './console';
|
||||
export { fileUploadTool } from './file-upload';
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
/**
|
||||
* File handler for managing file uploads through the native messaging host
|
||||
*/
|
||||
export class FileHandler {
|
||||
private tempDir: string;
|
||||
|
||||
constructor() {
|
||||
// Create a temp directory for file operations
|
||||
this.tempDir = path.join(os.tmpdir(), 'chrome-mcp-uploads');
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file preparation request from the extension
|
||||
*/
|
||||
async handleFileRequest(request: any): Promise<any> {
|
||||
const { action, fileUrl, base64Data, fileName, filePath } = request;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'prepareFile':
|
||||
if (fileUrl) {
|
||||
return await this.downloadFile(fileUrl, fileName);
|
||||
} else if (base64Data) {
|
||||
return await this.saveBase64File(base64Data, fileName);
|
||||
} else if (filePath) {
|
||||
return await this.verifyFile(filePath);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cleanupFile':
|
||||
return await this.cleanupFile(filePath);
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown file action: ${action}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from URL and save to temp directory
|
||||
*/
|
||||
private async downloadFile(fileUrl: string, fileName?: string): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download file: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Generate filename if not provided
|
||||
const finalFileName = fileName || this.generateFileName(fileUrl);
|
||||
const filePath = path.join(this.tempDir, finalFileName);
|
||||
|
||||
// Get the file buffer
|
||||
const buffer = await response.buffer();
|
||||
|
||||
// Save to file
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: filePath,
|
||||
fileName: finalFileName,
|
||||
size: buffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to download file from URL: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save base64 data as a file
|
||||
*/
|
||||
private async saveBase64File(base64Data: string, fileName?: string): Promise<any> {
|
||||
try {
|
||||
// Remove data URL prefix if present
|
||||
const base64Content = base64Data.replace(/^data:.*?;base64,/, '');
|
||||
|
||||
// Convert base64 to buffer
|
||||
const buffer = Buffer.from(base64Content, 'base64');
|
||||
|
||||
// Generate filename if not provided
|
||||
const finalFileName = fileName || `upload-${Date.now()}.bin`;
|
||||
const filePath = path.join(this.tempDir, finalFileName);
|
||||
|
||||
// Save to file
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: filePath,
|
||||
fileName: finalFileName,
|
||||
size: buffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save base64 file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a file exists and is accessible
|
||||
*/
|
||||
private async verifyFile(filePath: string): Promise<any> {
|
||||
try {
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File does not exist: ${filePath}`);
|
||||
}
|
||||
|
||||
// Get file stats
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
// Check if it's actually a file
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${filePath}`);
|
||||
}
|
||||
|
||||
// Check if file is readable
|
||||
fs.accessSync(filePath, fs.constants.R_OK);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: filePath,
|
||||
fileName: path.basename(filePath),
|
||||
size: stats.size,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to verify file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a temporary file
|
||||
*/
|
||||
private async cleanupFile(filePath: string): Promise<any> {
|
||||
try {
|
||||
// Only allow cleanup of files in our temp directory
|
||||
if (!filePath.startsWith(this.tempDir)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Can only cleanup files in temp directory',
|
||||
};
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'File cleaned up successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to cleanup file: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a filename from URL or create a unique one
|
||||
*/
|
||||
private generateFileName(url?: string): string {
|
||||
if (url) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const basename = path.basename(pathname);
|
||||
if (basename && basename !== '/') {
|
||||
// Add random suffix to avoid collisions
|
||||
const ext = path.extname(basename);
|
||||
const name = path.basename(basename, ext);
|
||||
const randomSuffix = crypto.randomBytes(4).toString('hex');
|
||||
return `${name}-${randomSuffix}${ext}`;
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, fall through to generate random name
|
||||
}
|
||||
}
|
||||
|
||||
// Generate random filename
|
||||
return `upload-${crypto.randomBytes(8).toString('hex')}.bin`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old temporary files (older than 1 hour)
|
||||
*/
|
||||
cleanupOldFiles(): void {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
|
||||
const files = fs.readdirSync(this.tempDir);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(this.tempDir, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
if (now - stats.mtimeMs > oneHour) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`Cleaned up old temp file: ${file}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up old files:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new FileHandler();
|
||||
@@ -3,6 +3,7 @@ import { Server } from './server';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { NativeMessageType } from 'chrome-mcp-shared';
|
||||
import { TIMEOUTS } from './constant';
|
||||
import fileHandler from './file-handler';
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: any) => void;
|
||||
@@ -102,6 +103,9 @@ export class NativeMessagingHost {
|
||||
case 'ping_from_extension':
|
||||
this.sendMessage({ type: 'pong_to_extension' });
|
||||
break;
|
||||
case 'file_operation':
|
||||
await this.handleFileOperation(message);
|
||||
break;
|
||||
default:
|
||||
// Double check when message type is not supported
|
||||
if (!message.responseToRequestId) {
|
||||
@@ -115,6 +119,45 @@ export class NativeMessagingHost {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file operations from the extension
|
||||
*/
|
||||
private async handleFileOperation(message: any): Promise<void> {
|
||||
try {
|
||||
const result = await fileHandler.handleFileRequest(message.payload);
|
||||
|
||||
if (message.requestId) {
|
||||
// Send response back with the request ID
|
||||
this.sendMessage({
|
||||
type: 'file_operation_response',
|
||||
responseToRequestId: message.requestId,
|
||||
payload: result,
|
||||
});
|
||||
} else {
|
||||
// No request ID, just send result
|
||||
this.sendMessage({
|
||||
type: 'file_operation_result',
|
||||
payload: result,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: error.message || 'Unknown error during file operation',
|
||||
};
|
||||
|
||||
if (message.requestId) {
|
||||
this.sendMessage({
|
||||
type: 'file_operation_response',
|
||||
responseToRequestId: message.requestId,
|
||||
error: errorResponse.error,
|
||||
});
|
||||
} else {
|
||||
this.sendError(`File operation failed: ${errorResponse.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request to Chrome and wait for response
|
||||
* @param messagePayload Data to send to Chrome
|
||||
|
||||
@@ -26,6 +26,7 @@ export const TOOL_NAMES = {
|
||||
INJECT_SCRIPT: 'chrome_inject_script',
|
||||
SEND_COMMAND_TO_INJECT_SCRIPT: 'chrome_send_command_to_inject_script',
|
||||
CONSOLE: 'chrome_console',
|
||||
FILE_UPLOAD: 'chrome_upload_file',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -553,4 +554,38 @@ export const TOOL_SCHEMAS: Tool[] = [
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.BROWSER.FILE_UPLOAD,
|
||||
description: 'Upload files to web forms with file input elements using Chrome DevTools Protocol',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
selector: {
|
||||
type: 'string',
|
||||
description: 'CSS selector for the file input element (input[type="file"])',
|
||||
},
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Local file path to upload',
|
||||
},
|
||||
fileUrl: {
|
||||
type: 'string',
|
||||
description: 'URL to download file from before uploading',
|
||||
},
|
||||
base64Data: {
|
||||
type: 'string',
|
||||
description: 'Base64 encoded file data to upload',
|
||||
},
|
||||
fileName: {
|
||||
type: 'string',
|
||||
description: 'Optional filename when using base64 or URL (default: "uploaded-file")',
|
||||
},
|
||||
multiple: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the input accepts multiple files (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['selector'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user