diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 287b959..bf18181 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -36,13 +36,42 @@ jobs: - name: setup node uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 - name: setup pnpm uses: pnpm/action-setup@v4 with: version: 10 + - name: get pnpm store directory + id: pnpm-store + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + echo "NODE_VERSION=$(node --version)" >> $GITHUB_OUTPUT + + - name: setup pnpm store cache + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-node-${{ steps.pnpm-store.outputs.NODE_VERSION }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-node-${{ steps.pnpm-store.outputs.NODE_VERSION }}- + + - name: setup Next.js cache + uses: actions/cache@v4 + with: + path: .next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}- + ${{ runner.os }}-nextjs- + + - name: setup Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri + - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: diff --git a/.prettierignore b/.prettierignore index 4f1c01d..621b74e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1 @@ -src-tauri \ No newline at end of file +src-tauri/target \ No newline at end of file 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..22599e5 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,8 +64,19 @@ export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { const [scanProgress, setScanProgress] = useState(0); const [error, setError] = useState(null); + const isDesktop = isTauri(); + + useEffect(() => { + if (!isDesktop) { + setShowNotAvailableDialog(true); + } + }, [isDesktop]); + const checkPort = useCallback( async (port: number) => { + if (!isDesktop) { + throw new Error(t("tools.portDetective.desktopOnly.description")); + } try { const result = await invoke("check_port", { port }); return result; @@ -66,10 +88,12 @@ export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { ); } }, - [t], + [t, isDesktop], ); const handleCheckSinglePort = async () => { + if (!isDesktop) return; + const port = parseInt(singlePort); if (isNaN(port) || port < 1 || port > 65535) { setError(t("tools.portDetective.errors.invalidPort")); @@ -97,6 +121,8 @@ export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { }; const handleKillProcess = async (pid: number, port?: number) => { + if (!isDesktop) return; + try { await invoke("kill_process", { pid }); toast.success(t("tools.portDetective.success.processKilled")); @@ -118,6 +144,8 @@ export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { }; const handleScanPorts = async () => { + if (!isDesktop) return; + const startPort = parseInt(scanStartPort); const endPort = parseInt(scanEndPort); @@ -190,6 +218,25 @@ export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { return (
+ + + + + {t("tools.portDetective.desktopOnly.title")} + + + {t("tools.portDetective.desktopOnly.description")} + + + + {t("common.ok")} + + + + @@ -265,7 +312,7 @@ export function PortDetective({ tabId: _tabId }: PortDetectiveProps) { />
-