feat: network scanner + SFTP context menu + CI fix
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m7s
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m7s
Network scanner (through SSH exec channels): - scan_network: ping sweep + ARP table + reverse DNS on remote network - scan_ports: TCP connect scan via bash /dev/tcp (parallel batches of 20) - quick_scan: 24 common ports (SSH, HTTP, RDP, SMB, DB, etc.) - Cross-platform: Linux + macOS - No agent/nmap required — uses standard POSIX commands - All scans run on the remote host through existing SSH tunnel SFTP context menu: - Right-click on files/folders shows Edit, Download, Rename, Delete - Right-click on folders shows Open Folder - Teleport menu to body for proper z-index layering - Click-away handler to close menu - Rename uses sftp_rename invoke CI fix: - Added default-run = "wraith" to Cargo.toml - The [[bin]] entry for wraith-mcp-bridge confused Cargo about which binary is the Tauri app main binary Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4532f3beb6
commit
2d0964f6b2
@ -2,6 +2,7 @@
|
|||||||
name = "wraith"
|
name = "wraith"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
default-run = "wraith"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "wraith_lib"
|
name = "wraith_lib"
|
||||||
|
|||||||
@ -8,3 +8,4 @@ pub mod rdp_commands;
|
|||||||
pub mod theme_commands;
|
pub mod theme_commands;
|
||||||
pub mod pty_commands;
|
pub mod pty_commands;
|
||||||
pub mod mcp_commands;
|
pub mod mcp_commands;
|
||||||
|
pub mod scanner_commands;
|
||||||
|
|||||||
44
src-tauri/src/commands/scanner_commands.rs
Normal file
44
src-tauri/src/commands/scanner_commands.rs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
//! Tauri commands for network scanning through SSH sessions.
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::scanner::{self, DiscoveredHost, PortResult};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Discover hosts on the remote network via ARP + ping sweep.
|
||||||
|
/// `subnet` should be the first 3 octets, e.g. "192.168.1"
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn scan_network(
|
||||||
|
session_id: String,
|
||||||
|
subnet: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<DiscoveredHost>, String> {
|
||||||
|
let session = state.ssh.get_session(&session_id)
|
||||||
|
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||||
|
scanner::scan_network(&session.handle, &subnet).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan specific ports on a target host through an SSH session.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn scan_ports(
|
||||||
|
session_id: String,
|
||||||
|
target: String,
|
||||||
|
ports: Vec<u16>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<PortResult>, String> {
|
||||||
|
let session = state.ssh.get_session(&session_id)
|
||||||
|
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||||
|
scanner::scan_ports(&session.handle, &target, &ports).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quick scan of common ports (22, 80, 443, 3389, etc.) on a target.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quick_scan(
|
||||||
|
session_id: String,
|
||||||
|
target: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<PortResult>, String> {
|
||||||
|
let session = state.ssh.get_session(&session_id)
|
||||||
|
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||||
|
scanner::quick_port_scan(&session.handle, &target).await
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ pub mod theme;
|
|||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod scanner;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -139,6 +140,7 @@ pub fn run() {
|
|||||||
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
||||||
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
|
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
|
||||||
commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context,
|
commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context,
|
||||||
|
commands::scanner_commands::scan_network, commands::scanner_commands::scan_ports, commands::scanner_commands::quick_scan,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
222
src-tauri/src/scanner/mod.rs
Normal file
222
src-tauri/src/scanner/mod.rs
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
//! Network scanner tools — IP discovery, port scanning, and network mapping
|
||||||
|
//! through SSH exec channels. No agent installation required.
|
||||||
|
//!
|
||||||
|
//! All scans run on the REMOTE host through the existing SSH connection,
|
||||||
|
//! giving visibility into the remote network without direct access.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use russh::client::Handle;
|
||||||
|
use russh::ChannelMsg;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
use crate::ssh::session::SshClient;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DiscoveredHost {
|
||||||
|
pub ip: String,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub vendor: Option<String>,
|
||||||
|
pub open_ports: Vec<u16>,
|
||||||
|
pub services: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PortResult {
|
||||||
|
pub port: u16,
|
||||||
|
pub open: bool,
|
||||||
|
pub service: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Well-known port → service name mapping for common ports.
|
||||||
|
fn service_name(port: u16) -> &'static str {
|
||||||
|
match port {
|
||||||
|
21 => "FTP",
|
||||||
|
22 => "SSH",
|
||||||
|
23 => "Telnet",
|
||||||
|
25 => "SMTP",
|
||||||
|
53 => "DNS",
|
||||||
|
80 => "HTTP",
|
||||||
|
110 => "POP3",
|
||||||
|
135 => "RPC",
|
||||||
|
139 => "NetBIOS",
|
||||||
|
143 => "IMAP",
|
||||||
|
443 => "HTTPS",
|
||||||
|
445 => "SMB",
|
||||||
|
993 => "IMAPS",
|
||||||
|
995 => "POP3S",
|
||||||
|
1433 => "MSSQL",
|
||||||
|
1521 => "Oracle",
|
||||||
|
3306 => "MySQL",
|
||||||
|
3389 => "RDP",
|
||||||
|
5432 => "PostgreSQL",
|
||||||
|
5900 => "VNC",
|
||||||
|
6379 => "Redis",
|
||||||
|
8080 => "HTTP-Alt",
|
||||||
|
8443 => "HTTPS-Alt",
|
||||||
|
27017 => "MongoDB",
|
||||||
|
_ => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover hosts on the remote network using ARP table and ping sweep.
|
||||||
|
pub async fn scan_network(
|
||||||
|
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
||||||
|
subnet: &str,
|
||||||
|
) -> Result<Vec<DiscoveredHost>, String> {
|
||||||
|
// Script that works on Linux and macOS:
|
||||||
|
// 1. Ping sweep the subnet to populate ARP cache
|
||||||
|
// 2. Read ARP table for IP/MAC pairs
|
||||||
|
// 3. Try reverse DNS for hostnames
|
||||||
|
let script = format!(r#"
|
||||||
|
OS=$(uname -s 2>/dev/null)
|
||||||
|
SUBNET="{subnet}"
|
||||||
|
|
||||||
|
# Ping sweep (background, fast)
|
||||||
|
if [ "$OS" = "Linux" ]; then
|
||||||
|
for i in $(seq 1 254); do
|
||||||
|
ping -c 1 -W 1 "$SUBNET.$i" > /dev/null 2>&1 &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
elif [ "$OS" = "Darwin" ]; then
|
||||||
|
for i in $(seq 1 254); do
|
||||||
|
ping -c 1 -t 1 "$SUBNET.$i" > /dev/null 2>&1 &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read ARP table
|
||||||
|
if [ "$OS" = "Linux" ]; then
|
||||||
|
arp -n 2>/dev/null | grep -v incomplete | awk 'NR>1 {{printf "%s|%s\n", $1, $3}}'
|
||||||
|
elif [ "$OS" = "Darwin" ]; then
|
||||||
|
arp -a 2>/dev/null | grep -v incomplete | awk '{{gsub(/[()]/, ""); printf "%s|%s\n", $2, $4}}'
|
||||||
|
fi
|
||||||
|
"#);
|
||||||
|
|
||||||
|
let output = exec_command(handle, &script).await
|
||||||
|
.ok_or_else(|| "Failed to execute network scan".to_string())?;
|
||||||
|
|
||||||
|
let mut hosts = Vec::new();
|
||||||
|
for line in output.lines() {
|
||||||
|
let parts: Vec<&str> = line.split('|').collect();
|
||||||
|
if parts.len() >= 2 && !parts[0].is_empty() {
|
||||||
|
let ip = parts[0].trim().to_string();
|
||||||
|
let mac = if parts[1].trim().is_empty() || parts[1].trim() == "(incomplete)" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(parts[1].trim().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
hosts.push(DiscoveredHost {
|
||||||
|
ip,
|
||||||
|
mac,
|
||||||
|
hostname: None,
|
||||||
|
vendor: None,
|
||||||
|
open_ports: Vec::new(),
|
||||||
|
services: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try reverse DNS for each host
|
||||||
|
if !hosts.is_empty() {
|
||||||
|
let ips: Vec<String> = hosts.iter().map(|h| h.ip.clone()).collect();
|
||||||
|
let dns_script = ips.iter()
|
||||||
|
.map(|ip| format!("echo \"{}|$(host {} 2>/dev/null | awk '/domain name pointer/ {{print $NF}}' | sed 's/\\.$//')\"", ip, ip))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if let Some(dns_output) = exec_command(handle, &dns_script).await {
|
||||||
|
for line in dns_output.lines() {
|
||||||
|
let parts: Vec<&str> = line.split('|').collect();
|
||||||
|
if parts.len() >= 2 && !parts[1].is_empty() {
|
||||||
|
if let Some(host) = hosts.iter_mut().find(|h| h.ip == parts[0]) {
|
||||||
|
host.hostname = Some(parts[1].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan specific ports on a target host through the SSH session.
|
||||||
|
pub async fn scan_ports(
|
||||||
|
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
||||||
|
target: &str,
|
||||||
|
ports: &[u16],
|
||||||
|
) -> Result<Vec<PortResult>, String> {
|
||||||
|
// Use bash /dev/tcp for port scanning — no nmap required
|
||||||
|
let port_checks: Vec<String> = ports.iter()
|
||||||
|
.map(|p| format!(
|
||||||
|
"(echo >/dev/tcp/{target}/{p}) 2>/dev/null && echo \"{p}|open\" || echo \"{p}|closed\""
|
||||||
|
))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Run in parallel batches of 20 for speed
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for chunk in port_checks.chunks(20) {
|
||||||
|
let script = chunk.join(" &\n") + " &\nwait";
|
||||||
|
let output = exec_command(handle, &script).await
|
||||||
|
.ok_or_else(|| "Port scan exec failed".to_string())?;
|
||||||
|
|
||||||
|
for line in output.lines() {
|
||||||
|
let parts: Vec<&str> = line.split('|').collect();
|
||||||
|
if parts.len() >= 2 {
|
||||||
|
if let Ok(port) = parts[0].parse::<u16>() {
|
||||||
|
results.push(PortResult {
|
||||||
|
port,
|
||||||
|
open: parts[1] == "open",
|
||||||
|
service: service_name(port).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort_by_key(|r| r.port);
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quick scan of common ports on a target.
|
||||||
|
pub async fn quick_port_scan(
|
||||||
|
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
||||||
|
target: &str,
|
||||||
|
) -> Result<Vec<PortResult>, String> {
|
||||||
|
let common_ports: Vec<u16> = vec![
|
||||||
|
21, 22, 23, 25, 53, 80, 110, 135, 139, 143,
|
||||||
|
443, 445, 993, 995, 1433, 1521, 3306, 3389,
|
||||||
|
5432, 5900, 6379, 8080, 8443, 27017,
|
||||||
|
];
|
||||||
|
scan_ports(handle, target, &common_ports).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
|
||||||
|
let mut channel = {
|
||||||
|
let h = handle.lock().await;
|
||||||
|
h.channel_open_session().await.ok()?
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.exec(true, cmd).await.ok()?;
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
loop {
|
||||||
|
match channel.wait().await {
|
||||||
|
Some(ChannelMsg::Data { ref data }) => {
|
||||||
|
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
|
||||||
|
Some(ChannelMsg::ExitStatus { .. }) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(output)
|
||||||
|
}
|
||||||
@ -98,6 +98,7 @@
|
|||||||
:class="{ 'bg-[var(--wraith-bg-tertiary)] ring-1 ring-inset ring-[var(--wraith-accent-blue)]': selectedEntry?.path === entry.path }"
|
:class="{ 'bg-[var(--wraith-bg-tertiary)] ring-1 ring-inset ring-[var(--wraith-accent-blue)]': selectedEntry?.path === entry.path }"
|
||||||
@click="selectedEntry = entry"
|
@click="selectedEntry = entry"
|
||||||
@dblclick="handleEntryDblClick(entry)"
|
@dblclick="handleEntryDblClick(entry)"
|
||||||
|
@contextmenu.prevent="openContextMenu($event, entry)"
|
||||||
>
|
>
|
||||||
<!-- Icon -->
|
<!-- Icon -->
|
||||||
<svg
|
<svg
|
||||||
@ -136,6 +137,62 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Context menu -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="contextMenu.visible"
|
||||||
|
class="fixed z-[100] w-44 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden py-1"
|
||||||
|
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
|
||||||
|
@click="contextMenu.visible = false"
|
||||||
|
@contextmenu.prevent
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="!contextMenu.entry?.isDir"
|
||||||
|
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
||||||
|
@click="handleEdit(contextMenu.entry!)"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!contextMenu.entry?.isDir"
|
||||||
|
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
||||||
|
@click="selectedEntry = contextMenu.entry!; handleDownload()"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="contextMenu.entry?.isDir"
|
||||||
|
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
||||||
|
@click="navigateTo(contextMenu.entry!.path)"
|
||||||
|
>
|
||||||
|
Open Folder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
||||||
|
@click="handleRename(contextMenu.entry!)"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-[#30363d] my-1" />
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-accent-red)] hover:bg-[#30363d] cursor-pointer"
|
||||||
|
@click="selectedEntry = contextMenu.entry!; handleDelete()"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Click-away handler to close context menu -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="contextMenu.visible"
|
||||||
|
class="fixed inset-0 z-[99]"
|
||||||
|
@click="contextMenu.visible = false"
|
||||||
|
@contextmenu.prevent="contextMenu.visible = false"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Follow terminal toggle -->
|
<!-- Follow terminal toggle -->
|
||||||
<div class="flex items-center gap-2 px-3 py-1.5 border-t border-[var(--wraith-border)]">
|
<div class="flex items-center gap-2 px-3 py-1.5 border-t border-[var(--wraith-border)]">
|
||||||
<label class="flex items-center gap-1.5 cursor-pointer text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] transition-colors">
|
<label class="flex items-center gap-1.5 cursor-pointer text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] transition-colors">
|
||||||
@ -170,6 +227,35 @@ const { addTransfer, completeTransfer, failTransfer } = useTransfers();
|
|||||||
/** Currently selected entry (single-click to select, double-click to open/navigate). */
|
/** Currently selected entry (single-click to select, double-click to open/navigate). */
|
||||||
const selectedEntry = ref<FileEntry | null>(null);
|
const selectedEntry = ref<FileEntry | null>(null);
|
||||||
|
|
||||||
|
/** Right-click context menu state. */
|
||||||
|
const contextMenu = ref<{ visible: boolean; x: number; y: number; entry: FileEntry | null }>({
|
||||||
|
visible: false, x: 0, y: 0, entry: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function openContextMenu(event: MouseEvent, entry: FileEntry): void {
|
||||||
|
selectedEntry.value = entry;
|
||||||
|
contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(entry: FileEntry): void {
|
||||||
|
emit("openFile", entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRename(entry: FileEntry): Promise<void> {
|
||||||
|
const newName = prompt("Rename to:", entry.name);
|
||||||
|
if (!newName || !newName.trim() || newName.trim() === entry.name) return;
|
||||||
|
|
||||||
|
const parentPath = entry.path.substring(0, entry.path.lastIndexOf("/"));
|
||||||
|
const newPath = parentPath + "/" + newName.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("sftp_rename", { sessionId: props.sessionId, oldPath: entry.path, newPath });
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SFTP rename error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Hidden file input element used for the upload flow. */
|
/** Hidden file input element used for the upload flow. */
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user