Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveAndroidWorkstation.tsx
2026-03-29 02:33:37 -05:00

737 lines
29 KiB
TypeScript
Raw 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, 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 };
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>;
apiBase: string;
/** 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;
apkPath: string;
setApkPath: (v: string) => void;
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
onCopy: (text: string) => void;
onRefreshDevices: () => void;
};
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 [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 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="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}
</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>
<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-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>
);
}