feat: support select child packer

This commit is contained in:
ReaJason
2026-06-28 21:22:31 +08:00
parent a267048ed3
commit f19b1be9d4
16 changed files with 842 additions and 509 deletions
@@ -1,4 +1,4 @@
import type { PackerConfig } from "@/types/memshell";
import type { PackerConfig, PackerOption } from "@/types/memshell";
import type { MemShellFormSchema } from "@/types/schema";
import { PackageIcon } from "lucide-react";
@@ -6,9 +6,8 @@ import { useMemo } from "react";
import { Controller, type UseFormReturn, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import PackerSelector from "@/components/packer-selector";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FieldLabel, FieldSet } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner";
export default function PackageConfigCard({
@@ -30,8 +29,8 @@ export default function PackageConfigCard({
name: "server",
});
const options = useMemo(() => {
const filteredOptions = (packerConfig ?? []).filter((name) => {
const parents = useMemo<PackerOption[]>(() => {
return (packerConfig ?? []).filter(({ name }) => {
if (!shellType || shellType === " ") {
return true;
}
@@ -43,12 +42,7 @@ export default function PackageConfigCard({
}
return !name.startsWith("Agent") && !name.toLowerCase().startsWith("xxl");
});
form.setValue("packingMethod", filteredOptions[0]);
return filteredOptions.map((name) => ({
name: t(name),
value: name,
}));
}, [packerConfig, shellType, server, t, form]);
}, [packerConfig, shellType, server]);
return (
<Card className="w-full">
@@ -59,30 +53,12 @@ export default function PackageConfigCard({
</CardTitle>
</CardHeader>
<CardContent>
{options.length > 0 ? (
{parents.length > 0 ? (
<Controller
control={form.control}
name="packingMethod"
render={({ field }) => (
<FieldSet>
<FieldLabel>{t("packerMethod")}</FieldLabel>
<RadioGroup
name={field.name}
value={field.value}
defaultValue={options[0].value}
onValueChange={field.onChange}
className="grid grid-cols-2 md:grid-cols-3"
>
{options.map(({ name, value }) => (
<div key={value} className="flex items-center space-x-3">
<RadioGroupItem value={value} id={value} />
<FieldLabel className="text-xs" htmlFor={value}>
{name}
</FieldLabel>
</div>
))}
</RadioGroup>
</FieldSet>
<PackerSelector parents={parents} value={field.value} onChange={field.onChange} />
)}
/>
) : (
@@ -27,6 +27,24 @@ export function ResultComponent({
const isAgent = packMethod.startsWith("Agent");
const isJar = packMethod.endsWith("Jar");
const { t } = useTranslation();
const shellClassName = generateResult?.shellClassName;
const handleDownload = useCallback(() => {
const fileName = shellClassName?.substring(shellClassName?.lastIndexOf(".") ?? 0) ?? "";
if (packMethod.includes("JSP")) {
const fileExtension = packMethod.includes("JSPX") ? ".jspx" : ".jsp";
const content = new Blob([packResult as string], { type: "text/plain" });
return downloadContent(content, fileName, fileExtension);
} else if (packMethod.includes("JavaCommons") || packMethod.includes("Hessian")) {
const content = new Blob([base64ToBytes(packResult as string)], {
type: "application/octet-stream",
});
return downloadContent(content, fileName, ".data");
} else if (packMethod === "Base64") {
return downloadBytes(packResult as string, shellClassName);
}
}, [packMethod, packResult, shellClassName]);
if (allPackResults) {
return (
<MultiPackResult
@@ -55,24 +73,6 @@ export function ResultComponent({
);
}
const shellClassName = generateResult?.shellClassName;
const handleDownload = useCallback(() => {
const fileName = shellClassName?.substring(shellClassName?.lastIndexOf(".") ?? 0) ?? "";
if (packMethod.includes("JSP")) {
const fileExtension = packMethod.includes("JSPX") ? ".jspx" : ".jsp";
const content = new Blob([packResult as string], { type: "text/plain" });
return downloadContent(content, fileName, fileExtension);
} else if (packMethod.includes("JavaCommons") || packMethod.includes("Hessian")) {
const content = new Blob([base64ToBytes(packResult as string)], {
type: "application/octet-stream",
});
return downloadContent(content, fileName, ".data");
} else if (packMethod === "Base64") {
return downloadBytes(packResult as string, shellClassName);
}
}, [packMethod, packResult, shellClassName]);
return (
<CodeViewer
code={packResult ?? ""}
+194
View File
@@ -0,0 +1,194 @@
import type { PackerOption } from "@/types/memshell";
import { Check, ChevronRight } from "lucide-react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
/** A leading radio-style indicator shared by every list item for a consistent look. */
function RadioDot({ active }: Readonly<{ active: boolean }>) {
return (
<span
aria-hidden="true"
className={cn(
"flex size-3.5 shrink-0 items-center justify-center rounded-full border transition-colors",
active ? "border-sidebar-accent-foreground" : "border-muted-foreground/40",
)}
>
<span
className={cn(
"size-1.5 rounded-full bg-sidebar-accent-foreground transition-transform",
active ? "scale-100" : "scale-0",
)}
/>
</span>
);
}
function ListItem({
label,
active,
onClick,
trailing,
}: Readonly<{
label: string;
active: boolean;
onClick: () => void;
trailing?: React.ReactNode;
}>) {
return (
<button
type="button"
role="radio"
aria-checked={active}
onClick={onClick}
className={cn(
"flex min-h-8 items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs leading-5 transition-colors",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-foreground hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground",
)}
>
<RadioDot active={active} />
<span className="flex-1 font-medium break-all">{label}</span>
{trailing}
</button>
);
}
/**
* packer 选择器。
* - 左侧分类(父)+ 右侧变体(子)的主从布局,选中某个具体子变体。
*
* 通过受控的 `value` / `onChange` 与表单字段 `packingMethod` 双向同步,
* `value` 始终是单一来源;UI 状态(当前激活的父)由 `value` 推导,避免状态不一致。
*/
export default function PackerSelector({
parents,
value,
onChange,
}: Readonly<{
parents: PackerOption[];
value: string;
onChange: (value: string) => void;
}>) {
const { t } = useTranslation("common");
const values = useMemo(() => {
return parents.flatMap((parent) =>
parent.children.length > 0 ? parent.children : [parent.name],
);
}, [parents]);
useEffect(() => {
if (values.length === 0) {
return;
}
const selectedParent = parents.find((parent) => parent.name === value);
if (selectedParent?.children.length) {
onChange(selectedParent.children[0]);
return;
}
if (!value || !values.includes(value)) {
const firstParent = parents[0];
onChange(firstParent?.children[0] ?? firstParent?.name ?? values[0]);
}
}, [parents, values, value, onChange]);
const activeParent = useMemo(
() =>
parents.find((parent) => parent.name === value || parent.children.includes(value)) ??
parents[0],
[parents, value],
);
const selectParent = (parent: PackerOption) => {
onChange(parent.children[0] ?? parent.name);
};
const isParentActive = (parent: PackerOption) => activeParent?.name === parent.name;
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t("packerMode.advancedDesc")}</p>
<div className="grid gap-3 md:grid-cols-2">
<section className="flex flex-col">
<div className="mb-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("packerMode.category")}
</div>
<ScrollArea className="h-64 rounded-md border">
<div
role="radiogroup"
aria-label={t("packerMethod")}
className="flex flex-col gap-0.5 p-1.5"
>
{parents.map((parent) => {
const active = isParentActive(parent);
const hasChildren = parent.children.length > 0;
return (
<ListItem
key={parent.name}
label={t(parent.name)}
active={active}
onClick={() => selectParent(parent)}
trailing={
hasChildren ? (
<span className="flex items-center gap-1.5">
<Badge
variant={active ? "secondary" : "outline"}
className="h-5 px-1.5 text-[11px] tabular-nums"
>
{parent.children.length}
</Badge>
<ChevronRight className="size-4 shrink-0 opacity-70" aria-hidden="true" />
</span>
) : undefined
}
/>
);
})}
</div>
</ScrollArea>
</section>
<section className="flex flex-col">
<div className="mb-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("packerMode.variant")}
</div>
<ScrollArea className="h-64 rounded-md border">
{activeParent && activeParent.children.length > 0 ? (
<div
role="radiogroup"
aria-label={t("packerMode.variant")}
className="flex flex-col gap-0.5 p-1.5"
>
{activeParent.children.map((child) => (
<ListItem
key={child}
label={t(child)}
active={value === child}
onClick={() => onChange(child)}
/>
))}
</div>
) : (
<div className="flex h-64 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-muted-foreground">
<Check className="size-6 text-primary" aria-hidden="true" />
<span>
<span className="font-medium text-foreground">{t(activeParent?.name ?? "")}</span>{" "}
{t("packerMode.noVariants")}
</span>
</div>
)}
</ScrollArea>
</section>
</div>
</div>
);
}
@@ -1,19 +1,13 @@
import type { PackerConfig } from "@/types/memshell";
import type { PackerConfig, PackerOption } from "@/types/memshell";
import type { ProbeShellFormSchema } from "@/types/schema";
import { PackageIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import PackerSelector from "@/components/packer-selector";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type Option = {
name: string;
value: string;
};
export default function PackageConfigCard({
packerConfig,
@@ -22,31 +16,17 @@ export default function PackageConfigCard({
packerConfig: PackerConfig | undefined;
form: UseFormReturn<ProbeShellFormSchema>;
}>) {
const [options, setOptions] = useState<Array<Option>>([]);
const { t } = useTranslation("common");
useEffect(() => {
const filteredOptions = (packerConfig ?? []).filter((name) => {
const parents = useMemo<PackerOption[]>(() => {
return (packerConfig ?? []).filter(({ name }) => {
return (
!name.startsWith("Agent") &&
!name.toLowerCase().startsWith("xxl") &&
!name.toLowerCase().endsWith("jar")
);
});
const mappedOptions = filteredOptions.map((name) => {
return {
name: name,
value: name,
};
});
setOptions(mappedOptions);
const currentValue = form.getValues("packingMethod");
if (filteredOptions.length > 0 && (!currentValue || !filteredOptions.includes(currentValue))) {
form.setValue("packingMethod", filteredOptions[0]);
}
}, [form, packerConfig]);
}, [packerConfig]);
return (
<Card className="w-full">
@@ -57,32 +37,12 @@ export default function PackageConfigCard({
</CardTitle>
</CardHeader>
<CardContent>
{options.length > 0 ? (
{parents.length > 0 ? (
<Controller
control={form.control}
name="packingMethod"
render={({ field }) => (
<div className="space-y-3">
<FieldLabel>{t("packerMethod")}</FieldLabel>
<div>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid grid-cols-2 md:grid-cols-3"
>
{options.map(({ name, value }) => (
<div key={value} className="flex items-center space-x-3">
<div>
<RadioGroupItem value={value} id={value} />
</div>
<FieldLabel className="text-xs" htmlFor={value}>
{name}
</FieldLabel>
</div>
))}
</RadioGroup>
</div>
</div>
<PackerSelector parents={parents} value={field.value} onChange={field.onChange} />
)}
/>
) : (
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+7
View File
@@ -22,6 +22,13 @@
"optional": "(Optional)",
"packerConfig.title": "Package Config",
"packerMethod": "Package Method",
"packerMode.default": "Default",
"packerMode.advanced": "Advanced",
"packerMode.defaultDesc": "Pick a packing method; it packs all of its variants at once.",
"packerMode.advancedDesc": "Pick a category, then choose a specific variant.",
"packerMode.category": "Category",
"packerMode.variant": "Variant",
"packerMode.noVariants": "has no variants — selection complete.",
"paramName": "ParamName",
"paramName.description": "Supports passing values via request parameter (param) or request header (header)",
"placeholders.input": "Please input",
+7
View File
@@ -22,6 +22,13 @@
"optional": "(可选)",
"packerConfig.title": "打包配置",
"packerMethod": "打包方式",
"packerMode.default": "默认模式",
"packerMode.advanced": "进阶模式",
"packerMode.defaultDesc": "选择打包方式,将一次性生成其所有子变体。",
"packerMode.advancedDesc": "先选择左侧分类,再挑选具体的子变体。",
"packerMode.category": "分类",
"packerMode.variant": "变体",
"packerMode.noVariants": "无子变体,已完成选择。",
"paramName": "参数名称",
"paramName.description": "支持请求参数 param 或请求头 header 传值",
"placeholders.input": "请输入",
+1 -1
View File
@@ -69,7 +69,7 @@ const fetchServerConfig = () => fetchJson<ServerConfig>(`${env.API_URL}/api/conf
const fetchMainConfig = () => fetchJson<MainConfig>(`${env.API_URL}/api/config`);
const fetchPackerConfig = () => fetchJson<PackerConfig>(`${env.API_URL}/api/config/packers`);
const fetchPackerConfig = () => fetchJson<PackerConfig>(`${env.API_URL}/api/config/packers/tree`);
export default function MemShellPage() {
const { data: serverConfig } = useQuery<ServerConfig>({
+1 -1
View File
@@ -36,7 +36,7 @@ export default function ProbeShellGenerator() {
const { data: packerConfig } = useQuery<PackerConfig>({
queryKey: ["packerConfig"],
queryFn: async () => {
const response = await fetch(`${env.API_URL}/api/config/packers`);
const response = await fetch(`${env.API_URL}/api/config/packers/tree`);
return await response.json();
},
});
+6 -1
View File
@@ -98,7 +98,12 @@ export interface MainConfig {
};
}
export type PackerConfig = Array<string>;
export interface PackerOption {
name: string;
children: string[];
}
export type PackerConfig = Array<PackerOption>;
export interface MemShellGenerateResponse {
memShellResult: MemShellResult;
+35 -1
View File
@@ -2,6 +2,38 @@ import type { InjectorConfig, ShellConfig, ShellToolConfig } from "@/types/memsh
import type { ProbeConfig, ProbeContentConfig } from "@/types/probeshell";
import type { MemShellFormSchema, ProbeShellFormSchema } from "@/types/schema";
const SPRING_GZIP_JDK17_RELATED_PACKERS = new Set([
"SpEL",
"SpELSpringGzipJDK17",
"OGNL",
"OGNLSpringGzipJDK17",
"JXPath",
"JXPathSpringGzipJDK17",
]);
const UPPERCASE_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const CLASS_NAME_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
function getRandomIndex(max: number) {
if (globalThis.crypto?.getRandomValues) {
return globalThis.crypto.getRandomValues(new Uint32Array(1))[0] % max;
}
return Math.floor(Math.random() * max);
}
function getRandomChar(chars: string) {
return chars[getRandomIndex(chars.length)];
}
function generateSpringExpressionInjectorClassName() {
const randomName = Array.from({ length: 5 }, () => getRandomChar(CLASS_NAME_LETTERS)).join("");
return `org.springframework.expression.${getRandomChar(UPPERCASE_LETTERS)}${randomName}Util`;
}
function isSpringGzipJdk17RelatedPacker(packer: string) {
return SPRING_GZIP_JDK17_RELATED_PACKERS.has(packer);
}
export function transformToPostData(formValue: MemShellFormSchema) {
const shellConfig: ShellConfig = {
server: formValue.server,
@@ -32,7 +64,9 @@ export function transformToPostData(formValue: MemShellFormSchema) {
const injectorConfig: InjectorConfig = {
urlPattern: formValue.urlPattern,
injectorClassName: formValue.injectorClassName,
injectorClassName: isSpringGzipJdk17RelatedPacker(formValue.packingMethod)
? generateSpringExpressionInjectorClassName()
: formValue.injectorClassName,
staticInitialize: formValue.staticInitialize,
};
return {