This commit is contained in:
eric
2026-03-28 15:21:30 -05:00
parent d74f3046a3
commit 6f79f259a1
4 changed files with 473 additions and 155 deletions

View File

@@ -1,6 +1,15 @@
"use client";
import { ArrowUpRight, Layers, Loader2, Package, Terminal } from "lucide-react";
import {
ArrowUpRight,
ChevronDown,
ChevronUp,
Copy,
Layers,
Loader2,
Package,
Terminal,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
type DeviceRow = { serial: string; model?: string; state: string };
@@ -31,6 +40,13 @@ type UiNode = {
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;
@@ -47,6 +63,7 @@ type Props = {
apkPath: string;
setApkPath: (v: string) => void;
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
onCopy: (text: string) => void;
};
export function LiveAndroidWorkstation({
@@ -64,6 +81,7 @@ export function LiveAndroidWorkstation({
apkPath,
setApkPath,
onAndroidAction,
onCopy,
}: Props) {
const [overview, setOverview] = useState<AndroidOverview | null>(null);
const [overviewErr, setOverviewErr] = useState<string | null>(null);
@@ -77,6 +95,8 @@ export function LiveAndroidWorkstation({
const [uiNodes, setUiNodes] = useState<UiNode[]>([]);
const [uiErr, setUiErr] = useState<string | null>(null);
const [openUrl, setOpenUrl] = useState("https://");
const [adbInputText, setAdbInputText] = useState("");
const [embedScrcpy, setEmbedScrcpy] = useState(false);
const [pending, setPending] = useState<string | null>(null);
const lock = !!(busy || pending);
@@ -206,6 +226,15 @@ export function LiveAndroidWorkstation({
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">
@@ -241,6 +270,14 @@ export function LiveAndroidWorkstation({
</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 text-amber-200/90">
{t(
"未检测到已授权设备:请 USB 连接并开启「USB 调试」,手机上允许本计算机调试。",
"No authorized device: enable USB debugging and authorize this computer.",
)}
</p>
) : null}
</div>
{adbAvailable && androidSerial ? (
@@ -330,6 +367,71 @@ export function LiveAndroidWorkstation({
>
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
@@ -366,6 +468,9 @@ export function LiveAndroidWorkstation({
value={pkgQuery}
onChange={(e) => setPkgQuery(e.target.value)}
placeholder="tiktok / musically / …"
onKeyDown={(e) => {
if (e.key === "Enter") void searchPackages();
}}
/>
<button
type="button"
@@ -408,70 +513,112 @@ export function LiveAndroidWorkstation({
) : 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">
<Terminal className="h-4 w-4 text-amber-400" />
{t("交互式 Shell(只读安全子集由你输入决定)", "ADB shell")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("等同 adb shell;勿执行不可逆命令。", "Same as adb shell; avoid destructive commands.")}
</p>
<textarea
className="mt-3 min-h-[72px] 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}
/>
<button
type="button"
disabled={lock}
onClick={() => void runShell()}
className="mt-2 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 ? (
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/40 p-3 font-mono text-[11px] text-zinc-400">
{shellOut}
</pre>
) : null}
</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("Logcat(最近行)", "Logcat (recent)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("执行 logcat -d -t N,适合快速排障。", "Runs logcat -d -t N for quick triage.")}
</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)}
<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();
}
}}
/>
<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>
<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)}
/>
<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>
{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">
{logcatText}
</pre>
) : null}
</div>
) : null}
@@ -583,13 +730,29 @@ export function LiveAndroidWorkstation({
{scrcpyUrl ? (
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
<span className="text-xs font-medium text-zinc-400">ws-scrcpy</span>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
<button
type="button"
onClick={() => setEmbedScrcpy((v) => !v)}
className="flex items-center gap-2 text-xs font-medium text-zinc-300 hover:text-white"
>
ws-scrcpy
{embedScrcpy ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
<span className="font-normal text-zinc-600">
{embedScrcpy ? t("收起内嵌", "Collapse") : t("展开内嵌(省资源)", "Expand embed")}
</span>
</button>
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
{t("新窗口打开", "Open tab")}
</a>
</div>
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
{embedScrcpy ? (
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
) : (
<p className="px-4 py-3 text-center text-[11px] text-zinc-600">
{t("默认不加载 iframe,避免占用带宽与解码;需要时点「展开内嵌」。", "Iframe off by default to save resources.")}
</p>
)}
</div>
) : null}
</div>