859 lines
35 KiB
TypeScript
859 lines
35 KiB
TypeScript
"use client";
|
||
|
||
import { Copy, ExternalLink, Layers, Loader2, Package, RefreshCw, Terminal } from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
type DeviceRow = { serial: string; model?: string; state: string };
|
||
type TFn = (zh: string, en: string) => string;
|
||
|
||
type AndroidOverview = {
|
||
serial?: string;
|
||
model?: string;
|
||
brand?: string;
|
||
android_version?: string;
|
||
sdk?: string;
|
||
battery?: string;
|
||
focus?: string;
|
||
resolution?: string;
|
||
rotation?: string;
|
||
screen_state?: string;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
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: "sdcard", cmd: "ls -la /sdcard | head -n 24" },
|
||
{ label: "net", cmd: "dumpsys connectivity | head -n 40" },
|
||
];
|
||
|
||
type Props = {
|
||
t: TFn;
|
||
notify: (msg: string) => void;
|
||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||
/** Parent busy (e.g. adb actions); combined with local pending for UI lock. */
|
||
busy: string | null;
|
||
androidSerial: string;
|
||
setAndroidSerial: (s: string) => void;
|
||
devices: DeviceRow[];
|
||
adbAvailable: boolean;
|
||
scrcpyUrl: string;
|
||
apkPath: string;
|
||
setApkPath: (v: string) => void;
|
||
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
|
||
onCopy: (text: string) => void;
|
||
};
|
||
|
||
export function LiveAndroidWorkstation({
|
||
t,
|
||
notify,
|
||
fetchJson,
|
||
busy,
|
||
androidSerial,
|
||
setAndroidSerial,
|
||
devices,
|
||
adbAvailable,
|
||
scrcpyUrl,
|
||
apkPath,
|
||
setApkPath,
|
||
onAndroidAction,
|
||
onCopy,
|
||
}: Props) {
|
||
const [overview, setOverview] = useState<AndroidOverview | null>(null);
|
||
const [overviewErr, setOverviewErr] = 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 [mirrorMode, setMirrorMode] = useState<"host" | "webusb">("host");
|
||
const [mirrorReloadKey, setMirrorReloadKey] = useState(0);
|
||
const [logcatLive, setLogcatLive] = useState(false);
|
||
const [pending, setPending] = useState<string | null>(null);
|
||
|
||
const PANDA_WEB_SCRCPY = "https://pandatestgrid.github.io/panda-web-scrcpy/";
|
||
const LINK_SCRCPY = "https://github.com/Genymobile/scrcpy";
|
||
const LINK_WS_SCRCPY = "https://github.com/NetrisTV/ws-scrcpy";
|
||
const LINK_PANDA_GH = "https://github.com/PandaTestGrid/panda-web-scrcpy";
|
||
|
||
const lock = !!(busy || pending);
|
||
|
||
const hostMirrorSrc = useMemo(() => {
|
||
if (!scrcpyUrl) return "";
|
||
try {
|
||
const u = new URL(scrcpyUrl);
|
||
u.searchParams.set("_lh", String(mirrorReloadKey));
|
||
return u.toString();
|
||
} catch {
|
||
const sep = scrcpyUrl.includes("?") ? "&" : "?";
|
||
return `${scrcpyUrl}${sep}_lh=${mirrorReloadKey}`;
|
||
}
|
||
}, [scrcpyUrl, mirrorReloadKey]);
|
||
|
||
const loadOverview = useCallback(async () => {
|
||
if (!androidSerial || !adbAvailable) {
|
||
setOverview(null);
|
||
setOverviewErr(null);
|
||
return;
|
||
}
|
||
setPending("overview");
|
||
setOverviewErr(null);
|
||
try {
|
||
const o = await fetchJson<AndroidOverview>(
|
||
`/android/overview?serial=${encodeURIComponent(androidSerial)}`,
|
||
);
|
||
setOverview(o);
|
||
} catch (e) {
|
||
setOverview(null);
|
||
setOverviewErr((e as Error).message);
|
||
} finally {
|
||
setPending(null);
|
||
}
|
||
}, [adbAvailable, androidSerial, fetchJson]);
|
||
|
||
useEffect(() => {
|
||
void loadOverview();
|
||
}, [loadOverview]);
|
||
|
||
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);
|
||
}, [logcatLive, androidSerial, adbAvailable, 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 tapNode = async (x: number, y: number) => {
|
||
await onAndroidAction("tap", { x, y });
|
||
};
|
||
|
||
const sendKey = async (keycode: string) => {
|
||
await onAndroidAction("key", { keycode });
|
||
};
|
||
|
||
const openUrlGo = async () => {
|
||
const u = openUrl.trim();
|
||
if (!u || u === "https://") {
|
||
notify(t("填写 URL", "Enter URL"));
|
||
return;
|
||
}
|
||
await onAndroidAction("open_url", { url: u });
|
||
};
|
||
|
||
const sendAdbText = async () => {
|
||
const raw = adbInputText.trim();
|
||
if (!raw) {
|
||
notify(t("填写要输入的文字", "Enter text"));
|
||
return;
|
||
}
|
||
await onAndroidAction("text", { text: raw });
|
||
};
|
||
|
||
return (
|
||
<div className="mx-auto max-w-5xl 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">
|
||
<h3 className="text-sm font-semibold text-white">{t("设备与工作台", "Device workstation")}</h3>
|
||
{!adbAvailable ? (
|
||
<span className="text-xs text-amber-200/90">{t("宿主机未检测到 adb", "adb not found on host")}</span>
|
||
) : (
|
||
<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 hover:bg-white/[0.08]"
|
||
>
|
||
{t("刷新设备信息", "Refresh device info")}
|
||
</button>
|
||
)}
|
||
</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="font-medium text-zinc-200">{d.model || d.serial}</div>
|
||
<div className="text-[10px] text-zinc-600">{d.serial}</div>
|
||
</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 接板子时请开「USB 调试」并授权;Redroid / 容器安卓可先 `adb connect IP:5555` 再刷新本页。",
|
||
"No device: enable USB debugging for dual-USB to the board; for Redroid use adb connect IP:5555 then refresh.",
|
||
)}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-2xl border border-violet-500/20 bg-gradient-to-b from-violet-500/[0.08] to-black/50">
|
||
<div className="flex flex-wrap gap-2 border-b border-white/10 px-3 py-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMirrorMode("host")}
|
||
className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
|
||
mirrorMode === "host"
|
||
? "bg-violet-500/25 text-violet-100 ring-1 ring-violet-400/40"
|
||
: "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
|
||
}`}
|
||
>
|
||
{t("宿主投屏 (板子 ws-scrcpy)", "Host ws-scrcpy")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMirrorMode("webusb")}
|
||
className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
|
||
mirrorMode === "webusb"
|
||
? "bg-cyan-500/20 text-cyan-100 ring-1 ring-cyan-400/35"
|
||
: "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
|
||
}`}
|
||
>
|
||
WebUSB · Panda
|
||
</button>
|
||
<div className="ml-auto flex flex-wrap items-center gap-2 self-center">
|
||
<a
|
||
href={LINK_SCRCPY}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-emerald-300"
|
||
>
|
||
scrcpy <ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
<a
|
||
href={LINK_WS_SCRCPY}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-violet-300"
|
||
>
|
||
ws-scrcpy <ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
<a
|
||
href={LINK_PANDA_GH}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-cyan-300"
|
||
>
|
||
panda-web-scrcpy <ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
{mirrorMode === "host" ? (
|
||
<div>
|
||
<div className="space-y-2 border-b border-white/5 px-4 py-3">
|
||
<p className="text-[11px] leading-relaxed text-zinc-400">
|
||
{t(
|
||
"板载 ws-scrcpy 同时只允许一个浏览器会话:多标签/总览与安卓页各嵌一个 iframe 会互相抢连接。请只保留一个投屏页,或反复点「重载投屏」。已打补丁的安装会踢掉旧连接以便嵌入。黑屏请先 Wake(会连发唤醒+滑动+Home)。",
|
||
"Only one ws-scrcpy browser session: multiple embedded iframes fight for the stream—keep one tab or use Reload. Patched installs replace the old session. Black screen: try Wake (wake+swipe+home), then Reload or New tab.",
|
||
)}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{adbAvailable && androidSerial ? (
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void onAndroidAction("wake")}
|
||
className="rounded-lg bg-amber-500/20 px-3 py-1.5 text-[11px] text-amber-100 ring-1 ring-amber-500/30"
|
||
>
|
||
Wake
|
||
</button>
|
||
) : null}
|
||
{scrcpyUrl ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMirrorReloadKey((k) => k + 1)}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-zinc-200"
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
{t("重载投屏", "Reload mirror")}
|
||
</button>
|
||
<a
|
||
href={scrcpyUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-violet-300"
|
||
>
|
||
{t("新窗口打开", "New tab")}
|
||
</a>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{scrcpyUrl ? (
|
||
<iframe
|
||
key={`ws-scrcpy-${mirrorReloadKey}`}
|
||
title="ws-scrcpy"
|
||
src={hostMirrorSrc}
|
||
className="h-[min(72vh,640px)] w-full bg-black"
|
||
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
|
||
referrerPolicy="no-referrer-when-downgrade"
|
||
loading="eager"
|
||
/>
|
||
) : (
|
||
<p className="px-4 py-8 text-center text-sm text-zinc-500">{t("未配置 ws-scrcpy 地址", "ws-scrcpy URL missing")}</p>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<p className="border-b border-white/5 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
|
||
{t(
|
||
"Panda / WebUSB:手机必须插在您正在打开本页的电脑 USB 上(与板载 ADB 无关)。与官方 scrcpy 协议生态同源,适合本地调试。仓库:",
|
||
"Panda WebUSB: phone plugs into the PC running this browser. Same scrcpy-protocol family. Repo: ",
|
||
)}
|
||
<a className="text-cyan-300 hover:underline" href={LINK_PANDA_GH} target="_blank" rel="noreferrer">
|
||
PandaTestGrid/panda-web-scrcpy
|
||
</a>
|
||
</p>
|
||
<iframe
|
||
key={`panda-${mirrorReloadKey}`}
|
||
title="panda-web-scrcpy"
|
||
src={PANDA_WEB_SCRCPY}
|
||
className="h-[min(72vh,640px)] w-full bg-black"
|
||
allow="fullscreen; usb"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{adbAvailable && androidSerial ? (
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<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("设备信息", "Device info")}
|
||
</h4>
|
||
{overviewErr ? <p className="mt-2 text-xs text-rose-300">{overviewErr}</p> : null}
|
||
{overview ? (
|
||
<dl className="mt-3 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">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>
|
||
) : !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">
|
||
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
|
||
{t("快捷控制", "Quick controls")}
|
||
</h4>
|
||
<p className="mt-2 text-[11px] text-zinc-500">
|
||
{t("按键与常用应用;深层调试用下方 Shell / Logcat。", "Keys & apps; use Shell / Logcat for deep debug.")}
|
||
</p>
|
||
<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 (center)")}</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 input text)", "Type text (adb)")}
|
||
</p>
|
||
<p className="mt-1 text-[10px] text-zinc-600">
|
||
{t("仅适合英文与数字;中文需剪贴板或无障碍方案。", "ASCII-focused; CJK needs clipboard.")}
|
||
</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>
|
||
) : 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>
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
{t("过滤 pm list packages;行内可启动或强停。", "Filter pm list packages; launch or force-stop.")}
|
||
</p>
|
||
<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 / …"
|
||
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" />
|
||
{t("交互式 Shell", "ADB shell")}
|
||
</h3>
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
{t("Ctrl+Enter 执行;勿执行不可逆命令。", "Ctrl+Enter to run.")}
|
||
</p>
|
||
<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 (recent)")}</h3>
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
{t("logcat -d -t N;大日志可能略慢。", "logcat -d -t N.")}
|
||
</p>
|
||
<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={() => {
|
||
if (!androidSerial) {
|
||
notify(t("请选择设备", "Pick a device"));
|
||
return;
|
||
}
|
||
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("界面节点(uiautomator dump)", "UI nodes")}
|
||
</h3>
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
{t("列出可交互节点,点击坐标模拟触控。", "Clickable nodes from UI dump; tap by coordinates.")}
|
||
</p>
|
||
<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 tapNode(n.center_x, 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 (path on device)")}</h3>
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb 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>
|
||
);
|
||
}
|