This commit is contained in:
eric
2026-05-16 19:24:30 -05:00
parent 19beec12a5
commit 75a0ca4e31
260 changed files with 51345 additions and 1 deletions

View File

@@ -0,0 +1,902 @@
"use client";
import {
Copy,
Layers,
Loader2,
Package,
ShieldCheck,
Terminal,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { LiveAndroidStreamConsole } from "@/components/live-product/LiveAndroidStreamConsole";
type DeviceRow = {
serial: string;
model?: string;
state: string;
transport?: string;
kind?: string;
};
type TFn = (zh: string, en: string) => string;
type AndroidDiagnostics = {
kind?: string;
transport?: string;
boot_completed?: string;
hardware?: string;
hardware_boot?: string;
fingerprint?: string;
abi?: string;
device_provisioned?: string;
user_setup_complete?: string;
google_play?: boolean;
google_services?: boolean;
setupwizard?: boolean;
magisk?: boolean;
root?: boolean;
ready?: boolean;
};
type AndroidOverview = {
serial?: string;
model?: string;
brand?: string;
android_version?: string;
sdk?: string;
battery?: string;
focus?: string;
resolution?: string;
rotation?: string;
screen_state?: string;
transport?: string;
kind?: string;
diagnostics?: AndroidDiagnostics;
};
type PkgRow = { package: string; label?: string };
type UiNode = {
label: string;
text?: string;
content_desc?: string;
resource_id?: string;
center_x: number;
center_y: number;
clickable?: boolean;
enabled?: boolean;
};
type RedroidInstance = {
name: string;
image?: string;
status_text?: string;
port?: number;
running?: boolean;
data_dir?: string;
adb_serial?: string;
diagnostics?: AndroidDiagnostics & {
model?: string;
android_version?: string;
};
};
type RedroidSummary = {
enabled?: boolean;
arch?: string;
image?: string;
device_profile?: string;
gpu_mode?: string;
default_port?: number;
instance_prefix?: string;
running?: boolean;
ready_count?: number;
requirements?: {
docker_available?: boolean;
adb_available?: boolean;
kvm?: boolean;
binderfs?: boolean;
binder_nodes?: string[];
ashmem?: boolean;
};
instances?: RedroidInstance[];
};
const SHELL_SNIPPETS: { label: string; cmd: string }[] = [
{ label: "getprop", cmd: "getprop ro.build.version.release" },
{ label: "top pkg", cmd: "dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1" },
{ label: "usb", cmd: "dumpsys usb | head -n 60" },
{ label: "surface", cmd: "dumpsys SurfaceFlinger | head -n 40" },
];
type Props = {
t: TFn;
notify: (msg: string) => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
apiBase: string;
busy: string | null;
androidSerial: string;
setAndroidSerial: (s: string) => void;
devices: DeviceRow[];
adbAvailable: boolean;
apkPath: string;
setApkPath: (v: string) => void;
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
onCopy: (text: string) => void;
onRefreshDevices: () => void;
};
function boolBadge(v: boolean | undefined, ok: string, bad: string) {
if (v) return <span className="rounded bg-emerald-500/15 px-2 py-0.5 text-[10px] text-emerald-200">{ok}</span>;
return <span className="rounded bg-zinc-500/15 px-2 py-0.5 text-[10px] text-zinc-400">{bad}</span>;
}
export function LiveAndroidWorkstation({
t,
notify,
fetchJson,
apiBase,
busy,
androidSerial,
setAndroidSerial,
devices,
adbAvailable,
apkPath,
setApkPath,
onAndroidAction,
onCopy,
onRefreshDevices,
}: Props) {
const [overview, setOverview] = useState<AndroidOverview | null>(null);
const [overviewErr, setOverviewErr] = useState<string | null>(null);
const [redroid, setRedroid] = useState<RedroidSummary | null>(null);
const [redroidErr, setRedroidErr] = useState<string | null>(null);
const [pkgQuery, setPkgQuery] = useState("");
const [pkgList, setPkgList] = useState<PkgRow[]>([]);
const [shellCmd, setShellCmd] = useState("getprop ro.product.model");
const [shellOut, setShellOut] = useState("");
const [logcatLines, setLogcatLines] = useState("300");
const [logcatText, setLogcatText] = useState("");
const [logcatErr, setLogcatErr] = useState<string | null>(null);
const [uiNodes, setUiNodes] = useState<UiNode[]>([]);
const [uiErr, setUiErr] = useState<string | null>(null);
const [openUrl, setOpenUrl] = useState("https://");
const [adbInputText, setAdbInputText] = useState("");
const [logcatLive, setLogcatLive] = useState(false);
const [pending, setPending] = useState<string | null>(null);
const lock = !!(busy || pending);
const currentDevice = devices.find((d) => d.serial === androidSerial) || null;
const loadOverview = useCallback(async () => {
if (!androidSerial || !adbAvailable) {
setOverview(null);
setOverviewErr(null);
return;
}
setPending("overview");
setOverviewErr(null);
try {
const data = await fetchJson<AndroidOverview>(`/android/overview?serial=${encodeURIComponent(androidSerial)}`);
setOverview(data);
} catch (e) {
setOverview(null);
setOverviewErr((e as Error).message);
} finally {
setPending(null);
}
}, [adbAvailable, androidSerial, fetchJson]);
const loadRedroid = useCallback(async () => {
setRedroidErr(null);
try {
const data = await fetchJson<RedroidSummary>("/redroid/summary");
setRedroid(data);
} catch (e) {
setRedroid(null);
setRedroidErr((e as Error).message);
}
}, [fetchJson]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
useEffect(() => {
void loadRedroid();
}, [loadRedroid]);
const searchPackages = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
setPending("packages");
try {
const q = encodeURIComponent(pkgQuery.trim());
const data = await fetchJson<{ packages: PkgRow[] }>(
`/android/packages?serial=${encodeURIComponent(androidSerial)}&query=${q}&limit=80`,
);
setPkgList(data.packages || []);
} catch (e) {
notify((e as Error).message);
setPkgList([]);
} finally {
setPending(null);
}
};
const runShell = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
const cmd = shellCmd.trim();
if (!cmd) return;
setPending("shell");
try {
const data = await fetchJson<{ output?: string; message?: string }>("/android/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "shell", serial: androidSerial, payload: { command: cmd } }),
});
setShellOut(data.output ?? data.message ?? "");
} catch (e) {
setShellOut((e as Error).message);
} finally {
setPending(null);
}
};
const refreshLogcat = useCallback(
async (opts?: { silent?: boolean }) => {
if (!androidSerial) return;
const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200));
const silent = !!opts?.silent;
if (!silent) setPending("logcat");
setLogcatErr(null);
try {
const data = await fetchJson<{ text?: string }>(
`/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`,
);
setLogcatText(data.text ?? "");
} catch (e) {
setLogcatText("");
setLogcatErr((e as Error).message);
} finally {
if (!silent) setPending(null);
}
},
[androidSerial, fetchJson, logcatLines],
);
useEffect(() => {
if (!logcatLive || !androidSerial || !adbAvailable) return;
const id = window.setInterval(() => void refreshLogcat({ silent: true }), 3000);
void refreshLogcat({ silent: true });
return () => clearInterval(id);
}, [adbAvailable, androidSerial, logcatLive, refreshLogcat]);
const loadUiNodes = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
setPending("ui");
setUiErr(null);
try {
const data = await fetchJson<{ nodes: UiNode[] }>(
`/android/ui?serial=${encodeURIComponent(androidSerial)}&limit=100`,
);
setUiNodes(data.nodes || []);
} catch (e) {
setUiNodes([]);
setUiErr((e as Error).message);
} finally {
setPending(null);
}
};
const runRedroidAction = async (action: string, name?: string) => {
setPending(`redroid:${action}`);
try {
const res = await fetchJson<{ message?: string; summary?: RedroidSummary }>("/redroid/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, name: name || "" }),
});
if (res.summary) setRedroid(res.summary);
notify(res.message || "OK");
await loadRedroid();
onRefreshDevices();
} catch (e) {
notify((e as Error).message);
} finally {
setPending(null);
}
};
const sendKey = async (keycode: string) => {
await onAndroidAction("key", { keycode });
};
const sendAdbText = async () => {
const raw = adbInputText.trim();
if (!raw) {
notify(t("请输入文字", "Enter text"));
return;
}
await onAndroidAction("text", { text: raw });
};
const openUrlGo = async () => {
const u = openUrl.trim();
if (!u || u === "https://") {
notify(t("请输入 URL", "Enter URL"));
return;
}
await onAndroidAction("open_url", { url: u });
};
return (
<div className="mx-auto max-w-6xl space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">{t("安卓设备工作台", "Android workstation")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("USB 真机与 Redroid 共用一套控制面,优先保证 USB 真机稳定。", "USB phone and Redroid share one control surface.")}
</p>
</div>
{!adbAvailable ? (
<span className="text-xs text-amber-200/90">{t("宿主机未检测到 adb", "adb not found on host")}</span>
) : (
<div className="flex gap-2">
<button
type="button"
disabled={lock || !androidSerial}
onClick={() => void loadOverview()}
className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300"
>
{t("刷新设备", "Refresh device")}
</button>
<button
type="button"
disabled={lock}
onClick={() => {
void loadRedroid();
onRefreshDevices();
}}
className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300"
>
{t("刷新 Redroid", "Refresh Redroid")}
</button>
</div>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{devices.map((d) => (
<button
key={d.serial}
type="button"
onClick={() => setAndroidSerial(d.serial)}
className={`rounded-xl border px-4 py-2 text-left text-xs ${
androidSerial === d.serial
? "border-violet-500/50 bg-violet-500/10 text-white"
: "border-white/10 bg-black/30 text-zinc-400"
}`}
>
<div className="flex items-center gap-2">
<span className="font-medium text-zinc-200">{d.model || d.serial}</span>
{d.transport ? (
<span className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-[9px] uppercase text-zinc-500">
{d.transport}
</span>
) : null}
{d.kind ? (
<span className="rounded bg-cyan-500/10 px-1.5 py-0.5 font-mono text-[9px] uppercase text-cyan-200">
{d.kind}
</span>
) : null}
</div>
<div className="text-[10px] text-zinc-600">{d.serial}</div>
{(d.state || "").toLowerCase() !== "device" ? (
<div className="mt-1 text-[10px] font-medium text-amber-200/90">
{d.state === "unauthorized"
? t("未授权,请在手机上允许 USB 调试。", "Unauthorized, allow USB debugging on device.")
: `${t("状态", "State")}: ${d.state}`}
</div>
) : null}
</button>
))}
</div>
{adbAvailable && devices.length === 0 ? (
<p className="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-xs leading-relaxed text-amber-200/90">
{t(
"当前没有已授权设备。USB 真机请检查调试授权;Redroid 请先启动实例,再 adb connect 127.0.0.1:5555。",
"No authorized devices. For USB phones, confirm USB debugging. For Redroid, start an instance then adb connect 127.0.0.1:5555.",
)}
</p>
) : null}
</div>
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/50 p-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-violet-300/90">
{t("真机诊断", "Phone diagnostics")}
</h4>
{overviewErr ? <p className="mt-2 text-xs text-rose-300">{overviewErr}</p> : null}
{overview ? (
<div className="mt-3 space-y-3">
<dl className="space-y-1.5 font-mono text-[11px] text-zinc-400">
<div>
<dt className="inline text-zinc-600">model</dt>{" "}
<dd className="inline text-zinc-300">
{overview.brand} {overview.model}
</dd>
</div>
<div>
<dt className="inline text-zinc-600">android</dt>{" "}
<dd className="inline text-zinc-300">
{overview.android_version} (sdk {overview.sdk})
</dd>
</div>
<div>
<dt className="inline text-zinc-600">kind</dt>{" "}
<dd className="inline text-zinc-300">
{overview.kind || currentDevice?.kind || "unknown"} / {overview.transport || currentDevice?.transport || "unknown"}
</dd>
</div>
<div>
<dt className="inline text-zinc-600">display</dt>{" "}
<dd className="inline text-zinc-300">{overview.resolution}</dd>
</div>
<div>
<dt className="inline text-zinc-600">rotation</dt>{" "}
<dd className="inline text-zinc-300">{overview.rotation}</dd>
</div>
<div className="whitespace-pre-wrap text-[10px] leading-snug text-zinc-500">{overview.battery}</div>
<div className="line-clamp-3 text-[10px] text-zinc-500">{overview.focus}</div>
</dl>
<div className="flex flex-wrap gap-2">
{boolBadge(overview.diagnostics?.ready, t("已启动", "Boot ready"), t("未完成启动", "Boot pending"))}
{boolBadge(overview.diagnostics?.google_play, "Google Play", "No Play")}
{boolBadge(overview.diagnostics?.google_services, "GMS", "No GMS")}
{boolBadge(overview.diagnostics?.magisk, "Magisk", "No Magisk")}
{boolBadge(overview.diagnostics?.root, "Root", "No root")}
</div>
<div className="rounded-xl border border-white/5 bg-black/20 p-3 text-[11px] text-zinc-400">
<div>boot_completed: {overview.diagnostics?.boot_completed || "-"}</div>
<div>provisioned: {overview.diagnostics?.device_provisioned || "-"}</div>
<div>setup_complete: {overview.diagnostics?.user_setup_complete || "-"}</div>
<div>abi: {overview.diagnostics?.abi || "-"}</div>
<div className="truncate">hardware: {overview.diagnostics?.hardware || "-"}</div>
</div>
</div>
) : !overviewErr ? (
<p className="mt-2 text-xs text-zinc-500">{t("加载中", "Loading")}</p>
) : null}
</div>
<div className="rounded-2xl border border-cyan-500/15 bg-zinc-950/50 p-5">
<div className="flex items-center justify-between gap-3">
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
{t("Redroid 板块", "Redroid panel")}
</h4>
<button
type="button"
disabled={lock}
onClick={() => void runRedroidAction("start")}
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs text-emerald-100 ring-1 ring-emerald-500/30"
>
{t("启动默认实例", "Start default")}
</button>
</div>
{redroidErr ? <p className="mt-2 text-xs text-rose-300">{redroidErr}</p> : null}
{redroid ? (
<div className="mt-3 space-y-3 text-[11px] text-zinc-400">
<div className="rounded-xl border border-white/5 bg-black/20 p-3">
<div>arch: {redroid.arch || "-"}</div>
<div className="truncate">image: {redroid.image || "-"}</div>
<div>profile: {redroid.device_profile || "-"}</div>
<div>gpu: {redroid.gpu_mode || "-"}</div>
<div>adb: 127.0.0.1:{redroid.default_port || 5555}</div>
</div>
<div className="flex flex-wrap gap-2">
{boolBadge(redroid.requirements?.docker_available, "Docker", "No Docker")}
{boolBadge(redroid.requirements?.adb_available, "ADB", "No ADB")}
{boolBadge(redroid.requirements?.kvm, "KVM", "No KVM")}
{boolBadge(redroid.requirements?.binderfs, "binderfs", "No binderfs")}
</div>
<div className="space-y-2">
{(redroid.instances || []).map((item) => (
<div key={item.name} className="rounded-xl border border-white/10 bg-black/25 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="font-medium text-zinc-100">{item.name}</div>
<div className="font-mono text-[10px] text-zinc-500">
{item.port ? `127.0.0.1:${item.port}` : "-"} / {item.status_text || "-"}
</div>
</div>
<div className="flex gap-1">
<button
type="button"
disabled={lock}
onClick={() => void runRedroidAction("start", item.name)}
className="rounded bg-emerald-500/15 px-2 py-1 text-[10px] text-emerald-200"
>
Start
</button>
<button
type="button"
disabled={lock}
onClick={() => void runRedroidAction("restart", item.name)}
className="rounded bg-amber-500/15 px-2 py-1 text-[10px] text-amber-200"
>
Restart
</button>
<button
type="button"
disabled={lock}
onClick={() => void runRedroidAction("adb_connect", item.name)}
className="rounded bg-cyan-500/15 px-2 py-1 text-[10px] text-cyan-200"
>
ADB
</button>
<button
type="button"
disabled={lock}
onClick={() => void runRedroidAction("stop", item.name)}
className="rounded bg-rose-500/15 px-2 py-1 text-[10px] text-rose-200"
>
Stop
</button>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{boolBadge(item.diagnostics?.ready, t("系统就绪", "System ready"), t("启动中", "Booting"))}
{boolBadge(item.diagnostics?.google_play, "Play", "No Play")}
{boolBadge(item.diagnostics?.magisk, "Magisk", "No Magisk")}
{boolBadge(item.diagnostics?.root, "Root", "No root")}
</div>
{item.data_dir ? (
<div className="mt-2 truncate font-mono text-[10px] text-zinc-500">{item.data_dir}</div>
) : null}
</div>
))}
{!redroid.instances?.length ? (
<p className="rounded-xl border border-white/5 bg-black/20 p-3 text-xs text-zinc-500">
{t("当前没有 Redroid 实例。默认动作会创建 redroid13-1。", "No Redroid instances yet. Default actions create redroid13-1.")}
</p>
) : null}
</div>
</div>
) : (
<p className="mt-2 text-xs text-zinc-500">{t("读取 Redroid 状态中", "Loading Redroid status")}</p>
)}
</div>
</div>
<LiveAndroidStreamConsole
t={t}
apiBase={apiBase}
fetchJson={fetchJson}
serial={androidSerial}
devices={devices}
setSerial={setAndroidSerial}
adbAvailable={adbAvailable}
lock={lock}
overview={overview}
onAndroidAction={onAndroidAction}
notify={notify}
onRefreshDevices={onRefreshDevices}
/>
{adbAvailable && androidSerial ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-emerald-300/90">
{t("快捷控制", "Quick controls")}
</h4>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" disabled={lock} onClick={() => void sendKey("3")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Home</button>
<button type="button" disabled={lock} onClick={() => void sendKey("4")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Back</button>
<button type="button" disabled={lock} onClick={() => void sendKey("187")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Recents</button>
<button type="button" disabled={lock} onClick={() => void onAndroidAction("wake")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Wake</button>
<button type="button" disabled={lock} onClick={() => void onAndroidAction("launch_package", { package: "com.zhiliaoapp.musically" })} className="rounded-lg bg-gradient-to-r from-pink-500/25 to-violet-500/25 px-3 py-1.5 text-xs ring-1 ring-white/10">TikTok</button>
<button type="button" disabled={lock} onClick={() => void sendKey("24")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Vol+</button>
<button type="button" disabled={lock} onClick={() => void sendKey("25")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Vol-</button>
<button type="button" disabled={lock} onClick={() => void sendKey("66")} className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs">Enter</button>
</div>
<p className="mt-3 text-[10px] text-zinc-600">{t("滑动手势", "Swipe gestures")}</p>
<div className="mt-1 flex flex-wrap gap-2">
{(["up", "down", "left", "right"] as const).map((dir) => (
<button
key={dir}
type="button"
disabled={lock}
onClick={() => void onAndroidAction("swipe", { direction: dir })}
className="rounded-lg border border-white/10 bg-black/30 px-2.5 py-1 font-mono text-[10px] uppercase text-zinc-300"
>
{dir}
</button>
))}
</div>
<div className="mt-4 rounded-lg border border-white/5 bg-black/20 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{t("ADB 文本输入", "ADB text input")}</p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<input
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
value={adbInputText}
onChange={(e) => setAdbInputText(e.target.value)}
placeholder="hello / 123"
onKeyDown={(e) => {
if (e.key === "Enter") void sendAdbText();
}}
/>
<button
type="button"
disabled={lock}
onClick={() => void sendAdbText()}
className="rounded-lg bg-emerald-500/20 px-3 py-2 text-xs text-emerald-100 ring-1 ring-emerald-500/30"
>
{t("发送", "Send")}
</button>
</div>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<input
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
value={openUrl}
onChange={(e) => setOpenUrl(e.target.value)}
placeholder="https://"
/>
<button
type="button"
disabled={lock}
onClick={() => void openUrlGo()}
className="rounded-lg bg-violet-500/20 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/30"
>
{t("打开链接", "Open URL")}
</button>
</div>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-5">
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
<ShieldCheck className="h-4 w-4" />
{t("USB 真机优化建议", "USB optimization")}
</h4>
<ul className="mt-3 space-y-2 text-xs leading-relaxed text-zinc-400">
<li>{t("优先使用主板直连 USB,不要经过不稳的 HUB。", "Prefer direct motherboard USB, not unstable hubs.")}</li>
<li>{t("开发板端固定 adb server,避免频繁 kill-server。", "Keep adb server stable and avoid frequent kill-server.")}</li>
<li>{t("镜像页默认支持 scrcpy H.264 真流,截图模式作为回退。", "Use scrcpy H.264 live stream first, screenshot mode as fallback.")}</li>
<li>{t("如果 TikTok / YouTube App 直播,要先确认屏幕常亮、分辨率稳定、USB 供电足够。", "For TikTok / YouTube app live, keep screen on, resolution stable, and USB power sufficient.")}</li>
</ul>
</div>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Package className="h-4 w-4 text-emerald-400" />
{t("包名搜索与启动", "Packages")}
</h3>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<input
className="min-w-0 flex-1 rounded-xl border border-white/10 bg-black/40 px-4 py-2 text-sm"
value={pkgQuery}
onChange={(e) => setPkgQuery(e.target.value)}
placeholder="tiktok / musically / youtube"
onKeyDown={(e) => {
if (e.key === "Enter") void searchPackages();
}}
/>
<button
type="button"
disabled={lock}
onClick={() => void searchPackages()}
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 ring-1 ring-emerald-500/30"
>
{t("搜索", "Search")}
</button>
</div>
<ul className="mt-4 max-h-48 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 font-mono text-[11px]">
{pkgList.map((p) => (
<li key={p.package} className="flex flex-wrap items-center justify-between gap-2 py-1 text-zinc-400">
<span className="min-w-0 truncate text-zinc-300">{p.package}</span>
<span className="flex shrink-0 gap-1">
<button type="button" disabled={lock} onClick={() => void onAndroidAction("launch_package", { package: p.package })} className="rounded bg-white/10 px-2 py-0.5 text-[10px] text-zinc-200">
{t("启动", "Launch")}
</button>
<button type="button" disabled={lock} onClick={() => void onAndroidAction("force_stop", { package: p.package })} className="rounded bg-rose-500/15 px-2 py-0.5 text-[10px] text-rose-200">
{t("停止", "Stop")}
</button>
</span>
</li>
))}
{!pkgList.length ? <li className="py-2 text-center text-zinc-600">{t("暂无结果", "No results")}</li> : null}
</ul>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="grid gap-6 xl:grid-cols-2">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Terminal className="h-4 w-4 text-amber-400" />
ADB shell
</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{SHELL_SNIPPETS.map((s) => (
<button
key={s.label}
type="button"
disabled={lock}
onClick={() => setShellCmd(s.cmd)}
className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-200"
>
{s.label}
</button>
))}
</div>
<textarea
className="mt-3 min-h-[100px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-3 font-mono text-xs text-zinc-200"
value={shellCmd}
onChange={(e) => setShellCmd(e.target.value)}
spellCheck={false}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void runShell();
}
}}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={lock}
onClick={() => void runShell()}
className="rounded-xl bg-amber-500/20 px-4 py-2 text-sm text-amber-100 ring-1 ring-amber-500/30"
>
{pending === "shell" ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Run
</span>
) : (
"Run"
)}
</button>
{shellOut ? (
<button
type="button"
disabled={!shellOut}
onClick={() => onCopy(shellOut)}
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
>
<Copy className="h-3.5 w-3.5" />
{t("复制输出", "Copy")}
</button>
) : null}
</div>
{shellOut ? (
<pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/40 p-3 font-mono text-[11px] text-zinc-400 xl:max-h-[min(24rem,50vh)]">
{shellOut}
</pre>
) : null}
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("Logcat", "Logcat")}</h3>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
className="w-24 rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
value={logcatLines}
onChange={(e) => setLogcatLines(e.target.value)}
/>
<label className="flex cursor-pointer items-center gap-2 text-[11px] text-zinc-400">
<input
type="checkbox"
className="rounded border-white/20 bg-black/40"
checked={logcatLive}
onChange={(e) => setLogcatLive(e.target.checked)}
/>
{t("每 3 秒自动刷新", "Auto every 3s")}
</label>
<button
type="button"
disabled={lock}
onClick={() => void refreshLogcat()}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("拉取日志", "Pull logs")}
</button>
{logcatText ? (
<button
type="button"
onClick={() => onCopy(logcatText)}
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
>
<Copy className="h-3.5 w-3.5" />
{t("复制", "Copy")}
</button>
) : null}
</div>
{logcatErr ? <p className="mt-2 text-xs text-rose-300">{logcatErr}</p> : null}
{logcatText ? (
<pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/50 p-3 font-mono text-[10px] leading-tight text-zinc-500 xl:max-h-[min(24rem,50vh)]">
{logcatText}
</pre>
) : null}
</div>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Layers className="h-4 w-4 text-fuchsia-400" />
{t("界面节点", "UI nodes")}
</h3>
<button
type="button"
disabled={lock}
onClick={() => void loadUiNodes()}
className="mt-3 rounded-xl border border-fuchsia-500/30 bg-fuchsia-500/10 px-4 py-2 text-sm text-fuchsia-100"
>
{t("刷新节点", "Refresh nodes")}
</button>
{uiErr ? <p className="mt-2 text-xs text-rose-300">{uiErr}</p> : null}
<ul className="mt-3 max-h-64 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 text-[11px]">
{uiNodes.map((n, i) => (
<li
key={`${n.center_x}-${n.center_y}-${i}`}
className="flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.04] py-1.5 text-zinc-400 last:border-0"
>
<div className="min-w-0 flex-1">
<span className="text-zinc-200">{n.label}</span>
{n.resource_id ? <span className="ml-2 font-mono text-[10px] text-zinc-600">{n.resource_id}</span> : null}
<div className="font-mono text-[10px] text-zinc-600">
({n.center_x}, {n.center_y}){n.clickable ? " click" : ""}
</div>
</div>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("tap", { x: n.center_x, y: n.center_y })}
className="shrink-0 rounded bg-white/10 px-2 py-1 text-[10px] text-zinc-200"
>
Tap
</button>
</li>
))}
{!uiNodes.length && !uiErr ? <li className="py-4 text-center text-zinc-600">{t("尚未加载", "Not loaded")}</li> : null}
</ul>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("安装 APK", "Install APK")}</h3>
<p className="mt-1 text-xs text-zinc-500">{t("先 adb push 到 /sdcard/ 再执行安装。", "Push the APK to /sdcard/ first.")}</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={apkPath}
onChange={(e) => setApkPath(e.target.value)}
/>
<button
type="button"
disabled={lock || !adbAvailable || !androidSerial}
onClick={() => void onAndroidAction("install_apk", { path: apkPath })}
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
>
pm install
</button>
</div>
) : null}
</div>
);
}