cicy-desktop 2.1.332 → 2.1.334
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/build-homepage.cjs +20 -0
- package/src/backends/homepage-react/panel/facebook-matrix.html +381 -0
- package/src/backends/homepage-react/panel/redroid-matrix.html +668 -0
- package/src/backends/homepage-react/panel/split-panel.html +832 -0
- package/src/backends/homepage-react/panel/telegram-matrix.html +521 -0
- package/src/backends/homepage-window.js +18 -18
- package/src/tabbrowser/newtab-protocol.js +36 -3
- package/test/homepage-remote.test.js +26 -13
- package/test/panel-remote-source.test.js +73 -0
- package/workers/render/public/panel/facebook-matrix.html +381 -0
- package/workers/render/public/panel/redroid-matrix.html +668 -0
- package/workers/render/public/panel/split-panel.html +832 -0
- package/workers/render/public/panel/telegram-matrix.html +521 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>Redroid 矩阵</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
--bg:#0b0b0d; --panel:#121215; --card:#17171b; --card2:#1d1d22; --line:rgba(255,255,255,.07); --line2:rgba(255,255,255,.12);
|
|
11
|
+
--fg:#ececf1; --fg2:#b6b6c0; --muted:#7c7c88; --accent:#4f8cff; --accent2:#3b6fd6;
|
|
12
|
+
--ok:#2ecc71; --warn:#f5a623; --bad:#ff5c5c; --info:#7aa2ff;
|
|
13
|
+
--gA:#2ecc71; --gB:#8fd14f; --gC:#f5a623; --gD:#ff5c5c;
|
|
14
|
+
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
|
15
|
+
}
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: var(--bg); color: var(--fg); font-size: 13px; line-height: 1.45; }
|
|
18
|
+
body { display: grid; grid-template-columns: 296px minmax(0, 1fr) 380px; }
|
|
19
|
+
button { font: inherit; border: 0; border-radius: 8px; padding: 7px 12px; color: #fff; background: var(--accent); cursor: pointer; font-weight: 600; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; line-height: 1.2; transition: background .12s, filter .12s; }
|
|
20
|
+
button:hover { filter: brightness(1.08); }
|
|
21
|
+
button:disabled { opacity: .5; cursor: default; filter: none; }
|
|
22
|
+
button.ghost { background: var(--card2); color: var(--fg); border: 1px solid var(--line2); }
|
|
23
|
+
button.ghost:hover { background: #26262c; }
|
|
24
|
+
button.subtle { background: transparent; color: var(--fg2); border: 1px solid transparent; }
|
|
25
|
+
button.subtle:hover { background: var(--card2); color: var(--fg); }
|
|
26
|
+
button.sm { padding: 4px 9px; font-size: 12px; border-radius: 6px; }
|
|
27
|
+
button.danger { background: #b3261e; }
|
|
28
|
+
button svg { width: 15px; height: 15px; flex: none; }
|
|
29
|
+
input, select, textarea { font: inherit; background: #0e0e11; border: 1px solid var(--line2); color: var(--fg); border-radius: 7px; padding: 6px 9px; min-width: 0; }
|
|
30
|
+
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(79,140,255,.2); }
|
|
31
|
+
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-thumb { background: #2a2a31; border-radius: 8px; }
|
|
32
|
+
.ico { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
33
|
+
|
|
34
|
+
/* ── sidebar ─────────────────────────────────────────────── */
|
|
35
|
+
aside { background: var(--panel); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-width: 0; }
|
|
36
|
+
.side-head { padding: 14px 14px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
37
|
+
.side-head h1 { margin: 0; font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
|
38
|
+
.side-head h1 .cnt { font-size: 11px; color: var(--muted); font-weight: 500; background: var(--card2); padding: 1px 7px; border-radius: 999px; }
|
|
39
|
+
.side-tools { display: flex; gap: 6px; padding: 0 14px 10px; }
|
|
40
|
+
.side-tools input { flex: 1; padding: 5px 9px; font-size: 12px; }
|
|
41
|
+
.list { flex: 1; overflow: auto; padding: 4px 10px 10px; display: flex; flex-direction: column; gap: 6px; }
|
|
42
|
+
.card { position: relative; padding: 10px 12px; border-radius: 10px; background: var(--card); border: 1px solid var(--line); cursor: pointer; transition: border-color .12s, background .12s; }
|
|
43
|
+
.card:hover { background: var(--card2); }
|
|
44
|
+
.card.active { border-color: var(--accent); background: rgba(79,140,255,.08); }
|
|
45
|
+
.card .l1 { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
46
|
+
.card .nm { font-weight: 650; font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
47
|
+
.card .ver { font-size: 10.5px; color: var(--fg2); background: var(--card2); border: 1px solid var(--line2); border-radius: 5px; padding: 1px 5px; flex: none; }
|
|
48
|
+
.card .st { margin-left: auto; font-size: 11px; color: var(--muted); white-space: nowrap; flex: none; }
|
|
49
|
+
.card .l2 { margin-top: 6px; display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--fg2); min-width: 0; }
|
|
50
|
+
.card .geo { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
51
|
+
.card .geo.none { color: var(--muted); }
|
|
52
|
+
.card .l3 { margin-top: 5px; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); }
|
|
53
|
+
.flag { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 16px; padding: 0 4px; border-radius: 4px; font-size: 10px; font-weight: 800; letter-spacing: .5px; color: #dfe6ff; background: rgba(122,162,255,.18); border: 1px solid rgba(122,162,255,.35); flex: none; }
|
|
54
|
+
.flag:empty { display: none; }
|
|
55
|
+
.grade { display: inline-flex; align-items: center; gap: 4px; font-size: 10.5px; font-weight: 700; padding: 1px 6px; border-radius: 5px; border: 1px solid; letter-spacing: .2px; flex: none; }
|
|
56
|
+
.grade.A { color: var(--gA); border-color: rgba(46,204,113,.45); background: rgba(46,204,113,.1); }
|
|
57
|
+
.grade.B { color: var(--gB); border-color: rgba(143,209,79,.45); background: rgba(143,209,79,.1); }
|
|
58
|
+
.grade.C { color: var(--gC); border-color: rgba(245,166,35,.45); background: rgba(245,166,35,.1); }
|
|
59
|
+
.grade.D { color: var(--gD); border-color: rgba(255,92,92,.45); background: rgba(255,92,92,.1); }
|
|
60
|
+
.grade.none { color: var(--muted); border-color: var(--line2); }
|
|
61
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: #3a3a42; flex: none; box-shadow: 0 0 0 2px rgba(0,0,0,.25); }
|
|
62
|
+
.dot.ok { background: var(--ok); } .dot.booting { background: var(--warn); animation: pulse 1s infinite; } .dot.bad { background: var(--bad); } .dot.off { background: #3a3a42; }
|
|
63
|
+
@keyframes pulse { 50% { opacity: .3; } }
|
|
64
|
+
#empty { display: none; padding: 40px 20px; color: var(--muted); text-align: center; line-height: 1.8; }
|
|
65
|
+
#empty.on { display: block; }
|
|
66
|
+
.side-foot { padding: 10px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; line-height: 1.5; }
|
|
67
|
+
|
|
68
|
+
/* create form */
|
|
69
|
+
#create { display: none; margin: 0 10px 8px; padding: 12px; border: 1px solid var(--line2); border-radius: 10px; background: var(--card); }
|
|
70
|
+
#create.on { display: block; }
|
|
71
|
+
#create h3 { margin: 0 0 10px; font-size: 13px; }
|
|
72
|
+
#create .f { display: grid; grid-template-columns: 56px 1fr; gap: 8px; align-items: center; margin-bottom: 8px; }
|
|
73
|
+
#create .f label { color: var(--muted); font-size: 12px; }
|
|
74
|
+
#create .res { display: flex; gap: 6px; } #create .res input { width: 33%; }
|
|
75
|
+
#create .btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 6px; }
|
|
76
|
+
.err { color: #ff8a8a; font-size: 11.5px; margin-top: 6px; white-space: pre-wrap; display: none; }
|
|
77
|
+
.err.on { display: block; }
|
|
78
|
+
|
|
79
|
+
/* ── stage ────────────────────────────────────────────────── */
|
|
80
|
+
main { min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
81
|
+
.topbar { height: 52px; flex: none; display: flex; align-items: center; gap: 12px; padding: 0 18px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
82
|
+
.topbar .title { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
|
83
|
+
.topbar .title b { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
84
|
+
.topbar .meta { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
85
|
+
.topbar .sp { flex: 1; }
|
|
86
|
+
.topbar .actions { display: flex; gap: 6px; }
|
|
87
|
+
.stage { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; padding: 14px 18px 12px; gap: 12px; }
|
|
88
|
+
.stage-fit { flex: 1; min-height: 0; width: 100%; display: flex; align-items: center; justify-content: center; }
|
|
89
|
+
.frame { position: relative; height: 100%; aspect-ratio: 9 / 16; max-width: 100%; border-radius: 30px; padding: 10px; background: linear-gradient(180deg, #202026, #141418); border: 1px solid var(--line2); box-shadow: 0 30px 80px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.04); }
|
|
90
|
+
.frame .glass { position: relative; width: 100%; height: 100%; border-radius: 21px; overflow: hidden; background: #000; }
|
|
91
|
+
#screen { display: block; width: 100%; height: 100%; object-fit: contain; cursor: default; user-select: none; -webkit-user-drag: none; }
|
|
92
|
+
#screen:active { cursor: grabbing; }
|
|
93
|
+
.overlay { position: absolute; inset: 0; display: none; place-items: center; text-align: center; padding: 28px; color: var(--fg); background: rgba(14,14,17,.92); z-index: 2; }
|
|
94
|
+
.overlay.on { display: grid; }
|
|
95
|
+
.overlay p { margin: 6px 0 0; color: var(--muted); font-size: 12.5px; line-height: 1.6; }
|
|
96
|
+
.overlay h4 { margin: 0; font-size: 14px; font-weight: 600; }
|
|
97
|
+
.spinner { width: 30px; height: 30px; border: 3px solid rgba(255,255,255,.1); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin: 0 auto 12px; }
|
|
98
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
99
|
+
.fps { position: absolute; right: 12px; bottom: 12px; z-index: 3; font-size: 10.5px; color: rgba(255,255,255,.55); background: rgba(0,0,0,.45); padding: 2px 7px; border-radius: 999px; pointer-events: none; }
|
|
100
|
+
.controls { flex: none; display: flex; flex-direction: column; align-items: center; gap: 10px; width: min(560px, 100%); }
|
|
101
|
+
.navbar { display: flex; gap: 4px; padding: 5px; background: var(--card); border: 1px solid var(--line2); border-radius: 12px; }
|
|
102
|
+
.navbar button { background: transparent; color: var(--fg2); padding: 7px 12px; border-radius: 8px; font-weight: 500; font-size: 12px; flex-direction: column; gap: 3px; min-width: 62px; }
|
|
103
|
+
.navbar button svg { width: 18px; height: 18px; }
|
|
104
|
+
.navbar button:hover { background: var(--card2); color: var(--fg); filter: none; }
|
|
105
|
+
.navbar button.on { color: var(--accent); }
|
|
106
|
+
.navbar .sep { width: 1px; background: var(--line2); margin: 6px 3px; }
|
|
107
|
+
.textrow { display: flex; gap: 6px; width: 100%; }
|
|
108
|
+
.textrow input { flex: 1; }
|
|
109
|
+
|
|
110
|
+
/* ── inspector ───────────────────────────────────────────── */
|
|
111
|
+
.inspector { background: var(--panel); border-left: 1px solid var(--line); display: flex; flex-direction: column; min-width: 0; }
|
|
112
|
+
.tabs { display: flex; padding: 8px 10px 0; gap: 2px; border-bottom: 1px solid var(--line); }
|
|
113
|
+
.tabs button { background: transparent; color: var(--muted); border-radius: 8px 8px 0 0; padding: 9px 13px; font-weight: 600; font-size: 12.5px; }
|
|
114
|
+
.tabs button:hover { color: var(--fg); background: var(--card); filter: none; }
|
|
115
|
+
.tabs button.on { color: var(--fg); background: var(--card); box-shadow: inset 0 -2px var(--accent); }
|
|
116
|
+
.pane { display: none; flex: 1; min-height: 0; overflow: auto; padding: 14px; }
|
|
117
|
+
.pane.on { display: block; }
|
|
118
|
+
.sec { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; margin-bottom: 10px; }
|
|
119
|
+
.sec h3 { margin: 0 0 10px; font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .5px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
120
|
+
.sec h3 .r { display: flex; gap: 6px; text-transform: none; letter-spacing: 0; }
|
|
121
|
+
.kv { display: grid; grid-template-columns: 78px 1fr; gap: 7px 10px; align-items: center; font-size: 12.5px; }
|
|
122
|
+
.kv label { color: var(--muted); }
|
|
123
|
+
.kv .v { display: flex; align-items: center; gap: 7px; min-width: 0; flex-wrap: wrap; }
|
|
124
|
+
code, .mono { font: 11.5px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
125
|
+
.kv code { color: #cfcfd8; background: #0e0e11; padding: 2px 6px; border-radius: 5px; border: 1px solid var(--line); word-break: break-all; }
|
|
126
|
+
.badge { font-size: 11px; border-radius: 999px; padding: 2px 8px; border: 1px solid var(--line2); color: var(--fg2); display: inline-flex; align-items: center; gap: 5px; }
|
|
127
|
+
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .9; }
|
|
128
|
+
.badge.ok { color: #7fe3a5; border-color: rgba(46,204,113,.4); } .badge.bad { color: #ff9a9a; border-color: rgba(255,92,92,.4); } .badge.warn { color: #ffcf7a; border-color: rgba(245,166,35,.4); } .badge.dim { color: var(--muted); }
|
|
129
|
+
.switch { position: relative; width: 36px; height: 20px; border-radius: 999px; background: #3a3a42; border: 0; padding: 0; cursor: pointer; flex: none; }
|
|
130
|
+
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
|
131
|
+
.switch.on { background: var(--ok); } .switch.on::after { transform: translateX(16px); }
|
|
132
|
+
.switch:disabled { opacity: .4; }
|
|
133
|
+
.row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
134
|
+
.danger-zone { border-color: rgba(255,92,92,.25); }
|
|
135
|
+
.danger-zone p { margin: 0 0 8px; color: var(--muted); font-size: 12px; }
|
|
136
|
+
.confirm { display: none; margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: rgba(255,92,92,.1); color: #ffb3b3; font-size: 12px; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
137
|
+
.confirm.on { display: flex; }
|
|
138
|
+
.confirm .sp { flex: 1; }
|
|
139
|
+
|
|
140
|
+
/* ip report */
|
|
141
|
+
.ipcard { border-radius: 10px; padding: 14px; background: linear-gradient(160deg, #1b1b21, #141418); border: 1px solid var(--line2); }
|
|
142
|
+
.ipcard .top { display: flex; align-items: center; gap: 12px; }
|
|
143
|
+
.ipcard .big { font-size: 20px; font-weight: 700; letter-spacing: .3px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
144
|
+
.ipcard .geo { color: var(--fg2); font-size: 12.5px; margin-top: 2px; display: flex; align-items: center; gap: 6px; }
|
|
145
|
+
.ipcard .gbox { margin-left: auto; text-align: center; min-width: 64px; padding: 8px 10px; border-radius: 10px; border: 1px solid; }
|
|
146
|
+
.ipcard .gbox b { display: block; font-size: 24px; line-height: 1; }
|
|
147
|
+
.ipcard .gbox span { font-size: 10.5px; }
|
|
148
|
+
.ipcard .gbox.A { color: var(--gA); border-color: rgba(46,204,113,.5); background: rgba(46,204,113,.08); }
|
|
149
|
+
.ipcard .gbox.B { color: var(--gB); border-color: rgba(143,209,79,.5); background: rgba(143,209,79,.08); }
|
|
150
|
+
.ipcard .gbox.C { color: var(--gC); border-color: rgba(245,166,35,.5); background: rgba(245,166,35,.08); }
|
|
151
|
+
.ipcard .gbox.D { color: var(--gD); border-color: rgba(255,92,92,.5); background: rgba(255,92,92,.08); }
|
|
152
|
+
.ipcard .gbox.none { color: var(--muted); border-color: var(--line2); }
|
|
153
|
+
.ipcard .verdict { margin-top: 12px; padding: 9px 10px; border-radius: 8px; background: rgba(255,255,255,.04); font-size: 12px; color: var(--fg2); line-height: 1.55; }
|
|
154
|
+
.checks { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 5px 10px; }
|
|
155
|
+
.chk { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--fg2); }
|
|
156
|
+
.chk i { width: 16px; height: 16px; border-radius: 50%; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 800; flex: none; }
|
|
157
|
+
.chk.good i { background: rgba(46,204,113,.18); color: var(--gA); } .chk.badf i { background: rgba(255,92,92,.2); color: var(--gD); } .chk.dim i { background: var(--card2); color: var(--muted); }
|
|
158
|
+
.ipcard .foot { margin-top: 10px; display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--muted); }
|
|
159
|
+
.chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
|
160
|
+
.chip { font-size: 11px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line2); background: var(--card2); color: var(--fg2); cursor: pointer; }
|
|
161
|
+
.chip:hover { color: var(--fg); border-color: var(--accent); }
|
|
162
|
+
.legend { margin-top: 10px; font-size: 11px; color: var(--muted); line-height: 1.7; }
|
|
163
|
+
.legend b { font-weight: 700; }
|
|
164
|
+
|
|
165
|
+
/* apps */
|
|
166
|
+
.apps-top { display: flex; gap: 6px; margin-bottom: 10px; } .apps-top input { flex: 1; }
|
|
167
|
+
.app { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; background: var(--card); border: 1px solid var(--line); margin-bottom: 5px; }
|
|
168
|
+
.app .pk { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
169
|
+
.app .pk small { display: block; color: var(--muted); font-size: 11px; }
|
|
170
|
+
.app-ico { width: 28px; height: 28px; border-radius: 8px; background: var(--card2); display: grid; place-items: center; color: var(--fg2); font-weight: 700; font-size: 12px; flex: none; }
|
|
171
|
+
|
|
172
|
+
/* terminal */
|
|
173
|
+
.term { display: flex; flex-direction: column; height: 100%; }
|
|
174
|
+
.term-out { flex: 1; overflow: auto; background: #0a0a0c; border: 1px solid var(--line2); border-radius: 8px; padding: 10px; white-space: pre-wrap; word-break: break-all; color: #d0d0d8; margin-bottom: 8px; }
|
|
175
|
+
.term-out .cmd { color: #8fb4ff; } .term-out .er { color: #ff8a8a; }
|
|
176
|
+
.term-in { display: flex; gap: 6px; } .term-in input { flex: 1; }
|
|
177
|
+
|
|
178
|
+
/* toasts */
|
|
179
|
+
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 50; pointer-events: none; }
|
|
180
|
+
.toast { background: #202027; border: 1px solid var(--line2); color: var(--fg); padding: 9px 13px; border-radius: 9px; font-size: 12.5px; box-shadow: 0 10px 30px rgba(0,0,0,.4); max-width: 380px; display: flex; gap: 8px; align-items: center; animation: in .15s ease-out; }
|
|
181
|
+
.toast.bad { border-color: rgba(255,92,92,.5); } .toast.ok { border-color: rgba(46,204,113,.5); }
|
|
182
|
+
.toast .sp { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; flex: none; }
|
|
183
|
+
@keyframes in { from { transform: translateY(6px); opacity: 0; } }
|
|
184
|
+
</style>
|
|
185
|
+
</head>
|
|
186
|
+
<body>
|
|
187
|
+
<svg style="display:none">
|
|
188
|
+
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
|
189
|
+
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></symbol>
|
|
190
|
+
<symbol id="i-play" viewBox="0 0 24 24"><path d="M6 4l14 8-14 8z"/></symbol>
|
|
191
|
+
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol>
|
|
192
|
+
<symbol id="i-restart" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15.5-6.3"/><path d="M21 12a9 9 0 0 1-15.5 6.3"/><path d="M18 2v5h-5M6 22v-5h5"/></symbol>
|
|
193
|
+
<symbol id="i-back" viewBox="0 0 24 24"><path d="M15 4L7 12l8 8"/></symbol>
|
|
194
|
+
<symbol id="i-home" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></symbol>
|
|
195
|
+
<symbol id="i-recents" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2.5"/></symbol>
|
|
196
|
+
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 3v9"/><path d="M6.3 6.3a8 8 0 1 0 11.4 0"/></symbol>
|
|
197
|
+
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M8 5v14M16 5v14"/></symbol>
|
|
198
|
+
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></symbol>
|
|
199
|
+
<symbol id="i-send" viewBox="0 0 24 24"><path d="M3 12l18-8-8 18-2-8z"/></symbol>
|
|
200
|
+
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></symbol>
|
|
201
|
+
<symbol id="i-vol-up" viewBox="0 0 24 24"><path d="M4 10v4h4l5 4V6L8 10z"/><path d="M16 9a4 4 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11"/></symbol>
|
|
202
|
+
<symbol id="i-vol-down" viewBox="0 0 24 24"><path d="M4 10v4h4l5 4V6L8 10z"/><path d="M16 9a4 4 0 0 1 0 6"/></symbol>
|
|
203
|
+
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></symbol>
|
|
204
|
+
<symbol id="i-box" viewBox="0 0 24 24"><path d="M3 7l9-4 9 4-9 4z"/><path d="M3 7v10l9 4 9-4V7"/><path d="M12 11v10"/></symbol>
|
|
205
|
+
</svg>
|
|
206
|
+
|
|
207
|
+
<aside>
|
|
208
|
+
<div class="side-head">
|
|
209
|
+
<h1>Redroid 矩阵 <span class="cnt" id="count">0</span></h1>
|
|
210
|
+
<button id="new-btn" class="sm" title="新建一台 redroid Android 容器"><svg class="ico"><use href="#i-plus"/></svg>新建设备</button>
|
|
211
|
+
</div>
|
|
212
|
+
<div class="side-tools"><input id="filter" placeholder="筛选设备"><button class="ghost sm" id="a-refresh" title="刷新列表与状态"><svg class="ico"><use href="#i-refresh"/></svg></button></div>
|
|
213
|
+
<div id="create">
|
|
214
|
+
<h3>新建设备</h3>
|
|
215
|
+
<div class="f"><label>名称</label><input id="c-name" placeholder="例如 tg-01" spellcheck="false"></div>
|
|
216
|
+
<div class="f"><label>Android</label><select id="c-ver"><option value="11">11 · 有 ARM 转译,兼容 armeabi APK</option><option value="13" selected>13 · 新,仅 x86_64 APK</option><option value="14">14</option></select></div>
|
|
217
|
+
<div class="f"><label>分辨率</label><div class="res"><input id="c-w" value="720" title="宽"><input id="c-h" value="1280" title="高"><input id="c-dpi" value="320" title="DPI"></div></div>
|
|
218
|
+
<div class="f"><label>代理</label><input id="c-proxy" placeholder="host:port,留空 = 直连" spellcheck="false"></div>
|
|
219
|
+
<div class="btns"><button class="ghost sm" id="c-cancel">取消</button><button class="sm" id="c-ok">创建</button></div>
|
|
220
|
+
<div class="err" id="c-err"></div>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="list" id="list"></div>
|
|
223
|
+
<div id="empty">还没有设备<br>点「新建设备」创建第一台 Android</div>
|
|
224
|
+
<div class="side-foot">设备 = docker 容器(redroid)。adb 端口从 15555 起分配。出口 IP 会自动探测并给出纯净度等级。</div>
|
|
225
|
+
</aside>
|
|
226
|
+
|
|
227
|
+
<main>
|
|
228
|
+
<div class="topbar">
|
|
229
|
+
<div class="title"><span class="dot" id="sel-dot"></span><b id="selected-name">未选择设备</b><span class="meta" id="sel-meta"></span></div>
|
|
230
|
+
<div class="sp"></div>
|
|
231
|
+
<div class="actions">
|
|
232
|
+
<button class="ghost sm" id="a-start"><svg class="ico"><use href="#i-play"/></svg>启动</button>
|
|
233
|
+
<button class="ghost sm" id="a-stop"><svg class="ico"><use href="#i-stop"/></svg>停止</button>
|
|
234
|
+
<button class="ghost sm" id="a-restart"><svg class="ico"><use href="#i-restart"/></svg>重启</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
<div class="stage">
|
|
238
|
+
<div class="stage-fit"><div class="frame">
|
|
239
|
+
<div class="glass">
|
|
240
|
+
<img id="screen" alt="" draggable="false">
|
|
241
|
+
<div class="fps" id="fps"></div>
|
|
242
|
+
<div class="overlay on" id="ov-empty"><div><h4>选择左侧一台设备</h4><p>画面可直接点按、拖动滑动、键盘输入、粘贴</p></div></div>
|
|
243
|
+
<div class="overlay" id="ov-wait"><div><div class="spinner"></div><h4 id="ov-wait-text">连接中…</h4><p id="ov-wait-sub"></p></div></div>
|
|
244
|
+
</div>
|
|
245
|
+
</div></div>
|
|
246
|
+
<div class="controls">
|
|
247
|
+
<div class="navbar">
|
|
248
|
+
<button data-key="back" title="返回"><svg class="ico"><use href="#i-back"/></svg>返回</button>
|
|
249
|
+
<button data-key="home" title="主页"><svg class="ico"><use href="#i-home"/></svg>主页</button>
|
|
250
|
+
<button data-key="recents" title="最近任务"><svg class="ico"><use href="#i-recents"/></svg>最近</button>
|
|
251
|
+
<div class="sep"></div>
|
|
252
|
+
<button data-key="volup" title="音量+"><svg class="ico"><use href="#i-vol-up"/></svg>音量+</button>
|
|
253
|
+
<button data-key="voldown" title="音量−"><svg class="ico"><use href="#i-vol-down"/></svg>音量−</button>
|
|
254
|
+
<button data-key="power" title="电源键"><svg class="ico"><use href="#i-power"/></svg>电源</button>
|
|
255
|
+
<div class="sep"></div>
|
|
256
|
+
<button id="shot-btn" title="把当前画面另存为 PNG"><svg class="ico"><use href="#i-camera"/></svg>截图</button>
|
|
257
|
+
<button id="live-btn" title="暂停 / 继续实时画面"><svg class="ico"><use href="#i-pause"/></svg>暂停</button>
|
|
258
|
+
</div>
|
|
259
|
+
<div class="textrow"><input id="send-text" placeholder="向设备输入文本(ASCII),Enter 发送;也可直接在画面上打字或粘贴" spellcheck="false"><button class="ghost sm" id="send-btn"><svg class="ico"><use href="#i-send"/></svg>发送</button></div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</main>
|
|
263
|
+
|
|
264
|
+
<section class="inspector">
|
|
265
|
+
<div class="tabs"><button class="on" data-pane="info">概览</button><button data-pane="net">网络</button><button data-pane="apps">应用</button><button data-pane="term">终端</button></div>
|
|
266
|
+
|
|
267
|
+
<div class="pane on" id="pane-info">
|
|
268
|
+
<div class="sec">
|
|
269
|
+
<h3>设备</h3>
|
|
270
|
+
<div class="kv">
|
|
271
|
+
<label>容器</label><div class="v"><code id="i-name">—</code><span id="i-status" class="badge dim">—</span></div>
|
|
272
|
+
<label>Android</label><div class="v"><span id="i-android">—</span></div>
|
|
273
|
+
<label>镜像</label><div class="v"><code id="i-image">—</code></div>
|
|
274
|
+
<label>分辨率</label><div class="v"><span id="i-size">—</span></div>
|
|
275
|
+
<label>运行时长</label><div class="v"><span id="i-uptime">—</span></div>
|
|
276
|
+
<label>指纹</label><div class="v"><code id="i-fp" title="ro.build.fingerprint">—</code></div>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="sec">
|
|
280
|
+
<h3>调试</h3>
|
|
281
|
+
<div class="kv">
|
|
282
|
+
<label>adb</label><div class="v"><code id="i-adb">—</code><span id="i-adbst" class="badge dim">—</span></div>
|
|
283
|
+
<label>Root</label><div class="v"><span id="i-root" class="badge dim">—</span><span style="color:var(--muted);font-size:11.5px">AOSP 原生:<code>su 0 <cmd></code></span></div>
|
|
284
|
+
<label>Frida</label><div class="v"><button class="switch" id="frida-sw" title="启动 / 停止 frida-server"></button><span id="i-frida" class="badge dim">—</span></div>
|
|
285
|
+
<label></label><div class="v"><code id="i-frida-addr" style="display:none"></code></div>
|
|
286
|
+
</div>
|
|
287
|
+
<div class="err" id="i-err"></div>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="sec danger-zone">
|
|
290
|
+
<h3>危险操作</h3>
|
|
291
|
+
<p>删除会移除容器;勾选「连同数据」会一并删除 /data(登录态、已装应用)。</p>
|
|
292
|
+
<div class="row-actions"><label style="display:flex;align-items:center;gap:6px;color:var(--fg2);font-size:12px"><input type="checkbox" id="rm-purge"> 连同数据</label><button class="ghost sm" id="rm-btn"><svg class="ico"><use href="#i-trash"/></svg>删除设备</button></div>
|
|
293
|
+
<div class="confirm" id="rm-confirm"><span>确认删除 <b id="rm-name"></b>?</span><span class="sp"></span><button class="sm danger" id="rm-yes">确认删除</button><button class="sm ghost" id="rm-no">取消</button></div>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div class="pane" id="pane-net">
|
|
298
|
+
<div class="sec">
|
|
299
|
+
<h3>出口 IP <span class="r"><button class="ghost sm" id="ip-btn"><svg class="ico"><use href="#i-refresh"/></svg>重新检测</button></span></h3>
|
|
300
|
+
<div class="ipcard" id="ipcard">
|
|
301
|
+
<div class="top">
|
|
302
|
+
<div><div class="big" id="ip-ip">—</div><div class="geo" id="ip-geo"><span class="flag"></span><span>尚未检测</span></div></div>
|
|
303
|
+
<div class="gbox none" id="ip-grade"><b>?</b><span>纯净度</span></div>
|
|
304
|
+
</div>
|
|
305
|
+
<div class="kv" style="margin-top:12px">
|
|
306
|
+
<label>运营商</label><div class="v" id="ip-isp">—</div>
|
|
307
|
+
<label>ASN</label><div class="v" id="ip-as">—</div>
|
|
308
|
+
<label>类型</label><div class="v" id="ip-kind">—</div>
|
|
309
|
+
<label>检测路径</label><div class="v" id="ip-via">—</div>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="checks" id="ip-checks"></div>
|
|
312
|
+
<div class="verdict" id="ip-verdict">点「重新检测」通过该设备当前代理探测出口 IP;结果合并 ip-api.com 与 ipapi.is 两个情报源。</div>
|
|
313
|
+
<div class="foot"><span id="ip-time"></span><span>A 住宅/移动 · B 骨干 · C 机房 · D 已标记</span></div>
|
|
314
|
+
</div>
|
|
315
|
+
<div class="legend"><b>纯净度怎么看:</b>注册 Facebook / Google / Telegram 一类账号时,风控首先看 IP 的 ASN 类型与黑名单。<b>A</b> 最友好;<b>B</b> 未被标记但非住宅线路,成功率一般;<b>C</b> 机房 IP 大概率被拦;<b>D</b> 已被情报库标记,基本必拦。</div>
|
|
316
|
+
</div>
|
|
317
|
+
<div class="sec">
|
|
318
|
+
<h3>设备代理</h3>
|
|
319
|
+
<div class="kv">
|
|
320
|
+
<label>http_proxy</label><div class="v"><input id="i-proxy" placeholder="host:port,留空 = 直连" spellcheck="false" style="width:200px"><button class="sm" id="proxy-save">应用</button></div>
|
|
321
|
+
</div>
|
|
322
|
+
<div class="chips"><span class="chip" id="chip-mihomo" title="本机 mihomo 的 redroid 监听(172.18.0.x:20011)">用本机 mihomo</span><span class="chip" id="chip-direct">直连</span></div>
|
|
323
|
+
<div class="legend">这是 Android 全局 <code>settings put global http_proxy</code>,应用层代理。切换节点请在 mihomo 的 <code>redroid-group</code> 里选。</div>
|
|
324
|
+
<div class="err" id="net-err"></div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div class="pane" id="pane-apps">
|
|
329
|
+
<div class="apps-top"><input id="apps-filter" placeholder="筛选包名"><button class="sm" id="apps-install"><svg class="ico"><use href="#i-box"/></svg>安装 APK / XAPK</button><button class="ghost sm" id="apps-refresh"><svg class="ico"><use href="#i-refresh"/></svg></button></div>
|
|
330
|
+
<div id="apps-list"></div>
|
|
331
|
+
<div class="err" id="apps-err"></div>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
<div class="pane" id="pane-term">
|
|
335
|
+
<div class="term">
|
|
336
|
+
<div class="term-out mono" id="term-out"></div>
|
|
337
|
+
<div class="term-in"><input id="term-cmd" class="mono" placeholder="adb shell 命令,↑↓ 翻历史" spellcheck="false"><button class="sm" id="term-run">运行</button></div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
</section>
|
|
341
|
+
<div id="toasts"></div>
|
|
342
|
+
|
|
343
|
+
<script>
|
|
344
|
+
const $ = (id) => document.getElementById(id);
|
|
345
|
+
const api = window.redroidAPI;
|
|
346
|
+
const listEl = $('list'), empty = $('empty'), countEl = $('count');
|
|
347
|
+
const screen = $('screen'), ovEmpty = $('ov-empty'), ovWait = $('ov-wait'), ovWaitText = $('ov-wait-text'), ovWaitSub = $('ov-wait-sub');
|
|
348
|
+
let devices = []; // [{name, image, state, status, version, port, running, info, ipInfo}]
|
|
349
|
+
let selected = null;
|
|
350
|
+
let defaults = { proxyHint: '' };
|
|
351
|
+
let live = true, shotTimer = null, shotBusy = false, shotSize = { w: 720, h: 1280 }, fpsT = [], probing = new Set();
|
|
352
|
+
const cards = new Map();
|
|
353
|
+
const esc = (v) => String(v || '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
354
|
+
const dev = () => devices.find((d) => d.name === selected) || null;
|
|
355
|
+
const short = (n) => n.replace(/^redroid-?/, '') || n;
|
|
356
|
+
const flag = (cc) => cc ? String(cc).toUpperCase().slice(0, 3) : '';
|
|
357
|
+
const fmtUp = (s) => s == null ? '—' : s < 3600 ? `${Math.floor(s / 60)} 分钟` : s < 86400 ? `${(s / 3600).toFixed(1)} 小时` : `${(s / 86400).toFixed(1)} 天`;
|
|
358
|
+
const fmtAgo = (iso) => { const t = Date.parse(iso || ''); if (!t) return ''; const m = Math.round((Date.now() - t) / 60000); return m < 1 ? '刚刚' : m < 60 ? `${m} 分钟前` : m < 1440 ? `${Math.round(m / 60)} 小时前` : `${Math.round(m / 1440)} 天前`; };
|
|
359
|
+
|
|
360
|
+
// ── toasts ────────────────────────────────────────────────────────────
|
|
361
|
+
function toast(text, { kind = '', ms = 3000, spin = false } = {}) {
|
|
362
|
+
const el = document.createElement('div'); el.className = `toast ${kind}`;
|
|
363
|
+
el.innerHTML = (spin ? '<span class="sp"></span>' : '') + `<span>${esc(text)}</span>`;
|
|
364
|
+
$('toasts').appendChild(el);
|
|
365
|
+
const t = { el, update(txt, k) { el.querySelector('span:last-child').textContent = txt; if (k) el.className = `toast ${k}`; el.querySelector('.sp') && el.querySelector('.sp').remove(); }, close() { el.remove(); } };
|
|
366
|
+
if (ms) setTimeout(() => el.remove(), ms);
|
|
367
|
+
return t;
|
|
368
|
+
}
|
|
369
|
+
const oops = (e) => toast((e && e.message) || String(e), { kind: 'bad', ms: 6000 });
|
|
370
|
+
function showErr(el, e) { el.textContent = e ? ((e.message) || String(e)) : ''; el.classList.toggle('on', !!e); }
|
|
371
|
+
|
|
372
|
+
// ── ip cache (per device + proxy) ─────────────────────────────────────
|
|
373
|
+
const ipKey = (d) => `redroid-ip:${d.name}:${(d.info && d.info.proxy) || 'direct'}`;
|
|
374
|
+
const loadIp = (d) => { try { return JSON.parse(localStorage.getItem(ipKey(d)) || 'null'); } catch { return null; } };
|
|
375
|
+
const saveIp = (d, v) => { try { localStorage.setItem(ipKey(d), JSON.stringify(v)); } catch {} };
|
|
376
|
+
|
|
377
|
+
// ── device list ───────────────────────────────────────────────────────
|
|
378
|
+
function dotOf(d) { if (!d.running) return 'off'; if (!d.info || d.info.adb !== 'device') return 'bad'; return d.info.booted ? 'ok' : 'booting'; }
|
|
379
|
+
function stateText(d) {
|
|
380
|
+
if (!d.running) return '已停止';
|
|
381
|
+
if (!d.info || d.info.adb !== 'device') return d.info && d.info.adb === 'offline' ? 'adb offline' : '开机中';
|
|
382
|
+
return d.info.booted ? '运行中' : '开机中';
|
|
383
|
+
}
|
|
384
|
+
function buildCard(d) {
|
|
385
|
+
const c = document.createElement('div'); c.className = 'card'; c.dataset.name = d.name;
|
|
386
|
+
c.innerHTML = `<div class="l1"><span class="dot"></span><span class="nm"></span><span class="ver"></span><span class="st"></span></div>
|
|
387
|
+
<div class="l2"><span class="flag"></span><span class="geo none">出口未检测</span><span class="grade none" style="margin-left:auto">?</span></div>
|
|
388
|
+
<div class="l3"><span class="port"></span><span>·</span><span class="proxy"></span></div>`;
|
|
389
|
+
c.onclick = () => select(d.name);
|
|
390
|
+
return c;
|
|
391
|
+
}
|
|
392
|
+
function renderCard(d) {
|
|
393
|
+
let c = cards.get(d.name);
|
|
394
|
+
if (!c) { c = buildCard(d); cards.set(d.name, c); listEl.appendChild(c); }
|
|
395
|
+
c.classList.toggle('active', d.name === selected);
|
|
396
|
+
c.querySelector('.dot').className = `dot ${dotOf(d)}`;
|
|
397
|
+
c.querySelector('.nm').textContent = short(d.name); c.querySelector('.nm').title = d.name;
|
|
398
|
+
c.querySelector('.ver').textContent = `Android ${d.version || '?'}`;
|
|
399
|
+
c.querySelector('.st').textContent = stateText(d);
|
|
400
|
+
c.querySelector('.port').textContent = d.port ? `adb :${d.port}` : '—';
|
|
401
|
+
c.querySelector('.proxy').textContent = d.info && d.info.proxy ? `代理 ${d.info.proxy}` : (d.running ? '直连' : '');
|
|
402
|
+
const ip = d.ipInfo, g = c.querySelector('.grade'), geo = c.querySelector('.geo'), fl = c.querySelector('.flag');
|
|
403
|
+
if (ip && ip.ip) {
|
|
404
|
+
fl.textContent = flag(ip.cc);
|
|
405
|
+
geo.className = 'geo'; geo.textContent = `${[ip.country, ip.city || ip.region].filter(Boolean).join(' · ')} · ${ip.ip}`; geo.title = `${ip.isp}${ip.as ? ' · ' + ip.as : ''}`;
|
|
406
|
+
g.className = `grade ${ip.grade}`; g.textContent = `${ip.grade} ${ip.kind}`;
|
|
407
|
+
} else {
|
|
408
|
+
fl.textContent = ''; geo.className = 'geo none'; geo.textContent = probing.has(d.name) ? '检测出口中…' : (d.running ? '出口未检测' : ''); g.className = 'grade none'; g.textContent = '?';
|
|
409
|
+
}
|
|
410
|
+
const f = $('filter').value.trim().toLowerCase();
|
|
411
|
+
c.style.display = !f || d.name.toLowerCase().includes(f) || (ip && (ip.ip + ip.country + ip.city).toLowerCase().includes(f)) ? '' : 'none';
|
|
412
|
+
}
|
|
413
|
+
function render() {
|
|
414
|
+
const names = new Set(devices.map((d) => d.name));
|
|
415
|
+
for (const [n, c] of cards) if (!names.has(n)) { c.remove(); cards.delete(n); }
|
|
416
|
+
devices.forEach(renderCard);
|
|
417
|
+
empty.classList.toggle('on', devices.length === 0);
|
|
418
|
+
countEl.textContent = devices.length;
|
|
419
|
+
renderSelected();
|
|
420
|
+
}
|
|
421
|
+
async function refresh({ quiet = false } = {}) {
|
|
422
|
+
if (!quiet) $('a-refresh').disabled = true;
|
|
423
|
+
try {
|
|
424
|
+
const list = await api.list();
|
|
425
|
+
const prev = new Map(devices.map((d) => [d.name, d]));
|
|
426
|
+
devices = list.map((d) => { const p = prev.get(d.name); const next = { ...d, ipInfo: null }; next.ipInfo = loadIp(next) || (p && p.ipInfo && p.info && p.info.proxy === (d.info && d.info.proxy) ? p.ipInfo : null); return next; });
|
|
427
|
+
if (selected && !devices.some((d) => d.name === selected)) selected = null;
|
|
428
|
+
if (!selected && devices[0]) selected = (devices.find((d) => d.name === localStorage.getItem('redroid-matrix-sel')) || devices[0]).name;
|
|
429
|
+
render();
|
|
430
|
+
// auto-probe egress for booted devices without a cached result
|
|
431
|
+
for (const d of devices) if (d.running && d.info && d.info.booted && !d.ipInfo && !probing.has(d.name)) probeIp(d, { silent: true });
|
|
432
|
+
} catch (e) { oops(e); }
|
|
433
|
+
finally { $('a-refresh').disabled = false; }
|
|
434
|
+
}
|
|
435
|
+
$('filter').oninput = render;
|
|
436
|
+
|
|
437
|
+
// ── selection ─────────────────────────────────────────────────────────
|
|
438
|
+
function select(name) {
|
|
439
|
+
if (selected === name) return;
|
|
440
|
+
selected = name; localStorage.setItem('redroid-matrix-sel', name);
|
|
441
|
+
screen.removeAttribute('src'); fpsT = [];
|
|
442
|
+
render(); apps = []; renderApps(); loadApps(); $('term-out').innerHTML = '';
|
|
443
|
+
kickShots();
|
|
444
|
+
}
|
|
445
|
+
const badge = (el, txt, cls) => { el.textContent = txt; el.className = `badge ${cls}`; };
|
|
446
|
+
function renderSelected() {
|
|
447
|
+
const d = dev();
|
|
448
|
+
$('selected-name').textContent = d ? short(d.name) : '未选择设备';
|
|
449
|
+
$('sel-dot').className = `dot ${d ? dotOf(d) : ''}`;
|
|
450
|
+
$('sel-meta').textContent = d ? `Android ${d.version} · ${stateText(d)}${d.info && d.info.size ? ' · ' + d.info.size : ''}` : '';
|
|
451
|
+
ovEmpty.classList.toggle('on', !d);
|
|
452
|
+
['a-start', 'a-stop', 'a-restart', 'ip-btn', 'proxy-save', 'apps-install', 'shot-btn'].forEach((id) => { $(id).disabled = !d; });
|
|
453
|
+
if (!d) return;
|
|
454
|
+
$('a-start').style.display = d.running ? 'none' : ''; $('a-stop').style.display = d.running ? '' : 'none';
|
|
455
|
+
const i = d.info || {};
|
|
456
|
+
$('i-name').textContent = d.name; badge($('i-status'), d.status, d.running ? 'ok' : 'dim');
|
|
457
|
+
$('i-android').textContent = `Android ${i.release || d.version || '?'}${i.model ? ' · ' + i.model : ''}`;
|
|
458
|
+
$('i-image').textContent = d.image; $('i-size').textContent = i.size || '—'; $('i-uptime').textContent = d.running ? fmtUp(i.uptime) : '—';
|
|
459
|
+
$('i-fp').textContent = i.fingerprint || '—';
|
|
460
|
+
$('i-adb').textContent = d.port ? `127.0.0.1:${d.port}` : '—';
|
|
461
|
+
badge($('i-adbst'), i.adb === 'device' ? (i.booted ? '已连接' : '开机中') : (i.adb || '未连接'), i.adb === 'device' ? (i.booted ? 'ok' : 'warn') : 'bad');
|
|
462
|
+
badge($('i-root'), i.root == null ? '—' : i.root ? '可用' : '不可用', i.root == null ? 'dim' : i.root ? 'ok' : 'bad');
|
|
463
|
+
badge($('i-frida'), i.frida ? '运行中' : '未运行', i.frida ? 'ok' : 'dim');
|
|
464
|
+
$('frida-sw').className = `switch ${i.frida ? 'on' : ''}`; $('frida-sw').disabled = !d.running || i.adb !== 'device';
|
|
465
|
+
$('i-frida-addr').style.display = i.frida ? '' : 'none'; $('i-frida-addr').textContent = i.frida ? `frida -H 127.0.0.1:${i.fridaPort}` : '';
|
|
466
|
+
if (document.activeElement !== $('i-proxy')) $('i-proxy').value = i.proxy || '';
|
|
467
|
+
$('rm-name').textContent = d.name;
|
|
468
|
+
renderIp(d);
|
|
469
|
+
const ready = d.running && i.adb === 'device';
|
|
470
|
+
ovWait.classList.toggle('on', !ready);
|
|
471
|
+
ovWaitText.textContent = !d.running ? '设备已停止' : '等待 adb 连接';
|
|
472
|
+
ovWaitSub.textContent = !d.running ? '点右上角「启动」' : '开机约需 30–60 秒';
|
|
473
|
+
screen.style.visibility = ready ? '' : 'hidden';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── ip report ─────────────────────────────────────────────────────────
|
|
477
|
+
function renderIp(d) {
|
|
478
|
+
const ip = d && d.ipInfo;
|
|
479
|
+
const busy = d && probing.has(d.name);
|
|
480
|
+
$('ip-btn').disabled = !d || busy;
|
|
481
|
+
if (!ip) {
|
|
482
|
+
$('ip-ip').textContent = busy ? '检测中…' : '—'; $('ip-geo').innerHTML = `<span class="flag"></span><span>${busy ? '经设备代理探测出口…' : '尚未检测'}</span>`;
|
|
483
|
+
$('ip-grade').className = 'gbox none'; $('ip-grade').innerHTML = '<b>?</b><span>纯净度</span>';
|
|
484
|
+
['ip-isp', 'ip-as', 'ip-kind', 'ip-via'].forEach((id) => { $(id).textContent = '—'; }); $('ip-checks').innerHTML = ''; $('ip-time').textContent = '';
|
|
485
|
+
$('ip-verdict').textContent = '点「重新检测」通过该设备当前代理探测出口 IP;结果合并 ip-api.com 与 ipapi.is 两个情报源。';
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
$('ip-ip').textContent = ip.ip;
|
|
489
|
+
$('ip-geo').innerHTML = `<span class="flag">${flag(ip.cc)}</span><span>${esc([ip.country, ip.region, ip.city].filter(Boolean).join(' · ') || '未知地区')}</span>`;
|
|
490
|
+
$('ip-grade').className = `gbox ${ip.grade}`; $('ip-grade').innerHTML = `<b>${ip.grade}</b><span>${esc(ip.kind)}</span>`;
|
|
491
|
+
$('ip-isp').textContent = ip.isp || ip.org || '—'; $('ip-as').textContent = ip.as || '—';
|
|
492
|
+
$('ip-kind').textContent = `${ip.kind}${ip.residential ? '(住宅/移动线路)' : ''}`;
|
|
493
|
+
$('ip-via').textContent = ip.via === 'direct' ? '直连(宿主出口)' : `经设备代理 ${ip.via}`;
|
|
494
|
+
const f = ip.flags || {};
|
|
495
|
+
const chk = (label, bad, good, dim) => `<div class="chk ${dim ? 'dim' : bad ? 'badf' : 'good'}"><i>${dim ? '·' : bad ? '!' : '✓'}</i>${label}${dim ? '' : bad ? '' : ''}</div>`;
|
|
496
|
+
$('ip-checks').innerHTML = [
|
|
497
|
+
chk(f.datacenter ? '机房 / 云主机' : '非机房', f.datacenter), chk(f.proxy ? '被标记为代理' : '未标记代理', f.proxy),
|
|
498
|
+
chk(f.vpn ? '被标记为 VPN' : '未标记 VPN', f.vpn), chk(f.tor ? 'Tor 出口' : '非 Tor', f.tor),
|
|
499
|
+
chk(f.abuser ? '滥用来源' : '无滥用记录', f.abuser), chk(f.mobile ? '移动网络' : (ip.residential ? '住宅线路' : '非住宅线路'), !f.mobile && !ip.residential, false, false),
|
|
500
|
+
].join('');
|
|
501
|
+
$('ip-verdict').textContent = ip.verdict;
|
|
502
|
+
$('ip-time').textContent = `${fmtAgo(ip.probedAt)}检测${ip.sources && !ip.sources.ipapis ? '(ipapi.is 未响应,仅 ip-api)' : ''}`;
|
|
503
|
+
}
|
|
504
|
+
async function probeIp(d, { silent = false } = {}) {
|
|
505
|
+
if (!d || probing.has(d.name)) return;
|
|
506
|
+
probing.add(d.name); renderCard(d); if (dev() === d) renderIp(d);
|
|
507
|
+
try {
|
|
508
|
+
const r = await api.probeIp(d.name);
|
|
509
|
+
d.ipInfo = r; saveIp(d, r);
|
|
510
|
+
if (!silent) toast(`${short(d.name)} 出口 ${r.ip} · ${r.country || ''} · 纯净度 ${r.grade}`, { kind: r.grade === 'A' || r.grade === 'B' ? 'ok' : 'bad' });
|
|
511
|
+
} catch (e) { if (!silent) oops(e); }
|
|
512
|
+
finally { probing.delete(d.name); renderCard(d); if (dev() === d) renderIp(d); }
|
|
513
|
+
}
|
|
514
|
+
$('ip-btn').onclick = () => { const d = dev(); if (d) { d.ipInfo = null; localStorage.removeItem(ipKey(d)); probeIp(d); } };
|
|
515
|
+
|
|
516
|
+
// ── screen stream + input ─────────────────────────────────────────────
|
|
517
|
+
function kickShots() { clearTimeout(shotTimer); shot(); }
|
|
518
|
+
async function shot() {
|
|
519
|
+
clearTimeout(shotTimer);
|
|
520
|
+
const d = dev();
|
|
521
|
+
if (!d || !live || !d.running || !d.info || d.info.adb !== 'device') { shotTimer = setTimeout(shot, 1500); return; }
|
|
522
|
+
if (shotBusy) return;
|
|
523
|
+
shotBusy = true;
|
|
524
|
+
try {
|
|
525
|
+
const src = await api.screenshot(d.name);
|
|
526
|
+
if (dev() && dev().name === d.name) { screen.src = src; fpsT.push(Date.now()); fpsT = fpsT.filter((t) => Date.now() - t < 3000); $('fps').textContent = `${(fpsT.length / 3).toFixed(1)} fps`; }
|
|
527
|
+
} catch (e) { /* keep last frame */ }
|
|
528
|
+
finally { shotBusy = false; shotTimer = setTimeout(shot, 250); }
|
|
529
|
+
}
|
|
530
|
+
// size the phone frame to whatever height is left above the controls (aspect-ratio does the width)
|
|
531
|
+
{ const fit = $('stage-fit') || document.querySelector('.stage-fit'); const fr = document.querySelector('.frame');
|
|
532
|
+
const doFit = () => { const h = fit.clientHeight, w = fit.clientWidth; const hh = Math.min(h, (w - 20) * 16 / 9); fr.style.height = `${Math.max(240, hh)}px`; };
|
|
533
|
+
new ResizeObserver(doFit).observe(fit); doFit(); }
|
|
534
|
+
screen.onload = () => { shotSize = { w: screen.naturalWidth, h: screen.naturalHeight }; };
|
|
535
|
+
function toDevice(ev) {
|
|
536
|
+
const r = screen.getBoundingClientRect();
|
|
537
|
+
const scale = Math.min(r.width / shotSize.w, r.height / shotSize.h);
|
|
538
|
+
const ox = (r.width - shotSize.w * scale) / 2, oy = (r.height - shotSize.h * scale) / 2;
|
|
539
|
+
return { x: Math.max(0, Math.min(shotSize.w, (ev.clientX - r.left - ox) / scale)), y: Math.max(0, Math.min(shotSize.h, (ev.clientY - r.top - oy) / scale)) };
|
|
540
|
+
}
|
|
541
|
+
let down = null;
|
|
542
|
+
screen.onpointerdown = (e) => { down = { ...toDevice(e), t: Date.now() }; screen.setPointerCapture(e.pointerId); screen.focus(); };
|
|
543
|
+
screen.onpointerup = async (e) => {
|
|
544
|
+
if (!down || !dev()) return;
|
|
545
|
+
const up = toDevice(e); const start = down; down = null;
|
|
546
|
+
const dist = Math.hypot(up.x - start.x, up.y - start.y);
|
|
547
|
+
const ev = dist < 12 ? { type: 'tap', x: up.x, y: up.y } : { type: 'swipe', x1: start.x, y1: start.y, x2: up.x, y2: up.y, ms: Math.min(1200, Math.max(120, Date.now() - start.t)) };
|
|
548
|
+
try { await api.input(dev().name, ev); setTimeout(kickShots, 100); } catch (err) { oops(err); }
|
|
549
|
+
};
|
|
550
|
+
screen.tabIndex = 0;
|
|
551
|
+
screen.onkeydown = async (e) => {
|
|
552
|
+
const d = dev(); if (!d) return;
|
|
553
|
+
const map = { Enter: 'enter', Backspace: 'del', Escape: 'back', Home: 'home' };
|
|
554
|
+
let ev = null;
|
|
555
|
+
if (map[e.key]) ev = { type: 'key', key: map[e.key] };
|
|
556
|
+
else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && /[\x20-\x7e]/.test(e.key)) ev = { type: 'text', text: e.key };
|
|
557
|
+
if (!ev) return;
|
|
558
|
+
e.preventDefault();
|
|
559
|
+
try { await api.input(d.name, ev); setTimeout(kickShots, 120); } catch (err) { oops(err); }
|
|
560
|
+
};
|
|
561
|
+
screen.onpaste = (e) => { const t = e.clipboardData && e.clipboardData.getData('text'); if (t) { e.preventDefault(); sendText(t); } };
|
|
562
|
+
async function sendText(t) {
|
|
563
|
+
const d = dev(); if (!d || !t) return;
|
|
564
|
+
if (/[^\x20-\x7e]/.test(t)) return toast('adb 只能输入 ASCII 文本(中文请在设备内输入法里打)', { kind: 'bad', ms: 5000 });
|
|
565
|
+
try { await api.input(d.name, { type: 'text', text: t }); setTimeout(kickShots, 150); } catch (err) { oops(err); }
|
|
566
|
+
}
|
|
567
|
+
$('send-btn').onclick = () => { sendText($('send-text').value); $('send-text').value = ''; };
|
|
568
|
+
$('send-text').onkeydown = (e) => { if (e.key === 'Enter') $('send-btn').click(); };
|
|
569
|
+
document.querySelectorAll('.navbar [data-key]').forEach((b) => { b.onclick = async () => { const d = dev(); if (!d) return; try { await api.input(d.name, { type: 'key', key: b.dataset.key }); setTimeout(kickShots, 200); } catch (e) { oops(e); } }; });
|
|
570
|
+
$('live-btn').onclick = () => { live = !live; $('live-btn').classList.toggle('on', !live); $('live-btn').querySelector('use').setAttribute('href', live ? '#i-pause' : '#i-play'); $('live-btn').lastChild.textContent = live ? '暂停' : '继续'; if (live) kickShots(); };
|
|
571
|
+
$('shot-btn').onclick = () => { const d = dev(); if (!d || !screen.src) return; const a = document.createElement('a'); a.href = screen.src; a.download = `${d.name}-${new Date().toISOString().replace(/[:.]/g, '-')}.png`; a.click(); toast('截图已保存到下载目录', { kind: 'ok' }); };
|
|
572
|
+
|
|
573
|
+
// ── actions ───────────────────────────────────────────────────────────
|
|
574
|
+
async function act(btnId, fn, okText) {
|
|
575
|
+
const b = $(btnId); b.disabled = true;
|
|
576
|
+
try { await fn(); if (okText) toast(okText, { kind: 'ok' }); await refresh({ quiet: true }); }
|
|
577
|
+
catch (e) { oops(e); }
|
|
578
|
+
finally { b.disabled = false; }
|
|
579
|
+
}
|
|
580
|
+
$('a-start').onclick = () => act('a-start', () => api.start(dev().name), '已启动,等待开机');
|
|
581
|
+
$('a-stop').onclick = () => act('a-stop', () => api.stop(dev().name), '已停止');
|
|
582
|
+
$('a-restart').onclick = () => act('a-restart', () => api.restart(dev().name), '重启中');
|
|
583
|
+
$('a-refresh').onclick = () => refresh();
|
|
584
|
+
$('proxy-save').onclick = () => act('proxy-save', async () => { const d = dev(); const v = await api.setProxy(d.name, $('i-proxy').value); d.ipInfo = null; toast(v ? `代理已设为 ${v},正在重新检测出口` : '已切为直连,正在重新检测出口', { kind: 'ok' }); setTimeout(() => refresh({ quiet: true }), 800); });
|
|
585
|
+
$('chip-mihomo').onclick = () => { if (defaults.proxyHint) $('i-proxy').value = defaults.proxyHint; else toast('未找到本机 mihomo 容器 IP', { kind: 'bad' }); };
|
|
586
|
+
$('chip-direct').onclick = () => { $('i-proxy').value = ''; };
|
|
587
|
+
$('frida-sw').onclick = () => act('frida-sw', async () => { const d = dev(); const on = await api.frida(d.name, !(d.info && d.info.frida)); toast(on ? `frida-server 已启动 · 宿主用 frida -H 127.0.0.1:${d.info.fridaPort}` : 'frida-server 已停止', { kind: 'ok', ms: 6000 }); });
|
|
588
|
+
$('rm-btn').onclick = () => $('rm-confirm').classList.add('on');
|
|
589
|
+
$('rm-no').onclick = () => $('rm-confirm').classList.remove('on');
|
|
590
|
+
$('rm-yes').onclick = () => act('rm-yes', async () => { const n = dev().name; await api.remove(n, $('rm-purge').checked); $('rm-confirm').classList.remove('on'); selected = null; toast(`${n} 已删除`, { kind: 'ok' }); });
|
|
591
|
+
|
|
592
|
+
// ── create ────────────────────────────────────────────────────────────
|
|
593
|
+
$('new-btn').onclick = () => { const on = !$('create').classList.contains('on'); $('create').classList.toggle('on', on); if (on) { if (!$('c-proxy').value && defaults.proxyHint) $('c-proxy').value = defaults.proxyHint; $('c-name').focus(); } };
|
|
594
|
+
$('c-cancel').onclick = () => $('create').classList.remove('on');
|
|
595
|
+
$('c-ok').onclick = async () => {
|
|
596
|
+
const b = $('c-ok'); b.disabled = true; showErr($('c-err'), null);
|
|
597
|
+
const t = toast('正在创建设备…', { ms: 0, spin: true });
|
|
598
|
+
try {
|
|
599
|
+
const r = await api.create({ name: $('c-name').value, version: $('c-ver').value, width: $('c-w').value, height: $('c-h').value, dpi: $('c-dpi').value, proxy: $('c-proxy').value });
|
|
600
|
+
$('create').classList.remove('on'); $('c-name').value = '';
|
|
601
|
+
t.update(`${r.name} 已创建(adb :${r.port}),开机中`, 'ok'); setTimeout(t.close, 6000);
|
|
602
|
+
await refresh({ quiet: true }); select(r.name);
|
|
603
|
+
} catch (e) { showErr($('c-err'), e); t.update('创建失败', 'bad'); setTimeout(t.close, 3000); }
|
|
604
|
+
finally { b.disabled = false; }
|
|
605
|
+
};
|
|
606
|
+
$('c-name').onkeydown = (e) => { if (e.key === 'Enter') $('c-ok').click(); };
|
|
607
|
+
let progressToast = null;
|
|
608
|
+
api.onProgress((m) => { if (!m || !m.message) return; if (progressToast) progressToast.update(m.message); else progressToast = toast(m.message, { ms: 0, spin: true }); clearTimeout(api._pt); api._pt = setTimeout(() => { progressToast && progressToast.close(); progressToast = null; }, 15000); });
|
|
609
|
+
|
|
610
|
+
// ── apps ──────────────────────────────────────────────────────────────
|
|
611
|
+
let apps = [];
|
|
612
|
+
const appName = (p) => { const s = p.split('.'); const last = s[s.length - 1]; const known = { katana: 'Facebook', 'org.telegram.messenger.web': 'Telegram', 'org.telegram.messenger': 'Telegram', 'com.whatsapp': 'WhatsApp', 'com.instagram.android': 'Instagram', 'com.twitter.android': 'X (Twitter)', 'com.google.android.youtube': 'YouTube', 'com.zhiliaoapp.musically': 'TikTok' }; return known[p] || known[last] || last.charAt(0).toUpperCase() + last.slice(1); };
|
|
613
|
+
function renderApps() {
|
|
614
|
+
const f = $('apps-filter').value.trim().toLowerCase();
|
|
615
|
+
const d = dev();
|
|
616
|
+
$('apps-list').innerHTML = apps.filter((p) => !f || p.includes(f) || appName(p).toLowerCase().includes(f)).map((p) => `<div class="app"><div class="app-ico">${esc(appName(p).charAt(0))}</div><div class="pk"><span>${esc(appName(p))}</span><small class="mono" title="${esc(p)}">${esc(p)}</small></div><button class="ghost sm" data-launch="${esc(p)}">打开</button><button class="subtle sm" data-un="${esc(p)}" title="卸载"><svg class="ico"><use href="#i-trash"/></svg></button></div>`).join('')
|
|
617
|
+
|| `<div style="color:var(--muted);padding:14px 4px">${!d ? '未选择设备' : !d.running ? '设备未运行' : apps.length ? '无匹配' : '没有第三方应用'}</div>`;
|
|
618
|
+
}
|
|
619
|
+
async function loadApps() {
|
|
620
|
+
const d = dev(); apps = []; renderApps(); if (!d || !d.running || !d.info || d.info.adb !== 'device') return;
|
|
621
|
+
try { apps = await api.apps(d.name); showErr($('apps-err'), null); } catch (e) { showErr($('apps-err'), e); }
|
|
622
|
+
renderApps();
|
|
623
|
+
}
|
|
624
|
+
$('apps-filter').oninput = renderApps;
|
|
625
|
+
$('apps-refresh').onclick = loadApps;
|
|
626
|
+
$('apps-install').onclick = () => act('apps-install', async () => { const r = await api.install(dev().name); if (!r.canceled) { toast(`已安装:${r.installed.join(', ')}`, { kind: 'ok', ms: 6000 }); loadApps(); } });
|
|
627
|
+
$('apps-list').onclick = async (e) => {
|
|
628
|
+
const b = e.target.closest('button'); const d = dev(); if (!b || !d) return;
|
|
629
|
+
b.disabled = true;
|
|
630
|
+
try {
|
|
631
|
+
if (b.dataset.launch) { await api.launch(d.name, b.dataset.launch); setTimeout(kickShots, 600); }
|
|
632
|
+
if (b.dataset.un) { await api.uninstall(d.name, b.dataset.un); toast(`已卸载 ${b.dataset.un}`, { kind: 'ok' }); loadApps(); }
|
|
633
|
+
} catch (err) { showErr($('apps-err'), err); } finally { b.disabled = false; }
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// ── terminal ──────────────────────────────────────────────────────────
|
|
637
|
+
const hist = []; let hi = 0;
|
|
638
|
+
async function runCmd() {
|
|
639
|
+
const d = dev(); const c = $('term-cmd').value.trim(); if (!d || !c) return;
|
|
640
|
+
$('term-cmd').value = ''; hist.push(c); hi = hist.length;
|
|
641
|
+
const out = $('term-out');
|
|
642
|
+
out.innerHTML += `<div class="cmd">$ ${esc(c)}</div>`;
|
|
643
|
+
try { const r = await api.shell(d.name, c); out.innerHTML += `<div>${esc(r)}</div>`; } catch (e) { out.innerHTML += `<div class="er">${esc(e.message)}</div>`; }
|
|
644
|
+
out.scrollTop = out.scrollHeight;
|
|
645
|
+
}
|
|
646
|
+
$('term-run').onclick = runCmd;
|
|
647
|
+
$('term-cmd').onkeydown = (e) => {
|
|
648
|
+
if (e.key === 'Enter') runCmd();
|
|
649
|
+
if (e.key === 'ArrowUp' && hist.length) { e.preventDefault(); hi = Math.max(0, hi - 1); $('term-cmd').value = hist[hi] || ''; }
|
|
650
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); hi = Math.min(hist.length, hi + 1); $('term-cmd').value = hist[hi] || ''; }
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// ── tabs ──────────────────────────────────────────────────────────────
|
|
654
|
+
document.querySelectorAll('.tabs button').forEach((b) => { b.onclick = () => {
|
|
655
|
+
document.querySelectorAll('.tabs button').forEach((x) => x.classList.toggle('on', x === b));
|
|
656
|
+
document.querySelectorAll('.pane').forEach((p) => p.classList.toggle('on', p.id === `pane-${b.dataset.pane}`));
|
|
657
|
+
localStorage.setItem('redroid-matrix-tab', b.dataset.pane);
|
|
658
|
+
if (b.dataset.pane === 'apps' && !apps.length) loadApps();
|
|
659
|
+
}; });
|
|
660
|
+
{ const t = localStorage.getItem('redroid-matrix-tab'); const b = t && document.querySelector(`.tabs [data-pane="${t}"]`); if (b) b.click(); }
|
|
661
|
+
|
|
662
|
+
// ── boot ──────────────────────────────────────────────────────────────
|
|
663
|
+
api.defaults().then((d) => { defaults = d || defaults; }).catch(() => {});
|
|
664
|
+
refresh().then(() => { if (selected) { loadApps(); kickShots(); } });
|
|
665
|
+
setInterval(() => refresh({ quiet: true }), 8000);
|
|
666
|
+
</script>
|
|
667
|
+
</body>
|
|
668
|
+
</html>
|