Files
2026-05-16 19:24:30 -05:00

903 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}