php8.1+laravel8+swoole(在laradock环境中 需配置php-worker和nginx)在web中对固定设备免密连接和自定义设备进行ssh+sftp连接的可用代码

AI摘要
【知识分享】该内容为技术性代码分享,详细展示了基于Swoole WebSocket、phpseclib3和xterm.js构建的SSH/SFTP终端管理系统的完整实现,涵盖Nginx反向代理配置、PHP服务端命令处理、前端界面及安全限制机制。内容聚焦于技术实现细节,不涉及违规内容。

php8.1+laravel8+swoole在web中对设备进行ssh+sftp连接的可用代码

php8.1+laravel8+swoole(在laradock环境中 需配置php-worker和nginx)在web中对设备进行ssh+sftp连接的可用代码

nginx配置


location /ws/ {
  proxy_pass http://php-worker:9506;
  proxy_http_version 1.1; # 必须指定HTTP/1.1,支持WebSocket
  proxy_set_header Upgrade $http_upgrade; # 升级协议头
  proxy_set_header Connection "upgrade"; # 连接类型为upgrade
  proxy_set_header Host $host; # 传递主机名
  proxy_set_header X-Real-IP $remote_addr; # 传递真实IP
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 传递转发IP
  proxy_connect_timeout 60; # 连接超时
  proxy_read_timeout 600; # 长连接超时(适配SSH终端长时间交互)
  proxy_send_timeout 600; # 发送超时
}

php-worker 配置

[program:ssh-terminal]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/autotest/platform/artisan swoole:ssh-terminal
directory=/var/www/autotest/platform
autostart=true
autorestart=true
numprocs=1
user=laradock
redirect_stderr=true
stdout_logfile=/var/www/autotest/platform/storage/logs/ssh_terminal.log
stopwaitsecs=10
killasgroup=true
stopasgroup=true

blade代码

<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>SFTP终端</title>

    <link rel="stylesheet" href="/css/xterm-6.0.0.css">

    <style>
        :root{
            --bg: #0a1220;
            --panel: rgba(255,255,255,.04);
            --panel2: rgba(255,255,255,.06);
            --stroke: rgba(255,255,255,.10);
            --stroke2: rgba(255,255,255,.14);
            --text: #e5e7eb;
            --muted: rgba(229,231,235,.70);
            --muted2: rgba(229,231,235,.55);

            --accent: #2563eb;
            --accent2: #06b6d4;
            --danger: #ef4444;
            --warn: #f59e0b;

            --shadow: 0 10px 30px rgba(0,0,0,.35);
            --r12: 12px;
            --r16: 16px;
        }

        *{ box-sizing:border-box; }
        html, body { height: 100%; }

        body{
            margin:0;
            display: flex;
            flex-direction: column;
            min-height: 100vh;
            overflow-x: hidden;
            font-family: "SF Pro Display","PingFang SC","Segoe UI","Hiragino Sans GB","Microsoft YaHei",sans-serif;

            background:
                radial-gradient(900px 420px at 10% 0%, rgba(37,99,235,.22), transparent 62%),
                radial-gradient(700px 420px at 90% 10%, rgba(6,182,212,.16), transparent 62%),
                var(--bg);
        }


        /* ===== Top Bar ===== */
        .topbar{
            position: sticky;
            top: 0;
            z-index: 50;
            backdrop-filter: blur(10px);
            background: rgba(10,18,32,.78);
            border-bottom: 1px solid var(--stroke);
        }

        .topbar-inner{
            padding: 10px 14px 8px;
            display: flex;
            align-items: center;
            gap: 10px;
            flex-wrap: wrap;
        }

        .brand{
            display:flex;
            align-items:center;
            gap:10px;
            padding: 8px 10px;
            border:1px solid var(--stroke);
            border-radius: 999px;
            background: rgba(255,255,255,.03);
        }
        .dot{
            width:10px; height:10px; border-radius:999px;
            background: linear-gradient(135deg, var(--accent), var(--accent2));
            box-shadow: 0 0 0 4px rgba(37,99,235,.16);
        }
        .brand b{ font-size: 13px; letter-spacing:.2px; }
        .brand span{ font-size: 12px; color: var(--muted); }

        .pill{
            padding: 6px 10px;
            border: 1px solid var(--stroke);
            border-radius: 999px;
            background: rgba(255,255,255,.03);
            color: var(--muted);
            font-size: 12px;
            max-width: 48vw;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }

        .controls{
            display:flex;
            align-items:center;
            gap:8px;
            flex-wrap: wrap;
        }

        .controls-main .btn{
            min-width: 96px;
            justify-content: center;
        }
        .box-web-access{
            display:flex;
            align-items:center;
            gap:8px;
            flex-wrap:wrap;
        }
        .box-web-label,
        .box-web-warning{
            font-size:12px;
            color:var(--muted);
            white-space:nowrap;
        }
        .box-web-warning{
            color:#fbbf24;
        }

        .controls-quick{
            width: 100%;
            gap: 8px;
        }

        .terminal-safety-layer{
            position: absolute;
            left: 0;
            right: 0;
            top: 64px;
            height: 42px;
            z-index: 80;
            overflow: hidden;
            pointer-events: none;
            contain: paint;
        }

        .terminal-safety-float{
            position: absolute;
            top: 0;
            left: 0;
            width: max-content;
            max-width: none;
            animation: safetyFloatCycle 18s linear infinite;
            will-change: transform, opacity;
        }

        .terminal-safety-float-inner{
            display: flex;
            align-items: center;
            gap: 8px;
            min-height: 36px;
            padding: 7px 14px;
            border: 1px solid rgba(251,191,36,.42);
            border-radius: 999px;
            background:
                linear-gradient(135deg, rgba(251,191,36,.18), rgba(239,68,68,.14)),
                rgba(11,16,32,.92);
            color: #fde68a;
            box-shadow: 0 12px 30px rgba(0,0,0,.28), 0 0 0 1px rgba(239,68,68,.10) inset;
            overflow: hidden;
            white-space: nowrap;
        }

        .terminal-safety-icon{
            flex: 0 0 auto;
            width: 22px;
            height: 22px;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            border-radius: 999px;
            background: rgba(239,68,68,.22);
            color: #fecaca;
            font-size: 13px;
        }

        .terminal-safety-text{
            min-width: 0;
            overflow: hidden;
            text-overflow: ellipsis;
            font-size: 12px;
            line-height: 1.2;
            font-weight: 700;
        }

        @keyframes safetyFloatCycle {
            0%, 8% {
                opacity: 0;
                transform: translateX(calc(100vw + 32px));
            }
            14%, 78% {
                opacity: 1;
            }
            92%, 100% {
                opacity: 0;
                transform: translateX(calc(-100% - 32px));
            }
        }

        .btn{
            border: 1px solid var(--stroke);
            background: rgba(255,255,255,.03);
            color: var(--text);
            padding: 8px 12px;
            border-radius: 999px;
            cursor: pointer;
            user-select:none;
            display:inline-flex;
            align-items:center;
            gap:8px;
            transition: transform .05s ease, background .15s ease, border-color .15s ease;
            font-size: 13px;
        }
        .btn:hover{
            background: rgba(255,255,255,.06);
            border-color: var(--stroke2);
        }
        .btn:active{ transform: translateY(1px); }
        .btn.primary{
            background: linear-gradient(135deg, rgba(37,99,235,.34), rgba(6,182,212,.14));
            border-color: rgba(37,99,235,.42);
        }
        .btn.good{
            background: linear-gradient(135deg, rgba(34,197,94,.30), rgba(34,197,94,.10));
            border-color: rgba(34,197,94,.35);
        }
        .btn.danger{
            background: linear-gradient(135deg, rgba(239,68,68,.28), rgba(239,68,68,.10));
            border-color: rgba(239,68,68,.35);
        }
        .btn.ghost{ background: transparent; }
        .btn:disabled{
            opacity: .45;
            cursor: not-allowed;
            transform: none;
        }

        .inp{
            border: 1px solid var(--stroke);
            background: rgba(255,255,255,.03);
            color: var(--text);
            padding: 9px 12px;
            border-radius: 999px;
            outline: none;
            width: 220px;
            font-size: 13px;
        }
        .inp:focus{
            border-color: rgba(37,99,235,.58);
            box-shadow: 0 0 0 4px rgba(37,99,235,.16);
        }

        .spacer{ flex: 1; }

        .quick-strip{
            padding: 0 14px 10px;
            border-top: 1px dashed rgba(255,255,255,.09);
        }
        .quick-strip.hidden{
            display: none;
        }

        .status-group{
            display:flex;
            align-items:center;
            gap:8px;
            flex-wrap: wrap;
        }

        #wrap{
            flex: 1;          /* 占满 topbar 下面剩余高度 */
            min-height: 0;    /* 关键:允许内部滚动容器正确收缩 */
            padding: 12px;
            display: grid;
            grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
            gap: 12px;
        }
        #wrap.sftp-hidden{
            grid-template-columns: minmax(0, 1fr);
        }
        #wrap.sftp-hidden #sftpPanel{
            display: none;
        }

        .card{
            border: 1px solid var(--stroke);
            border-radius: var(--r16);
            background: var(--panel);
            box-shadow: var(--shadow);
            overflow: hidden;
            min-height: 0;
        }

        /* ===== SFTP Panel ===== */
        .sftp-head{
            padding: 12px;
            border-bottom: 1px solid var(--stroke);
            background: linear-gradient(180deg, rgba(255,255,255,.05), transparent);
            display:flex;
            flex-direction: column;
            gap: 10px;
        }

        .sftp-toolbar{
            display:flex;
            gap: 10px;
            flex-wrap: wrap;
            align-items: center;
        }

        .crumbs{
            display:flex;
            align-items:center;
            gap:8px;
            flex-wrap: wrap;
            padding: 8px 10px;
            border:1px solid var(--stroke);
            border-radius: var(--r12);
            background: rgba(255,255,255,.03);
            font-size: 12px;
            color: var(--muted);
        }
        .crumbs button{
            border: none;
            background: transparent;
            color: var(--text);
            cursor: pointer;
            padding: 4px 6px;
            border-radius: 8px;
            font-size: 12px;
        }
        .crumbs button:hover{ background: rgba(255,255,255,.06); }
        .crumbs .sep{ color: var(--muted2); }

        .sftp-sub{
            display:flex;
            align-items:center;
            gap:10px;
        }
        .sftp-sub .inp{ flex: 1; width: auto; border-radius: var(--r12); }

        .file-area{
            display:flex;
            flex-direction: column;
            min-height: 0;
        }

        .table-wrap{
            flex: 1;
            min-height: 0;
            overflow: auto;   /* 列表多就滚动 */
            padding: 10px;
        }


        .dropzone{
            margin-top: 8px;
            padding: 10px 12px;
            border: 1px dashed rgba(255,255,255,.20);
            border-radius: var(--r12);
            background: rgba(255,255,255,.02);
            color: var(--muted);
            font-size: 12px;
            display:flex;
            align-items:flex-start;
            justify-content: space-between;
            gap: 10px;
            flex-wrap: wrap;
        }
        .dropzone strong{ color: var(--text); font-weight: 600; }
        .dropzone-copy{
            flex: 1 1 220px;
            min-width: 0;
            line-height: 1.5;
        }
        .dropzone-actions{
            flex: 0 1 auto;
            display: flex;
            flex-direction: column;
            align-items: flex-start;
            gap: 8px;
            max-width: 100%;
        }
        .dropzone-actions-primary,
        .dropzone-actions-secondary{
            display:flex;
            align-items:center;
            justify-content:flex-start;
            gap:8px;
            flex-wrap: wrap;
            width: 100%;
        }
        .dropzone.dragover{
            border-color: rgba(124,58,237,.60);
            background: rgba(124,58,237,.10);
            color: var(--text);
        }

        .progress{
            height: 10px;
            border-radius: 999px;
            background: rgba(255,255,255,.08);
            overflow: hidden;
            border: 1px solid rgba(255,255,255,.10);
        }
        .progress > div{
            height: 100%;
            width: 0%;
            background: linear-gradient(90deg, rgba(124,58,237,.9), rgba(34,197,94,.9));
            transition: width .08s ease;
        }
        .mini{
            font-size: 12px;
            color: var(--muted);
            display:flex;
            align-items:center;
            justify-content: space-between;
            gap: 10px;
        }

        /* 更小更精致的操作按钮 */
        .iconbtn{
            width: 28px;
            height: 28px;
            border-radius: 8px;
            padding: 0;
            border: 1px solid rgba(255,255,255,.12);
            background: rgba(255,255,255,.03);
            color: rgba(229,231,235,.92);
            cursor: pointer;
            display:inline-flex;
            align-items:center;
            justify-content:center;
            transition: background .15s ease, border-color .15s ease, transform .05s ease;
        }

        .iconbtn:hover{
            background: rgba(255,255,255,.06);
            border-color: rgba(255,255,255,.18);
        }

        .iconbtn:active{ transform: translateY(1px); }

        /* SVG 图标大小 */
        .iconbtn svg{
            width: 16px;
            height: 16px;
            display:block;
        }

        /* 删除按钮微红但不刺眼 */
        .iconbtn.danger{
            border-color: rgba(239,68,68,.35);
            background: rgba(239,68,68,.08);
        }
        .iconbtn.danger:hover{
            background: rgba(239,68,68,.12);
        }


        /* ===== TABLE (稳定版) ===== */
        .table{
            width: 100%;
            border-collapse: separate;
            border-spacing: 0;
            border: 1px solid var(--stroke);
            border-radius: var(--r12);
            background: rgba(255,255,255,.02);
            table-layout: fixed; /* 关键:列宽按th比例固定,不会挤成一坨 */
            overflow: hidden;
        }

        .table thead th{
            text-align: left;
            font-size: 12px;
            color: var(--muted);
            padding: 10px 12px;
            border-bottom: 1px solid var(--stroke);
            position: sticky;
            top: 0;
            z-index: 10;
            /* 给 sticky 一个实底,避免看起来像紫色遮罩 */
            background: rgba(11,16,32,.92);
            backdrop-filter: blur(6px);
        }

        .table td{
            padding: 10px 12px;
            border-bottom: 1px solid rgba(255,255,255,.06);
            font-size: 13px;
            color: var(--text);
            vertical-align: middle;
            overflow: hidden; /* 防止相邻列互相盖住 */
        }

        .table tbody tr:hover td{ background: rgba(255,255,255,.03); }

        /* 选中行的紫色高亮:不想要就把这段删掉 */
        .table tbody tr.active td{
            background: rgba(124,58,237,.15);
            border-bottom-color: rgba(124,58,237,.25);
        }

        .check-cell{
            text-align:center;
        }
        .file-check,
        #fileCheckAll{
            width: 16px;
            height: 16px;
            margin: 0;
            accent-color: var(--accent);
            cursor: pointer;
        }
        .file-check:disabled{
            cursor: not-allowed;
            opacity: .35;
        }
        .selected-count{
            display:inline-flex;
            align-items:center;
            min-height: 28px;
            padding: 0 10px;
            border: 1px solid var(--stroke);
            border-radius: 999px;
            background: rgba(255,255,255,.03);
            color: var(--muted);
            font-size: 12px;
            white-space: nowrap;
        }

        /* 名称列:可换行 */
        .name-cell{
            display:flex;
            align-items:flex-start;
            gap:10px;
            min-width:0;
        }
        .icon{
            width: 28px; height: 28px;
            display:flex;
            align-items:center;
            justify-content:center;
            border-radius: 10px;
            background: rgba(255,255,255,.04);
            border: 1px solid rgba(255,255,255,.08);
            flex: 0 0 auto;
        }
        .fname{
            min-width: 0;
            white-space: normal;
            word-break: break-all;
            overflow-wrap: anywhere;
            line-height: 1.35;
        }

        /* 右侧meta列:不换行+省略,避免顶到操作列 */
        .meta{
            color: var(--muted);
            font-size: 12px;
            text-align:right;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        /* 只针对 大小 列:允许换行,展示完整,不要省略号 */
        .table td.meta.size{
            white-space: normal;      /* 允许换行 */
            overflow: visible;        /* 不裁切 */
            text-overflow: clip;      /* 不要 ... */
            line-height: 1.25;
            word-break: break-word;
            overflow-wrap: anywhere;
            text-align: left;         /* 你要右对齐就删掉这行 */
        }

        /* 只针对 修改时间 列:允许换行,不要省略号 */
        .table td.meta.mtime{
            white-space: normal;      /* 允许换行 */
            overflow: visible;        /* 不裁切 */
            text-overflow: clip;      /* 不要 ... */
            line-height: 1.25;
            text-align: left;         /* 看着更顺(你要右对齐就删掉这行) */
        }

        .table td.meta.mtime .celltext{
            white-space: normal;
            overflow: visible;
            text-overflow: clip;
            word-break: break-word;
            overflow-wrap: anywhere;
        }

        /* 修改时间包一层:强制省略(你JS里用<span class="celltext">...</span>最好) */
        .celltext{
            display:block;
            overflow:hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }

        /* 操作列:永远不被其它列盖住 */
        .table td:last-child,
        .table thead th:last-child{
            white-space: nowrap;
        }
        .table td:last-child{
            overflow: visible;
            position: relative;
            z-index: 30;
        }

        .ops{
            display:flex;
            justify-content:flex-end;
            gap:8px;
            flex-wrap: nowrap;
            position: relative;
            z-index: 40;
        }
        .ops .iconbtn{
            position: relative;
            z-index: 50;
        }

        /* ===== Terminal ===== */
        #terminalWrap{ width:100%; height:100%; padding: 10px; min-width: 0; }
        #terminal{
            width:100%;
            height:auto;
            flex: 1 1 auto;
            min-height: 0;
            min-width: 0;
            border:1px solid var(--stroke);
            border-radius: 12px;
            overflow:hidden;
            background: rgba(0,0,0,.18);
            box-shadow: inset 0 1px 0 rgba(255,255,255,.04);
        }
        #terminalWrap{
            display:flex;
            flex-direction: column;
            gap: 10px;
            padding: 10px;
        }
        .terminal-head{
            flex: 0 0 auto;
            display:flex;
            align-items:center;
            justify-content: space-between;
            gap: 12px;
            border:1px solid var(--stroke);
            border-radius: var(--r12);
            padding: 10px 12px;
            background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.01));
        }
        .terminal-title{
            color: var(--text);
            font-size: 14px;
            font-weight: 700;
            letter-spacing: .2px;
        }
        .terminal-sub{
            color: var(--muted);
            font-size: 12px;
            margin-top: 2px;
        }
        .kbd-hint{
            color: var(--muted2);
            font-size: 12px;
            text-align: right;
            white-space: nowrap;
        }
        .pill{
            border-color: rgba(255,255,255,.14);
        }
        .pill[data-ok="1"]{
            color: #86efac;
            background: rgba(34,197,94,.12);
            border-color: rgba(34,197,94,.30);
        }
        .pill[data-ok="0"]{
            color: #fca5a5;
            background: rgba(239,68,68,.10);
            border-color: rgba(239,68,68,.30);
        }
        .pill[data-ok="-"]{
            color: var(--muted);
            background: rgba(255,255,255,.03);
        }

        /* ===== Read-only command whitelist ===== */
        .command-whitelist-overlay[hidden]{
            display: none;
        }
        .command-whitelist-overlay{
            position: fixed;
            inset: 0;
            z-index: 1200;
            display: grid;
            place-items: center;
            padding: 24px;
            background: rgba(1,6,18,.72);
            backdrop-filter: blur(8px);
        }
        .command-whitelist-modal{
            width: min(1040px, 96vw);
            max-height: min(820px, 92vh);
            display: flex;
            flex-direction: column;
            overflow: hidden;
            border: 1px solid rgba(148,163,184,.24);
            border-radius: 18px;
            background:
                radial-gradient(700px 260px at 15% 0%, rgba(37,99,235,.20), transparent 62%),
                #0b1425;
            color: var(--text);
            box-shadow: 0 30px 80px rgba(0,0,0,.58);
        }
        .command-whitelist-head{
            flex: 0 0 auto;
            display: flex;
            align-items: flex-start;
            gap: 14px;
            padding: 18px 20px;
            border-bottom: 1px solid var(--stroke);
        }
        .command-whitelist-title{
            margin: 0;
            font-size: 18px;
        }
        .command-whitelist-subtitle{
            margin: 5px 0 0;
            color: var(--muted);
            font-size: 12px;
            line-height: 1.6;
        }
        .command-whitelist-state{
            display: inline-flex;
            align-items: center;
            margin-top: 8px;
            padding: 4px 9px;
            border: 1px solid rgba(34,197,94,.32);
            border-radius: 999px;
            background: rgba(34,197,94,.12);
            color: #86efac;
            font-size: 12px;
        }
        .command-whitelist-state.is-reference{
            border-color: rgba(245,158,11,.34);
            background: rgba(245,158,11,.12);
            color: #fde68a;
        }
        .command-whitelist-close{
            margin-left: auto;
            flex: 0 0 auto;
        }
        .command-whitelist-toolbar{
            flex: 0 0 auto;
            display: flex;
            align-items: center;
            gap: 12px;
            padding: 12px 20px;
            border-bottom: 1px solid var(--stroke);
            background: rgba(255,255,255,.025);
        }
        .command-whitelist-search{
            width: min(420px, 100%);
        }
        .command-whitelist-count{
            color: var(--muted);
            font-size: 12px;
            white-space: nowrap;
        }
        .command-whitelist-body{
            min-height: 0;
            overflow: auto;
            padding: 18px 20px 22px;
        }
        .command-whitelist-notes{
            margin: 0 0 16px;
            padding: 12px 14px 12px 32px;
            border: 1px solid rgba(245,158,11,.22);
            border-radius: 12px;
            background: rgba(245,158,11,.07);
            color: #fde9ad;
            font-size: 12px;
            line-height: 1.7;
        }
        .command-whitelist-grid{
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 12px;
        }
        .command-whitelist-group{
            padding: 14px;
            border: 1px solid var(--stroke);
            border-radius: 14px;
            background: rgba(255,255,255,.025);
        }
        .command-whitelist-group h3{
            margin: 0;
            font-size: 14px;
        }
        .command-whitelist-group p{
            min-height: 36px;
            margin: 5px 0 11px;
            color: var(--muted);
            font-size: 12px;
            line-height: 1.5;
        }
        .command-whitelist-chips{
            display: flex;
            flex-wrap: wrap;
            gap: 6px;
        }
        .command-whitelist-chip{
            padding: 4px 8px;
            border: 1px solid rgba(96,165,250,.22);
            border-radius: 7px;
            background: rgba(37,99,235,.10);
            color: #bfdbfe;
            font: 12px/1.35 "SF Mono", Menlo, Consolas, monospace;
        }
        .command-whitelist-rules-title{
            margin: 20px 0 10px;
            font-size: 14px;
        }
        .command-whitelist-rules{
            display: grid;
            gap: 7px;
        }
        .command-whitelist-rule{
            display: grid;
            grid-template-columns: minmax(190px, .75fr) minmax(0, 2fr);
            gap: 12px;
            padding: 9px 11px;
            border: 1px solid var(--stroke);
            border-radius: 10px;
            background: rgba(255,255,255,.02);
            font-size: 12px;
            line-height: 1.55;
        }
        .command-whitelist-rule code{
            color: #93c5fd;
            font-family: "SF Mono", Menlo, Consolas, monospace;
        }
        .command-whitelist-rule span{
            color: var(--muted);
        }
        .command-whitelist-empty{
            margin: 16px 0 0;
            color: var(--muted);
            text-align: center;
            font-size: 13px;
        }


        /* ===== Responsive ===== */
        @media (max-width: 1100px){
            #wrap{ grid-template-columns: 1fr; height: auto; }
            #terminalWrap{ height: 55vh; }
            .table-wrap{ max-height: 45vh; }
            .kbd-hint{ white-space: normal; text-align: left; }
            .controls-main .btn{ min-width: auto; }
            .terminal-safety-text{
                font-size: 12px;
            }
            .dropzone-actions{
                width: 100%;
                align-items: stretch;
            }
            .dropzone-actions-primary,
            .dropzone-actions-secondary{
                justify-content: flex-start;
            }
            .command-whitelist-grid{
                grid-template-columns: 1fr;
            }
            .command-whitelist-rule{
                grid-template-columns: 1fr;
                gap: 4px;
            }
        }
    </style>

</head>

<body>
<!-- ===== Top Bar ===== -->
<div class="topbar">
    <div class="terminal-safety-layer" aria-hidden="true">
        <div class="terminal-safety-float">
            <div class="terminal-safety-float-inner">
                <span class="terminal-safety-icon">!</span>
                <span class="terminal-safety-text">您的行为正在受到监控,严禁替换修改程序,一切操作都将被平台记录并可追溯,由您导致的测试结果错误变更将由您承担责任</span>
            </div>
        </div>
    </div>
    <div class="topbar-inner">


        <div class="controls controls-main">
            <button id="btnFav" class="btn primary">⭐ 收藏夹</button>
            <button id="btnCommandWhitelist" class="btn">🛡️ 查看只读白名单</button>

            <button id="btnConnect" class="btn good">🖥️ 连接 SSH</button>
            <button id="btnSftpConnect" class="btn primary">📁 连接 SFTP</button>
            <button id="btnSftpPanelToggle" class="btn" hidden>📁 展开 SFTP</button>
            <div class="box-web-access">
                <span class="box-web-label">盒子网页</span>
                <button type="button" class="btn primary" data-box-web-target="https://192.168.3.254">https://192.168.3.254</button>
                <button type="button" class="btn primary" data-box-web-target="https://{{ \App\Services\EdgeGateway\BoxBusinessAddressResolver::DEFAULT_BUSINESS_ADDRESS }}">https://{{ \App\Services\EdgeGateway\BoxBusinessAddressResolver::DEFAULT_BUSINESS_ADDRESS }}</button>
                <input id="webPreviewUrl" class="inp" style="width:min(28vw,360px); min-width:220px;" placeholder="自定义网址" title="自定义网址可能出现不兼容现象" />
                <button id="btnOpenBoxWeb" class="btn">打开自定义</button>
                <span class="box-web-warning">其他网页可能会出现不兼容现象</span>
            </div>
            <button id="btnQuickToggle" class="btn">⚡ 收起快捷命令</button>
        </div>

        <div class="spacer"></div>

        <div class="status-group">
            <span class="pill" id="wsStatus">WS: -</span>
            <span class="pill" id="sshStatus">SSH: -</span>
            <span class="pill" id="sftpStatus">SFTP: -</span>
            <span class="pill" id="wsUrlPill">URL: -</span>
        </div>
        <div class="controls">
            <button id="btnDisconnect" class="btn ghost">⛔ 断开 SSH</button>
            <button id="btnSftpDisconnect" class="btn ghost">⛔ 断开 SFTP</button>
            <button id="btnClear" class="btn">🧹 清空终端</button>
        </div>
    </div>
    <div class="quick-strip" id="quickCmdPanel">
        <div class="controls controls-quick">
            <button id="btn-auth-check" class="btn primary">🔐 授权检测</button>
            <button id="btn-hosts-check" class="btn">🧾 hosts检查</button>
            <button id="btn-hosts-repair" class="btn good">🛠️ hosts修复</button>
            <button id="btn-algorithm-auth" class="btn primary">🧠 算法授权</button>
            <button id="btn-reboot" class="btn danger">♻️ 重启设备</button>
            <input id="aiboxVer" class="inp" style="width:140px;" placeholder="版本号" />
            <button id="btn-aibox-update" class="btn good">🚀 版本更新</button>
            <input id="boxSnInput" class="inp" style="width:160px;" placeholder="SN号" />
            <button id="btn-register-sn" class="btn good">SN</button>
        </div>
    </div>
</div>
<!-- ===== Favorites Modal ===== -->
<div id="favMask" style="position:fixed; inset:0; background:rgba(0,0,0,.55); display:none; z-index:999;"></div>

<div id="favModal" style="position:fixed; left:50%; top:50%; transform:translate(-50%,-50%);
  width:min(920px,92vw); height:min(720px,86vh); display:none; z-index:1000;
  border:1px solid rgba(255,255,255,.12); border-radius:16px; background:rgba(11,16,32,.92);
  backdrop-filter: blur(10px); box-shadow: 0 10px 40px rgba(0,0,0,.55); overflow:hidden;">

    <div style="padding:12px 14px; display:flex; align-items:center; gap:10px; border-bottom:1px solid rgba(255,255,255,.12);">
        <b style="color:#e5e7eb;">⭐ 设备收藏夹</b>
        <span style="color:rgba(229,231,235,.6); font-size:12px;">保存在本地 localStorage(清浏览器数据才会消失)</span>
        <div style="flex:1;"></div>
        <input id="favSearch" class="inp" style="width:220px;" placeholder="搜索设备..." />
        <button id="btnFavClose" class="btn">关闭</button>
    </div>

    <div style="display:grid; grid-template-columns: 1.1fr .9fr; gap:12px; padding:12px; height:calc(100% - 54px); min-height:0;">
        <!-- Left list -->
        <div class="card" style="min-height:0;">
            <div style="padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.10); display:flex; gap:10px; align-items:center;">
                <button id="btnFavAdd" class="btn good">➕ 新增设备</button>
                <button id="btnFavImport" class="btn">导入 JSON</button>
                <button id="btnFavExport" class="btn">导出 JSON</button>
                <div style="flex:1;"></div>
                <button id="btnFavClearStatus" class="btn">清空状态</button>
            </div>

            <div style="padding:10px; overflow:auto; height:calc(100% - 52px);">
                <table class="table">
                    <thead>
                    <tr>
                        <th style="width:36%;">名称</th>
                        <th style="width:32%;">Host</th>
                        <th style="width:18%;">用户</th>
                        <th style="width:14%;">操作</th>
                    </tr>
                    </thead>
                    <tbody id="favTbody"></tbody>
                </table>
            </div>
        </div>

        <!-- Right editor -->
        <div class="card" style="min-height:0;">
            <div style="padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.10); display:flex; align-items:center; gap:10px;">
                <b style="color:#e5e7eb;">设备信息</b>
                <span id="favEditHint" style="color:rgba(229,231,235,.55); font-size:12px;">选择左侧设备或新增</span>
            </div>

            <div style="padding:12px; overflow:auto; height:calc(100% - 46px);">
                <div style="display:grid; grid-template-columns: 1fr 1fr; gap:10px;">
                    <input id="f_name" class="inp" placeholder="设备名称/别名 例如:Box-01" />
                    <input id="f_port" class="inp" placeholder="端口(可选,默认22)" />
                    <input id="f_host" class="inp" placeholder="SSH IP/域名 例如:192.168.1.10" />
                    <input id="f_user" class="inp" placeholder="登录名 例如:root" />
                </div>

                <div style="margin-top:10px; display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
                    <span style="color:rgba(229,231,235,.7); font-size:12px;">认证方式:</span>
                    <label style="color:#e5e7eb; font-size:12px; display:flex; align-items:center; gap:6px;">
                        <input type="radio" name="authType" value="password" checked /> 密码
                    </label>
                    <label style="color:#e5e7eb; font-size:12px; display:flex; align-items:center; gap:6px;">
                        <input type="radio" name="authType" value="key" /> 私钥
                    </label>
                </div>

                <div id="authPasswordWrap" style="margin-top:10px;">
                    <input id="f_password" class="inp" style="width:100%; border-radius:12px;" placeholder="密码(会保存在本地)" />
                </div>

                <div id="authKeyWrap" style="margin-top:10px; display:none;">
          <textarea id="f_privateKey" style="width:100%; height:180px; resize:vertical;
            border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.03); color:#e5e7eb;
            border-radius:12px; padding:10px 12px; outline:none; font-size:12px;"
                    placeholder="粘贴 OpenSSH 私钥内容(会保存在本地)"></textarea>
                    <input id="f_passphrase" class="inp" style="width:100%; border-radius:12px; margin-top:10px;" placeholder="Passphrase(可选)" />
                </div>

                <div style="margin-top:10px;">
          <textarea id="f_note" style="width:100%; height:90px; resize:vertical;
            border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.03); color:#e5e7eb;
            border-radius:12px; padding:10px 12px; outline:none; font-size:12px;"
                    placeholder="备注(可选)"></textarea>
                </div>

                <div style="margin-top:12px; display:flex; gap:10px; flex-wrap:wrap;">
                    <button id="btnFavSave" class="btn good">💾 保存</button>
                    <button id="btnFavConnect" class="btn primary">🖥️ 用此设备连接 SSH</button>
                    <button id="btnFavDelete" class="btn danger">🗑️ 删除</button>
                </div>

                <div style="margin-top:10px; font-size:12px; color:rgba(229,231,235,.6); line-height:1.5;">
                    <div><b style="color:#e5e7eb;">上次状态:</b><span id="favLastStatus">-</span></div>
                    <div><b style="color:#e5e7eb;">上次信息:</b><span id="favLastMsg">-</span></div>
                    <div><b style="color:#e5e7eb;">上次时间:</b><span id="favLastAt">-</span></div>
                </div>

                <div style="margin-top:10px; font-size:12px; color:rgba(253,224,71,.95);">
                    ⚠️ 本地保存密码/私钥有风险:同一台电脑的其他用户可能读取浏览器数据。”。
                </div>
            </div>
        </div>
    </div>
</div>

@php
    $commandWhitelist = $terminalCommandWhitelist ?? [
        'groups' => [],
        'restricted_rules' => [],
        'global_rules' => [],
        'command_count' => 0,
    ];
@endphp
<!-- ===== Read-only command whitelist ===== -->
<div id="commandWhitelistOverlay" class="command-whitelist-overlay" hidden>
    <section class="command-whitelist-modal" role="dialog" aria-modal="true" aria-labelledby="commandWhitelistTitle">
        <header class="command-whitelist-head">
            <div>
                <h2 id="commandWhitelistTitle" class="command-whitelist-title">🛡️ 终端只读命令白名单</h2>
                <p class="command-whitelist-subtitle">本页内容与服务端实际校验策略来自同一份配置,用于客户查看当前可用的诊断能力。</p>
                <span class="command-whitelist-state {{ ($terminalStrongRestriction ?? true) ? '' : 'is-reference' }}">
                    {{ ($terminalStrongRestriction ?? true) ? '当前设备:强限制已开启' : '当前设备:仅作为参考(强限制未开启)' }}
                </span>
            </div>
            <button id="btnCommandWhitelistClose" type="button" class="btn command-whitelist-close">关闭</button>
        </header>

        <div class="command-whitelist-toolbar">
            <input id="commandWhitelistSearch" class="inp command-whitelist-search" placeholder="搜索命令或规则,例如 du、df、systemctl" autocomplete="off" />
            <span class="command-whitelist-count">{{ (int) ($commandWhitelist['command_count'] ?? 0) }} 个命令入口</span>
        </div>

        <div class="command-whitelist-body">
            <ul class="command-whitelist-notes">
                @foreach (($commandWhitelist['global_rules'] ?? []) as $rule)
                    <li>{{ $rule }}</li>
                @endforeach
            </ul>

            <div class="command-whitelist-grid">
                @foreach (($commandWhitelist['groups'] ?? []) as $group)
                    <article class="command-whitelist-group" data-whitelist-group>
                        <h3>{{ $group['title'] }}</h3>
                        <p>{{ $group['description'] }}</p>
                        <div class="command-whitelist-chips">
                            @foreach ($group['commands'] as $command)
                                <code class="command-whitelist-chip" data-whitelist-command>{{ $command }}</code>
                            @endforeach
                        </div>
                    </article>
                @endforeach
            </div>

            <h3 class="command-whitelist-rules-title">双用命令参数限制</h3>
            <div class="command-whitelist-rules">
                @foreach (($commandWhitelist['restricted_rules'] ?? []) as $command => $rule)
                    <div class="command-whitelist-rule" data-whitelist-rule>
                        <code>{{ $command }}</code>
                        <span>{{ $rule }}</span>
                    </div>
                @endforeach
            </div>
            <p id="commandWhitelistEmpty" class="command-whitelist-empty" hidden>没有找到匹配的命令或规则。</p>
        </div>
    </section>
</div>

<script>
    (() => {
        const openButton = document.getElementById('btnCommandWhitelist');
        const closeButton = document.getElementById('btnCommandWhitelistClose');
        const overlay = document.getElementById('commandWhitelistOverlay');
        const search = document.getElementById('commandWhitelistSearch');
        const empty = document.getElementById('commandWhitelistEmpty');
        if (!openButton || !closeButton || !overlay || !search || !empty) return;

        let previousBodyOverflow = '';
        let pointerStartedOnOverlay = false;

        const filterWhitelist = () => {
            const keyword = search.value.trim().toLowerCase();
            let visibleGroups = 0;
            let visibleRules = 0;

            overlay.querySelectorAll('[data-whitelist-group]').forEach((group) => {
                const groupCopy = `${group.querySelector('h3')?.textContent || ''} ${group.querySelector('p')?.textContent || ''}`.toLowerCase();
                const groupMatched = !keyword || groupCopy.includes(keyword);
                let visibleCommands = 0;

                group.querySelectorAll('[data-whitelist-command]').forEach((command) => {
                    const matched = groupMatched || command.textContent.toLowerCase().includes(keyword);
                    command.hidden = !matched;
                    if (matched) visibleCommands++;
                });

                group.hidden = visibleCommands === 0;
                if (!group.hidden) visibleGroups++;
            });

            overlay.querySelectorAll('[data-whitelist-rule]').forEach((rule) => {
                const matched = !keyword || rule.textContent.toLowerCase().includes(keyword);
                rule.hidden = !matched;
                if (matched) visibleRules++;
            });

            empty.hidden = visibleGroups + visibleRules > 0;
        };

        const openWhitelist = () => {
            previousBodyOverflow = document.body.style.overflow;
            document.body.style.overflow = 'hidden';
            overlay.hidden = false;
            search.value = '';
            filterWhitelist();
            window.requestAnimationFrame(() => search.focus());
        };

        const closeWhitelist = () => {
            overlay.hidden = true;
            document.body.style.overflow = previousBodyOverflow;
            openButton.focus();
        };

        openButton.addEventListener('click', openWhitelist);
        closeButton.addEventListener('click', closeWhitelist);
        search.addEventListener('input', filterWhitelist);
        overlay.addEventListener('pointerdown', (event) => {
            pointerStartedOnOverlay = event.target === overlay;
        });
        overlay.addEventListener('pointerup', (event) => {
            if (pointerStartedOnOverlay && event.target === overlay) closeWhitelist();
            pointerStartedOnOverlay = false;
        });
        document.addEventListener('keydown', (event) => {
            if (event.key === 'Escape' && !overlay.hidden) closeWhitelist();
        });
    })();
</script>

<!-- ===== Main ===== -->
<div id="wrap" class="sftp-hidden">
    <!-- ===== SFTP Panel ===== -->
    <div class="card file-area" id="sftpPanel" aria-hidden="true">
        <div class="sftp-head">
            <div class="sftp-toolbar">
                <button id="btnBack" class="btn">⬅️ 返回</button>
                <button id="btnRefresh" class="btn">🔄 刷新</button>
                <button id="btnDownloadSelected" class="btn primary" disabled>⬇️ 下载选中</button>
                <button id="btnNewFolder" class="btn">📂 新建目录</button>
                <button id="btnRename" class="btn">✏️ 重命名</button>
                <span id="selectedCount" class="selected-count">已选 0 个文件</span>
            </div>

            <div class="sftp-sub">
                <div class="crumbs" id="crumbs"></div>
            </div>

            <div class="sftp-sub">
                <input id="pathInput" class="inp" placeholder="输入路径,例如 /home" />
                <button id="btnGo" class="btn">前往</button>
                <input id="searchInput" class="inp" style="width: 180px;" placeholder="搜索文件名..." />
            </div>

            <div class="dropzone" id="dropzone">
                <div class="dropzone-copy">拖拽文件到此处上传到 <strong id="dropPath">/</strong></div>
                <div class="dropzone-actions">
                    <div class="dropzone-actions-primary">
                        <input type="file" id="uploadFile" style="display:none" />
                        <button id="btnPick" class="btn">选择文件</button>
                    </div>
                    <div class="dropzone-actions-secondary">
                        <button id="btnUpload" class="btn good">上传</button>
                        <button id="btnCancelOp" class="btn danger" disabled>取消</button>
                    </div>
                </div>
            </div>

            <div class="mini">
                <div id="opHint">就绪</div>
                <div style="width: 180px;">
                    <div class="progress"><div id="opBar"></div></div>
                </div>
            </div>
        </div>

        <div class="table-wrap">
            <table class="table">
                <thead>
                <tr>
                    <th style="width: 7%;" class="check-cell">
                        <input type="checkbox" id="fileCheckAll" title="选择当前列表全部文件" disabled>
                    </th>
                    <th style="width: 43%;">名称</th>
                    <th style="width: 18%;">大小</th>
                    <th style="width: 18%;">修改时间</th>
                    <th style="width: 14%;">操作</th>

                </tr>
                </thead>

                <tbody id="fileTbody">
                <!-- rows -->
                </tbody>
            </table>
        </div>
    </div>

    <!-- ===== Terminal ===== -->
    <div class="card" id="terminalWrap">
        <div class="terminal-head">
            <div>
                <div class="terminal-title">实时终端会话</div>
                <div class="terminal-sub">输出采用帧级缓冲渲染,窗口变化会自动重算尺寸</div>
            </div>
            <div class="kbd-hint">Ctrl+Shift+K 清屏</div>
        </div>
        <div id="terminal"></div>
    </div>
</div>
<script>
    window.GLOBAL_BUNNY_LOADER_CONFIG = Object.assign({}, window.GLOBAL_BUNNY_LOADER_CONFIG || {}, {
        text: '数据加载中...'
    });
</script>
<script src="{{ asset('js/global-loading-bunny.js') }}"></script>
<script src="{{ asset('js/http-error-message.js') }}?v={{ filemtime(public_path('js/http-error-message.js')) }}"></script>
<script>
    window.CAPTCHA_TYPE = "{{ config('platform.captcha_mode', 'smart') }}";
    function fetchWithAlert(url, options = {}) {
        const { skipGlobal422 = false, skipGlobalLoading = true, ...fetchOptions } = options;
        const token = getToken();
        const requestPath = (() => {
            try {
                return new URL(url, window.location.origin).pathname;
            } catch (e) {
                return String(url || '').split('?')[0];
            }
        })();
        const noLoadingUrls = [
            '/api/general/user',
            '/api/general/login',
            '/api/general/recommend',
            '/api/general/finger',
            '/api/general/platform-version',
            '/api/general/navigation',
            '/api/general/clues',
            '/api/general/check-user-agent'
        ];
        const shouldShowGlobalLoading = !skipGlobalLoading && !noLoadingUrls.includes(requestPath);

        fetchOptions.headers = fetchOptions.headers || {};
        if (token && !fetchOptions.headers['Authorization']) {
            fetchOptions.headers['Authorization'] = `Bearer ${token}`;
        }

        if (shouldShowGlobalLoading && typeof window.showGlobalBunnyLoader === 'function') {
            window.showGlobalBunnyLoader();
        }

        fetchOptions.__globalLoadingHandled = true;

        return fetch(url, fetchOptions)
            .then(async response => {
                if (response.status === 460) {
                    window.location.href = '/ban';
                    return Promise.reject(new Error('460'));
                }

                const noAlertUrls = [
                    '/api/general/user',
                    '/api/general/login',
                    '/api/general/recommend',
                    '/api/general/finger',
                    '/api/general/platform-version'
                ];

                if (!noAlertUrls.includes(url)) {
                    if (response.status === 403) {
                        alert('您的账户权限受限!');
                    } else if (response.status === 401) {
                        deleteCookie('accessToken')
                        deleteCookie('userType')
                        window.location.href = '/login';
                    } else if (response.status === 422 && !skipGlobal422) {
                        alert(await window.getResponseErrorMessage(response));
                    }
                }

                return response;
            })
            .catch(error => {
                console.error('请求失败:', error);
                throw error;
            })
            .finally(() => {
                if (shouldShowGlobalLoading && typeof window.hideGlobalBunnyLoader === 'function') {
                    window.hideGlobalBunnyLoader();
                }
            });
    }
</script>
<script src="{{ asset('js/xterm-6.0.0.js') }}?v={{ filemtime(public_path('js/xterm-6.0.0.js')) }}"></script>
<script src="{{ asset('js/xterm-addon-fit-0.11.0.js') }}?v={{ filemtime(public_path('js/xterm-addon-fit-0.11.0.js')) }}"></script>
<script src="{{ asset('getToken.js') }}"></script>
<script  src="{{ asset('js/login.js') }}"></script>
<script>
    function getCookie(name) {
        const cookies = document.cookie.split(';');
        for (let cookie of cookies) {
            const [cookieName, ...rest] = cookie.split('=');
            if (cookieName.trim() === name) {
                return decodeURIComponent(rest.join('='));
            }
        }
        return null;
    }
</script>
<script>

    (() => {
        const ssh_key = getCookie("ssh_value");   // 例如 login.js 里提供了 getCookie()
        if (!ssh_key) {
            alert('未授权或登录已过期,请重新登录');
            return;
        }
        // ===== 1) URL box id =====
        const qs = new URLSearchParams(window.location.search);
        const favId = (qs.get('fav') || '').trim();
        const isFavoriteConnection = Boolean(favId);

        const boxId = (qs.get('id') || '').trim();
        const TERMINAL_STRONG_RESTRICTION = @json((bool) ($terminalStrongRestriction ?? true));

        if (!getToken()) {
            alert('未登录,请先登录后再连接。');
            return;
        }

        // ===== 2) WS URL =====
        const wsProto = (window.location.protocol === 'https:') ? 'wss' : 'ws';

        const WS_URL = `${wsProto}://${window.location.host}/ws/?id=${encodeURIComponent(boxId)}&key=${encodeURIComponent(ssh_key)}`;


        const wsStatusEl = document.getElementById('wsStatus');
        const sshStatusEl = document.getElementById('sshStatus');
        const sftpStatusEl = document.getElementById('sftpStatus');
        const wsUrlPill = document.getElementById('wsUrlPill');
        wsUrlPill.textContent = `URL: ${WS_URL}`;
        wsUrlPill.title = WS_URL;

        const pageWrap = document.getElementById('wrap');
        const sftpPanel = document.getElementById('sftpPanel');
        const btnSftpPanelToggle = document.getElementById('btnSftpPanelToggle');
        const quickPanel = document.getElementById('quickCmdPanel');
        const btnQuickToggle = document.getElementById('btnQuickToggle');
        const webPreviewUrl = document.getElementById('webPreviewUrl');
        const btnOpenBoxWeb = document.getElementById('btnOpenBoxWeb');
        const boxWebTargetButtons = document.querySelectorAll('[data-box-web-target]');
        const QUICK_PANEL_KEY = 'ssh_terminal_quick_open_v1';
        const WEB_PREVIEW_URL_KEY = `ssh_terminal_web_preview_url_v2_${boxId || 'default'}`;
        const BOX_WEB_PROXY_KEY_QUERY = '__box_proxy_key';
        const BOX_WEB_PROXY_TARGET_QUERY = '__box_proxy_target';

        const setPill = (el, label, text, ok) => {
            el.textContent = `${label}: ${text}`;
            if (ok === true) {
                el.dataset.ok = '1';
            } else if (ok === false) {
                el.dataset.ok = '0';
            } else {
                el.dataset.ok = '-';
            }
        };
        const setWsStatus = (text, ok) => setPill(wsStatusEl, 'WS', text, ok);
        const setSshStatus = (text, ok) => {
            setPill(sshStatusEl, 'SSH', text, ok);
            updateActionAvailability();
        };
        const setSftpStatus = (text, ok) => {
            setPill(sftpStatusEl, 'SFTP', text, ok);
            updateActionAvailability();
        };

        // ===== 3) xterm =====
        if (typeof Terminal === 'undefined') { alert('Terminal 未定义:请确认 xterm.js 路径'); return; }
        if (!window.FitAddon || !window.FitAddon.FitAddon) { alert('FitAddon 未定义'); return; }

        const term = new Terminal({
            cursorBlink: true,
            disableStdin: true,
            fontSize: 14,
            fontFamily: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
            lineHeight: 1.2,
            scrollback: 8000,
            convertEol: false,
            windowsMode: true,
            theme: { background: '#081120', foreground: '#e5e7eb' }
        });
        term.options.backspaceAsControlH = false;
        const fitAddon = new FitAddon.FitAddon();
        term.loadAddon(fitAddon);
        const terminalEl = document.getElementById('terminal');
        term.open(terminalEl);
        fitAddon.fit();
        term.focus();
        terminalEl?.addEventListener('mousedown', () => {
            setTimeout(() => term.focus(), 0);
        });
        terminalEl?.addEventListener('click', () => term.focus());

        const sendTerminalKeyPayload = (payload) => {
            if (!payload) return true;

            if (!sshConnected || !isWsOpen()) {
                return true;
            }

            sendTerminalInput(payload, true);
            return true;
        };
        const ownTerminalKeyEvent = (event) => {
            event.preventDefault();
            event.stopPropagation();
            if (typeof event.stopImmediatePropagation === 'function') {
                event.stopImmediatePropagation();
            }
        };
        const forwardTerminalBrowserShortcut = (event) => {
            if (event.type !== 'keydown') return false;
            if (event.isComposing) return false;
            const key = (event.key || '').toLowerCase();

            if (key === 'tab' && !event.ctrlKey && !event.metaKey && !event.altKey) {
                ownTerminalKeyEvent(event);
                return sendTerminalKeyPayload(event.shiftKey ? '\x1b[Z' : '\t');
            }

            const isCtrlC = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && key === 'c';
            if (isCtrlC) {
                if (typeof term.hasSelection === 'function' && term.hasSelection()) {
                    return false;
                }
                ownTerminalKeyEvent(event);
                return sendTerminalKeyPayload('\x03');
            }

            return false;
        };
        const terminalTextArea = term.textarea || terminalEl?.querySelector('textarea');
        terminalTextArea?.addEventListener('keydown', forwardTerminalBrowserShortcut, true);
        if (typeof term.attachCustomKeyEventHandler === 'function') {
            term.attachCustomKeyEventHandler((event) => {
                if (forwardTerminalBrowserShortcut(event)) {
                    return false;
                }
                return true;
            });
        }

        const TERMINAL_LOW_LATENCY_MODE = true;
        const ENABLE_TERMINAL_STATUS_LOG = false;

        const println = (s) => term.writeln(s);
        const print = (s) => {
            if (!s) return;
            term.write(s);
        };

        const printStatus = (message) => {
            if (!ENABLE_TERMINAL_STATUS_LOG || !message) return;
            println(message);
        };

        if (!boxId) {
            println('\x1b[31m[ERROR]\x1b[0m URL 缺少 ?id=xxx,将无法连接 SSH/SFTP。\r\n');
        }
        if (TERMINAL_STRONG_RESTRICTION) {
            println('\x1b[33m[安全模式]\x1b[0m 终端强限制已开启:允许白名单内的只读诊断命令(可点击页面顶部“查看只读白名单”);SFTP 禁止上传、删除、改名和新建。\r\n');
        }

        // ===== 4) WS =====
        let ws = null;
        let wsOpenPromise = null;
        let sshConnected = false;
        let sftpConnected = false;
        let sftpPanelVisible = false;
        let selectedPaths = new Set();
        let currentOp = { type: null, path: null, cancelled: false }; // upload/download
        let userDisconnectedSsh = false; // 标记:用户是否主动断开SSH(默认false=允许自动连)
        let userDisconnectedSftp = false;
        let pageIsLeaving = false;
        let reconnectTimer = null;
        let reconnectTimes = 0;
        let wsHeartbeatTimer = null;
        let wsLastPongAt = 0;
        const WS_HEARTBEAT_INTERVAL_MS = 25 * 1000;
        const terminalInputState = {
            buffer: '',
            timer: null,
            flushDelayMs: 8,
            maxBatchSize: 256,
        };
        const RESIZE_SYNC_DELAYS = [0, 120, 360];
        const lastTerminalSize = { cols: 0, rows: 0 };
        let fitTicking = false;

        const sshDependentButtonIds = [
            'btn-auth-check',
            'btn-hosts-check',
            'btn-hosts-repair',
            'btn-algorithm-auth',
            'btn-reboot',
            'btn-aibox-update',
            'btn-register-sn',
        ];
        const sftpDependentButtonIds = [
            'btnBack',
            'btnRefresh',
            'btnDownloadSelected',
            'btnNewFolder',
            'btnRename',
            'btnGo',
            'btnPick',
            'btnUpload',
            'pathInput',
            'searchInput',
            'uploadFile',
        ];
        const sshRestrictedButtonIds = [
            'btn-auth-check',
            'btn-hosts-repair',
            'btn-algorithm-auth',
            'btn-reboot',
            'btn-aibox-update',
            'btn-register-sn',
        ];
        const sftpRestrictedButtonIds = [
            'btnNewFolder',
            'btnRename',
            'btnPick',
            'btnUpload',
            'uploadFile',
        ];

        function setQuickPanelVisible(visible) {
            if (!quickPanel) return;
            quickPanel.classList.toggle('hidden', !visible);
            if (btnQuickToggle) {
                btnQuickToggle.textContent = visible ? '⚡ 收起快捷命令' : '⚡ 展开快捷命令';
            }
            localStorage.setItem(QUICK_PANEL_KEY, visible ? '1' : '0');
        }

        function setElementsDisabled(ids, disabled) {
            ids.forEach((id) => {
                const el = document.getElementById(id);
                if (!el) return;
                el.disabled = disabled;
            });
        }

        function updateSftpPanelToggle() {
            if (!btnSftpPanelToggle) return;
            const canToggle = sftpConnected || sftpPanelVisible;
            btnSftpPanelToggle.hidden = !canToggle;
            btnSftpPanelToggle.disabled = !canToggle;
            btnSftpPanelToggle.textContent = sftpPanelVisible ? '📁 收起 SFTP' : '📁 展开 SFTP';
        }

        function setSftpPanelVisible(visible, resize = true) {
            sftpPanelVisible = Boolean(visible);
            pageWrap?.classList.toggle('sftp-hidden', !sftpPanelVisible);
            sftpPanel?.setAttribute('aria-hidden', sftpPanelVisible ? 'false' : 'true');
            updateSftpPanelToggle();

            if (resize) {
                syncTerminalSize();
            }
        }

        function updateActionAvailability() {
            const terminalInputDisabled = !sshConnected || !isWsOpen();
            if (term.options.disableStdin !== terminalInputDisabled) {
                term.options.disableStdin = terminalInputDisabled;
            }
            terminalEl?.setAttribute('aria-disabled', terminalInputDisabled ? 'true' : 'false');

            setElementsDisabled(sshDependentButtonIds, !sshConnected);
            setElementsDisabled(sftpDependentButtonIds, !sftpConnected);
            if (TERMINAL_STRONG_RESTRICTION) {
                setElementsDisabled(sshRestrictedButtonIds, true);
                setElementsDisabled(sftpRestrictedButtonIds, true);
            }

            const btnDisconnect = document.getElementById('btnDisconnect');
            if (btnDisconnect) btnDisconnect.disabled = !sshConnected;
            const btnSftpDisconnect = document.getElementById('btnSftpDisconnect');
            if (btnSftpDisconnect) btnSftpDisconnect.disabled = !sftpConnected;
            updateSftpPanelToggle();
            updateSelectedDownloadUi();
        }

        function normalizeBoxWebTarget(rawValue) {
            const raw = (rawValue || '').trim();
            if (!raw) return null;

            const prefixed = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;

            try {
                const parsed = new URL(prefixed);
                if (!['http:', 'https:'].includes(parsed.protocol)) {
                    return null;
                }
                return parsed.toString();
            } catch (err) {
                return null;
            }
        }

        async function buildBoxWebProxyUrl(targetUrl) {
            if (!boxId || !targetUrl) return null;

            const response = await fetchWithAlert('/api/general/box-web-proxy-access', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Accept': 'application/json',
                },
                body: JSON.stringify({
                    box_id: Number(boxId),
                    target: targetUrl,
                }),
                skipGlobal422: true,
            });
            const responseErrorMessage = !response.ok
                ? await window.getResponseErrorMessage(response, '无法获取盒子网页访问凭证')
                : '';
            const payload = await response.json().catch(() => ({}));
            if (!response.ok || !payload.access_key) {
                throw new Error(responseErrorMessage || payload.message || '无法获取盒子网页访问凭证');
            }

            const rawPath = `/box-web-proxy/${encodeURIComponent(boxId)}/`;
            const baseUrl = typeof window.appUrl === 'function' ? window.appUrl(rawPath) : rawPath;
            const proxyUrl = new URL(baseUrl, window.location.origin);
            proxyUrl.searchParams.set(BOX_WEB_PROXY_KEY_QUERY, payload.access_key);
            proxyUrl.searchParams.set(BOX_WEB_PROXY_TARGET_QUERY, targetUrl);
            return proxyUrl.toString();
        }

        async function openBoxWebTarget(rawTargetUrl, remember = false) {
            if (!boxId) {
                toast('当前页面缺少盒子 ID', 'warn');
                return false;
            }

            const normalizedTargetUrl = normalizeBoxWebTarget(rawTargetUrl || '');
            if (!normalizedTargetUrl) {
                toast('设备网页地址不合法', 'warn');
                webPreviewUrl?.focus();
                return false;
            }

            const popup = window.open('about:blank', '_blank');
            if (!popup) {
                toast('浏览器阻止了新窗口,请允许弹窗后重试', 'warn');
                return false;
            }
            popup.opener = null;

            try {
                popup.document.title = '正在打开盒子网页';
                popup.document.body.textContent = '正在获取短时访问凭证...';

                const targetUrl = await buildBoxWebProxyUrl(normalizedTargetUrl);
                if (!targetUrl) {
                    throw new Error('设备网页地址不合法');
                }

                if (remember) {
                    localStorage.setItem(WEB_PREVIEW_URL_KEY, normalizedTargetUrl);
                }

                popup.location.replace(targetUrl);
                return true;
            } catch (error) {
                popup.close();
                toast(error?.message || '盒子网页打开失败', 'warn');
                return false;
            }
        }

        function clearPendingTerminalInput() {
            if (terminalInputState.timer) {
                clearTimeout(terminalInputState.timer);
                terminalInputState.timer = null;
            }
            terminalInputState.buffer = '';
        }

        function flushPendingTerminalInput() {
            if (terminalInputState.timer) {
                clearTimeout(terminalInputState.timer);
                terminalInputState.timer = null;
            }
            if (TERMINAL_LOW_LATENCY_MODE) {
                terminalInputState.buffer = '';
                return false;
            }
            if (!terminalInputState.buffer) return false;

            const payload = terminalInputState.buffer;
            terminalInputState.buffer = '';

            if (!isWsOpen()) return false;
            ws.send(JSON.stringify({ action: 'input', data: payload }));
            return true;
        }

        function shouldSendTerminalInputImmediately(data) {
            if (typeof data !== 'string' || data === '') return false;
            if (data.length >= terminalInputState.maxBatchSize) return true;
            return /[\r\n\x00-\x1f\x7f]/.test(data) || data.includes('\x1b');
        }

        function sendTerminalInput(data, immediate = false) {
            if (typeof data !== 'string' || data === '') return false;
            if (!isWsOpen()) return false;

            if (TERMINAL_LOW_LATENCY_MODE) {
                ws.send(JSON.stringify({ action: 'input', data }));
                return true;
            }

            // 普通可打印字符短暂缓冲后合并发送;Tab / Enter / 方向键 / Ctrl 组合键等特殊输入立即发送。
            if (immediate || shouldSendTerminalInputImmediately(data)) {
                flushPendingTerminalInput();
                ws.send(JSON.stringify({ action: 'input', data }));
                return true;
            }

            terminalInputState.buffer += data;
            if (terminalInputState.buffer.length >= terminalInputState.maxBatchSize) {
                return flushPendingTerminalInput();
            }

            if (!terminalInputState.timer) {
                terminalInputState.timer = setTimeout(() => flushPendingTerminalInput(), terminalInputState.flushDelayMs);
            }
            return true;
        }

        const savedQuickState = localStorage.getItem(QUICK_PANEL_KEY);
        const shouldOpenQuick = savedQuickState === null ? window.innerWidth > 1200 : savedQuickState === '1';
        setQuickPanelVisible(shouldOpenQuick);
        const savedWebPreviewUrl = localStorage.getItem(WEB_PREVIEW_URL_KEY);
        if (webPreviewUrl && savedWebPreviewUrl && !webPreviewUrl.value) {
            webPreviewUrl.value = savedWebPreviewUrl;
        }
        btnQuickToggle?.addEventListener('click', () => {
            const isOpen = !quickPanel?.classList.contains('hidden');
            setQuickPanelVisible(!isOpen);
            setTimeout(() => sendResize(), 80);
        });
        btnSftpPanelToggle?.addEventListener('click', () => {
            setSftpPanelVisible(!sftpPanelVisible);
        });
        btnOpenBoxWeb?.addEventListener('click', () => {
            openBoxWebTarget(webPreviewUrl?.value || '', true);
        });
        boxWebTargetButtons.forEach((button) => {
            button.addEventListener('click', () => openBoxWebTarget(button.dataset.boxWebTarget || '', false));
        });
        webPreviewUrl?.addEventListener('keydown', (event) => {
            if (event.key !== 'Enter') return;
            event.preventDefault();
            btnOpenBoxWeb?.click();
        });

        if (window.ResizeObserver) {
            const ro = new ResizeObserver(() => sendResize());
            const wrapEl = document.getElementById('terminalWrap');
            const topbarEl = document.querySelector('.topbar');
            if (wrapEl) ro.observe(wrapEl);
            if (topbarEl) ro.observe(topbarEl);
        }

        updateActionAvailability();

        function flushCommandLogOnLeave() {
        }

        function isWsOpen(){ return ws && ws.readyState === WebSocket.OPEN; }

        function clearReconnectTimer() {
            if (reconnectTimer) {
                clearTimeout(reconnectTimer);
                reconnectTimer = null;
            }
        }

        function clearWsHeartbeat() {
            if (wsHeartbeatTimer) {
                clearInterval(wsHeartbeatTimer);
                wsHeartbeatTimer = null;
            }
        }

        function startWsHeartbeat() {
            clearWsHeartbeat();
            wsLastPongAt = Date.now();
            wsHeartbeatTimer = setInterval(() => {
                if (!isWsOpen()) {
                    clearWsHeartbeat();
                    return;
                }

                send('ping', { ts: Date.now() });
            }, WS_HEARTBEAT_INTERVAL_MS);
        }

        function triggerSftpConnect() {
            if (!isWsOpen() || sftpConnected) return false;
            setSftpPanelVisible(true);
            setSftpStatus('连接中...', false);
            toast('SFTP 连接中...', 'warn');

            send('sftp_connect');
            return true;
        }

        function disposeWsForPageLeave() {
            if (pageIsLeaving) return;
            pageIsLeaving = true;
            clearPendingTerminalInput();
            clearReconnectTimer();
            clearWsHeartbeat();
            wsOpenPromise = null;

            if (!ws) return;

            try {
                if (ws.readyState === WebSocket.OPEN) {
                    ws.send(JSON.stringify({ action: 'disconnect' }));
                }
            } catch (err) {
            }

            try {
                if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
                    ws.close(1000, 'page leaving');
                }
            } catch (err) {
            }
        }

        function ensureWSOpen(){
            if (pageIsLeaving) return Promise.resolve(false);
            if (isWsOpen()) return Promise.resolve(true);
            if (wsOpenPromise) return wsOpenPromise;

            setWsStatus('连接中...', false);
            setSshStatus('-', false);
            setSftpStatus('-', false);
            sshConnected = false;
            sftpConnected = false;
            setSftpPanelVisible(false, false);

            wsOpenPromise = new Promise((resolve) => {
                ws = new WebSocket(WS_URL);

                ws.onopen = () => {
                    if (pageIsLeaving) {
                        wsOpenPromise = null;
                        try { ws.close(1000, 'page leaving'); } catch (err) {}
                        resolve(false);
                        return;
                    }

                    reconnectTimes = 0;
                    clearReconnectTimer();
                    startWsHeartbeat();
                    setWsStatus('已连接', true);
                    printStatus('\r\n\x1b[32m[WS] WebSocket连接成功\x1b[0m\r\n');
                    if (boxId) {
                        const shouldAutoConnectSsh = !userDisconnectedSsh;

                        if (shouldAutoConnectSsh) {
                            sendSshConnect();
                            setSshStatus('自动连接中...', false);
                            printStatus('\x1b[36m[自动连接] 开始连接SSH\x1b[0m');
                        }

                        setSftpStatus('待手动连接', false);
                    } else {
                        printStatus('\x1b[33m[提示] 缺少boxId,跳过自动连接SSH/SFTP\x1b[0m');
                    }

                    resolve(true);
                };

                ws.onmessage = (e) => {
                    let msg;
                    try { msg = JSON.parse(e.data); } catch { return; }

                    if (msg.type === 'pong') {
                        wsLastPongAt = Date.now();
                        return;
                    }

                    if (msg.type === 'output') {
                        print(msg.data);
                        return;
                    }

                    if (msg.type === 'connected') {
                        sshConnected = true;
                        userDisconnectedSsh = false;
                        setSshStatus('已连接', true);
                        onSshResultToFav('ok', msg.msg);
                        printStatus(`\r\n\x1b[32m[SSH] ${msg.msg}\x1b[0m\r\n`);
                        term.focus();
                        syncTerminalSize();
                        setSftpStatus(sftpConnected ? '已连接' : '待手动连接', sftpConnected);
                        return;
                    }




                    if (msg.type === 'sftp') { handleSftpMsg(msg); return; }

                    if (msg.type === 'system') {
                        const systemMsg = String(msg.msg || '');
                        if (systemMsg === 'SSH disconnected.') {
                            clearPendingTerminalInput();
                            sshConnected = false;
                            sftpConnected = false;
                            setSshStatus('已断开', false);
                            setSftpStatus('待手动连接', false);
                            setSftpPanelVisible(false, false);
                        }
                        printStatus(`\r\n\x1b[36m[系统] ${msg.msg}\x1b[0m\r\n`);
                        return;
                    }

                    if (msg.type === 'error') {
                        // 可能来自 SSH 或 SFTP
                        setSshStatus(sshConnected ? '已连接' : '错误', sshConnected);
                        setSftpStatus(sftpConnected ? '已连接' : '错误', sftpConnected);
                        if (typeof currentOp !== 'undefined' && currentOp.type === 'download') {
                            downloading = null;
                            clearOp('下载失败');
                        }
                        onSshResultToFav('bad', msg.msg);
                        toast(msg.msg, 'bad');
                        println(`\r\n\x1b[31m[错误] ${msg.msg}\x1b[0m\r\n`);
                        return;
                    }
                };

                ws.onclose = (event) => {
                    wsOpenPromise = null;
                    clearWsHeartbeat();
                    if (pageIsLeaving) {
                        return;
                    }

                    clearPendingTerminalInput();
                    sshConnected = false;
                    sftpConnected = false;
                    setWsStatus('已断开', false);
                    setSshStatus('-', false);
                    setSftpStatus('-', false);
                    const delay = Math.min(10000, 3000 + reconnectTimes * 1200);
                    const closeCode = event?.code ? ` code=${event.code}` : '';
                    const closeReason = event?.reason ? ` ${event.reason}` : '';
                    console.warn(`[WS] closed${closeCode}${closeReason}`);
                    toast(`WebSocket已断开,${Math.ceil(delay / 1000)}秒后重连`, 'bad');
                    println(`\r\n\x1b[31m[WS] WebSocket已断开,${Math.ceil(delay / 1000)}秒后重连\x1b[0m\r\n`);
                    reconnectTimes += 1;
                    reconnectTimer = setTimeout(() => ensureWSOpen(), delay);
                };

                ws.onerror = () => {
                    wsOpenPromise = null;
                    clearWsHeartbeat();
                    if (pageIsLeaving) {
                        resolve(false);
                        return;
                    }

                    clearPendingTerminalInput();
                    sshConnected = false;
                    sftpConnected = false;
                    setWsStatus('连接失败', false);
                    setSshStatus('-', false);
                    setSftpStatus('-', false);
                    toast('WebSocket连接失败', 'bad');
                    println('\r\n\x1b[31m[WS] WebSocket连接失败\x1b[0m\r\n');
                    resolve(false);
                };
            });

            return wsOpenPromise;
        }

        function send(action, payload = {}){
            if (!isWsOpen()) return false;
            ws.send(JSON.stringify({ action, ...payload }));
            return true;
        }

        function sendSshConnect(payload = {}) {
            fitAddon.fit();
            const cols = term.cols;
            const rows = term.rows;
            lastTerminalSize.cols = cols;
            lastTerminalSize.rows = rows;
            return send('connect_ssh', { cols, rows, ...payload });
        }


        // ===================== Favorites (localStorage) =====================
        const FAV_KEY = 'ssh_favorites_v1';

        function loadFavs(){
            try { return JSON.parse(localStorage.getItem(FAV_KEY) || '[]') || []; }
            catch { return []; }
        }
        function saveFavs(arr){
            localStorage.setItem(FAV_KEY, JSON.stringify(arr || []));
        }
        async function autoConnectFromFavQuery(){
            if (!favId) return;

            const favsNow = loadFavs();
            const it = favsNow.find(x => x.id === favId);
            if (!it) {
                println(`\r\n\x1b[31m[FAV]\x1b[0m 未找到收藏:${favId}\r\n`);
                return;
            }

            // 可选:标题显示设备名,多个 tab 不迷路
            document.title = (it.name || it.host || 'SSH Terminal') + ' - SSH';

            const ok = await ensureWSOpen();
            if (!ok) return;

            println(`\r\n\x1b[36m[FAV]\x1b[0m Auto connect: ${it.name || it.host}\r\n`);

            // 记录一下,connected/error 时写回 lastStatus
            favPendingConnectId = it.id;

            const auth = (it.authType === 'key')
                ? { type:'key', privateKey: it.privateKey || '', passphrase: it.passphrase || '' }
                : { type:'password', password: it.password || '' };

            sendSshConnect({
                host: it.host,
                user: it.user,
                auth,
                port: it.port ? Number(it.port) : undefined,
                name: it.name || undefined,
            });

            userDisconnectedSsh = false;
            setSshStatus('连接中...', false);
            toast(`连接中:${it.name || it.host}`, 'warn');
        }

        function uuid(){
            return 'f_' + Math.random().toString(16).slice(2) + Date.now().toString(16);
        }
        function nowISO(){
            return new Date().toISOString();
        }

        let favs = loadFavs();
        let favSelectedId = null;        // 当前编辑的设备id
        let favPendingConnectId = null;  // 本次连接发起所用的设备id(用于写回结果)

// modal els
        const favMask = document.getElementById('favMask');
        const favModal = document.getElementById('favModal');
        const btnFav = document.getElementById('btnFav');
        const btnFavClose = document.getElementById('btnFavClose');
        const favTbody = document.getElementById('favTbody');
        const favSearch = document.getElementById('favSearch');

        const f_name = document.getElementById('f_name');
        const f_port = document.getElementById('f_port');
        const f_host = document.getElementById('f_host');
        const f_user = document.getElementById('f_user');
        const f_password = document.getElementById('f_password');
        const f_privateKey = document.getElementById('f_privateKey');
        const f_passphrase = document.getElementById('f_passphrase');
        const f_note = document.getElementById('f_note');

        const authPasswordWrap = document.getElementById('authPasswordWrap');
        const authKeyWrap = document.getElementById('authKeyWrap');

        const btnFavAdd = document.getElementById('btnFavAdd');
        const btnFavSave = document.getElementById('btnFavSave');
        const btnFavDelete = document.getElementById('btnFavDelete');
        const btnFavConnect = document.getElementById('btnFavConnect');
        const btnFavImport = document.getElementById('btnFavImport');
        const btnFavExport = document.getElementById('btnFavExport');
        const btnFavClearStatus = document.getElementById('btnFavClearStatus');

        const favLastStatus = document.getElementById('favLastStatus');
        const favLastMsg = document.getElementById('favLastMsg');
        const favLastAt = document.getElementById('favLastAt');
        const favEditHint = document.getElementById('favEditHint');

        function openFav(){
            favMask.style.display = 'block';
            favModal.style.display = 'block';
            renderFavTable();
            syncAuthUI();
        }
        function closeFav(){
            favMask.style.display = 'none';
            favModal.style.display = 'none';
        }
        btnFav?.addEventListener('click', openFav);
        btnFavClose?.addEventListener('click', closeFav);
        favMask?.addEventListener('click', closeFav);

        function getAuthType(){
            const r = document.querySelector('input[name="authType"]:checked');
            return r ? r.value : 'password';
        }
        function setAuthType(v){
            document.querySelectorAll('input[name="authType"]').forEach(x => x.checked = (x.value === v));
            syncAuthUI();
        }
        function syncAuthUI(){
            const t = getAuthType();
            authPasswordWrap.style.display = (t === 'password') ? 'block' : 'none';
            authKeyWrap.style.display = (t === 'key') ? 'block' : 'none';
        }

        document.querySelectorAll('input[name="authType"]').forEach(r => {
            r.addEventListener('change', syncAuthUI);
        });

        function fmtLastAt(v){
            if (!v) return '-';
            try {
                const d = new Date(v);
                const pad = (x)=>String(x).padStart(2,'0');
                return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
            } catch { return String(v); }
        }

        function renderFavTable(){
            const q = (favSearch?.value || '').trim().toLowerCase();
            const list = favs.filter(it => {
                if (!q) return true;
                return (it.name||'').toLowerCase().includes(q)
                    || (it.host||'').toLowerCase().includes(q)
                    || (it.user||'').toLowerCase().includes(q);
            });

            favTbody.innerHTML = '';
            list.forEach(it => {
                const tr = document.createElement('tr');
                const statusDot = it.lastStatus === 'ok' ? '🟢' : (it.lastStatus === 'bad' ? '🔴' : '⚪');
                tr.innerHTML = `
      <td style="width:36%;">
        <div style="display:flex; align-items:center; gap:8px; min-width:0;">
          <span title="${it.lastStatus||''}">${statusDot}</span>
          <span style="color:#e5e7eb; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${it.name || '-'}</span>
        </div>
      </td>
      <td style="width:32%;" class="meta">${it.host || '-'}</td>
      <td style="width:18%;" class="meta">${it.user || '-'}</td>
      <td style="width:14%;">
        <div class="ops">
          <button class="iconbtn" data-act="edit" title="编辑">
            <svg viewBox="0 0 24 24" fill="none"><path d="M4 20h4l10-10-4-4L4 16v4z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
          </button>
          <button class="iconbtn" data-act="use" title="连接">
            <svg viewBox="0 0 24 24" fill="none"><path d="M7 7h10v10H7z" stroke="currentColor" stroke-width="2"/><path d="M3 12h4m10 0h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
          </button>
        </div>
      </td>
    `;
                tr.querySelector('[data-act="edit"]').addEventListener('click', () => selectFav(it.id));
                tr.querySelector('[data-act="use"]').addEventListener('click', () => {
                    selectFav(it.id);
                    openFavInNewTab(it.id);
                });

                tr.addEventListener('click', () => selectFav(it.id));
                favTbody.appendChild(tr);
            });

            if (favs.length && !favSelectedId) {
                selectFav(favs[0].id);
            } else if (!favs.length) {
                clearFavForm();
            }
        }
        favSearch?.addEventListener('input', renderFavTable);

        function clearFavForm(){
            favSelectedId = null;
            favEditHint.textContent = '选择左侧设备或新增';
            f_name.value = '';
            f_port.value = '';
            f_host.value = '';
            f_user.value = '';
            f_password.value = '';
            f_privateKey.value = '';
            f_passphrase.value = '';
            f_note.value = '';
            setAuthType('password');
            favLastStatus.textContent = '-';
            favLastMsg.textContent = '-';
            favLastAt.textContent = '-';
        }

        function selectFav(id){
            const it = favs.find(x => x.id === id);
            if (!it) return;

            favSelectedId = id;
            favEditHint.textContent = `编辑:${it.name || it.host || it.id}`;

            f_name.value = it.name || '';
            f_port.value = it.port || '';
            f_host.value = it.host || '';
            f_user.value = it.user || '';
            f_note.value = it.note || '';

            setAuthType(it.authType || 'password');
            f_password.value = it.password || '';
            f_privateKey.value = it.privateKey || '';
            f_passphrase.value = it.passphrase || '';

            favLastStatus.textContent = it.lastStatus || '-';
            favLastMsg.textContent = it.lastMessage || '-';
            favLastAt.textContent = fmtLastAt(it.lastAt);
        }

        function readFavForm(){
            const authType = getAuthType();
            const obj = {
                id: favSelectedId || uuid(),
                name: (f_name.value || '').trim(),
                host: (f_host.value || '').trim(),
                user: (f_user.value || '').trim(),
                port: (f_port.value || '').trim(),
                authType,
                password: authType === 'password' ? (f_password.value || '') : '',
                privateKey: authType === 'key' ? (f_privateKey.value || '') : '',
                passphrase: authType === 'key' ? (f_passphrase.value || '') : '',
                note: (f_note.value || ''),

                // keep status fields if exists
                lastStatus: undefined,
                lastMessage: undefined,
                lastAt: undefined,
            };

            // basic validate (front-end)
            if (!obj.host) { toast('Host 不能为空', 'warn'); return null; }
            if (!obj.user) { toast('用户不能为空', 'warn'); return null; }
            if (authType === 'password' && !obj.password) { toast('密码不能为空(或改用私钥)', 'warn'); return null; }
            if (authType === 'key' && !obj.privateKey) { toast('私钥不能为空(或改用密码)', 'warn'); return null; }

            return obj;
        }

        btnFavAdd?.addEventListener('click', () => {
            clearFavForm();
            f_name.focus();
        });

        btnFavSave?.addEventListener('click', () => {
            const obj = readFavForm();
            if (!obj) return;

            const old = favs.find(x => x.id === obj.id);
            if (old) {
                obj.lastStatus = old.lastStatus;
                obj.lastMessage = old.lastMessage;
                obj.lastAt = old.lastAt;
                favs = favs.map(x => x.id === obj.id ? obj : x);
            } else {
                favs.unshift(obj);
            }
            saveFavs(favs);
            toast('已保存到收藏夹', 'ok');
            renderFavTable();
            selectFav(obj.id);
        });

        btnFavDelete?.addEventListener('click', () => {
            if (!favSelectedId) return toast('未选择设备', 'warn');
            const it = favs.find(x => x.id === favSelectedId);
            if (!it) return;
            if (!confirm(`确定删除设备:${it.name || it.host} ?`)) return;
            favs = favs.filter(x => x.id !== favSelectedId);
            saveFavs(favs);
            toast('已删除', 'ok');
            favSelectedId = null;
            renderFavTable();
            clearFavForm();
        });

        btnFavClearStatus?.addEventListener('click', () => {
            favs = favs.map(x => ({...x, lastStatus: undefined, lastMessage: undefined, lastAt: undefined}));
            saveFavs(favs);
            toast('已清空状态', 'ok');
            renderFavTable();
            if (favSelectedId) selectFav(favSelectedId);
        });

// Export / Import
        btnFavExport?.addEventListener('click', () => {
            const data = JSON.stringify(favs, null, 2);
            const blob = new Blob([data], {type:'application/json'});
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'ssh_favorites.json';
            document.body.appendChild(a);
            a.click();
            a.remove();
            URL.revokeObjectURL(url);
            toast('已导出 JSON', 'ok');
        });

        btnFavImport?.addEventListener('click', async () => {
            const text = prompt('粘贴要导入的 JSON(会与现有合并,id 相同则覆盖)');
            if (!text) return;
            try {
                const arr = JSON.parse(text);
                if (!Array.isArray(arr)) throw new Error('JSON不是数组');

                // merge by id
                const map = new Map(favs.map(x => [x.id, x]));
                for (const it of arr) {
                    if (!it || typeof it !== 'object') continue;
                    if (!it.id) it.id = uuid();
                    map.set(it.id, {...map.get(it.id), ...it});
                }
                favs = Array.from(map.values());
                saveFavs(favs);
                toast('导入成功', 'ok');
                renderFavTable();
            } catch (e) {
                toast('导入失败:JSON格式不正确', 'bad');
            }
        });

// ===== Connect with selected fav =====
        async function connectWithSelectedFav(){
            const it = favs.find(x => x.id === favSelectedId);
            if (!it) return toast('未选择设备', 'warn');

            // 确保 WS
            const ok = await ensureWSOpen();
            if (!ok) return;

            // 让 UI 也同步一下(可选)
            println(`\r\n\x1b[36m[FAV]\x1b[0m Using favorite: ${it.name || it.host}\r\n`);

            // 标记本次连接来源,后面 connected/error 要写回
            favPendingConnectId = it.id;

            // 你说后端支持 3 个参数:这里给你一个清晰的 payload
            // 你可按后端最终字段名调整:例如 host/user/auth 或 ip/username/password 等
            const auth = (it.authType === 'key')
                ? { type:'key', privateKey: it.privateKey || '', passphrase: it.passphrase || '' }
                : { type:'password', password: it.password || '' };

            sendSshConnect({
                // === three params (example) ===
                host: it.host,
                user: it.user,
                auth,

                // optional
                port: it.port ? Number(it.port) : undefined,
                name: it.name || undefined,
            });

            setSshStatus('连接中...', false);
            toast(`连接中:${it.name || it.host}`, 'warn');
        }

        function openFavInNewTab(id){
            const url = new URL(window.location.href);

            // 用 fav 参数标识:新页从 localStorage 读配置再连接
            url.searchParams.set('fav', id);

            // 重要:不要把密码/私钥放 URL
            window.open(url.toString(), '_blank', 'noopener');
        }

        btnFavConnect?.addEventListener('click', () => {
            if (!favSelectedId) return toast('未选择设备', 'warn');
            openFavInNewTab(favSelectedId);
        });


// ===================== Hook WS events: write back status =====================
// 在你现有 ws.onmessage 里:connected/error 分支处加两行调用即可:
// onSshResultToFav('ok', msg.msg) / onSshResultToFav('bad', msg.msg)

        function onSshResultToFav(status, message){
            if (!favPendingConnectId) return;
            const id = favPendingConnectId;
            favPendingConnectId = null;

            const it = favs.find(x => x.id === id);
            if (!it) return;

            it.lastStatus = status;
            it.lastMessage = String(message || '');
            it.lastAt = nowISO();
            saveFavs(favs);

            // 如果弹窗开着,刷新右侧状态
            if (favModal.style.display !== 'none') {
                renderFavTable();
                if (favSelectedId === id) selectFav(id);
            }
        }



        // ===== Quick Command Runner (send terminal command) =====
        function executeTerminalCommand(cmd, noNewline = false){
            if (!sshConnected) {
                println("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n");
                return;
            }
            if (!isWsOpen()) {
                println("\r\n\x1b[31m[ERROR]\x1b[0m WS not open\r\n");
                return;
            }

            println(`\r\n\x1b[36m[CMD]\x1b[0m ${cmd}\r\n`);

            const payload = noNewline ? cmd : (cmd.endsWith('\n') ? cmd : (cmd + '\n'));
            sendTerminalInput(payload, true);
        }

// ===== 5 Buttons events =====
        document.getElementById('btn-auth-check')?.addEventListener('click', () => {
            executeTerminalCommand('cd /home/nle/app/sdk/lic/bin && ./licCheck');
        });

        document.getElementById('btn-hosts-check')?.addEventListener('click', () => {
            executeTerminalCommand('cat /etc/hosts');
        });

        document.getElementById('btn-hosts-repair')?.addEventListener('click', () => {
            executeTerminalCommand(`echo nle | sudo -S sed -i '/auth.newland.com.cn/d' /etc/hosts && echo "192.168.136.180 auth.newland.com.cn" | sudo tee -a /etc/hosts`);
        });

        document.getElementById('btn-algorithm-auth')?.addEventListener('click', () => {
            // 这里保持你原来的 Laravel Blade 写法
            executeTerminalCommand('{!! config('platform.algorithm_license_cmd') !!}');
        });

        document.getElementById('btn-reboot')?.addEventListener('click', () => {
            if (confirm("确认要重启当前设备吗?重启后终端连接会中断!")) {
                executeTerminalCommand('sudo reboot');
            }
        });
        document.getElementById('btn-aibox-update')?.addEventListener('click', () => {
            if (!sshConnected) {
                println("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n");
                return;
            }

            const ver = (document.getElementById('aiboxVer')?.value || '').trim();
            if (!ver) {
                toast('请输入版本号', 'warn');
                document.getElementById('aiboxVer')?.focus();
                return;
            }

            // 前两条执行(会回车)
            executeTerminalCommand('cd /home/nle');
            executeTerminalCommand('chmod 777 AiBox-Update.sh');

            // 拼接版本号:不回车(你说“你不要回车”)
            executeTerminalCommand(`./AiBox-Update.sh ${ver}`, true);

            println('\r\n\x1b[36m[CMD]\x1b[0m 已拼接版本号(未回车),需要你手动回车执行。\r\n');
        });

        document.getElementById('btn-register-sn')?.addEventListener('click', () => {
            if (!sshConnected) {
                println("\r\n\x1b[31m[ERROR]\x1b[0m SSH not connected\r\n");
                return;
            }

            const input = document.getElementById('boxSnInput');
            const sn = (input?.value || '').trim();
            if (!/^[A-Za-z0-9_-]{4,64}$/.test(sn)) {
                toast('请输入有效 SN 号,仅支持字母、数字、下划线和短横线', 'warn');
                input?.focus();
                return;
            }

            if (confirm(`确认要将 SN 号 ${sn} 写入当前设备吗?\n\n将发送 RegisterSNtoCloud 命令到下方 SSH 终端。`)) {
                executeTerminalCommand(`cd /usr/local/bin/NLRemoteUpdate/ && ./RegisterSNtoCloud ${sn}`);
            }
        });



        function doDelete(item){
            if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止删除文件', 'warn');
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            if (!item) return toast('无效项', 'warn');

            const target = joinPath(currentPath, item.name);
            if (!confirm(`确定删除:\n${target}\n\n`)) return;
            send('sftp_rm', { path: target });
        }

        function doDownload(item){
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            if (!item) return toast('无效项', 'warn');
            if (!isDownloadableItem(item)) return toast('目录不支持直接下载', 'warn');
            if (currentOp.type) return toast('当前有传输任务进行中,请先完成或取消', 'warn');

            const remote = joinPath(currentPath, item.name);

            startSingleDownload(remote);
        }

        function startSingleDownload(remote, queueMeta = null){
            setOp('download', remote);
            downloading = {
                path: remote,
                chunks: [],
                size: 0,
                receivedBytes: 0,
                queueMeta,
            };

            setProgress(0);
            if (queueMeta) {
                toast(`开始下载 ${queueMeta.index}/${queueMeta.total}: ${remote.split('/').pop() || remote}`, 'warn');
            } else {
                toast('开始下载...', 'warn');
            }
            send('sftp_download_begin', { path: remote, chunk: CHUNK });
        }

        function doDownloadSelected(){
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            if (currentOp.type) return toast('当前有传输任务进行中,请先完成或取消', 'warn');

            const downloadablePaths = new Set(
                allItems
                    .filter(isDownloadableItem)
                    .map(item => joinPath(currentPath, item.name))
            );
            const paths = Array.from(selectedPaths).filter(path => downloadablePaths.has(path));
            if (paths.length !== selectedPaths.size) {
                selectedPaths = new Set(paths);
                updateSelectedDownloadUi();
            }
            if (paths.length === 0) return toast('请先勾选要下载的文件', 'warn');

            if (paths.length === 1) {
                const onlyPath = paths[0];
                startSingleDownload(onlyPath, { index: 1, total: 1, fromSelection: true });
                return;
            }

            if (!confirm(`确定下载选中的 ${paths.length} 个文件吗?\n浏览器会按顺序逐个下载。`)) {
                return;
            }

            downloadQueue = paths.slice();
            downloadQueueTotal = downloadQueue.length;
            startNextQueuedDownload();
        }

        function startNextQueuedDownload(){
            if (!sftpConnected) {
                downloadQueue = [];
                downloadQueueTotal = 0;
                toast('SFTP 已断开,下载队列已停止', 'warn');
                return;
            }

            const nextPath = downloadQueue.shift();
            if (!nextPath) {
                downloadQueue = [];
                downloadQueueTotal = 0;
                selectedPaths.clear();
                updateSelectedDownloadUi();
                applySearch();
                clearOp('全部下载完成');
                return;
            }

            const index = downloadQueueTotal - downloadQueue.length;
            startSingleDownload(nextPath, {
                index,
                total: downloadQueueTotal,
            });
        }

        const btnCancelOp = document.getElementById('btnCancelOp');

        function setOp(type, path){
            currentOp.type = type;
            currentOp.path = path;
            currentOp.cancelled = false;
            btnCancelOp.disabled = false;
            btnCancelOp.textContent = `取消${type === 'upload' ? '上传' : '下载'}`;
            updateSelectedDownloadUi();
        }

        function clearOp(msg){
            currentOp.type = null;
            currentOp.path = null;
            currentOp.cancelled = false;
            btnCancelOp.disabled = true;
            btnCancelOp.textContent = '取消';
            if (msg) {
                toast(msg, 'warn');
                setProgress(0);
            }
            updateSelectedDownloadUi();
        }
        btnCancelOp.addEventListener('click', () => {
            if (!currentOp.type) return;

            currentOp.cancelled = true;

            if (currentOp.type === 'upload') {
                // 通知后端清理临时文件
                send('sftp_upload_cancel', { path: currentOp.path });
                clearOp('已取消上传');
            }

            if (currentOp.type === 'download') {
                // 通知后端取消下载协程
                send('sftp_download_cancel', { path: currentOp.path });
                downloadQueue = [];
                downloadQueueTotal = 0;
                downloading = null;
                clearOp('已取消下载');
            }
        });

        function publishResize(force = false) {
            const cols = term.cols;
            const rows = term.rows;
            if (!force && lastTerminalSize.cols === cols && lastTerminalSize.rows === rows) {
                return;
            }

            lastTerminalSize.cols = cols;
            lastTerminalSize.rows = rows;
            send('resize', { cols, rows });
        }

        function sendResize(force = false){
            if (force) {
                fitTicking = false;
                fitAddon.fit();
                publishResize(true);
                return;
            }
            if (fitTicking) return;
            fitTicking = true;
            requestAnimationFrame(() => {
                fitTicking = false;
                fitAddon.fit();
                publishResize(false);
            });
        }
        function syncTerminalSize(){
            RESIZE_SYNC_DELAYS.forEach((delay, index) => {
                setTimeout(() => sendResize(index === 0), delay);
            });
        }

        // ===== 5) SSH events (保留原逻辑) =====
        document.getElementById('btnConnect').addEventListener('click', async () => {
            if (!boxId) return alert('URL 缺少 ?id=xxx,不能连接。');
            const ok = await ensureWSOpen();
            if (!ok) return;
            userDisconnectedSsh = false;
            sendSshConnect();
            setSshStatus('连接中...', false);
        });

        document.getElementById('btnDisconnect').addEventListener('click', () => {
            clearPendingTerminalInput();
            send('disconnect');

            sshConnected = false;
            userDisconnectedSsh = true; // 标记:用户主动断开,后续WS重连不再自动连
            setSshStatus('已断开', false);
            printStatus('\x1b[33m[手动操作] 已主动断开SSH,后续WS重连不会自动连接\x1b[0m');
        });

        document.getElementById('btnClear').addEventListener('click', () => term.clear());


        term.onData((data) => {
            if (!sshConnected || !isWsOpen()) return;

            sendTerminalInput(data);
        });

        window.addEventListener('resize', () => sendResize());
        window.addEventListener('pagehide', () => {
            flushPendingTerminalInput();
            flushCommandLogOnLeave();
            disposeWsForPageLeave();
        });
        window.addEventListener('beforeunload', () => {
            flushPendingTerminalInput();
            flushCommandLogOnLeave();
            disposeWsForPageLeave();
        });

        // ===== 6) SFTP Modern UI =====
        const pathInput = document.getElementById('pathInput');
        const crumbsEl = document.getElementById('crumbs');
        const tbody = document.getElementById('fileTbody');
        const searchInput = document.getElementById('searchInput');
        const dropzone = document.getElementById('dropzone');
        const dropPathEl = document.getElementById('dropPath');
        const uploadFileEl = document.getElementById('uploadFile');
        const opHint = document.getElementById('opHint');
        const opBar = document.getElementById('opBar');
        const btnDownloadSelected = document.getElementById('btnDownloadSelected');
        const selectedCountEl = document.getElementById('selectedCount');
        const fileCheckAll = document.getElementById('fileCheckAll');

        let currentPath = '/';
        let allItems = [];
        let selected = null;

        // download state
        let downloading = null; // {path, chunks:[], size, receivedBytes}
        let downloadQueue = [];
        let downloadQueueTotal = 0;
        const CHUNK = 64 * 1024;

        function escapeHtml(value){
            return String(value ?? '')
                .replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#39;');
        }

        function toast(text, type='ok'){
            opHint.textContent = text;
            if (type === 'bad') opHint.style.color = 'rgba(252,165,165,.95)';
            else if (type === 'warn') opHint.style.color = 'rgba(253,224,71,.95)';
            else opHint.style.color = 'rgba(134,239,172,.95)';
            setTimeout(() => { opHint.style.color = 'var(--muted)'; }, 1800);
        }

        function setProgress(pct){
            opBar.style.width = `${Math.max(0, Math.min(100, pct))}%`;
        }

        function normalizePath(p){
            p = (p || '/').trim();
            if (!p) p = '/';
            if (!p.startsWith('/')) p = '/' + p;
            // normalize .. and .
            const parts = [];
            p.split('/').forEach(seg => {
                if (!seg || seg === '.') return;
                if (seg === '..') { parts.pop(); return; }
                parts.push(seg);
            });
            return '/' + parts.join('/');
        }

        function joinPath(base, name){
            base = normalizePath(base);
            if (!base.endsWith('/')) base += '/';
            return normalizePath(base + name);
        }

        function fmtSize(n){
            n = Number(n || 0);
            if (n < 1024) return `${n} B`;
            if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
            if (n < 1024*1024*1024) return `${(n/1024/1024).toFixed(1)} MB`;
            return `${(n/1024/1024/1024).toFixed(1)} GB`;
        }

        function fmtTime(ts){
            ts = Number(ts || 0);
            if (!ts) return '-';
            const d = new Date(ts * 1000);
            const pad = (x) => String(x).padStart(2,'0');
            return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
        }

        function isDownloadableItem(item){
            if (!item || item.is_dir) return false;
            return item.is_file !== false;
        }

        function buildCrumbs(path){
            path = normalizePath(path);
            const parts = path.split('/').filter(Boolean);

            crumbsEl.innerHTML = '';
            const rootBtn = document.createElement('button');
            rootBtn.textContent = 'Root';
            rootBtn.onclick = () => goList('/');
            crumbsEl.appendChild(rootBtn);

            let acc = '';
            parts.forEach((seg) => {
                const sep = document.createElement('span');
                sep.className = 'sep';
                sep.textContent = '›';
                crumbsEl.appendChild(sep);

                acc += '/' + seg;
                const btn = document.createElement('button');
                btn.textContent = seg;
                btn.onclick = () => goList(acc);
                crumbsEl.appendChild(btn);
            });
        }
        const COLS = { check: 7, name: 43, size: 18, mtime: 18, ops: 14 };

        function visibleFilePaths(){
            const body = document.getElementById('fileTbody');
            if (!body) return [];
            return [...body.querySelectorAll('.file-check')]
                .map(input => input.dataset.path)
                .filter(Boolean);
        }

        function updateSelectedDownloadUi(){
            const btn = document.getElementById('btnDownloadSelected');
            const countEl = document.getElementById('selectedCount');
            const checkAll = document.getElementById('fileCheckAll');
            const count = selectedPaths.size;
            const busy = typeof currentOp !== 'undefined' && !!currentOp.type;

            if (countEl) {
                countEl.textContent = `已选 ${count} 个文件`;
            }
            if (btn) {
                btn.textContent = count > 0 ? `⬇️ 下载选中(${count})` : '⬇️ 下载选中';
                btn.disabled = !sftpConnected || count === 0 || busy;
            }
            if (checkAll) {
                const visible = visibleFilePaths();
                const selectedVisible = visible.filter(path => selectedPaths.has(path)).length;
                checkAll.disabled = !sftpConnected || visible.length === 0;
                checkAll.checked = visible.length > 0 && selectedVisible === visible.length;
                checkAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length;
            }
        }

        function renderTable(items){
            tbody.innerHTML = '';
            selected = null;

            // parent row (..)
            if (currentPath !== '/') {
                const tr = document.createElement('tr');
                tr.innerHTML = `
  <td style="width:${COLS.check}%;" class="check-cell"></td>
  <td style="width:${COLS.name}%;">
    <div class="name-cell">
      <div class="icon">⬆️</div>
      <div class="fname">..</div>
    </div>
  </td>
  <td style="width:${COLS.size}%;" class="meta">DIR</td>
  <td style="width:${COLS.mtime}%;" class="meta">-</td>
  <td style="width:${COLS.ops}%;"></td>
`;

                tr.ondblclick = () => {
                    const parent = normalizePath(currentPath.replace(/\/+$/,'')).split('/').slice(0,-1).join('/') || '/';
                    goList(parent);
                };
                tbody.appendChild(tr);
            }

            items.forEach(it => {
                const tr = document.createElement('tr');
                const icon = it.is_dir ? '📁' : '📄';
                const pathKey = joinPath(currentPath, it.name);
                const safeName = escapeHtml(it.name);
                const checked = selectedPaths.has(pathKey) ? 'checked' : '';
                const canDownload = isDownloadableItem(it);

                tr.innerHTML = `
  <td style="width:${COLS.check}%;" class="check-cell">
    ${canDownload ? `<input type="checkbox" class="file-check" data-path="${escapeHtml(pathKey)}" ${checked} title="选择下载">` : ''}
  </td>
  <td style="width:${COLS.name}%;">
    <div class="name-cell">
      <div class="icon">${icon}</div>
      <div class="fname" title="${safeName}">${safeName}</div>
    </div>
  </td>
 <td style="width:${COLS.size}%;" class="meta size">${it.is_dir ? 'DIR' : fmtSize(it.size)}</td>

<td style="width:${COLS.mtime}%;" class="meta mtime">
  <span class="celltext">${fmtTime(it.mtime)}</span>
</td>


  <td style="width:${COLS.ops}%;">
    <div class="ops">
    ${canDownload ? `
  <button class="iconbtn" data-act="download" title="下载">
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M12 3v10m0 0l4-4m-4 4l-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
      <path d="M4 17v3h16v-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
    </svg>
  </button>
` : ''}
${TERMINAL_STRONG_RESTRICTION ? '' : `
<button class="iconbtn danger" data-act="delete" title="删除">
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
    <path d="M9 3h6m-8 4h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
    <path d="M10 11v7m4-7v7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
    <path d="M6 7l1 14h10l1-14" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
  </svg>
</button>
`}

    </div>
  </td>
`;


                // 行选中
                tr.onclick = () => {
                    [...tbody.querySelectorAll('tr')].forEach(x => x.classList.remove('active'));
                    tr.classList.add('active');
                    selected = it;
                };

                // 双击进目录
                tr.ondblclick = () => {
                    if (it.is_dir) goList(joinPath(currentPath, it.name));
                };

                // 操作按钮事件(阻止冒泡,避免影响选中/双击)
                tr.querySelectorAll('button[data-act]').forEach(btn => {
                    btn.addEventListener('click', (e) => {
                        e.stopPropagation();
                        const act = btn.getAttribute('data-act');
                        if (act === 'download') doDownload(it);
                        if (act === 'delete') doDelete(it);
                    });
                    btn.addEventListener('dblclick', (e) => e.stopPropagation());
                });

                const fileCheck = tr.querySelector('.file-check');
                if (fileCheck) {
                    fileCheck.addEventListener('click', (e) => e.stopPropagation());
                    fileCheck.addEventListener('change', () => {
                        if (fileCheck.checked) {
                            selectedPaths.add(pathKey);
                        } else {
                            selectedPaths.delete(pathKey);
                        }
                        updateSelectedDownloadUi();
                    });
                }

                tbody.appendChild(tr);
            });

            updateSelectedDownloadUi();

        }

        function applySearch(){
            const q = (searchInput.value || '').trim().toLowerCase();
            if (!q) { renderTable(allItems); return; }
            renderTable(allItems.filter(it => (it.name || '').toLowerCase().includes(q)));
        }

        async function goList(path){
            const ok = await ensureWSOpen();
            if (!ok) return;

            if (!sftpConnected) {
                toast('请先连接 SFTP', 'warn');
                return;
            }

            currentPath = normalizePath(path);
            pathInput.value = currentPath;
            dropPathEl.textContent = currentPath;

            setProgress(0);
            toast('加载目录中...', 'warn');
            send('sftp_list', { path: currentPath });
        }

        // ===== SFTP controls =====
        document.getElementById('btnSftpConnect').addEventListener('click', async () => {
            if (!boxId) return alert('URL 缺少 ?id=xxx,不能连接。');
            if (sftpConnected) {
                setSftpPanelVisible(true);
                return;
            }
            const ok = await ensureWSOpen();
            if (!ok) return;
            userDisconnectedSftp = false;
            triggerSftpConnect();
        });

        document.getElementById('btnSftpDisconnect').addEventListener('click', () => {
            send('sftp_disconnect');
            sftpConnected = false;
            userDisconnectedSftp = true;
            setSftpPanelVisible(false);
            setSftpStatus('已断开', false);
            toast('SFTP 已断开', 'warn');
            printStatus('\x1b[33m[手动操作] 已主动断开SFTP\x1b[0m');
        });

        document.getElementById('btnGo').addEventListener('click', () => {
            goList(pathInput.value || '/');
        });

        document.getElementById('btnRefresh').addEventListener('click', () => goList(currentPath));
        document.getElementById('btnBack').addEventListener('click', () => {
            const parent = normalizePath(currentPath.replace(/\/+$/,'')).split('/').slice(0,-1).join('/') || '/';
            goList(parent);
        });

        fileCheckAll.addEventListener('change', () => {
            const paths = visibleFilePaths();
            if (fileCheckAll.checked) {
                paths.forEach(path => selectedPaths.add(path));
            } else {
                paths.forEach(path => selectedPaths.delete(path));
            }
            tbody.querySelectorAll('.file-check').forEach(input => {
                input.checked = selectedPaths.has(input.dataset.path);
            });
            updateSelectedDownloadUi();
        });

        btnDownloadSelected.addEventListener('click', () => doDownloadSelected());

        document.getElementById('btnNewFolder').addEventListener('click', () => {
            if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止新建目录', 'warn');
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            const name = prompt('新目录名称(创建在当前目录下)');
            if (!name) return;
            const target = joinPath(currentPath, name);
            send('sftp_mkdir', { path: target });
        });



        document.getElementById('btnRename').addEventListener('click', () => {
            if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止重命名', 'warn');
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');
            if (!selected) return toast('请先选中一个文件/目录', 'warn');
            const oldPath = joinPath(currentPath, selected.name);
            const newName = prompt('新名称', selected.name);
            if (!newName) return;
            const newPath = joinPath(currentPath, newName);
            send('sftp_rename', { from: oldPath, to: newPath });
        });


        document.getElementById('btnPick').addEventListener('click', () => {
            if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止上传文件', 'warn');
            uploadFileEl.click();
        });
        document.getElementById('btnUpload').addEventListener('click', async () => {
            if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止上传文件', 'warn');
            const f = uploadFileEl.files && uploadFileEl.files[0];
            if (!f) return toast('请选择要上传的文件', 'warn');
            await uploadFile(f);
        });

        function debounce(fn, wait = 100) {
            let timer = null;
            return (...args) => {
                if (timer) clearTimeout(timer);
                timer = setTimeout(() => fn(...args), wait);
            };
        }

        const applySearchDebounced = debounce(applySearch, 100);
        searchInput.addEventListener('input', () => applySearchDebounced());
        pathInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') goList(pathInput.value || '/'); });

        window.addEventListener('keydown', (e) => {
            if (!e.ctrlKey || !e.shiftKey) return;
            const key = (e.key || '').toLowerCase();
            if (key !== 'k') return;
            e.preventDefault();
            term.clear();
            toast('终端已清屏', 'ok');
        });

        // ===== Drag & Drop Upload =====
        dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
        dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
        dropzone.addEventListener('drop', async (e) => {
            e.preventDefault();
            dropzone.classList.remove('dragover');
            if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止上传文件', 'warn');
            const f = e.dataTransfer.files && e.dataTransfer.files[0];
            if (!f) return;
            await uploadFile(f);
        });

        function arrayBufferToBase64(buffer){
            let binary = '';
            const bytes = new Uint8Array(buffer);
            const len = bytes.byteLength;
            for (let i = 0; i < len; i++) binary += String.fromCharCode(bytes[i]);
            return btoa(binary);
        }

        function base64ToUint8Array(b64){
            const binary = atob(b64);
            const bytes = new Uint8Array(binary.length);
            for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
            return bytes;
        }

        function triggerDownload(filename, uint8){
            const blob = new Blob([uint8]);
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            a.remove();
            URL.revokeObjectURL(url);
        }

        async function uploadFile(file){
            if (TERMINAL_STRONG_RESTRICTION) return toast('终端强限制已开启,禁止上传文件', 'warn');
            if (!sftpConnected) return toast('请先连接 SFTP', 'warn');

            const remote = joinPath(currentPath, file.name);

            setOp('upload', remote);
            toast(`上传中:${file.name}`, 'warn');
            setProgress(0);

            send('sftp_upload_begin', { path: remote, size: file.size });

            const chunk = CHUNK;
            const total = file.size;
            let seq = 0;

            for (let offset = 0; offset < total; offset += chunk) {
                if (currentOp.cancelled) {
                    // 不再发送剩余分片,也不发 upload_end
                    return;
                }

                const blob = file.slice(offset, Math.min(total, offset + chunk));
                const buf = await blob.arrayBuffer();
                const b64 = arrayBufferToBase64(buf);

                send('sftp_upload_chunk', { seq, data_b64: b64 });
                seq++;

                const pct = Math.floor(((offset + blob.size) / total) * 100);
                setProgress(pct);

                await new Promise(r => setTimeout(r, 1));
            }

            if (!currentOp.cancelled) {
                send('sftp_upload_end');
            }
        }


        // ===== Handle SFTP messages =====
        function handleSftpMsg(msg){
            if (msg.event === 'connected') {
                sftpConnected = true;
                userDisconnectedSftp = false;
                setSftpPanelVisible(true);
                setSftpStatus('已连接', true);
                toast('SFTP 已连接', 'ok');
                printStatus(`\r\n\x1b[32m[SFTP] ${msg.msg}\x1b[0m\r\n`);
                // 自动拉目录
                currentPath = normalizePath(pathInput.value || '/');
                goList(currentPath);
                return;
            }

            if (msg.event === 'disconnected') {
                sftpConnected = false;
                setSftpPanelVisible(false);
                setSftpStatus('已断开', false);
                toast('SFTP 已断开', 'warn');
                return;
            }
            if (msg.event === 'upload_put_start') {
                if (currentOp.type === 'upload') {
                    toast('文件已发送,正在写入远端...', 'warn');
                    setProgress(100);
                }
                return;
            }


            if (msg.event === 'upload_cancelled') {
                clearOp('服务端已取消上传');
                return;
            }
            if (msg.event === 'download_cancelled') {
                downloadQueue = [];
                downloadQueueTotal = 0;
                downloading = null;
                clearOp('服务端已取消下载');
                return;
            }

            if (msg.event === 'list') {
                currentPath = normalizePath(msg.path || currentPath);
                pathInput.value = currentPath;
                dropPathEl.textContent = currentPath;
                buildCrumbs(currentPath);

                allItems = (msg.items || []);
                selectedPaths.clear();
                applySearch();

                toast(`已加载:${currentPath}`, 'ok');
                setProgress(0);
                return;
            }

            if (msg.event === 'mkdir_ok' || msg.event === 'rm_ok' || msg.event === 'rename_ok' || msg.event === 'upload_ok') {


                if (msg.event === 'upload_ok') {
                    clearOp('上传完成');
                    toast('上传完成 ✅', 'ok');
                    setProgress(0);
                    send('sftp_list', { path: currentPath });
                    return;
                }


                toast(`${msg.event}`, 'ok');
                setProgress(0);

                // 刷新
                send('sftp_list', { path: currentPath });
                return;
            }


            if (msg.event === 'download_begin') {
                // 服务端会 normalize path,这里用服务端的 path 作为唯一准
                const serverPath = msg.path;
                const queueMeta = downloading && downloading.path === serverPath
                    ? downloading.queueMeta
                    : null;

                // 让 currentOp / downloading 都对齐到 serverPath
                currentOp.type = 'download';
                currentOp.path = serverPath;
                currentOp.cancelled = false;
                btnCancelOp.disabled = false;

                downloading = {
                    path: serverPath,
                    filename: msg.filename || (serverPath.split('/').pop() || 'download.bin'),
                    chunks: [],
                    size: msg.size || 0,
                    receivedBytes: 0,
                    queueMeta,
                };

                setProgress(0);
                return;
            }


            if (msg.event === 'download_chunk') {
                if (currentOp.cancelled) return;
                if (!downloading || downloading.path !== msg.path) return;

                const bin = base64ToUint8Array(msg.data_b64);
                downloading.chunks.push(bin);
                downloading.receivedBytes += bin.byteLength;

                if (downloading.size > 0) {
                    setProgress(Math.floor(downloading.receivedBytes / downloading.size * 100));
                }
                return;
            }


            if (msg.event === 'download_end') {
                if (currentOp.cancelled) return;
                if (!downloading || downloading.path !== msg.path) return;

                if ((!downloading.chunks || downloading.chunks.length === 0) && Number(downloading.size || 0) > 0) {
                    downloading = null;
                    clearOp('下载结束,但没收到数据');
                    return;
                }

                const total = downloading.chunks.reduce((s, c) => s + c.byteLength, 0);
                const merged = new Uint8Array(total);
                let off = 0;
                for (const c of downloading.chunks) { merged.set(c, off); off += c.byteLength; }

                const filename = msg.filename || downloading.filename || (downloading.path.split('/').pop() || 'download.bin');
                const queueMeta = downloading.queueMeta || null;
                const downloadedPath = downloading.path;
                triggerDownload(filename, merged);

                downloading = null;
                if (queueMeta && downloadQueue.length > 0) {
                    clearOp(`已完成 ${queueMeta.index}/${queueMeta.total},继续下载...`);
                    setTimeout(() => startNextQueuedDownload(), 350);
                    return;
                }

                if (queueMeta) {
                    selectedPaths.clear();
                    updateSelectedDownloadUi();
                    applySearch();
                    clearOp('全部下载完成');
                    return;
                }

                clearOp('下载完成');
                return;
            }


        }


        // ===== Auto connect WS only =====
        ensureWSOpen();
        autoConnectFromFavQuery();
    })();
</script>
</body>
</html>

command代码

<?php

namespace App\Console\Commands;

use App\Models\Box;
use App\Models\EdgeGateway\EdgeGatewayLog;
use App\Services\RestrictedTerminalLineEditor;
use App\Services\TerminalCommandRestrictionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SFTP;
use phpseclib3\Net\SSH2;
use Swoole\Coroutine;
use Swoole\Runtime;
use Swoole\Server\Task;
use Swoole\Table;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;

class SwooleSshTerminal extends Command
{
    protected $signature = 'swoole:ssh-terminal';
    protected $description = 'Swoole WebSocket SSH Terminal (xterm.js) + SFTP - connect by box id';

    private Server $ws;

    /**
     * fd => [
     *   'ssh' => SSH2|null,
     *   'sftp' => SFTP|null,
     *   'connected' => bool,
     *   'disconnecting' => bool,
     *   'reader_co' => int|null,
     *   'box_id' => int|null,
     *   'ip' => string,
     *   'port' => int,
     *   'user' => string,
     *   'upload_tmp' => string|null,   // temp file path for upload
     *   'upload_target' => string|null // remote target path
     * ]
     */
    private array $clients = [];
    private array $boxFdMap = [];
    private Table $boxFdTable;

    // ========== SFTP SAFETY ==========
    // 允许访问的远端根目录(强烈建议设置,防止访问 /etc 等)
    private ?string $sftpRoot = '/'; // 例如:'/home' 或 '/home/ubuntu'

    // 下载/上传分片大小(字节)
    private int $chunkSize = 64 * 1024; // 64KB

    // 交互终端优先追求输入/输出手感,避免空转等待过长。
    private int $sshConnectTimeout = 5;
    private int $sftpConnectTimeout = 5;
    private int $sftpUploadConnectTimeout = 10;
    private float $sshReadTimeout = 0.05;
    private float $sshInitWait = 0.01;
    private float $sshIdleSleep = 0.001;
    private float $sshStartupDrainMaxWait = 0.8;
    private float $sshStartupDrainQuietWait = 0.12;
    private bool $debugEnabled = false;
    private ?TerminalCommandRestrictionService $terminalRestrictionService = null;

    /**
     * @var array<string, string>
     */
    private array $boxPrivateKeyPemCache = [];

    /**
     * @var array<string, int>
     */
    private array $boxPrivateKeyMtimeCache = [];

    /**
     * @var array<string, object>
     */
    private array $boxPrivateKeyObjectCache = [];

    public function handle()
    {
        Runtime::enableCoroutine(true);
        $this->debugEnabled = filter_var((string) env('SWOOLE_SSH_TERMINAL_DEBUG', 'false'), FILTER_VALIDATE_BOOL);

        $cpuNum = function_exists('swoole_cpu_num') ? max(1, (int) swoole_cpu_num()) : 2;
        $workerNum = max(2, min(4, $cpuNum));
        $taskWorkerNum = max(2, min(4, $workerNum));

        $this->boxFdTable = new Table(4096);
        $this->boxFdTable->column('fd', Table::TYPE_INT, 8);
        $this->boxFdTable->column('updated_at', Table::TYPE_INT, 8);
        $this->boxFdTable->create();

        $this->ws = new Server('0.0.0.0', 9506);
        $this->ws->set([
            'task_worker_num' => $taskWorkerNum, // SFTP put 走 task worker
            'task_enable_coroutine' => true, // 可选:让 task 里也能用协程(不影响)
            'worker_num' => $workerNum,
            'daemonize' => false,
            'log_file' => storage_path('logs/ssh_terminal.log'),
            'log_level' => SWOOLE_LOG_INFO,
            'open_tcp_nodelay' => true,
            'enable_coroutine' => true,
            'max_conn' => 1024,
            'dispatch_mode' => 2,
            'heartbeat_check_interval' => 60,
            'heartbeat_idle_time' => 300,
            'max_request' => 0,
            'reload_async' => true,
        ]);


        $this->ws->on('Open', function (Server $server, $request) {
            $fd = $request->fd;
            $key = (string)($request->get['key'] ?? '');
            if ($key === '' || !Redis::exists($key)) {
                $server->push($fd, json_encode(['type' => 'error','msg' => 'Invalid/expired key'], JSON_UNESCAPED_UNICODE));
                $server->disconnect($fd);
                return;
            }
            $loginUserId = (int) Redis::get($key);
            if ($loginUserId <= 0) {
                $server->push($fd, json_encode(['type' => 'error','msg' => 'Invalid login user'], JSON_UNESCAPED_UNICODE));
                $server->disconnect($fd);
                return;
            }

            $rawId = (string)($request->get['id'] ?? '');
            $boxId = ctype_digit($rawId) ? (int)$rawId : null;

            $this->clients[$fd] = [
                'ssh' => null,
                'sftp' => null,
                'connected' => false,
                'disconnecting' => false,
                'reader_co' => null,
                'box_id' => $boxId,
                'ip' => '',
                'port' => 0,
                'user' => '',
                'user_id' => $loginUserId,
                'upload_tmp' => null,
                'upload_target' => null,
                'download_co' => null,
                'download_path' => null,
                'auth' => null,            // 保存 sftp/ssh 的 auth,用于 task worker 重新登录
                'upload_task_id' => null,  // 可选:记录上传 task id(不影响功能)
                'history_marker' => null,
                'history_file' => null,
                'history_marker_buffer' => '',
                'history_last_no' => null,
                'terminal_restricted' => null,
                'restricted_line_editor' => null,
                'restricted_interactive_command' => null,
                'restricted_completion_sftp' => null,
                'restricted_completion_home' => null,
                'restricted_completion_cwd' => '~',
                'restricted_completion_previous_cwd' => null,
            ];
            if ($boxId) {
                $this->boxFdMap[$boxId] = $fd;
                $this->boxFdTable->set((string) $boxId, [
                    'fd' => $fd,
                    'updated_at' => time(),
                ]);
            }
            $this->dbg($fd, 'WS Open', [
                'remote_addr' => $request->server['remote_addr'] ?? '',
                'remote_port' => $request->server['remote_port'] ?? '',
                'box_id' => $boxId,
                'raw_id' => $rawId,
            ]);

        });

        $this->ws->on('Message', function (Server $server, Frame $frame) {
            $fd = $frame->fd;

            $data = json_decode($frame->data, true);
            if (!is_array($data) || empty($data['action'])) {
                $this->err($fd, 'Bad message format', null, ['raw' => $frame->data]);
                return;
            }

            try {
                switch ($data['action']) {
                    case 'ping':
                        if ($server->isEstablished($fd)) {
                            $server->push($fd, json_encode([
                                'type' => 'pong',
                                'ts' => $data['ts'] ?? null,
                            ], JSON_UNESCAPED_UNICODE));
                        }
                        break;

                    case 'sftp_upload_cancel':
                        $this->sftpUploadCancel($fd);
                        break;

                    case 'sftp_download_cancel':
                        $this->sftpDownloadCancel($fd);
                        break;

                    case 'connect_ssh':
                        $cols = (int)($data['cols'] ?? 80);
                        $rows = (int)($data['rows'] ?? 24);

                        $host = trim((string)($data['host'] ?? ''));
                        $user = trim((string)($data['user'] ?? ''));
                        $port = (int)($data['port'] ?? 22);
                        $auth = $data['auth'] ?? null;

                        if ($host !== '' && $user !== '' && is_array($auth)) {
                            $this->connectSshCustom($fd, $host, $port, $user, $cols, $rows, $auth);
                        } else {
                            // 兼容老逻辑:按 box_id
                            $this->connectSshByBoxId($fd, $cols, $rows);
                        }
                        break;


                    case 'input':
                        $this->writeInput($fd, (string)($data['data'] ?? ''));
                        break;

                    case 'resize':
                        $this->resizePty($fd, (int)($data['cols'] ?? 80), (int)($data['rows'] ?? 24));
                        break;

                    case 'disconnect':
                        $this->disconnect($fd);
                        break;

                    // ===== SFTP (新增) =====
                    case 'sftp_connect':
                        $host = trim((string)($data['host'] ?? ''));
                        $user = trim((string)($data['user'] ?? ''));
                        $port = (int)($data['port'] ?? 22);
                        $auth = $data['auth'] ?? null;

                        if ($host !== '' && $user !== '' && is_array($auth)) {
                            $this->connectSftpCustom($fd, $host, $port, $user, $auth);
                        } else {
                            $this->connectSftpByBoxId($fd);
                        }
                        break;


                    case 'sftp_list':
                        $this->sftpList($fd, (string)($data['path'] ?? '/'));
                        break;

                    case 'sftp_mkdir':
                        $this->sftpMkdir($fd, (string)($data['path'] ?? ''));
                        break;

                    case 'sftp_rm':
                        $this->sftpRemove($fd, (string)($data['path'] ?? ''));
                        break;

                    case 'sftp_rename':
                        $this->sftpRename($fd, (string)($data['from'] ?? ''), (string)($data['to'] ?? ''));
                        break;

                    case 'sftp_download_begin':
                        $this->sftpDownloadBegin($fd, (string)($data['path'] ?? ''), (int)($data['chunk'] ?? $this->chunkSize));
                        break;

                    case 'sftp_upload_begin':
                        $this->sftpUploadBegin($fd, (string)($data['path'] ?? ''), (int)($data['size'] ?? 0));
                        break;

                    case 'sftp_upload_chunk':
                        $this->sftpUploadChunk($fd, (int)($data['seq'] ?? 0), (string)($data['data_b64'] ?? ''));
                        break;

                    case 'sftp_upload_end':
                        $this->sftpUploadEnd($fd);
                        break;

                    case 'sftp_disconnect':
                        $this->sftpDisconnect($fd, false);
                        break;

                    default:
                        $this->pushError($fd, 'Unknown action: ' . $data['action']);
                }
            } catch (\Throwable $e) {
                $this->err($fd, 'Message handler exception', $e, ['action' => $data['action'] ?? '']);
            }
        });



        $this->ws->on('Task', function (Server $server, Task $task) {

            $payload = $task->data;

//            Log::info('[TASK] start', [
//                'taskId' => $task->id,
//                'srcWorkerId' => $task->worker_id,
//                'payload_type' => gettype($payload),
//            ]);

            $result = null;

            try {
                if (!is_array($payload) || ($payload['type'] ?? '') !== 'sftp_upload_put') {
                    $result = ['ok' => false, 'msg' => 'unknown task'];
                    $task->finish($result);
                    return;
                }
                $fd     = (int)($payload['fd'] ?? 0);
                $boxId  = (int)($payload['box_id'] ?? 0);
                $userId = (int)($payload['user_id'] ?? 0);

                if ($this->terminalRestriction()->enabledForBox($boxId)) {
                    $result = ['ok' => false, 'msg' => $this->terminalRestrictionMessage()];
                    $task->finish($result);
                    return;
                }

                $host   = (string)($payload['host'] ?? '');
                $port   = (int)($payload['port'] ?? 22);
                $user   = (string)($payload['user'] ?? '');
                $auth   = $payload['auth'] ?? null;
                $remote = (string)($payload['remote'] ?? '');
                $tmp    = (string)($payload['tmp'] ?? '');

//                Log::info('[TASK] put begin', [
//                    'taskId' => $task->id,
//                    'remote' => $remote,
//                    'tmp' => $tmp,
//                    'size' => (is_file($tmp) ? filesize($tmp) : -1),
//                ]);

                if ($fd <= 0 || $host === '' || $user === '' || $remote === '' || $tmp === '') {
                    $result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'task payload invalid', 'remote' => $remote, 'tmp' => $tmp];
                    $task->finish($result);
                    return;
                }
                if (!is_array($auth)) {
                    $result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'missing auth', 'remote' => $remote, 'tmp' => $tmp];
                    $task->finish($result);
                    return;
                }
                if (!is_file($tmp)) {
                    $result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'tmp missing', 'remote' => $remote, 'tmp' => $tmp];
                    $task->finish($result);
                    return;
                }

                $sftp = new \phpseclib3\Net\SFTP($host, $port, $this->sftpUploadConnectTimeout);

                $type = (string)($auth['type'] ?? '');
                $okLogin = false;

                if ($type === 'password') {
                    $pwd = (string)($auth['password'] ?? '');
                    if ($pwd === '') {
                        $result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'password empty', 'remote' => $remote, 'tmp' => $tmp];
                        $task->finish($result);
                        return;
                    }
                    $okLogin = $sftp->login($user, $pwd);
                } elseif ($type === 'key') {
                    $privateKey = (string)($auth['privateKey'] ?? '');
                    $passphrase = (string)($auth['passphrase'] ?? '');
                    if ($privateKey === '') {
                        $result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'privateKey empty', 'remote' => $remote, 'tmp' => $tmp];
                        $task->finish($result);
                        return;
                    }
                    $key = $passphrase !== ''
                        ? \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
                        : \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey($privateKey);

                    $okLogin = $sftp->login($user, $key);
                } else {
                    $result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'unknown auth.type', 'remote' => $remote, 'tmp' => $tmp];
                    $task->finish($result);
                    return;
                }

                if (!$okLogin) {
                    $result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'SFTP login failed', 'remote' => $remote, 'tmp' => $tmp];
                    $task->finish($result);
                    return;
                }

                $okPut = $sftp->put($remote, $tmp, \phpseclib3\Net\SFTP::SOURCE_LOCAL_FILE);

//                Log::info('[TASK] put end', [
//                    'taskId' => $task->id,
//                    'ok' => (bool)$okPut,
//                    'last' => $sftp->getLastError(),
//                ]);

                if (!$okPut) {
                    $result = ['ok' => false, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'msg' => 'SFTP put failed: ' . $sftp->getLastError(), 'remote' => $remote, 'tmp' => $tmp];
                    $task->finish($result);
                    return;
                }

                $result = ['ok' => true, 'fd' => $fd, 'box_id' => $boxId, 'user_id' => $userId, 'remote' => $remote, 'tmp' => $tmp];
                $task->finish($result);
                return;
            } catch (\Throwable $e) {
                $result = [
                    'ok' => false,
                    'msg' => $e->getMessage(),
                    'trace' => $e->getTraceAsString(),
                ];
                $task->finish($result);
                return;
            }
        });

        $this->ws->on('Finish', function (Server $server, int $taskId, $result) {

//            Log::info('[FINISH] recv', [
//                'taskId' => $taskId,
//                'result_type' => is_array($result) ? 'array' : gettype($result),
//                'result' => $result,
//            ]);

            if (!is_array($result)) {
                return;
            }

            $fd    = (int)($result['fd'] ?? 0);
            $boxId = (int)($result['box_id'] ?? 0);
            $userId = (int)($result['user_id'] ?? 0);

            $pushFd = ($fd > 0 && $server->isEstablished($fd))
                ? $fd
                : ($boxId > 0 ? $this->findAliveFdByBoxId($boxId) : null);
            $remote = (string)($result['remote'] ?? '');

            if (!empty($result['ok'])) {
                $this->recordOperationLog((int)($pushFd ?: $fd), '上传  ' . $remote, $boxId, $userId);
            }

//            Log::info('[FINISH] route', [
//                'taskId' => $taskId,
//                'fd' => $fd,
//                'boxId' => $boxId,
//                'pushFd' => $pushFd,
//                'pushFd_established' => $pushFd ? $server->isEstablished($pushFd) : null,
//                'ok' => !empty($result['ok']),
//                'remote' => (string)($result['remote'] ?? ''),
//            ]);

            if ($pushFd && $server->isEstablished($pushFd)) {
                if (!empty($result['ok'])) {
                    $server->push($pushFd, json_encode([
                        'type'  => 'sftp',
                        'event' => 'upload_ok',
                        'path'  => $remote,
                    ], JSON_UNESCAPED_UNICODE));
                } else {
                    $server->push($pushFd, json_encode([
                        'type' => 'error',
                        'msg'  => 'upload_end: put failed | ' . (string)($result['msg'] ?? 'unknown'),
                    ], JSON_UNESCAPED_UNICODE));
                }
            }

            $tmp = $result['tmp'] ?? null;
            if (is_string($tmp) && $tmp !== '' && is_file($tmp)) {
                @unlink($tmp);
            }
        });





        $this->ws->on('Close', function (Server $server, int $fd) {
            $this->dbg($fd, 'WS Close');

            $boxId = $this->clients[$fd]['box_id'] ?? null;
            if ($boxId && (($this->boxFdMap[$boxId] ?? null) === $fd)) {
                unset($this->boxFdMap[$boxId]);
            }
            if ($boxId) {
                $row = $this->boxFdTable->get((string) $boxId);
                if ($row && (int) ($row['fd'] ?? 0) === $fd) {
                    $this->boxFdTable->del((string) $boxId);
                }
            }

            $this->disconnect($fd, true);
            unset($this->clients[$fd]);
        });


        $this->info('SSH+SFTP Terminal WS started at ws://0.0.0.0:9506');
        $this->ws->start();
    }

    private function sftpUploadCancel(int $fd): void
    {
        // 清理临时文件 & 状态
        $this->sftpUploadCleanup($fd);

        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'upload_cancelled',
            ], JSON_UNESCAPED_UNICODE));
        }
    }


    /**
     *  按 box_id 查 Box,拿 ssh_ip / ssh_port / ssh_name
     */
    private function connectSshByBoxId(int $fd, int $cols, int $rows): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) {
            return;
        }

        $boxId = $conn['box_id'] ?? null;
        if (!$boxId) {
            $this->pushError($fd, 'Missing/invalid box id (ws url must have ?id=123).');
            return;
        }

        $box = Box::query()->find($boxId);
        if (!$box) {
            $this->pushError($fd, "Box not found: id={$boxId}");
            return;
        }
        if (!$this->ensureBoxTerminalAccess($fd, $box)) {
            return;
        }

        $ip = (string)($box->ssh_ip ?? '');
        $port = (int)($box->ssh_port ?? 0);
        $user = (string)($box->ssh_name ?? '');

        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            $this->pushError($fd, "Invalid ssh_ip in Box(id={$boxId}): {$ip}");
            return;
        }
        if ($port < 1 || $port > 65535) {
            $this->pushError($fd, "Invalid ssh_port in Box(id={$boxId}): {$port}");
            return;
        }
        if ($user === '') {
            $this->pushError($fd, "Invalid ssh_name in Box(id={$boxId})");
            return;
        }

        $this->connectSsh($fd, $ip, $port, $user, $cols, $rows, $boxId);
    }

    /**
     *  自定义 SSH 连接(来自收藏夹)
     * $auth example:
     *   ['type'=>'password','password'=>'xxx']
     *   ['type'=>'key','privateKey'=>'-----BEGIN...','passphrase'=>'optional']
     */
    private function connectSshCustom(int $fd, string $host, int $port, string $user, int $cols, int $rows, array $auth): void
    {
        if (!isset($this->clients[$fd])) {
            return;
        }

        [$cols, $rows] = $this->normalizeTerminalSize($cols, $rows);

        // 基本校验
        $port = ($port >= 1 && $port <= 65535) ? $port : 22;

        // host 允许 IP 或域名
        if ($host === '') {
            $this->pushError($fd, 'Custom SSH: host empty');
            return;
        }
        if ($user === '') {
            $this->pushError($fd, 'Custom SSH: user empty');
            return;
        }

        $this->dbg($fd, 'connect_ssh custom', [
            'host' => $host,
            'port' => $port,
            'user' => $user,
            'cols' => $cols,
            'rows' => $rows,
            'auth_type' => (string)($auth['type'] ?? ''),
        ]);

        // 断开旧连接(SSH + SFTP都断,避免冲突)
        $this->disconnect($fd, true);

        try {
            $ssh = new SSH2($host, $port, $this->sshConnectTimeout);

            $type = (string)($auth['type'] ?? '');
            $ok = false;

            if ($type === 'password') {
                $password = (string)($auth['password'] ?? '');
                if ($password === '') {
                    $this->pushError($fd, 'Custom SSH: password empty');
                    return;
                }
                $ok = $ssh->login($user, $password);
            } elseif ($type === 'key') {
                $privateKey = (string)($auth['privateKey'] ?? '');
                $passphrase = (string)($auth['passphrase'] ?? '');

                if ($privateKey === '') {
                    $this->pushError($fd, 'Custom SSH: privateKey empty');
                    return;
                }

                // phpseclib 支持带 passphrase 的 key
                $key = $passphrase !== ''
                    ? PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
                    : PublicKeyLoader::loadPrivateKey($privateKey);

                $ok = $ssh->login($user, $key);
            } else {
                $this->pushError($fd, 'Custom SSH: unknown auth.type');
                return;
            }

            if (!$ok) {
                $this->pushError($fd, 'Custom SSH: login returned false.');
                return;
            }

            $this->prepareInteractivePty($ssh, $cols, $rows);
            $ssh->exec('env TERM=xterm-256color /bin/bash -li');
            $ssh->setTimeout($this->sshReadTimeout);
            Coroutine::sleep($this->sshInitWait);
            $this->drainStartupOutput($fd, $ssh);

            $this->clients[$fd]['ssh'] = $ssh;
            $this->clients[$fd]['connected'] = true;
            $this->clients[$fd]['disconnecting'] = false;
            $this->clients[$fd]['ip'] = $host;
            $this->clients[$fd]['port'] = $port;
            $this->clients[$fd]['user'] = $user;
            $this->clients[$fd]['auth'] = $auth;
            $this->configureRemoteHistoryLogging($fd, $ssh);

            $this->ws->push($fd, json_encode([
                'type' => 'connected',
                'msg'  => "Connected: {$user}@{$host}:{$port} (Custom)"
            ], JSON_UNESCAPED_UNICODE));

            $this->startReaderCoroutine($fd, $ssh);
        } catch (\Throwable $e) {
            $this->err($fd, 'connectSshCustom exception', $e);
            $this->disconnect($fd);
        }
    }

    /**
     *  固定密钥 id_rsa + PTY + bash + reader
     */
    private function connectSsh(int $fd, string $ip, int $port, string $user, int $cols, int $rows, int $boxId): void
    {
        if (!isset($this->clients[$fd])) {
            return;
        }

        [$cols, $rows] = $this->normalizeTerminalSize($cols, $rows);

        $this->dbg($fd, 'connect_ssh resolved from Box', [
            'box_id' => $boxId,
            'ip' => $ip,
            'port' => $port,
            'user' => $user,
            'cols' => $cols,
            'rows' => $rows,
        ]);

        // 断开旧连接(SSH + SFTP 都断,避免冲突)
        $this->disconnect($fd, true);

        try {
            $keyMeta = $this->loadBoxPrivateKey($boxId);
            if ($keyMeta === null) {
                $this->pushError($fd, sprintf('Key not found for box: %d', $boxId));
                return;
            }

            $ssh = new SSH2($ip, $port, $this->sshConnectTimeout);
            $this->dbg($fd, 'login start');
            $ok = $ssh->login($user, $keyMeta['key']);
            $this->dbg($fd, 'login result', ['ok' => $ok]);

            if (!$ok) {
                $this->pushError($fd, 'SSH login returned false.');
                return;
            }

            $this->prepareInteractivePty($ssh, $cols, $rows);
            $ssh->exec('env TERM=xterm-256color /bin/bash -li');
            $ssh->setTimeout($this->sshReadTimeout);
            Coroutine::sleep($this->sshInitWait);
            $this->drainStartupOutput($fd, $ssh);

            $this->clients[$fd]['ssh'] = $ssh;
            $this->clients[$fd]['connected'] = true;
            $this->clients[$fd]['disconnecting'] = false;
            $this->clients[$fd]['ip'] = $ip;
            $this->clients[$fd]['port'] = $port;
            $this->clients[$fd]['user'] = $user;
            $this->configureRemoteHistoryLogging($fd, $ssh);

            if (!isset($this->clients[$fd]) || !$this->ws->isEstablished($fd)) {
                $ssh->disconnect();
                return;
            }

            $this->ws->push($fd, json_encode([
                'type' => 'connected',
                'msg'  => "Connected: {$user}@{$ip}:{$port} (BoxID={$boxId})"
            ], JSON_UNESCAPED_UNICODE));

            $this->startReaderCoroutine($fd, $ssh);
        } catch (\Throwable $e) {
            $this->err($fd, 'connectSsh exception', $e);
            $this->disconnect($fd);
        }
    }

    private function startReaderCoroutine(int $fd, SSH2 $ssh): void
    {
        $readerCo = $this->clients[$fd]['reader_co'] ?? null;
        if ($readerCo && $readerCo !== Coroutine::getCid()) {
            try {
                Coroutine::cancel($readerCo);
            } catch (\Throwable $e) {
            }
            $this->clients[$fd]['reader_co'] = null;
        }

        $this->clients[$fd]['reader_co'] = Coroutine::create(function () use ($fd, $ssh) {
            try {
                while (true) {
                    if (!isset($this->clients[$fd])) {
                        break;
                    }
                    if (($this->clients[$fd]['disconnecting'] ?? false) === true) {
                        break;
                    }
                    if (($this->clients[$fd]['connected'] ?? false) !== true) {
                        break;
                    }
                    if (!$this->ws->isEstablished($fd)) {
                        break;
                    }
                    if (!$ssh->isConnected()) {
                        break;
                    }

                    $out = $ssh->read('', SSH2::READ_NEXT);
                    $readTimedOut = $ssh->isTimeout();

                    if (!is_string($out)) {
                        if (!$this->shouldCloseReaderForReadResult($out, $readTimedOut)) {
                            Coroutine::sleep($this->sshIdleSleep);
                            continue;
                        }

                        $this->safeLog('warning', '[SSH_READER_CLOSED_BY_INVALID_READ]', [
                            'fd' => $fd,
                            'read_type' => gettype($out),
                            'timed_out' => $readTimedOut,
                        ]);
                        break;
                    }

                    if ($out !== '') {
                        $this->pushOutput($fd, $out);
                        continue;
                    }

                    Coroutine::sleep($this->sshIdleSleep);
                }
            } catch (\Throwable $e) {
                $this->safeLog('error', '[ERR] reader exception', [
                    'fd' => $fd,
                    'error' => $e->getMessage(),
                    'trace' => $e->getTraceAsString(),
                ]);
            }

            if (isset($this->clients[$fd]) && (($this->clients[$fd]['reader_co'] ?? null) === Coroutine::getCid())) {
                $this->clients[$fd]['reader_co'] = null;
            }
            $this->disconnect($fd, false);
        });
    }

    private function shouldCloseReaderForReadResult($out, bool $timedOut = false): bool
    {
        return !is_string($out) && !$timedOut;
    }

    private function prepareInteractivePty(SSH2 $ssh, int $cols, int $rows): void
    {
        if (method_exists($ssh, 'setTerminal')) {
            $ssh->setTerminal('xterm-256color');
        }
        if (method_exists($ssh, 'setWindowSize')) {
            $ssh->setWindowSize($cols, $rows);
        }
        $ssh->enablePTY();
    }

    private function drainStartupOutput(int $fd, SSH2 $ssh): void
    {
        $deadline = microtime(true) + $this->sshStartupDrainMaxWait;
        $quietDeadline = microtime(true) + $this->sshStartupDrainQuietWait;

        while (microtime(true) < $deadline) {
            if (!isset($this->clients[$fd])
                || ($this->clients[$fd]['disconnecting'] ?? false)
                || !$this->ws->isEstablished($fd)
                || !$ssh->isConnected()) {
                return;
            }

            $remaining = max(0.001, min($deadline, $quietDeadline) - microtime(true));
            $ssh->setTimeout(min($this->sshReadTimeout, $remaining));

            $out = $ssh->read('', SSH2::READ_NEXT);
            if (is_string($out) && $out !== '') {
                $this->pushOutput($fd, $out);
                $quietDeadline = microtime(true) + $this->sshStartupDrainQuietWait;
                continue;
            }

            if (microtime(true) >= $quietDeadline) {
                break;
            }

            Coroutine::sleep($this->sshIdleSleep);
        }

        $ssh->setTimeout($this->sshReadTimeout);
    }

    private function configureRemoteHistoryLogging(int $fd, SSH2 $ssh): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) {
            return;
        }
        if (($conn['disconnecting'] ?? false)) {
            return;
        }

        $boxId = (int)($conn['box_id'] ?? 0);
        $userId = (int)($conn['user_id'] ?? 0);
        if ($boxId <= 0 || $userId <= 0) {
            return;
        }

        $marker = 'autotest_' . $this->randomHex(12);
        $historyFile = sprintf('/tmp/autotest_ssh_history_%d_%d_%s', $boxId, $fd, $this->randomHex(6));

        $this->clients[$fd]['history_marker'] = $marker;
        $this->clients[$fd]['history_file'] = $historyFile;
        $this->clients[$fd]['history_marker_buffer'] = '';
        $this->clients[$fd]['history_last_no'] = null;

        $script = implode("\n", [
            'set +o history 2>/dev/null || true',
            'export AUTOTEST_HISTORY_MARKER=' . $this->shellSingleQuote($marker),
            'export HISTFILE=' . $this->shellSingleQuote($historyFile),
            'export HISTTIMEFORMAT=',
            'export HISTCONTROL=',
            'export HISTIGNORE=',
            'export HISTSIZE=100000',
            'export HISTFILESIZE=100000',
            'shopt -s histappend 2>/dev/null || true',
            'history -c 2>/dev/null || true',
            ': > "$HISTFILE"',
            '__autotest_history_emit() {',
            '    local __at_entry __at_no __at_cmd',
            '    history -a 2>/dev/null || true',
            '    __at_entry=$(HISTTIMEFORMAT= history 1 2>/dev/null)',
            '    __at_no=$(printf \'%s\n\' "$__at_entry" | sed -n \'s/^[[:space:]]*\([0-9][0-9]*\)[[:space:]]*.*/\1/p\')',
            '    __at_cmd=$(printf \'%s\n\' "$__at_entry" | sed \'s/^[[:space:]]*[0-9][0-9]*[[:space:]]*//\')',
            '    [ -n "$__at_no" ] || return 0',
            '    [ -n "$__at_cmd" ] || return 0',
            '    printf \'\033]777;AUTOTEST_HISTORY;%s;%s;%s\007\' "$AUTOTEST_HISTORY_MARKER" "$__at_no" "$__at_cmd"',
            '}',
            'case ";$PROMPT_COMMAND;" in',
            '    *"__autotest_history_emit"*) ;;',
            '    *) PROMPT_COMMAND="__autotest_history_emit${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;;',
            'esac',
            'history -c 2>/dev/null || true',
            ': > "$HISTFILE"',
            'set -o history 2>/dev/null || true',
        ]);

        try {
            if (($this->clients[$fd]['disconnecting'] ?? false) || !$ssh->isConnected()) {
                return;
            }
            $ssh->write($script . "\n");
            $this->drainSilentOutput($fd, $ssh, 0.35);
        } catch (\Throwable $e) {
            $this->clients[$fd]['history_marker'] = null;
            $this->clients[$fd]['history_file'] = null;
            $this->clients[$fd]['history_marker_buffer'] = '';
            $this->err($fd, 'configure history logging exception', $e);
        }
    }

    private function cleanupRemoteHistoryLogging(int $fd, SSH2 $ssh): void
    {
        if (!isset($this->clients[$fd])) {
            return;
        }
        $historyFile = (string)($this->clients[$fd]['history_file'] ?? '');
        $this->clients[$fd]['history_marker'] = null;
        $this->clients[$fd]['history_marker_buffer'] = '';
        $this->clients[$fd]['history_last_no'] = null;

        if ($historyFile === '') {
            return;
        }

        try {
            if (($this->clients[$fd]['disconnecting'] ?? false) || !$ssh->isConnected()) {
                return;
            }
            $ssh->write(
                'set +o history 2>/dev/null || true; ' .
                'rm -f ' . $this->shellSingleQuote($historyFile) . '; ' .
                'unset AUTOTEST_HISTORY_MARKER; ' .
                "set -o history 2>/dev/null || true\n"
            );
        } catch (\Throwable $e) {
            $this->dbg($fd, 'cleanup history logging failed', ['error' => $e->getMessage()]);
        }

        if (isset($this->clients[$fd])) {
            $this->clients[$fd]['history_file'] = null;
        }
    }

    private function drainSilentOutput(int $fd, SSH2 $ssh, float $maxWait): void
    {
        $deadline = microtime(true) + max(0.02, $maxWait);
        $quietDeadline = microtime(true) + 0.05;

        while (microtime(true) < $deadline) {
            if (!isset($this->clients[$fd]) || ($this->clients[$fd]['disconnecting'] ?? false) || !$ssh->isConnected()) {
                return;
            }

            $remaining = max(0.001, min($deadline, $quietDeadline) - microtime(true));
            $ssh->setTimeout(min($this->sshReadTimeout, $remaining));
            $out = $ssh->read('', SSH2::READ_NEXT);

            if (is_string($out) && $out !== '') {
                $quietDeadline = microtime(true) + 0.05;
                continue;
            }

            if (microtime(true) >= $quietDeadline) {
                break;
            }

            Coroutine::sleep($this->sshIdleSleep);
        }

        $ssh->setTimeout($this->sshReadTimeout);
    }

    private function writeInput(int $fd, string $data): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn || !$conn['connected'] || !empty($conn['disconnecting']) || !$conn['ssh']) {
            $this->pushError($fd, 'SSH not connected.');
            return;
        }

        try {
            $restricted = $this->clients[$fd]['terminal_restricted'] ?? null;
            if ($restricted === null) {
                $boxId = (int) ($this->clients[$fd]['box_id'] ?? 0);
                $restricted = $this->terminalRestriction()->enabledForBox($boxId);
                $this->clients[$fd]['terminal_restricted'] = $restricted;
            }
            if ($restricted) {
                $this->writeRestrictedInput($fd, $conn['ssh'], $data);
                return;
            }

            $conn['ssh']->write($data);
        } catch (\Throwable $e) {
            $this->err($fd, 'writeInput exception', $e);
            $this->disconnect($fd);
        }
    }

    private function writeRestrictedInput(int $fd, SSH2 $ssh, string $data): void
    {
        $editor = $this->clients[$fd]['restricted_line_editor'] ?? null;
        if (!$editor instanceof RestrictedTerminalLineEditor) {
            $editor = new RestrictedTerminalLineEditor(
                $this->terminalRestriction()->commandNames(),
                fn (string $buffer): ?string => $this->completeRestrictedPath($fd, $buffer)
            );
            $this->clients[$fd]['restricted_line_editor'] = $editor;
        }

        if (($this->clients[$fd]['restricted_interactive_command'] ?? null) === 'top') {
            $ssh->write($data);
            if (str_contains($data, 'q') || str_contains($data, "\x03")) {
                $this->clients[$fd]['restricted_interactive_command'] = null;
                $editor->clearLine();
            }
            return;
        }

        foreach ($editor->feed($data) as $event) {
            if ($event['type'] === 'forward') {
                $ssh->write((string) ($event['data'] ?? ''));
                continue;
            }

            if ($event['type'] === 'bell') {
                $this->pushOutput($fd, "\x07");
                continue;
            }

            $command = (string) ($event['command'] ?? '');
            $lineEnding = (string) ($event['data'] ?? "\r");
            $inspection = $this->terminalRestriction()->inspect($command);
            if (!$inspection['allowed']) {
                $ssh->write("\x15");
                $this->pushTerminalRestrictionBlocked($fd, $command, $inspection['reason']);
                continue;
            }

            $ssh->write($lineEnding);
            $editor->remember($command);
            $this->trackRestrictedWorkingDirectory($fd, $command);
            if ($this->terminalRestriction()->isInteractiveCommand($command)) {
                $this->clients[$fd]['restricted_interactive_command'] = 'top';
            }
        }
    }

    private function completeRestrictedPath(int $fd, string $buffer): ?string
    {
        if (!preg_match('/(?:^|\s)([^\s]*)$/u', $buffer, $matches)) {
            return null;
        }

        $token = (string) ($matches[1] ?? '');
        if (
            $token === ''
            || str_starts_with($token, '-')
            || preg_match('/[\\\\*?\[\]{}()\'"`$;|&<>]/u', $token)
        ) {
            return null;
        }

        $sftp = $this->restrictedCompletionSftp($fd);
        if (!$sftp) {
            return null;
        }

        $slash = strrpos($token, '/');
        $directoryToken = $slash === false ? '' : substr($token, 0, $slash + 1);
        $namePrefix = $slash === false ? $token : substr($token, $slash + 1);
        $directory = $this->restrictedCompletionDirectory($fd, $directoryToken);
        if ($directory === null) {
            return null;
        }

        try {
            $entries = $sftp->rawlist($directory);
        } catch (\Throwable $e) {
            $this->dbg($fd, 'restricted completion list failed', ['error' => $e->getMessage()]);
            return null;
        }
        if (!is_array($entries)) {
            return null;
        }

        $names = [];
        foreach (array_keys($entries) as $name) {
            $name = (string) $name;
            if (
                $name === '.' || $name === '..'
                || !str_starts_with($name, $namePrefix)
                || !$this->isSafeRestrictedCompletionName($name)
            ) {
                continue;
            }
            $names[] = $name;
        }
        sort($names, SORT_STRING);
        if ($names === []) {
            return null;
        }

        $completion = array_shift($names);
        foreach ($names as $name) {
            $completion = $this->restrictedCommonPrefix($completion, $name);
        }

        $suffix = substr($completion, strlen($namePrefix));
        if ($names === []) {
            $fullPath = rtrim($directory, '/') . '/' . $completion;
            try {
                if ($sftp->is_dir($fullPath)) {
                    $suffix .= '/';
                }
            } catch (\Throwable) {
            }
        }

        return $suffix !== '' ? $suffix : null;
    }

    private function restrictedCompletionSftp(int $fd): ?SFTP
    {
        $existing = $this->clients[$fd]['restricted_completion_sftp'] ?? null;
        if ($existing instanceof SFTP) {
            return $existing;
        }

        $conn = $this->clients[$fd] ?? null;
        if (!$conn || empty($conn['connected'])) {
            return null;
        }

        $host = (string) ($conn['ip'] ?? '');
        $port = (int) ($conn['port'] ?? 22);
        $user = (string) ($conn['user'] ?? '');
        if ($host === '' || $user === '') {
            return null;
        }

        try {
            $sftp = new SFTP($host, $port, $this->sftpConnectTimeout);
            $auth = $conn['auth'] ?? null;
            if (is_array($auth)) {
                $type = (string) ($auth['type'] ?? '');
                if ($type === 'password') {
                    $credential = (string) ($auth['password'] ?? '');
                } elseif ($type === 'key') {
                    $privateKey = (string) ($auth['privateKey'] ?? '');
                    $passphrase = (string) ($auth['passphrase'] ?? '');
                    if ($privateKey === '') {
                        return null;
                    }
                    $credential = $passphrase !== ''
                        ? PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
                        : PublicKeyLoader::loadPrivateKey($privateKey);
                } else {
                    return null;
                }
            } else {
                $boxId = (int) ($conn['box_id'] ?? 0);
                $keyMeta = $boxId > 0 ? $this->loadBoxPrivateKey($boxId) : null;
                if ($keyMeta === null) {
                    return null;
                }
                $credential = $keyMeta['key'];
            }

            if (!$sftp->login($user, $credential)) {
                return null;
            }

            $home = (string) ($sftp->pwd() ?: '/');
            $logicalCwd = (string) ($conn['restricted_completion_cwd'] ?? '~');
            $cwd = $this->restrictedAbsolutePath($logicalCwd, $home, $home);
            $resolvedCwd = $sftp->realpath($cwd);
            if (!is_string($resolvedCwd) || !$sftp->is_dir($resolvedCwd)) {
                $resolvedCwd = $home;
            }

            $this->clients[$fd]['restricted_completion_sftp'] = $sftp;
            $this->clients[$fd]['restricted_completion_home'] = $home;
            $this->clients[$fd]['restricted_completion_cwd'] = $resolvedCwd;

            return $sftp;
        } catch (\Throwable $e) {
            $this->dbg($fd, 'restricted completion SFTP failed', ['error' => $e->getMessage()]);
            return null;
        }
    }

    private function restrictedCompletionDirectory(int $fd, string $directoryToken): ?string
    {
        $home = (string) ($this->clients[$fd]['restricted_completion_home'] ?? '');
        $cwd = (string) ($this->clients[$fd]['restricted_completion_cwd'] ?? '');
        if ($home === '' || $cwd === '') {
            return null;
        }

        $candidate = $directoryToken === '' ? $cwd : $directoryToken;
        $absolute = $this->restrictedAbsolutePath($candidate, $cwd, $home);
        $sftp = $this->clients[$fd]['restricted_completion_sftp'] ?? null;
        if (!$sftp instanceof SFTP) {
            return null;
        }

        try {
            $resolved = $sftp->realpath($absolute);
            return is_string($resolved) && $sftp->is_dir($resolved) ? $resolved : null;
        } catch (\Throwable) {
            return null;
        }
    }

    private function trackRestrictedWorkingDirectory(int $fd, string $command): void
    {
        if (!preg_match('/^cd(?:\s+([^\s]+))?\s*$/u', trim($command), $matches)) {
            return;
        }

        $target = (string) ($matches[1] ?? '~');
        if (
            $target !== '~'
            && ($target === ''
                || preg_match('/[\\\\*?\[\]{}()\'"`$;|&<>]/u', $target))
        ) {
            return;
        }

        $current = (string) ($this->clients[$fd]['restricted_completion_cwd'] ?? '~');
        if ($target === '-') {
            $previous = $this->clients[$fd]['restricted_completion_previous_cwd'] ?? null;
            if (is_string($previous) && $previous !== '') {
                $this->clients[$fd]['restricted_completion_previous_cwd'] = $current;
                $this->clients[$fd]['restricted_completion_cwd'] = $previous;
            }
            return;
        }

        $home = (string) ($this->clients[$fd]['restricted_completion_home'] ?? '~');
        $candidate = $this->restrictedAbsolutePath($target, $current, $home);
        $sftp = $this->clients[$fd]['restricted_completion_sftp'] ?? null;
        if ($sftp instanceof SFTP) {
            try {
                $resolved = $sftp->realpath($candidate);
                if (!is_string($resolved) || !$sftp->is_dir($resolved)) {
                    return;
                }
                $candidate = $resolved;
            } catch (\Throwable) {
                return;
            }
        }

        $this->clients[$fd]['restricted_completion_previous_cwd'] = $current;
        $this->clients[$fd]['restricted_completion_cwd'] = $candidate;
    }

    private function restrictedAbsolutePath(string $path, string $cwd, string $home): string
    {
        $symbolicHome = $home === '~';
        if ($path === '~') {
            return $home;
        } elseif (str_starts_with($path, '~/')) {
            if (!$symbolicHome) {
                $path = rtrim($home, '/') . substr($path, 1);
            }
        } elseif (!str_starts_with($path, '/')) {
            $path = rtrim($cwd, '/') . '/' . $path;
        }

        $usesSymbolicHome = $symbolicHome && str_starts_with($path, '~/');
        if ($usesSymbolicHome) {
            $path = substr($path, 2);
        }

        $parts = [];
        foreach (explode('/', $path) as $part) {
            if ($part === '' || $part === '.') {
                continue;
            }
            if ($part === '..') {
                if ($parts !== [] && end($parts) !== '..') {
                    array_pop($parts);
                } elseif ($usesSymbolicHome) {
                    $parts[] = '..';
                }
                continue;
            }
            $parts[] = $part;
        }

        if ($usesSymbolicHome) {
            return $parts === [] ? '~' : '~/' . implode('/', $parts);
        }

        return '/' . implode('/', $parts);
    }

    private function isSafeRestrictedCompletionName(string $name): bool
    {
        return preg_match('/^[\pL\pN._@%+=:,~-]+$/u', $name) === 1;
    }

    private function restrictedCommonPrefix(string $left, string $right): string
    {
        $leftCharacters = preg_split('//u', $left, -1, PREG_SPLIT_NO_EMPTY) ?: [];
        $rightCharacters = preg_split('//u', $right, -1, PREG_SPLIT_NO_EMPTY) ?: [];
        $limit = min(count($leftCharacters), count($rightCharacters));
        $prefix = [];

        for ($index = 0; $index < $limit && $leftCharacters[$index] === $rightCharacters[$index]; $index++) {
            $prefix[] = $leftCharacters[$index];
        }

        return implode('', $prefix);
    }

    private function pushTerminalRestrictionBlocked(int $fd, string $command, string $reason): void
    {
        $command = trim($command);
        $detail = $reason !== '' ? $reason : $this->terminalRestrictionMessage();
        $line = $command !== ''
            ? "已阻止命令:{$command};{$detail}"
            : $detail;

        $this->pushOutput($fd, "\r\n\x1b[31m[BLOCKED]\x1b[0m {$line}\r\n");
    }

    private function terminalRestriction(): TerminalCommandRestrictionService
    {
        if (!$this->terminalRestrictionService) {
            $this->terminalRestrictionService = app(TerminalCommandRestrictionService::class);
        }

        return $this->terminalRestrictionService;
    }

    private function terminalRestrictionMessage(): string
    {
        return '终端强限制已开启,仅允许 cat、tail、head、grep、ls 等查看类命令,禁止上传、删除、改名、新建和修改服务状态';
    }

    private function rejectMutatingSftpWhenRestricted(int $fd, string $action): bool
    {
        $boxId = (int) ($this->clients[$fd]['box_id'] ?? 0);
        if (!$this->terminalRestriction()->enabledForBox($boxId)) {
            return false;
        }

        $this->pushError($fd, "{$action} 已被阻止:{$this->terminalRestrictionMessage()}");
        return true;
    }

    private function resizePty(int $fd, int $cols, int $rows): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn || !$conn['connected'] || !empty($conn['disconnecting']) || !$conn['ssh']) {
            return;
        }

        [$cols, $rows] = $this->normalizeTerminalSize($cols, $rows);

        try {
            if (method_exists($conn['ssh'], 'setWindowSize')) {
                $conn['ssh']->setWindowSize($cols, $rows);
            }
            $this->sendPtyWindowChange($fd, $conn['ssh'], $cols, $rows);
        } catch (\Throwable $e) {
            $this->err($fd, 'resize exception', $e);
        }
    }

    private function sendPtyWindowChange(int $fd, SSH2 $ssh, int $cols, int $rows): bool
    {
        try {
            $sendWindowChange = \Closure::bind(function (int $columns, int $rowCount): bool {
                $channel = \phpseclib3\Net\SSH2::CHANNEL_EXEC;
                if (!isset($this->server_channels[$channel])) {
                    return false;
                }

                $packet = \phpseclib3\Common\Functions\Strings::packSSH2(
                    'CNsCN4',
                    98, // SSH_MSG_CHANNEL_REQUEST
                    $this->server_channels[$channel],
                    'window-change',
                    0,
                    $columns,
                    $rowCount,
                    0,
                    0
                );

                $this->send_binary_packet($packet);
                return true;
            }, $ssh, \phpseclib3\Net\SSH2::class);

            return (bool) $sendWindowChange($cols, $rows);
        } catch (\Throwable $e) {
            $this->err($fd, 'window-change exception', $e);
            return false;
        }
    }

    /**
     * @return array{0:int,1:int}
     */
    private function normalizeTerminalSize(int $cols, int $rows): array
    {
        return [
            min(max($cols, 40), 260),
            min(max($rows, 10), 120),
        ];
    }

    private function randomHex(int $bytes): string
    {
        try {
            return bin2hex(random_bytes($bytes));
        } catch (\Throwable $e) {
            return str_replace('.', '', uniqid('', true));
        }
    }

    private function shellSingleQuote(string $value): string
    {
        return "'" . str_replace("'", "'\"'\"'", $value) . "'";
    }

    private function filterHistoryMarkersFromOutput(int $fd, string $out): string
    {
        if (($this->clients[$fd]['history_marker'] ?? null) === null) {
            return $out;
        }

        $buffer = (string)($this->clients[$fd]['history_marker_buffer'] ?? '') . $out;
        $this->clients[$fd]['history_marker_buffer'] = '';

        $clean = '';
        $offset = 0;
        $pattern = "/\x1b\]777;AUTOTEST_HISTORY;([^;\x07]*);([0-9]+);([^\x07]*)\x07/s";

        while (preg_match($pattern, $buffer, $match, PREG_OFFSET_CAPTURE, $offset)) {
            $markerStart = $match[0][1];
            $markerEnd = $markerStart + strlen($match[0][0]);
            $clean .= substr($buffer, $offset, $markerStart - $offset);

            $this->recordHistoryCommand(
                $fd,
                (string)$match[1][0],
                (int)$match[2][0],
                (string)$match[3][0]
            );

            $offset = $markerEnd;
        }

        $remaining = substr($buffer, $offset);
        $prefix = "\x1b]777;AUTOTEST_HISTORY;";
        $tailPos = strrpos($remaining, $prefix);
        $pendingStart = null;

        if ($tailPos !== false && strpos(substr($remaining, $tailPos), "\x07") === false) {
            $pendingStart = $tailPos;
        } else {
            $partialLen = $this->historyMarkerPrefixSuffixLength($remaining, $prefix);
            if ($partialLen > 0) {
                $pendingStart = strlen($remaining) - $partialLen;
            }
        }

        if ($pendingStart !== null) {
            $clean .= substr($remaining, 0, $pendingStart);
            $pending = substr($remaining, $pendingStart);
            if (strlen($pending) <= 8192) {
                $this->clients[$fd]['history_marker_buffer'] = $pending;
            } else {
                $clean .= $pending;
            }
        } else {
            $clean .= $remaining;
        }

        return $clean;
    }

    private function historyMarkerPrefixSuffixLength(string $value, string $prefix): int
    {
        $maxLen = min(strlen($value), strlen($prefix) - 1);
        for ($len = $maxLen; $len > 0; $len--) {
            if (substr($value, -$len) === substr($prefix, 0, $len)) {
                return $len;
            }
        }

        return 0;
    }

    private function recordHistoryCommand(int $fd, string $marker, int $historyNo, string $command): void
    {
        if (!isset($this->clients[$fd])) {
            return;
        }
        if ($marker === '' || $marker !== (string)($this->clients[$fd]['history_marker'] ?? '')) {
            return;
        }
        if ($historyNo <= 0 || (int)($this->clients[$fd]['history_last_no'] ?? 0) === $historyNo) {
            return;
        }

        $this->clients[$fd]['history_last_no'] = $historyNo;
        $command = $this->sanitizeOperationLog($command);
        if ($this->shouldSkipHistoryCommand($command)) {
            return;
        }

        $this->recordOperationLog($fd, $command);
    }

    private function shouldSkipHistoryCommand(string $command): bool
    {
        $trimmed = trim($command);
        if ($trimmed === '') {
            return true;
        }

        foreach ([
            'AUTOTEST_HISTORY',
            '__autotest_history_emit',
            'AUTOTEST_HISTORY_MARKER',
            'autotest_ssh_history_',
        ] as $needle) {
            if (stripos($trimmed, $needle) !== false) {
                return true;
            }
        }

        return false;
    }

    private function sanitizeOperationLog(string $log): string
    {
        $log = str_replace(["\r", "\n", "\0"], ' ', $log);
        $log = trim($log);

        return strlen($log) > 20000 ? substr($log, 0, 20000) : $log;
    }

    private function recordOperationLog(int $fd, string $log, ?int $boxId = null, ?int $userId = null): void
    {
        $log = $this->sanitizeOperationLog($log);
        if ($log === '') {
            return;
        }

        $conn = $this->clients[$fd] ?? [];
        $boxId = (int)($boxId ?: ($conn['box_id'] ?? 0));
        $userId = (int)($userId ?: ($conn['user_id'] ?? 0));
        if ($boxId <= 0 || $userId <= 0) {
            return;
        }

        try {
            EdgeGatewayLog::query()->create([
                'user_id' => $userId,
                'operator_name' => $this->clients[$fd]['user_name'] ?? null,
                'box_id' => $boxId,
                'action_type' => 'terminal',
                'log' => $log,
            ]);
        } catch (\Throwable $e) {
            $this->safeLog('warning', '[SSH_OPERATION_LOG_FAILED]', [
                'fd' => $fd,
                'box_id' => $boxId,
                'user_id' => $userId,
                'error' => $e->getMessage(),
            ]);
        }
    }

    // =========================
    // ========== SFTP ==========
    // =========================

    private function connectSftpByBoxId(int $fd): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) {
            return;
        }

        $boxId = $conn['box_id'] ?? null;
        if (!$boxId) {
            $this->pushError($fd, 'Missing/invalid box id (ws url must have ?id=123).');
            return;
        }

        $box = Box::query()->find($boxId);
        if (!$box) {
            $this->pushError($fd, "Box not found: id={$boxId}");
            return;
        }
        if (!$this->ensureBoxTerminalAccess($fd, $box)) {
            return;
        }

        $ip = (string)($box->ssh_ip ?? '');
        $port = (int)($box->ssh_port ?? 0);
        $user = (string)($box->ssh_name ?? '');

        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            $this->pushError($fd, "Invalid ssh_ip in Box(id={$boxId}): {$ip}");
            return;
        }
        if ($port < 1 || $port > 65535) {
            $this->pushError($fd, "Invalid ssh_port in Box(id={$boxId}): {$port}");
            return;
        }
        if ($user === '') {
            $this->pushError($fd, "Invalid ssh_name in Box(id={$boxId})");
            return;
        }

        $this->connectSftp($fd, $ip, $port, $user, $boxId);
    }

    private function connectSftpCustom(int $fd, string $host, int $port, string $user, array $auth): void
    {
        if (!isset($this->clients[$fd])) {
            return;
        }

        $port = ($port >= 1 && $port <= 65535) ? $port : 22;

        // 先断开旧 SFTP(保留 SSH 不动)
        $this->sftpDisconnect($fd, true);

        try {
            $sftp = new SFTP($host, $port, $this->sftpConnectTimeout);

            $type = (string)($auth['type'] ?? '');
            $ok = false;

            if ($type === 'password') {
                $password = (string)($auth['password'] ?? '');
                if ($password === '') {
                    $this->pushError($fd, 'Custom SFTP: password empty');
                    return;
                }
                $ok = $sftp->login($user, $password);
            } elseif ($type === 'key') {
                $privateKey = (string)($auth['privateKey'] ?? '');
                $passphrase = (string)($auth['passphrase'] ?? '');
                if ($privateKey === '') {
                    $this->pushError($fd, 'Custom SFTP: privateKey empty');
                    return;
                }
                $key = $passphrase !== ''
                    ? PublicKeyLoader::loadPrivateKey($privateKey, $passphrase)
                    : PublicKeyLoader::loadPrivateKey($privateKey);

                $ok = $sftp->login($user, $key);
            } else {
                $this->pushError($fd, 'Custom SFTP: unknown auth.type');
                return;
            }

            if (!$ok) {
                $this->pushError($fd, 'Custom SFTP: login returned false.');
                return;
            }

            $this->clients[$fd]['sftp'] = $sftp;
            $this->clients[$fd]['ip'] = $host;
            $this->clients[$fd]['port'] = $port;
            $this->clients[$fd]['user'] = $user;
            $this->clients[$fd]['auth'] = $auth;
            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'connected',
                'msg' => "SFTP connected: {$user}@{$host}:{$port} (Custom)",
                'root' => $this->sftpRoot,
            ], JSON_UNESCAPED_UNICODE));
        } catch (\Throwable $e) {
            $this->err($fd, 'connectSftpCustom exception', $e);
            $this->clients[$fd]['sftp'] = null;
        }
    }

    private function connectSftp(int $fd, string $ip, int $port, string $user, int $boxId): void
    {
        if (!isset($this->clients[$fd])) {
            return;
        }

        // 先断开旧 SFTP(保留 SSH 不动)
        $this->sftpDisconnect($fd, true);

        try {
            $keyMeta = $this->loadBoxPrivateKey($boxId);
            if ($keyMeta === null) {
                $this->pushError($fd, sprintf('Key not found for box: %d', $boxId));
                return;
            }

            $sftp = new SFTP($ip, $port, $this->sftpConnectTimeout);
            $ok = $sftp->login($user, $keyMeta['key']);

            if (!$ok) {
                $this->pushError($fd, 'SFTP login returned false.');
                return;
            }

            $this->clients[$fd]['sftp'] = $sftp;
            $this->clients[$fd]['ip'] = $ip;
            $this->clients[$fd]['port'] = $port;
            $this->clients[$fd]['user'] = $user;

            //  关键:保存 auth,给 task worker 的 put 使用
            $this->clients[$fd]['auth'] = [
                'type' => 'key',
                'privateKey' => $keyMeta['pem'],
                'passphrase' => '',
            ];

            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'connected',
                'msg' => "SFTP connected: {$user}@{$ip}:{$port} (BoxID={$boxId})",
                'root' => $this->sftpRoot,
            ], JSON_UNESCAPED_UNICODE));
        } catch (\Throwable $e) {
            $this->err($fd, 'connectSftp exception', $e);
            $this->clients[$fd]['sftp'] = null;
            // 如果失败,也清掉 auth,避免后续误用旧 auth
            if (isset($this->clients[$fd])) {
                $this->clients[$fd]['auth'] = null;
            }
        }
    }


    private function sftpEnsure(int $fd): ?SFTP
    {
        $sftp = $this->clients[$fd]['sftp'] ?? null;
        if (!$sftp) {
            $this->pushError($fd, 'SFTP not connected. Click "Connect SFTP" first.');
            return null;
        }
        return $sftp;
    }

    private function sftpNormalizePath(string $path): string
    {
        $path = trim($path);
        if ($path === '') {
            return '/';
        }
        if ($path[0] !== '/') {
            $path = '/' . $path;
        }

        // 规范化:去掉 /./ 和处理 /../
        $parts = [];
        foreach (explode('/', $path) as $p) {
            if ($p === '' || $p === '.') {
                continue;
            }
            if ($p === '..') {
                array_pop($parts);
                continue;
            }
            $parts[] = $p;
        }
        return '/' . implode('/', $parts);
    }

    private function sftpAssertAllowed(int $fd, string $path): ?string
    {
        $norm = $this->sftpNormalizePath($path);
        if ($this->sftpRoot === null) {
            return $norm;
        }

        $root = $this->sftpNormalizePath($this->sftpRoot);
        if ($root !== '/' && !str_starts_with($norm . '/', $root . '/')) {
            $this->pushError($fd, "Path not allowed. root={$root}, path={$norm}");
            return null;
        }
        return $norm;
    }

    private function sftpList(int $fd, string $path): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) {
            return;
        }

        $path = $this->sftpAssertAllowed($fd, $path);
        if ($path === null) {
            return;
        }

        $list = $sftp->rawlist($path);
        if ($list === false) {
            $this->pushError($fd, "List failed: {$path}");
            return;
        }

        $items = [];
        foreach ($list as $name => $meta) {
            if ($name === '.' || $name === '..') {
                continue;
            }
            $type = $meta['type'] ?? null; // 1=file, 2=dir, 3=symlink (phpseclib)
            $childPath = $this->sftpNormalizePath(rtrim($path, '/') . '/' . $name);
            $stat = $sftp->stat($childPath);
            $statType = $stat !== false ? ($stat['type'] ?? null) : null;
            $resolvedType = $statType ?? $type;
            $isDir = $resolvedType === 2;
            $isFile = $resolvedType === 1;

            $items[] = [
                'name' => $name,
                'is_dir' => $isDir,
                'is_file' => $isFile,
                'type' => $resolvedType,
                'size' => (int)(($stat !== false ? ($stat['size'] ?? null) : null) ?? ($meta['size'] ?? 0)),
                'mtime' => (int)(($stat !== false ? ($stat['mtime'] ?? null) : null) ?? ($meta['mtime'] ?? 0)),
            ];
        }

        usort($items, function ($a, $b) {
            if ($a['is_dir'] !== $b['is_dir']) {
                return $a['is_dir'] ? -1 : 1;
            }
            return strcmp($a['name'], $b['name']);
        });

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'list',
            'path' => $path,
            'items' => $items,
        ], JSON_UNESCAPED_UNICODE));
    }

    private function sftpMkdir(int $fd, string $path): void
    {
        if ($this->rejectMutatingSftpWhenRestricted($fd, '新建目录')) {
            return;
        }

        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) {
            return;
        }

        $path = $this->sftpAssertAllowed($fd, $path);
        if ($path === null) {
            return;
        }

        $ok = $sftp->mkdir($path, -1, true);
        if (!$ok) {
            $this->pushError($fd, "mkdir failed: {$path}");
            return;
        }

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'mkdir_ok',
            'path' => $path,
        ], JSON_UNESCAPED_UNICODE));
    }

    /**
     * 递归删除文件/目录(模拟 rm -rf)- 适配 phpseclib3
     */
    private function sftpRemove(int $fd, string $path): void
    {
        if ($this->rejectMutatingSftpWhenRestricted($fd, '删除文件')) {
            return;
        }

        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) {
            return;
        }

        $path = $this->sftpAssertAllowed($fd, $path);
        if ($path === null) {
            return;
        }

        try {
            // 1. 检查路径是否存在(phpseclib3 stat失败返回false,无异常)
            $st = $sftp->stat($path);
            if ($st === false) {
                $error = $sftp->getLastError() ?: '路径不存在或无访问权限';
                $this->pushError($fd, "删除失败: {$path} | {$error}");
                return;
            }

            $isDir = (($st['type'] ?? null) === 2);
            $ok = false;

            if ($isDir) {
                // 2. 目录:递归删除内部所有内容
                $ok = $this->sftpRecursiveRemoveDir($sftp, $path);
            } else {
                // 3. 文件:直接删除
                $ok = $sftp->delete($path);
            }

            if (!$ok) {
                $error = $sftp->getLastError() ?: '未知错误';
                $this->pushError($fd, ($isDir ? "递归删除目录" : "删除文件") . "失败: {$path} | {$error} | 请检查SFTP用户权限");
                return;
            }

            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'rm_ok',
                'path' => $path,
            ], JSON_UNESCAPED_UNICODE));
        } catch (\Throwable $e) {
            // 捕获phpseclib3可能抛出的异常
            $this->err($fd, '删除操作异常', $e, ['path' => $path]);
        }
    }

    /**
     * 递归删除目录(内部方法)- 适配 phpseclib3
     */
    private function sftpRecursiveRemoveDir(SFTP $sftp, string $dir): bool
    {
        try {
            // 获取目录下所有文件/子目录(包含隐藏文件,phpseclib3 rawlist返回数组或false)
            $list = $sftp->rawlist($dir);
            if ($list === false) {
                return false;
            }

            foreach ($list as $name => $meta) {
                if ($name === '.' || $name === '..') {
                    continue;
                }

                $fullPath = rtrim($dir, '/') . '/' . $name;
                $isSubDir = (($meta['type'] ?? null) === 2);

                if ($isSubDir) {
                    // 递归删除子目录
                    if (!$this->sftpRecursiveRemoveDir($sftp, $fullPath)) {
                        return false;
                    }
                } else {
                    // 删除文件(phpseclib3 delete返回bool)
                    if (!$sftp->delete($fullPath)) {
                        return false;
                    }
                }
            }

            // 最后删除空目录(phpseclib3 rmdir返回bool)
            return $sftp->rmdir($dir);
        } catch (\Throwable $e) {
            // 记录递归删除中的异常
            $this->safeLog('error', '递归删除目录异常', [
                'dir' => $dir,
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString()
            ]);
            return false;
        }
    }

    private function sftpRename(int $fd, string $from, string $to): void
    {
        if ($this->rejectMutatingSftpWhenRestricted($fd, '重命名')) {
            return;
        }

        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) {
            return;
        }

        $from = $this->sftpAssertAllowed($fd, $from);
        $to = $this->sftpAssertAllowed($fd, $to);
        if ($from === null || $to === null) {
            return;
        }

        $ok = $sftp->rename($from, $to);
        if (!$ok) {
            $this->pushError($fd, "rename failed: {$from} -> {$to}");
            return;
        }

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'rename_ok',
            'from' => $from,
            'to' => $to,
        ], JSON_UNESCAPED_UNICODE));
    }

    private function sftpDownloadBegin(int $fd, string $path, int $chunk): void
    {
        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) {
            return;
        }

        $path = $this->sftpAssertAllowed($fd, $path);
        if ($path === null) {
            return;
        }

        $chunk = max(16 * 1024, min(512 * 1024, $chunk)); // 16KB ~ 512KB

        $st = $sftp->stat($path);
        if ($st === false) {
            $this->pushError($fd, "download stat failed: {$path}");
            return;
        }
        if (($st['type'] ?? null) === 2) {
            $this->pushError($fd, "download failed: is a directory: {$path}");
            return;
        }

        $size = (int)($st['size'] ?? 0);

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'download_begin',
            'path' => $path,
            'size' => $size,
            'chunk' => $chunk,
        ], JSON_UNESCAPED_UNICODE));

        // 开始新下载前,先取消旧的

        if (!empty($this->clients[$fd]['download_co'])) {
            try {
                Coroutine::cancel($this->clients[$fd]['download_co']);
            } catch (\Throwable $e) {
            }
            $this->clients[$fd]['download_co'] = null;
            $this->clients[$fd]['download_path'] = null;
        }



        $this->clients[$fd]['download_path'] = $path;

        $coId = Coroutine::create(function () use ($fd, $sftp, $path, $size, $chunk) {
            try {
                $offset = 0;
                $seq = 0;

                while ($offset < $size) {
                    if (!isset($this->clients[$fd]) || !$this->ws->isEstablished($fd)) {
                        return;
                    }

                    //  如果被取消/切换到另一个下载,就退出
                    if (($this->clients[$fd]['download_path'] ?? '') !== $path) {
                        return;
                    }


                    $len = min($chunk, $size - $offset);
                    $bin = $sftp->get($path, false, $offset, $len);
                    if ($bin === false) {
                        $this->pushError($fd, "download get failed at offset={$offset}: {$path}");
                        return;
                    }

                    $this->ws->push($fd, json_encode([
                        'type' => 'sftp',
                        'event' => 'download_chunk',
                        'path' => $path,
                        'seq' => $seq,
                        'offset' => $offset,
                        'data_b64' => base64_encode($bin),
                    ], JSON_UNESCAPED_UNICODE));

                    $offset += $len;
                    $seq++;
                    Coroutine::sleep(0.001);
                }

                // 正常结束时,清掉状态
                if (isset($this->clients[$fd])) {
                    $this->clients[$fd]['download_co'] = null;
                    $this->clients[$fd]['download_path'] = null;
                }

                $this->recordOperationLog($fd, '下载  ' . $path);

                $this->ws->push($fd, json_encode([
                    'type' => 'sftp',
                    'event' => 'download_end',
                    'path' => $path,
                    'chunks' => $seq,
                    'size' => $size,
                ], JSON_UNESCAPED_UNICODE));
            } catch (\Throwable $e) {
                $this->err($fd, 'download coroutine exception', $e);
            }
        });

        $this->clients[$fd]['download_co'] = $coId;
    }

    private function sftpDownloadCancel(int $fd, bool $notify = true): void
    {
        $hadActiveDownload = false;
        if (isset($this->clients[$fd])) {
            $hadActiveDownload = !empty($this->clients[$fd]['download_path']) || !empty($this->clients[$fd]['download_co']);
            //  先让协程的 while 检查能立刻退出
            $this->clients[$fd]['download_path'] = null;
        }

        $co = $this->clients[$fd]['download_co'] ?? null;
        if ($co) {
            try {
                Coroutine::cancel($co);
            } catch (\Throwable $e) {
            }
        }

        if (isset($this->clients[$fd])) {
            $this->clients[$fd]['download_co'] = null;
        }

        if ($notify && $hadActiveDownload && $this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'download_cancelled',
            ], JSON_UNESCAPED_UNICODE));
        }
    }



    private function sftpUploadBegin(int $fd, string $remotePath, int $size): void
    {
        if ($this->rejectMutatingSftpWhenRestricted($fd, '上传文件')) {
            return;
        }

        $sftp = $this->sftpEnsure($fd);
        if (!$sftp) {
            return;
        }

        $remotePath = $this->sftpAssertAllowed($fd, $remotePath);
        if ($remotePath === null) {
            return;
        }

        // 清理旧的
        $this->sftpUploadCleanup($fd);

        $tmp = storage_path('app/sftp_upload_' . $fd . '_' . md5($remotePath . microtime(true)) . '.tmp');
        @file_put_contents($tmp, '');

        $this->clients[$fd]['upload_tmp'] = $tmp;
        $this->clients[$fd]['upload_target'] = $remotePath;

        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'upload_begin_ok',
            'path' => $remotePath,
            'tmp' => basename($tmp),
            'size' => $size,
        ], JSON_UNESCAPED_UNICODE));
    }

    private function sftpUploadChunk(int $fd, int $seq, string $b64): void
    {
        if ($this->rejectMutatingSftpWhenRestricted($fd, '上传文件')) {
            return;
        }

        $tmp = $this->clients[$fd]['upload_tmp'] ?? null;
        if (!$tmp || !is_string($tmp)) {
            $this->pushError($fd, 'upload_chunk: no active upload (call sftp_upload_begin first)');
            return;
        }

        $bin = base64_decode($b64, true);
        if ($bin === false) {
            $this->pushError($fd, 'upload_chunk: invalid base64');
            return;
        }

        $ok = @file_put_contents($tmp, $bin, FILE_APPEND);
        if ($ok === false) {
            $this->pushError($fd, 'upload_chunk: write tmp failed');
            return;
        }

        // 可选:回 ACK(前端可不处理)
        $this->ws->push($fd, json_encode([
            'type' => 'sftp',
            'event' => 'upload_chunk_ok',
            'seq' => $seq,
        ], JSON_UNESCAPED_UNICODE));
    }
    private function findAliveFdByBoxId(int $boxId): ?int
    {
        if ($boxId <= 0) {
            return null;
        }

        $tableRow = $this->boxFdTable->get((string) $boxId);
        if ($tableRow) {
            $fd = (int) ($tableRow['fd'] ?? 0);
            if ($fd > 0 && $this->ws->isEstablished($fd)) {
                return $fd;
            }
            $this->boxFdTable->del((string) $boxId);
        }

        //  优先用 boxFdMap(最准)
        $fd = $this->boxFdMap[$boxId] ?? null;
        if ($fd && $this->ws->isEstablished((int)$fd)) {
            $this->boxFdTable->set((string) $boxId, [
                'fd' => (int) $fd,
                'updated_at' => time(),
            ]);
            return (int)$fd;
        }

        //  fallback:遍历 clients
        foreach ($this->clients as $fd2 => $conn) {
            if (($conn['box_id'] ?? null) === $boxId && $this->ws->isEstablished((int)$fd2)) {
                // 顺手修正映射
                $this->boxFdMap[$boxId] = (int)$fd2;
                $this->boxFdTable->set((string) $boxId, [
                    'fd' => (int) $fd2,
                    'updated_at' => time(),
                ]);
                return (int)$fd2;
            }
        }

        return null;
    }




    private function sftpUploadEnd(int $fd): void
    {
        if ($this->rejectMutatingSftpWhenRestricted($fd, '上传文件')) {
            $this->sftpUploadCleanup($fd);
            return;
        }

        $tmp = $this->clients[$fd]['upload_tmp'] ?? null;
        $remote = $this->clients[$fd]['upload_target'] ?? null;

        if (!$tmp || !$remote) {
            $this->pushError($fd, 'upload_end: no active upload');
            return;
        }

        if (!is_file($tmp)) {
            $this->pushError($fd, 'upload_end: tmp missing');
            $this->sftpUploadCleanup($fd);
            return;
        }

        $host = (string)($this->clients[$fd]['ip'] ?? '');
        $port = (int)($this->clients[$fd]['port'] ?? 22);
        $user = (string)($this->clients[$fd]['user'] ?? '');
        $auth = $this->clients[$fd]['auth'] ?? null;
        $boxId = (int)($this->clients[$fd]['box_id'] ?? 0);
        $userId = (int)($this->clients[$fd]['user_id'] ?? 0);

        if ($host === '' || $user === '' || !is_array($auth)) {
            $this->pushError($fd, 'upload_end: missing sftp connection/auth (connect sftp first)');
            return;
        }

        //  提示前端:开始“远端写入”
        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type'  => 'sftp',
                'event' => 'upload_put_start',
                'path'  => $remote,
            ], JSON_UNESCAPED_UNICODE));
        }

        // 投递 task
        $taskId = $this->ws->task([
            'type'   => 'sftp_upload_put',
            'fd'     => $fd,
            'box_id' => $boxId,
            'user_id' => $userId,
            'host'   => $host,
            'port'   => $port,
            'user'   => $user,
            'auth'   => $auth,
            'remote' => $remote,
            'tmp'    => $tmp,
        ]);

        $this->clients[$fd]['upload_task_id'] = $taskId;

        //  清掉上传状态,避免重复 end;但 tmp 不删,Finish 里删
        $this->clients[$fd]['upload_tmp'] = null;
        $this->clients[$fd]['upload_target'] = null;
    }




    private function sftpUploadCleanup(int $fd): void
    {
        $tmp = $this->clients[$fd]['upload_tmp'] ?? null;
        if ($tmp && is_string($tmp) && is_file($tmp)) {
            @unlink($tmp);
        }
        if (isset($this->clients[$fd])) {
            $this->clients[$fd]['upload_tmp'] = null;
            $this->clients[$fd]['upload_target'] = null;
        }
    }

    private function sftpDisconnect(int $fd, bool $silent = false): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) {
            return;
        }

        $this->sftpUploadCleanup($fd);

        if (!empty($conn['sftp'])) {
            try {
                $conn['sftp']->disconnect();
            } catch (\Throwable $e) {
            }
            $this->clients[$fd]['sftp'] = null;
        }

        if (isset($this->clients[$fd])) {
            $this->clients[$fd]['auth'] = null;
        }

        if (!$silent && $this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'sftp',
                'event' => 'disconnected',
                'msg' => 'SFTP disconnected.',
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    // =========================
    // ===== Disconnect All =====
    // =========================

    private function disconnect(int $fd, bool $isClose = false): void
    {
        $conn = $this->clients[$fd] ?? null;
        if (!$conn) {
            return;
        }
        if (($conn['disconnecting'] ?? false)) {
            $readerCo = $conn['reader_co'] ?? null;
            if ($readerCo && $readerCo !== Coroutine::getCid()) {
                try {
                    Coroutine::cancel($readerCo);
                } catch (\Throwable $e) {
                }
            }
            return;
        }
        $this->clients[$fd]['disconnecting'] = true;
        $this->clients[$fd]['connected'] = false;

        // 先停 SSH reader
        $readerCo = $conn['reader_co'] ?? null;
        if ($readerCo && $readerCo !== Coroutine::getCid()) {
            try {
                Coroutine::cancel($readerCo);
            } catch (\Throwable $e) {
            }
        }
        if (isset($this->clients[$fd])) {
            $this->clients[$fd]['reader_co'] = null;
        }

        // 断 SSH
        if (!empty($conn['ssh'])) {
            try {
                $this->cleanupRemoteHistoryLogging($fd, $conn['ssh']);
                $conn['ssh']->disconnect();
            } catch (\Throwable $e) {
            }
            $this->clients[$fd]['ssh'] = null;
        }
        $this->sftpDownloadCancel($fd, false);
        // 断 SFTP(新增)
        $this->sftpDisconnect($fd, true);
        if (!empty($conn['restricted_completion_sftp'])) {
            try {
                $conn['restricted_completion_sftp']->disconnect();
            } catch (\Throwable $e) {
            }
        }
        $this->clients[$fd]['ip'] = '';
        $this->clients[$fd]['port'] = 0;
        $this->clients[$fd]['user'] = '';
        $this->clients[$fd]['auth'] = null;
        $this->clients[$fd]['upload_task_id'] = null;
        $this->clients[$fd]['terminal_restricted'] = null;
        $this->clients[$fd]['restricted_line_editor'] = null;
        $this->clients[$fd]['restricted_interactive_command'] = null;
        $this->clients[$fd]['restricted_completion_sftp'] = null;
        $this->clients[$fd]['restricted_completion_home'] = null;
        $this->clients[$fd]['restricted_completion_cwd'] = '~';
        $this->clients[$fd]['restricted_completion_previous_cwd'] = null;

        if (!$isClose && $this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'system',
                'msg'  => 'SSH disconnected.'
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    private function pushOutput(int $fd, string $out): void
    {
        $out = $this->filterHistoryMarkersFromOutput($fd, $out);
        if ($out === '') {
            return;
        }
        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'output',
                'data' => $out
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    private function pushError(int $fd, string $msg): void
    {
        $this->safeLog('warning', '[PUSH_ERROR]', ['fd' => $fd, 'msg' => $msg]);
        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'error',
                'msg'  => $msg
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    private function dbg(int $fd, string $msg, array $ctx = []): void
    {
        if (!$this->debugEnabled) {
            return;
        }

        if ($this->ws->isEstablished($fd)) {
            $this->ws->push($fd, json_encode([
                'type' => 'system',
                'msg'  => '[DBG] ' . $msg . (empty($ctx) ? '' : ' ' . json_encode($ctx, JSON_UNESCAPED_UNICODE))
            ], JSON_UNESCAPED_UNICODE));
        }
    }

    private function err(int $fd, string $msg, ?\Throwable $e = null, array $ctx = []): void
    {
        $payload = array_merge(['fd' => $fd], $ctx);
        if ($e) {
            $payload['error'] = $e->getMessage();
            $payload['trace'] = $e->getTraceAsString();
            $payload['file'] = $e->getFile();
            $payload['line'] = $e->getLine();
        }
        $this->safeLog('error', '[ERR] ' . $msg, $payload);
        $this->pushError($fd, $msg . ($e ? (' | ' . $e->getMessage()) : ''));
    }

    private function safeLog(string $level, string $message, array $context = []): void
    {
        try {
            if ($level === 'error') {
                Log::error($message, $context);
                return;
            }

            if ($level === 'warning') {
                Log::warning($message, $context);
                return;
            }

            Log::info($message, $context);
        } catch (\Throwable) {
        }
    }

    private function ensureBoxTerminalAccess(int $fd, Box $box): bool
    {
        return true;
    }

    /**
     * @return array{path:string,pem:string,key:object}|null
     */
    private function loadBoxPrivateKey(int $boxId): ?array
    {
        $path = $this->resolveBoxPrivateKeyPath($boxId);
        if ($path === null) {
            return null;
        }

        $mtime = (int) (@filemtime($path) ?: 0);
        if (($this->boxPrivateKeyMtimeCache[$path] ?? null) !== $mtime) {
            unset($this->boxPrivateKeyPemCache[$path], $this->boxPrivateKeyObjectCache[$path]);
            $this->boxPrivateKeyMtimeCache[$path] = $mtime;
        }

        if (!isset($this->boxPrivateKeyPemCache[$path])) {
            $pem = @file_get_contents($path);
            if (!is_string($pem) || trim($pem) === '') {
                return null;
            }

            $this->boxPrivateKeyPemCache[$path] = $pem;
        }

        if (!isset($this->boxPrivateKeyObjectCache[$path])) {
            $this->boxPrivateKeyObjectCache[$path] = PublicKeyLoader::loadPrivateKey($this->boxPrivateKeyPemCache[$path]);
        }

        return [
            'path' => $path,
            'pem' => $this->boxPrivateKeyPemCache[$path],
            'key' => $this->boxPrivateKeyObjectCache[$path],
        ];
    }

    private function resolveBoxPrivateKeyPath(int $boxId): ?string
    {
        $boxKeyPath = storage_path('app/edge-gateway-id-rsa/id_rsa_' . $boxId);
        if (is_file($boxKeyPath)) {
            return $boxKeyPath;
        }

        $defaultKeyPath = storage_path('app/edge-gateway-id-rsa/id_rsa');

        return is_file($defaultKeyPath) ? $defaultKeyPath : null;
    }
}
chowjiawei
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!