From fca94d025ea7752cfe1f073deb51f939a1443959 Mon Sep 17 00:00:00 2001 From: YueMiyuki <76854136+YueMiyuki@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:23:25 +0800 Subject: [PATCH 1/6] feat(core): add unified API layer with Tauri invoke for desktop tools --- .vscode/settings.json | 3 +- app/api/whistle/route.ts | 460 ------------- app/page.tsx | 4 +- components/tools/curl-converter.tsx | 21 +- components/tools/port-detective.tsx | 38 +- components/tools/ssl-toothbrush.tsx | 59 +- components/tools/tcp-udp-whistle.tsx | 139 ++-- components/ui/alert-dialog.tsx | 153 +++++ components/ui/button.tsx | 2 +- lib/api.ts | 175 +++++ lib/tauri.ts | 26 + messages/en.json | 9 + messages/zh-TW.json | 9 + messages/zh.json | 9 + package.json | 5 +- pnpm-lock.yaml | 46 ++ src-tauri/Cargo.lock | 516 +++++++++++++- src-tauri/Cargo.toml | 13 +- src-tauri/capabilities/default.json | 7 - src-tauri/capabilities/main.json | 17 + src-tauri/src/lib.rs | 961 ++++++++++++++++++++++++++- src-tauri/tauri.conf.json | 2 +- 22 files changed, 2057 insertions(+), 617 deletions(-) delete mode 100644 app/api/whistle/route.ts create mode 100644 components/ui/alert-dialog.tsx create mode 100644 lib/api.ts create mode 100644 lib/tauri.ts delete mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/capabilities/main.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 5ac1c06..469845f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "files.associations": { "*.css": "tailwindcss" - } + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/app/api/whistle/route.ts b/app/api/whistle/route.ts deleted file mode 100644 index 4916b7a..0000000 --- a/app/api/whistle/route.ts +++ /dev/null @@ -1,460 +0,0 @@ -import dgram from "node:dgram"; -import net from "node:net"; -import type { NextRequest } from "next/server"; - -export const runtime = "nodejs"; - -type Mode = "tcp-send" | "udp-send" | "tcp-listen" | "udp-listen"; - -interface WhistleRequest { - mode: Mode; - host?: string; - port: number; - payload?: string; - timeoutMs?: number; - delayMs?: number; - chunkSize?: number; - durationMs?: number; - malformed?: boolean; - echo?: boolean; - echoPayload?: string; - respondDelayMs?: number; - maxCapture?: number; -} - -interface CaptureEntry { - at: string; - remoteAddress?: string | null; - remotePort?: number | null; - bytes: number; - hex: string; - text: string; - elapsedMs?: number; - note?: string; -} - -const MAX_PAYLOAD_BYTES = 4096; -const MAX_RESPONSE_BYTES = 128 * 1024; -const MAX_DURATION_MS = 600000; -const MAX_DELAY_MS = 8000; - -function toSafeDelay(value: unknown, fallback: number): number { - const num = Number(value); - if (Number.isNaN(num) || num < 0) return fallback; - return Math.min(num, MAX_DELAY_MS); -} - -function toSafeDuration(value: unknown, fallback: number): number { - const num = Number(value); - if (Number.isNaN(num) || num <= 0) return fallback; - return Math.min(num, MAX_DURATION_MS); -} - -function buildPayload( - payload: string | undefined, - malformed?: boolean, -): Buffer { - const trimmed = payload ?? ""; - const baseBuffer = Buffer.from(trimmed).subarray(0, MAX_PAYLOAD_BYTES); - if (!malformed) return baseBuffer; - - const noise = Buffer.from([0x00, 0xff, 0x13, 0x37]); - return Buffer.concat([baseBuffer, noise]).subarray(0, MAX_PAYLOAD_BYTES); -} - -function preview(buffer: Buffer) { - return { - text: buffer.toString("utf8"), - hex: buffer.toString("hex"), - bytes: buffer.length, - }; -} - -function validatePort(port: number): string | null { - if (!Number.isInteger(port) || port < 1 || port > 65535) { - return "Invalid port"; - } - return null; -} - -function validateHost(host?: string): string | null { - if (!host) return "Host is required"; - if (typeof host !== "string" || host.trim().length === 0) { - return "Host is required"; - } - if (host.length > 255) return "Host too long"; - - const normalizedHost = host.trim().toLowerCase(); - const allowedHosts = ["localhost", "127.0.0.1", "::1", "[::1]"]; - - // Check if it's an allowed host - if (!allowedHosts.includes(normalizedHost)) { - // Allow IPv4 loopback range (127.0.0.0/8) - const ipv4Regex = - /^127\.(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){2}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - if (!ipv4Regex.test(normalizedHost)) { - return "Only localhost/loopback addresses are allowed for security reasons"; - } - } - - return null; -} - -function parseChunkSize(value: unknown): number | undefined { - const num = Number(value); - if (Number.isNaN(num) || num <= 0) return undefined; - return Math.floor(num); -} - -async function wait(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function handleTcpSend(req: WhistleRequest) { - const payloadBuffer = buildPayload(req.payload, req.malformed); - const delayMs = toSafeDelay(req.delayMs, 0); - const timeoutMs = Math.max(Number(req.timeoutMs) || 5000, 500); - const chunkSize = parseChunkSize(req.chunkSize); - const start = Date.now(); - - return new Promise((resolve, reject) => { - const socket = new net.Socket(); - let collected = Buffer.alloc(0); - let settled = false; - - const finalize = (error?: Error) => { - if (settled) return; - settled = true; - socket.destroy(); - if (error) { - reject(error); - return; - } - - const elapsedMs = Date.now() - start; - const truncated = collected.subarray(0, MAX_RESPONSE_BYTES); - resolve({ - ok: true, - mode: "tcp-send" as const, - elapsedMs, - bytesSent: payloadBuffer.length, - bytesReceived: truncated.length, - response: preview(truncated), - }); - }; - - socket.setTimeout(timeoutMs, () => { - finalize(new Error("Timed out while waiting for a response")); - }); - socket.on("error", (err) => finalize(err)); - socket.on("data", (chunk: Buffer) => { - if (settled) return; - collected = Buffer.concat([collected, chunk]).subarray( - 0, - MAX_RESPONSE_BYTES, - ); - }); - socket.on("close", () => finalize()); - - socket.connect(req.port, req.host ?? "", () => { - (async () => { - if (delayMs > 0) await wait(delayMs); - - if (chunkSize && chunkSize < payloadBuffer.length) { - for (let i = 0; i < payloadBuffer.length; i += chunkSize) { - socket.write(payloadBuffer.subarray(i, i + chunkSize)); - await wait(120); - } - socket.end(); - } else { - socket.end(payloadBuffer); - } - })().catch((err) => finalize(err)); - }); - }); -} - -async function handleUdpSend(req: WhistleRequest) { - const payloadBuffer = buildPayload(req.payload, req.malformed); - const delayMs = toSafeDelay(req.delayMs, 0); - const timeoutMs = Math.max(Number(req.timeoutMs) || 4000, 500); - const start = Date.now(); - const family = net.isIP(req.host ?? "") === 6 ? "udp6" : "udp4"; - - return new Promise((resolve, reject) => { - const socket = dgram.createSocket(family); - let settled = false; - let responseBuffer = Buffer.alloc(0); - let timer: NodeJS.Timeout | null = null; - - const finalize = (error?: Error) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - socket.close(); - if (error) { - reject(error); - return; - } - const elapsedMs = Date.now() - start; - const truncated = responseBuffer.subarray(0, MAX_RESPONSE_BYTES); - resolve({ - ok: true, - mode: "udp-send" as const, - elapsedMs, - bytesSent: payloadBuffer.length, - bytesReceived: truncated.length, - response: preview(truncated), - }); - }; - - timer = setTimeout( - () => finalize(new Error("UDP receive timeout")), - timeoutMs, - ); - - socket.on("message", (msg) => { - responseBuffer = Buffer.concat([responseBuffer, msg]).subarray( - 0, - MAX_RESPONSE_BYTES, - ); - finalize(); - }); - socket.on("error", (err) => finalize(err)); - - (async () => { - if (delayMs > 0) await wait(delayMs); - socket.send(payloadBuffer, req.port, req.host ?? "", (err) => { - if (err) finalize(err); - }); - })().catch((err) => finalize(err)); - }); -} - -async function handleTcpListen(req: WhistleRequest, signal?: AbortSignal) { - const durationMs = toSafeDuration(req.durationMs, 600000); - const respondDelayMs = toSafeDelay(req.respondDelayMs, 0); - const maxCapture = Math.min(req.maxCapture ?? 10, 25); - const echoPayload = buildPayload(req.echoPayload, req.malformed); - const captures: CaptureEntry[] = []; - const activeSockets = new Set(); - - return new Promise((resolve, reject) => { - const server = net.createServer((socket) => { - activeSockets.add(socket); - const started = Date.now(); - let collected = Buffer.alloc(0); - - const removeSocket = () => { - activeSockets.delete(socket); - }; - - socket.on("close", removeSocket); - - socket.on("data", (chunk: Buffer) => { - collected = Buffer.concat([collected, chunk]).subarray( - 0, - MAX_RESPONSE_BYTES, - ); - }); - - socket.on("end", async () => { - if (captures.length >= maxCapture) return; - - const truncated = collected.subarray(0, MAX_RESPONSE_BYTES); - captures.push({ - at: new Date().toISOString(), - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - bytes: truncated.length, - hex: truncated.toString("hex"), - text: truncated.toString("utf8"), - elapsedMs: Date.now() - started, - }); - - if (req.echo) { - try { - await wait(respondDelayMs); - socket.write(echoPayload); - } catch { - // socket may have been destroyed - } - } - socket.end(); - }); - - socket.on("error", (err) => { - removeSocket(); - captures.push({ - at: new Date().toISOString(), - remoteAddress: socket.remoteAddress, - remotePort: socket.remotePort, - bytes: 0, - hex: "", - text: "", - note: `Socket error: ${err.message}`, - }); - }); - }); - - server.on("error", (err) => reject(err)); - - const cleanup = () => { - for (const socket of activeSockets) { - socket.destroy(); - } - activeSockets.clear(); - - server.close(() => { - resolve({ - ok: true, - mode: "tcp-listen" as const, - durationMs: Date.now() - serverStartTime, - captures, - }); - }); - }; - - let serverStartTime = 0; - let timer: NodeJS.Timeout | null = null; - - if (signal) { - signal.addEventListener("abort", () => { - if (timer) clearTimeout(timer); - cleanup(); - }); - } - - server.listen(req.port, () => { - serverStartTime = Date.now(); - timer = setTimeout(() => { - cleanup(); - }, durationMs); - }); - }); -} - -async function handleUdpListen(req: WhistleRequest, signal?: AbortSignal) { - const durationMs = toSafeDuration(req.durationMs, 5000); - const respondDelayMs = toSafeDelay(req.respondDelayMs, 0); - const maxCapture = Math.min(req.maxCapture ?? 10, 25); - const family = req.host && net.isIP(req.host) === 6 ? "udp6" : "udp4"; - const captures: CaptureEntry[] = []; - const reply = buildPayload(req.echoPayload, req.malformed); - - return new Promise((resolve, reject) => { - const socket = dgram.createSocket(family); - let serverStartTime = 0; - let timer: NodeJS.Timeout | null = null; - - const cleanup = () => { - if (timer) clearTimeout(timer); - socket.close(); - resolve({ - ok: true, - mode: "udp-listen" as const, - durationMs: Date.now() - serverStartTime, - captures, - }); - }; - - if (signal) { - signal.addEventListener("abort", cleanup); - } - - socket.on("message", async (msg, rinfo) => { - if (captures.length < maxCapture) { - const truncated = msg.subarray(0, MAX_RESPONSE_BYTES); - captures.push({ - at: new Date().toISOString(), - remoteAddress: rinfo.address, - remotePort: rinfo.port, - bytes: truncated.length, - hex: truncated.toString("hex"), - text: truncated.toString("utf8"), - }); - } - - if (req.echo) { - try { - await wait(respondDelayMs); - socket.send(reply, rinfo.port, rinfo.address); - } catch { - // socket may have been closed - } - } - }); - - socket.on("error", (err) => reject(err)); - - socket.bind(req.port, req.host, () => { - serverStartTime = Date.now(); - timer = setTimeout(cleanup, durationMs); - }); - }); -} - -export async function POST(request: NextRequest) { - try { - const body = (await request.json()) as Partial; - const mode = body.mode as Mode | undefined; - const portError = validatePort(Number(body.port)); - const hostError = - mode === "tcp-send" || mode === "udp-send" - ? validateHost(body.host) - : null; - - if (!mode) { - return Response.json({ error: "Mode is required" }, { status: 400 }); - } - if (portError) { - return Response.json({ error: portError }, { status: 400 }); - } - if (hostError) { - return Response.json({ error: hostError }, { status: 400 }); - } - - const req: WhistleRequest = { - mode, - host: body.host?.trim(), - port: Number(body.port), - payload: body.payload ?? "", - timeoutMs: body.timeoutMs, - delayMs: body.delayMs, - chunkSize: body.chunkSize, - durationMs: body.durationMs, - malformed: body.malformed ?? false, - echo: body.echo ?? false, - echoPayload: body.echoPayload ?? "ack", - respondDelayMs: body.respondDelayMs, - maxCapture: body.maxCapture, - }; - - if (req.mode === "tcp-send") { - const result = await handleTcpSend(req); - return Response.json(result); - } - if (req.mode === "udp-send") { - const result = await handleUdpSend(req); - return Response.json(result); - } - if (req.mode === "tcp-listen") { - const result = await handleTcpListen(req, request.signal); - return Response.json(result); - } - if (req.mode === "udp-listen") { - const result = await handleUdpListen(req, request.signal); - return Response.json(result); - } - - return Response.json({ error: "Unsupported mode" }, { status: 400 }); - } catch (error) { - console.error("[whistle] request failed", error); - return Response.json( - { - error: error instanceof Error ? error.message : "Request failed", - }, - { status: 500 }, - ); - } -} diff --git a/app/page.tsx b/app/page.tsx index 4ffce29..42d954a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -71,7 +71,9 @@ export default function Home() { }); if (confirmed) { - appWindow.close(); + // Use exit from @tauri-apps/plugin-process for clean app termination + const { exit } = await import("@tauri-apps/plugin-process"); + await exit(0); } }); diff --git a/components/tools/curl-converter.tsx b/components/tools/curl-converter.tsx index 0785a84..21dd3a6 100644 --- a/components/tools/curl-converter.tsx +++ b/components/tools/curl-converter.tsx @@ -29,6 +29,7 @@ import { import { cn } from "@/lib/utils"; import { useCopyAnimation } from "@/hooks/use-copy-animation"; import { useTranslation } from "react-i18next"; +import { httpProxy } from "@/lib/api"; interface ParsedRequest { method: string; @@ -268,22 +269,14 @@ export function CurlConverter({ tabId: _tabId }: CurlConverterProps) { } }); - // Use proxy to avoid CORS issues - const res = await fetch("/api/proxy", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - InputUrl: url, - method, - headers: headersObj, - body: method !== "GET" && body ? body : undefined, - }), + // Use unified API (Tauri invoke or fetch proxy) + const data = await httpProxy({ + InputUrl: url, + method, + headers: headersObj, + body: method !== "GET" && body ? body : undefined, }); - const data = await res.json(); - if (data.error) { setError(data.error); return; diff --git a/components/tools/port-detective.tsx b/components/tools/port-detective.tsx index 07a1905..af307d5 100644 --- a/components/tools/port-detective.tsx +++ b/components/tools/port-detective.tsx @@ -2,7 +2,7 @@ import type React from "react"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -14,6 +14,15 @@ import { } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Progress } from "@/components/ui/progress"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Search, Trash2, @@ -26,6 +35,7 @@ import { import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; +import { isTauri } from "@/lib/tauri"; interface PortInfo { port: number; @@ -42,6 +52,7 @@ interface PortDetectiveProps { // eslint-disable-next-line @typescript-eslint/no-unused-vars export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { const { t } = useTranslation(); + const [showNotAvailableDialog, setShowNotAvailableDialog] = useState(false); const [singlePort, setSinglePort] = useState("3000"); const [singlePortInfo, setSinglePortInfo] = useState(null); const [isCheckingPort, setIsCheckingPort] = useState(false); @@ -53,6 +64,12 @@ export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { const [scanProgress, setScanProgress] = useState(0); const [error, setError] = useState(null); + useEffect(() => { + if (!isTauri()) { + setShowNotAvailableDialog(true); + } + }, []); + const checkPort = useCallback( async (port: number) => { try { @@ -190,6 +207,25 @@ export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { return (
+ + + + + {t("tools.portDetective.desktopOnly.title")} + + + {t("tools.portDetective.desktopOnly.description")} + + + + {t("common.ok")} + + + + diff --git a/components/tools/ssl-toothbrush.tsx b/components/tools/ssl-toothbrush.tsx index 7a2bd04..86a1f40 100644 --- a/components/tools/ssl-toothbrush.tsx +++ b/components/tools/ssl-toothbrush.tsx @@ -28,33 +28,7 @@ import { } from "lucide-react"; import { useCopyAnimation } from "@/hooks/use-copy-animation"; import { cn } from "@/lib/utils"; - -type WarningKey = - | "expired" - | "expiresSoon" - | "hostnameMismatch" - | "chainInvalid"; - -interface CertificateResult { - source: "remote" | "pem"; - subject: string | null; - subjectCN: string | null; - issuer: string | null; - issuerCN: string | null; - san: string[]; - validFrom: string | null; - validTo: string | null; - daysRemaining: number | null; - isExpired: boolean; - aboutToExpire: boolean; - chainValid: boolean | null; - authorizationError: string | null; - validForHost: boolean | null; - rawPem: string | null; - serialNumber: string | null; - fingerprint256: string | null; - warnings: WarningKey[]; -} +import { certCheck, type CertificatePayload, type WarningKey } from "@/lib/api"; interface SSLToothbrushProps { tabId: string; @@ -95,7 +69,7 @@ export function SSLToothbrush({ tabId: _tabId }: SSLToothbrushProps) { const [pem, setPem] = useState(""); const [checking, setChecking] = useState(false); const [error, setError] = useState(null); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const { copyWithAnimation } = useCopyAnimation(); const warningMessages = useMemo(() => { @@ -178,18 +152,10 @@ export function SSLToothbrush({ tabId: _tabId }: SSLToothbrushProps) { setChecking(true); setError(null); try { - const response = await fetch("/api/cert-check", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - host: host.trim(), - port: parseInt(port, 10) || 443, - }), + const data = await certCheck({ + host: host.trim(), + port: parseInt(port, 10) || 443, }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || "Request failed"); - } setResult(data); } catch (err) { setError(err instanceof Error ? err.message : "Request failed"); @@ -207,15 +173,10 @@ export function SSLToothbrush({ tabId: _tabId }: SSLToothbrushProps) { setChecking(true); setError(null); try { - const response = await fetch("/api/cert-check", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ host: host.trim() || undefined, certPem: pem }), + const data = await certCheck({ + host: host.trim() || undefined, + certPem: pem, }); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || "Request failed"); - } setResult(data); } catch (err) { setError(err instanceof Error ? err.message : "Request failed"); @@ -340,7 +301,7 @@ export function SSLToothbrush({ tabId: _tabId }: SSLToothbrushProps) {