mirror of
https://github.com/GSManagerXZ/GameServerManager.git
synced 2026-09-06 16:09:44 +08:00
Add EasyTier installation, profile storage, secure runtime control, authenticated plugin UI, and operator documentation. Harden embedded plugin auth/assets and FRP helper installation alongside the new networking workflow.
2028 lines
64 KiB
HTML
2028 lines
64 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>内网穿透助手</title>
|
||
<script src="gsm3-api.js"></script>
|
||
<style>
|
||
:root {
|
||
color-scheme: light dark;
|
||
--bg: #f6f7f9;
|
||
--panel: #ffffff;
|
||
--panel-soft: #f1f5f9;
|
||
--border: #d8dee8;
|
||
--text: #162033;
|
||
--muted: #64748b;
|
||
--primary: #2563eb;
|
||
--primary-strong: #1d4ed8;
|
||
--success: #15803d;
|
||
--danger: #b91c1c;
|
||
--warning: #a16207;
|
||
--code: #0f172a;
|
||
}
|
||
|
||
:root[data-theme="dark"] {
|
||
color-scheme: dark;
|
||
--bg: #0f172a;
|
||
--panel: #111827;
|
||
--panel-soft: #1f2937;
|
||
--border: #334155;
|
||
--text: #e5e7eb;
|
||
--muted: #94a3b8;
|
||
--primary: #3b82f6;
|
||
--primary-strong: #60a5fa;
|
||
--success: #22c55e;
|
||
--danger: #f87171;
|
||
--warning: #facc15;
|
||
--code: #020617;
|
||
}
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #0f172a;
|
||
--panel: #111827;
|
||
--panel-soft: #1f2937;
|
||
--border: #334155;
|
||
--text: #e5e7eb;
|
||
--muted: #94a3b8;
|
||
--primary: #3b82f6;
|
||
--primary-strong: #60a5fa;
|
||
--success: #22c55e;
|
||
--danger: #f87171;
|
||
--warning: #facc15;
|
||
--code: #020617;
|
||
}
|
||
}
|
||
|
||
:root[data-theme="light"] {
|
||
color-scheme: light;
|
||
--bg: #f6f7f9;
|
||
--panel: #ffffff;
|
||
--panel-soft: #f1f5f9;
|
||
--border: #d8dee8;
|
||
--text: #162033;
|
||
--muted: #64748b;
|
||
--primary: #2563eb;
|
||
--primary-strong: #1d4ed8;
|
||
--success: #15803d;
|
||
--danger: #b91c1c;
|
||
--warning: #a16207;
|
||
--code: #0f172a;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
main {
|
||
width: 100%;
|
||
max-width: 1360px;
|
||
margin: 0 auto;
|
||
padding: clamp(12px, 2vw, 24px);
|
||
}
|
||
|
||
header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
header > div:first-child {
|
||
min-width: 0;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0;
|
||
font-size: clamp(20px, 2vw, 24px);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0 0 14px;
|
||
font-size: 17px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
min-width: 280px;
|
||
}
|
||
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(340px, .92fr) minmax(430px, 1.35fr);
|
||
gap: clamp(12px, 1.5vw, 18px);
|
||
align-items: start;
|
||
}
|
||
|
||
.panel {
|
||
min-width: 0;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: clamp(14px, 1.5vw, 18px);
|
||
box-shadow: 0 1px 2px rgb(15 23 42 / 6%);
|
||
}
|
||
|
||
.stack {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
label {
|
||
display: grid;
|
||
gap: 6px;
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
}
|
||
|
||
input,
|
||
select,
|
||
textarea {
|
||
width: 100%;
|
||
min-width: 0;
|
||
min-height: 38px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 8px 10px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
font: inherit;
|
||
}
|
||
|
||
textarea {
|
||
min-height: 118px;
|
||
resize: vertical;
|
||
font-family: "Cascadia Code", Consolas, monospace;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.hint {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.input-action {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||
gap: 8px;
|
||
}
|
||
|
||
.input-action button {
|
||
min-width: 68px;
|
||
}
|
||
|
||
.help-link {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tool-status.ok {
|
||
color: var(--success);
|
||
}
|
||
|
||
.tool-status.warn {
|
||
color: var(--warning);
|
||
}
|
||
|
||
.tool-status.error {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.segmented {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.segment {
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
cursor: pointer;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
text-align: left;
|
||
}
|
||
|
||
.segment.active {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 2px rgb(37 99 235 / 14%);
|
||
}
|
||
|
||
button {
|
||
border: 0;
|
||
border-radius: 6px;
|
||
padding: 9px 12px;
|
||
cursor: pointer;
|
||
background: var(--primary);
|
||
color: #fff;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
button.secondary {
|
||
background: var(--panel-soft);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
button.danger {
|
||
background: var(--danger);
|
||
color: #fff;
|
||
}
|
||
|
||
button:disabled {
|
||
cursor: not-allowed;
|
||
opacity: .55;
|
||
}
|
||
|
||
button:hover:not(:disabled) {
|
||
background: var(--primary-strong);
|
||
}
|
||
|
||
button.secondary:hover:not(:disabled) {
|
||
background: var(--border);
|
||
}
|
||
|
||
button.danger:hover:not(:disabled) {
|
||
background: color-mix(in srgb, var(--danger), #000 14%);
|
||
}
|
||
|
||
.message {
|
||
display: none;
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
background: var(--panel-soft);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.message.show {
|
||
display: block;
|
||
}
|
||
|
||
.message.success {
|
||
border-color: color-mix(in srgb, var(--success), transparent 58%);
|
||
color: var(--success);
|
||
}
|
||
|
||
.message.error {
|
||
border-color: color-mix(in srgb, var(--danger), transparent 58%);
|
||
color: var(--danger);
|
||
}
|
||
|
||
.message.warn {
|
||
border-color: color-mix(in srgb, var(--warning), transparent 58%);
|
||
color: var(--warning);
|
||
}
|
||
|
||
pre {
|
||
margin: 0;
|
||
min-height: clamp(180px, 28vh, 300px);
|
||
max-height: min(48vh, 520px);
|
||
overflow: auto;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--code);
|
||
color: #dbeafe;
|
||
padding: 14px;
|
||
font-family: "Cascadia Code", Consolas, monospace;
|
||
font-size: 12px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.meta {
|
||
display: grid;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
background: var(--panel-soft);
|
||
}
|
||
|
||
.meta-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(86px, 110px) minmax(0, 1fr);
|
||
gap: 8px;
|
||
}
|
||
|
||
.meta-row span {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.meta-row strong {
|
||
color: var(--muted);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.config-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.config-card {
|
||
display: grid;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--panel);
|
||
}
|
||
|
||
.config-card.active {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 2px rgb(37 99 235 / 14%);
|
||
}
|
||
|
||
.config-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.config-head h2 {
|
||
margin: 0;
|
||
}
|
||
|
||
.config-title {
|
||
min-width: 0;
|
||
font-weight: 700;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.config-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.config-actions button {
|
||
padding: 6px 9px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.preview-section {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.advanced-section {
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--panel-soft), transparent 38%);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.advanced-section summary {
|
||
cursor: pointer;
|
||
padding: 11px 12px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
list-style-position: inside;
|
||
}
|
||
|
||
.advanced-section > .stack {
|
||
padding: 0 12px 12px;
|
||
}
|
||
|
||
.checkbox-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 8px 12px;
|
||
}
|
||
|
||
.check-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.check-row input {
|
||
width: 16px;
|
||
min-width: 16px;
|
||
min-height: 16px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.check-row span {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.config-confirm {
|
||
color: var(--danger);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.hidden {
|
||
display: none !important;
|
||
}
|
||
|
||
@media (max-width: 1180px) {
|
||
.layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
main {
|
||
padding: 12px;
|
||
}
|
||
|
||
header,
|
||
.grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
header {
|
||
display: grid;
|
||
}
|
||
|
||
.toolbar {
|
||
justify-content: flex-start;
|
||
min-width: 0;
|
||
}
|
||
|
||
.toolbar button {
|
||
flex: 1 1 150px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 560px) {
|
||
body {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.panel {
|
||
padding: 12px;
|
||
}
|
||
|
||
.input-action {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.input-action input {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.input-action button {
|
||
min-width: 0;
|
||
}
|
||
|
||
.segmented,
|
||
.meta-row,
|
||
.checkbox-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
pre {
|
||
min-height: 180px;
|
||
}
|
||
}
|
||
|
||
@media (max-height: 760px) {
|
||
pre {
|
||
min-height: 160px;
|
||
max-height: 36vh;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<header>
|
||
<div>
|
||
<h1>内网穿透助手 (frp)</h1>
|
||
<p>为现有游戏实例生成和管理 frp 配置,不修改游戏实例本身。</p>
|
||
</div>
|
||
<div class="toolbar">
|
||
<button class="secondary" id="refreshInstancesButton" type="button">刷新实例</button>
|
||
<button class="secondary" id="previewButton" type="button">预览配置</button>
|
||
<button class="secondary" id="saveConfigButton" type="button">仅保存配置</button>
|
||
<button id="createButton" type="button">保存并创建实例</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="layout">
|
||
<section class="panel stack">
|
||
<h2>目标实例</h2>
|
||
<label>
|
||
游戏实例
|
||
<select id="instanceSelect"></select>
|
||
<span class="hint">穿透实例会独立创建,不会修改所选游戏实例。</span>
|
||
</label>
|
||
<div class="meta" id="instanceMeta">
|
||
<div class="meta-row"><strong>工作目录</strong><span id="instancePath">-</span></div>
|
||
<div class="meta-row"><strong>启动命令</strong><span id="instanceCommand">-</span></div>
|
||
<div class="meta-row"><strong>状态</strong><span id="instanceStatus">-</span></div>
|
||
</div>
|
||
|
||
<div class="message show warn" role="note">
|
||
EasyTier 已迁移到独立的“EasyTier 管理控制台”插件。旧配置会由服务端自动迁移并保留回滚备份,本页面只显示和管理 frp 配置。
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<label>
|
||
配置名称
|
||
<input id="profileName" autocomplete="off" placeholder="terraria-7777">
|
||
</label>
|
||
<label>
|
||
本地地址
|
||
<input id="localAddress" autocomplete="off" value="127.0.0.1">
|
||
</label>
|
||
<label>
|
||
本地端口
|
||
<input id="localPort" type="number" min="1" max="65535" value="7777">
|
||
</label>
|
||
<label>
|
||
协议
|
||
<select id="protocol">
|
||
<option value="tcp">TCP</option>
|
||
<option value="udp">UDP</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div id="frpFields" class="stack">
|
||
<h2>frp 参数</h2>
|
||
<label>
|
||
frpc 可执行文件
|
||
<div class="input-action">
|
||
<input id="frpcPath" autocomplete="off" value="frpc" placeholder="frpc 或 C:\frp\frpc.exe">
|
||
<button class="secondary" id="checkFrpcButton" type="button">检测</button>
|
||
<button class="secondary" id="installFrpcButton" type="button">下载/更新</button>
|
||
</div>
|
||
<span class="hint tool-status" id="frpcToolStatus">
|
||
需先在本机安装 frp 客户端,或填写 frpc.exe 绝对路径。
|
||
<a class="help-link" href="https://github.com/fatedier/frp/releases" target="_blank" rel="noreferrer">下载 frp</a>
|
||
</span>
|
||
</label>
|
||
<div class="grid">
|
||
<label>
|
||
frps 地址
|
||
<input id="frpServerAddr" autocomplete="off" placeholder="example.com">
|
||
</label>
|
||
<label>
|
||
frps 端口
|
||
<input id="frpServerPort" type="number" min="1" max="65535" value="7000">
|
||
</label>
|
||
<label>
|
||
远端端口
|
||
<input id="remotePort" type="number" min="1" max="65535" value="7777">
|
||
</label>
|
||
<label>
|
||
认证 token
|
||
<input id="frpToken" type="password" autocomplete="off" placeholder="可选">
|
||
</label>
|
||
</div>
|
||
<details class="advanced-section">
|
||
<summary>frp 高级参数</summary>
|
||
<div class="stack">
|
||
<div class="grid">
|
||
<label>
|
||
客户端标识 user
|
||
<input id="frpUser" autocomplete="off" placeholder="可选,用于区分同名代理">
|
||
</label>
|
||
<label>
|
||
客户端传输协议
|
||
<select id="frpTransportProtocol">
|
||
<option value="">默认</option>
|
||
<option value="tcp">TCP</option>
|
||
<option value="kcp">KCP</option>
|
||
<option value="quic">QUIC</option>
|
||
<option value="websocket">WebSocket</option>
|
||
<option value="wss">WebSocket TLS</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
TLS
|
||
<select id="frpTlsEnable">
|
||
<option value="">默认</option>
|
||
<option value="true">启用</option>
|
||
<option value="false">禁用</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
TCP 多路复用
|
||
<select id="frpTcpMux">
|
||
<option value="">默认</option>
|
||
<option value="true">启用</option>
|
||
<option value="false">禁用</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
连接池数量
|
||
<input id="frpPoolCount" type="number" min="0" max="200" placeholder="默认">
|
||
</label>
|
||
<label>
|
||
连接代理
|
||
<input id="frpProxyUrl" autocomplete="off" placeholder="http://user:pass@proxy:8080">
|
||
</label>
|
||
<label>
|
||
日志等级
|
||
<select id="frpLogLevel">
|
||
<option value="">默认</option>
|
||
<option value="trace">trace</option>
|
||
<option value="debug">debug</option>
|
||
<option value="info">info</option>
|
||
<option value="warn">warn</option>
|
||
<option value="error">error</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
日志输出
|
||
<input id="frpLogTo" autocomplete="off" placeholder="默认;例如 console 或 ./frpc.log">
|
||
</label>
|
||
<label>
|
||
日志保留天数
|
||
<input id="frpLogMaxDays" type="number" min="1" max="3650" placeholder="默认">
|
||
</label>
|
||
<label>
|
||
登录失败后退出
|
||
<select id="frpLoginFailExit">
|
||
<option value="">默认</option>
|
||
<option value="true">是</option>
|
||
<option value="false">否,持续重连</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
UDP 包大小
|
||
<input id="frpUdpPacketSize" type="number" min="576" max="65535" placeholder="默认">
|
||
</label>
|
||
</div>
|
||
<div class="checkbox-grid">
|
||
<label class="check-row">
|
||
<input id="frpUseEncryption" type="checkbox">
|
||
<span>代理流量加密</span>
|
||
</label>
|
||
<label class="check-row">
|
||
<input id="frpUseCompression" type="checkbox">
|
||
<span>代理流量压缩</span>
|
||
</label>
|
||
<label class="check-row">
|
||
<input id="frpDisableLogColor" type="checkbox">
|
||
<span>禁用日志颜色</span>
|
||
</label>
|
||
</div>
|
||
<div class="grid">
|
||
<label>
|
||
带宽限制
|
||
<input id="frpBandwidthLimit" autocomplete="off" placeholder="例如 1MB 或 100KB">
|
||
</label>
|
||
<label>
|
||
限速位置
|
||
<select id="frpBandwidthLimitMode">
|
||
<option value="">默认</option>
|
||
<option value="client">client</option>
|
||
<option value="server">server</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Proxy Protocol
|
||
<select id="frpProxyProtocolVersion">
|
||
<option value="">不启用</option>
|
||
<option value="v1">v1</option>
|
||
<option value="v2">v2</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
负载均衡组
|
||
<input id="frpLoadBalancerGroup" autocomplete="off" placeholder="可选">
|
||
</label>
|
||
<label>
|
||
负载均衡密钥
|
||
<input id="frpLoadBalancerGroupKey" type="password" autocomplete="off" placeholder="可选">
|
||
</label>
|
||
<label>
|
||
健康检查类型
|
||
<select id="frpHealthCheckType">
|
||
<option value="">不启用</option>
|
||
<option value="tcp">tcp</option>
|
||
<option value="http">http</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
健康检查路径
|
||
<input id="frpHealthCheckPath" autocomplete="off" placeholder="HTTP 检查时填写,例如 /health">
|
||
</label>
|
||
<label>
|
||
检查间隔秒
|
||
<input id="frpHealthCheckInterval" type="number" min="1" max="3600" placeholder="默认">
|
||
</label>
|
||
<label>
|
||
检查超时秒
|
||
<input id="frpHealthCheckTimeout" type="number" min="1" max="3600" placeholder="默认">
|
||
</label>
|
||
<label>
|
||
连续失败阈值
|
||
<input id="frpHealthCheckMaxFailed" type="number" min="1" max="100" placeholder="默认">
|
||
</label>
|
||
</div>
|
||
<label>
|
||
额外客户端 TOML
|
||
<textarea id="frpExtraClientConfig" placeholder="例如: metadatas.env = "prod""></textarea>
|
||
<span class="hint">追加到 frpc.toml 顶层,适合填写插件暂未暴露的客户端配置。</span>
|
||
</label>
|
||
<label>
|
||
额外代理 TOML
|
||
<textarea id="frpExtraProxyConfig" placeholder="例如: transport.bandwidthLimitMode = "client""></textarea>
|
||
<span class="hint">追加到当前 [[proxies]] 内,适合填写插件暂未暴露的单代理配置。</span>
|
||
</label>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div id="message" class="message"></div>
|
||
</section>
|
||
|
||
<section class="panel stack">
|
||
<div class="config-head">
|
||
<h2>已有配置</h2>
|
||
<button class="secondary" id="refreshConfigsButton" type="button">刷新配置</button>
|
||
</div>
|
||
<div class="config-list" id="configList">
|
||
<p>正在加载配置...</p>
|
||
</div>
|
||
|
||
<div class="preview-section hidden" id="savedPreviewSection">
|
||
<h2>配置预览</h2>
|
||
<div class="meta" id="savedMeta">
|
||
<div class="meta-row"><strong>配置名称</strong><span id="savedName">-</span></div>
|
||
<div class="meta-row"><strong>工具</strong><span id="savedTool">-</span></div>
|
||
<div class="meta-row"><strong>关联实例</strong><span id="savedInstance">-</span></div>
|
||
<div class="meta-row"><strong>连接地址</strong><span id="savedAddress">-</span></div>
|
||
</div>
|
||
<pre id="savedPreview"></pre>
|
||
</div>
|
||
|
||
<h2>生成结果</h2>
|
||
<div class="meta" id="resultMeta">
|
||
<div class="meta-row"><strong>配置目录</strong><span id="resultDir">-</span></div>
|
||
<div class="meta-row"><strong>启动命令</strong><span id="resultCommand">-</span></div>
|
||
<div class="meta-row"><strong>连接地址</strong><span id="resultAddress">-</span></div>
|
||
</div>
|
||
<pre id="preview"></pre>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
const state = {
|
||
instances: [],
|
||
systemInfo: null,
|
||
lastPlan: null,
|
||
savedConfigs: [],
|
||
selectedConfigPath: '',
|
||
pendingDeletePath: '',
|
||
installingTool: '',
|
||
autoDefaults: {
|
||
profileName: { enabled: true, value: '' },
|
||
localPort: { enabled: true, value: '' },
|
||
remotePort: { enabled: true, value: '' }
|
||
}
|
||
}
|
||
|
||
let themeSyncTimer = null
|
||
let themeObserver = null
|
||
|
||
const $ = (id) => document.getElementById(id)
|
||
|
||
const fields = [
|
||
'instanceSelect', 'profileName', 'localAddress', 'localPort', 'protocol',
|
||
'frpcPath', 'frpServerAddr', 'frpServerPort', 'remotePort', 'frpToken',
|
||
'frpUser', 'frpTransportProtocol', 'frpTlsEnable', 'frpTcpMux', 'frpPoolCount',
|
||
'frpProxyUrl', 'frpLogLevel', 'frpLogTo', 'frpLogMaxDays', 'frpUdpPacketSize',
|
||
'frpUseEncryption', 'frpUseCompression', 'frpDisableLogColor', 'frpLoginFailExit', 'frpBandwidthLimit',
|
||
'frpBandwidthLimitMode', 'frpProxyProtocolVersion', 'frpLoadBalancerGroup',
|
||
'frpLoadBalancerGroupKey', 'frpHealthCheckType', 'frpHealthCheckPath',
|
||
'frpHealthCheckInterval', 'frpHealthCheckTimeout', 'frpHealthCheckMaxFailed',
|
||
'frpExtraClientConfig', 'frpExtraProxyConfig'
|
||
]
|
||
|
||
const read = (id) => $(id).value.trim()
|
||
const readCheckbox = (id) => Boolean($(id) && $(id).checked)
|
||
const selectedInstanceLooksWindows = () => {
|
||
const instance = getSelectedInstance()
|
||
const workingDirectory = String(instance && instance.workingDirectory || '')
|
||
const startCommand = String(instance && instance.startCommand || '')
|
||
return /^[a-z]:[\\/]/i.test(workingDirectory) ||
|
||
workingDirectory.includes('\\') ||
|
||
startCommand.includes('.\\') ||
|
||
/\.exe\b/i.test(startCommand)
|
||
}
|
||
|
||
const isWindows = () => {
|
||
const raw = state.systemInfo && state.systemInfo.rawPlatform
|
||
const platform = state.systemInfo && state.systemInfo.platform
|
||
return raw === 'win32' ||
|
||
String(platform || '').toLowerCase().includes('windows') ||
|
||
selectedInstanceLooksWindows()
|
||
}
|
||
|
||
function showMessage(type, text) {
|
||
const message = $('message')
|
||
message.className = `message show ${type}`
|
||
message.textContent = text
|
||
}
|
||
|
||
function clearMessage() {
|
||
const message = $('message')
|
||
message.className = 'message'
|
||
message.textContent = ''
|
||
}
|
||
|
||
function normalizeListResponse(result) {
|
||
if (!result) return []
|
||
if (Array.isArray(result)) return result
|
||
if (Array.isArray(result.data)) return result.data
|
||
if (result.data && Array.isArray(result.data.instances)) return result.data.instances
|
||
return []
|
||
}
|
||
|
||
function notify(type, message) {
|
||
if (window.gsm3 && typeof window.gsm3.showNotification === 'function') {
|
||
window.gsm3.showNotification(type, message)
|
||
}
|
||
}
|
||
|
||
function getErrorMessage(error) {
|
||
return error && error.message ? error.message : String(error || '未知错误')
|
||
}
|
||
|
||
function applyPanelTheme() {
|
||
let theme = ''
|
||
try {
|
||
const storedTheme = window.parent && window.parent.localStorage
|
||
? window.parent.localStorage.getItem('gsm3-theme')
|
||
: ''
|
||
if (storedTheme) {
|
||
const parsedTheme = JSON.parse(storedTheme)
|
||
theme = parsedTheme && parsedTheme.state && parsedTheme.state.theme
|
||
}
|
||
|
||
const parentRoot = window.parent && window.parent.document && window.parent.document.documentElement
|
||
if (!theme && parentRoot && parentRoot.classList.contains('dark')) {
|
||
theme = 'dark'
|
||
}
|
||
} catch {
|
||
// 无法访问父页面时回退到系统主题
|
||
}
|
||
|
||
if (!theme && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||
theme = 'dark'
|
||
}
|
||
|
||
document.documentElement.dataset.theme = theme === 'dark' ? 'dark' : 'light'
|
||
}
|
||
|
||
function startThemeSync() {
|
||
applyPanelTheme()
|
||
|
||
try {
|
||
const parentRoot = window.parent && window.parent.document && window.parent.document.documentElement
|
||
if (parentRoot && !themeObserver) {
|
||
themeObserver = new MutationObserver(applyPanelTheme)
|
||
themeObserver.observe(parentRoot, { attributes: true, attributeFilter: ['class'] })
|
||
}
|
||
} catch {
|
||
// 父页面不可访问时由定时同步兜底
|
||
}
|
||
|
||
if (!themeSyncTimer) {
|
||
themeSyncTimer = window.setInterval(applyPanelTheme, 1000)
|
||
}
|
||
}
|
||
|
||
function dataExists(result) {
|
||
if (!result || result.success === false) return false
|
||
if (typeof result.exists === 'boolean') return result.exists
|
||
if (typeof result.data === 'boolean') return result.data
|
||
if (result.data && typeof result.data.exists === 'boolean') return result.data.exists
|
||
return true
|
||
}
|
||
|
||
function delay(ms) {
|
||
return new Promise(resolve => window.setTimeout(resolve, ms))
|
||
}
|
||
|
||
async function waitForPluginToken() {
|
||
if (!window.gsm3 || typeof window.gsm3.initialize !== 'function') return false
|
||
try {
|
||
await window.gsm3.initialize()
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function sanitizeSlug(value) {
|
||
return value
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\u4e00-\u9fa5_-]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 48) || `tunnel-${Date.now()}`
|
||
}
|
||
|
||
function tomlString(value) {
|
||
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||
}
|
||
|
||
function psArg(value) {
|
||
return `'${String(value).replace(/'/g, "''")}'`
|
||
}
|
||
|
||
function shArg(value) {
|
||
return `'${String(value).replace(/'/g, "'\\''")}'`
|
||
}
|
||
|
||
function shellArg(value) {
|
||
return isWindows() ? psArg(value) : shArg(value)
|
||
}
|
||
|
||
function redactSensitiveText(text) {
|
||
return String(text)
|
||
.replace(/auth\.token\s*=\s*"[^"]*"/g, 'auth.token = "******"')
|
||
.replace(/loadBalancer\.groupKey\s*=\s*"[^"]*"/g, 'loadBalancer.groupKey = "******"')
|
||
.replace(/("(?:frpToken|frpLoadBalancerGroupKey)"\s*:\s*)"[^"]*"/g, '$1"******"')
|
||
}
|
||
|
||
function commandPrefix(executablePath) {
|
||
const quoted = shellArg(executablePath)
|
||
return isWindows() ? `& ${quoted}` : quoted
|
||
}
|
||
|
||
function executableLooksLikePath(executablePath) {
|
||
return /[\\/]/.test(executablePath) || /^[a-z]:/i.test(executablePath)
|
||
}
|
||
|
||
function executableMissingMessage(toolLabel, executablePath) {
|
||
return `未找到 ${toolLabel} 可执行文件:${executablePath}。请先安装客户端,或在插件中填写可执行文件绝对路径后重新保存实例。`
|
||
}
|
||
|
||
function buildExecutableCommand(executablePath, args, toolLabel) {
|
||
const command = `${commandPrefix(executablePath)} ${args.map(shellArg).join(' ')}`.trim()
|
||
const missingMessage = executableMissingMessage(toolLabel, executablePath)
|
||
|
||
if (isWindows()) {
|
||
const check = executableLooksLikePath(executablePath)
|
||
? `Test-Path -LiteralPath ${psArg(executablePath)}`
|
||
: `Get-Command ${psArg(executablePath)} -ErrorAction SilentlyContinue`
|
||
return `if (${check}) { ${command} } else { Write-Error ${psArg(missingMessage)} }`
|
||
}
|
||
|
||
const check = executableLooksLikePath(executablePath)
|
||
? `[ -x ${shArg(executablePath)} ]`
|
||
: `command -v ${shArg(executablePath)} >/dev/null 2>&1`
|
||
return `if ${check}; then ${command}; else echo ${shArg(missingMessage)} >&2; false; fi`
|
||
}
|
||
|
||
function getToolDefinition() {
|
||
return {
|
||
inputId: 'frpcPath',
|
||
statusId: 'frpcToolStatus',
|
||
buttonId: 'checkFrpcButton',
|
||
installButtonId: 'installFrpcButton',
|
||
label: 'frpc',
|
||
defaultCommand: 'frpc'
|
||
}
|
||
}
|
||
|
||
function setToolStatus(tool, type, text) {
|
||
const definition = getToolDefinition(tool)
|
||
const status = $(definition.statusId)
|
||
status.className = `hint tool-status ${type || ''}`.trim()
|
||
status.textContent = text
|
||
}
|
||
|
||
function setToolButtonsDisabled(tool, disabled) {
|
||
const definition = getToolDefinition(tool)
|
||
$(definition.buttonId).disabled = disabled
|
||
$(definition.installButtonId).disabled = disabled
|
||
}
|
||
|
||
function shouldFillInstalledToolPath(tool) {
|
||
const definition = getToolDefinition(tool)
|
||
const currentValue = read(definition.inputId)
|
||
return !currentValue || currentValue === definition.defaultCommand
|
||
}
|
||
|
||
async function hydrateInstalledTool(tool) {
|
||
const definition = getToolDefinition(tool)
|
||
try {
|
||
const result = await window.gsm3.getFrpToolStatus()
|
||
const data = result.data || result
|
||
|
||
if (!data.installed || !data.executablePath) {
|
||
return false
|
||
}
|
||
|
||
if (shouldFillInstalledToolPath(tool)) {
|
||
$(definition.inputId).value = data.executablePath
|
||
}
|
||
|
||
setToolStatus(tool, 'ok', `已安装 ${definition.label} ${data.version || ''}:${data.executablePath}`.trim())
|
||
return true
|
||
} catch (error) {
|
||
setToolStatus(tool, 'warn', `读取 ${definition.label} 安装状态失败:${getErrorMessage(error)}`)
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function hydrateInstalledTools() {
|
||
await hydrateInstalledTool('frp')
|
||
}
|
||
|
||
async function checkToolAvailability(tool = 'frp', showPanelMessage = true) {
|
||
const definition = getToolDefinition(tool)
|
||
const executablePath = read(definition.inputId)
|
||
const button = $(definition.buttonId)
|
||
|
||
if (!executablePath) {
|
||
setToolStatus(tool, 'error', `请填写 ${definition.label} 可执行文件`)
|
||
return false
|
||
}
|
||
|
||
setToolButtonsDisabled(tool, true)
|
||
const originalText = button.textContent
|
||
button.textContent = '检测中...'
|
||
|
||
try {
|
||
const result = await window.gsm3.resolveExecutable(
|
||
executablePath,
|
||
state.lastPlan ? state.lastPlan.workingDirectory : ''
|
||
)
|
||
const data = result.data || result
|
||
|
||
if (data.found) {
|
||
setToolStatus(tool, 'ok', `已找到:${data.resolvedPath}`)
|
||
if (showPanelMessage) {
|
||
showMessage('success', `${definition.label} 可用`)
|
||
}
|
||
return true
|
||
}
|
||
|
||
const text = executableMissingMessage(definition.label, executablePath)
|
||
setToolStatus(tool, 'warn', text)
|
||
if (showPanelMessage) {
|
||
showMessage('warn', text)
|
||
}
|
||
return false
|
||
} catch (error) {
|
||
const text = `检测 ${definition.label} 失败:${getErrorMessage(error)}`
|
||
setToolStatus(tool, 'error', text)
|
||
if (showPanelMessage) {
|
||
showMessage('error', text)
|
||
}
|
||
return false
|
||
} finally {
|
||
setToolButtonsDisabled(tool, false)
|
||
button.textContent = originalText
|
||
}
|
||
}
|
||
|
||
function parseSseBlock(block) {
|
||
const event = { event: 'message', data: '' }
|
||
const dataLines = []
|
||
|
||
for (const rawLine of block.split(/\r?\n/)) {
|
||
const line = rawLine.trimEnd()
|
||
if (line.startsWith('event:')) {
|
||
event.event = line.slice('event:'.length).trim()
|
||
} else if (line.startsWith('data:')) {
|
||
dataLines.push(line.slice('data:'.length).trimStart())
|
||
}
|
||
}
|
||
|
||
event.data = dataLines.join('\n')
|
||
return event
|
||
}
|
||
|
||
async function installTool(tool) {
|
||
const definition = getToolDefinition(tool)
|
||
if (state.installingTool) return
|
||
|
||
await waitForPluginToken()
|
||
|
||
state.installingTool = tool
|
||
setToolButtonsDisabled(tool, true)
|
||
const installButton = $(definition.installButtonId)
|
||
const originalText = installButton.textContent
|
||
installButton.textContent = '安装中...'
|
||
setToolStatus(tool, 'warn', `正在准备下载 ${definition.label}...`)
|
||
clearMessage()
|
||
|
||
try {
|
||
const response = await window.gsm3.installFrpTool()
|
||
if (!response.ok) {
|
||
let errorText = `HTTP ${response.status}`
|
||
try {
|
||
const data = await response.json()
|
||
errorText = data.message || data.error || errorText
|
||
} catch {
|
||
// SSE 前的错误可能不是 JSON
|
||
}
|
||
throw new Error(errorText)
|
||
}
|
||
|
||
const reader = response.body && response.body.getReader()
|
||
if (!reader) {
|
||
throw new Error('无法读取下载进度')
|
||
}
|
||
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
let installError = null
|
||
|
||
const handleEvent = (block) => {
|
||
if (!block.trim()) return
|
||
const sse = parseSseBlock(block)
|
||
let data = {}
|
||
try {
|
||
data = sse.data ? JSON.parse(sse.data) : {}
|
||
} catch {
|
||
data = { message: sse.data }
|
||
}
|
||
|
||
if (sse.event === 'status') {
|
||
setToolStatus(tool, 'warn', data.status || '正在安装...')
|
||
return
|
||
}
|
||
|
||
if (sse.event === 'progress') {
|
||
const progress = typeof data.progress === 'number' ? data.progress : 0
|
||
setToolStatus(tool, 'warn', `正在安装 ${definition.label}... ${progress}%`)
|
||
return
|
||
}
|
||
|
||
if (sse.event === 'complete') {
|
||
const result = data.data || {}
|
||
if (result.executablePath) {
|
||
$(definition.inputId).value = result.executablePath
|
||
}
|
||
setToolStatus(tool, 'ok', `已安装 ${definition.label} ${result.version || ''}:${result.executablePath || ''}`.trim())
|
||
showMessage('success', `${definition.label} 安装完成,已填入可执行文件路径。请重新保存配置或重建穿透实例`)
|
||
notify('success', `${definition.label} 安装完成`)
|
||
buildPlan(false)
|
||
return
|
||
}
|
||
|
||
if (sse.event === 'error') {
|
||
const message = data.error || data.message || `${definition.label} 安装失败`
|
||
installError = new Error(message)
|
||
}
|
||
}
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const blocks = buffer.split(/\r?\n\r?\n/)
|
||
buffer = blocks.pop() || ''
|
||
for (const block of blocks) {
|
||
handleEvent(block)
|
||
}
|
||
}
|
||
|
||
if (buffer.trim()) {
|
||
handleEvent(buffer)
|
||
}
|
||
|
||
if (installError) {
|
||
throw installError
|
||
}
|
||
} catch (error) {
|
||
const message = `${definition.label} 安装失败:${getErrorMessage(error)}`
|
||
setToolStatus(tool, 'error', message)
|
||
showMessage('error', message)
|
||
notify('error', message)
|
||
} finally {
|
||
state.installingTool = ''
|
||
setToolButtonsDisabled(tool, false)
|
||
installButton.textContent = originalText
|
||
}
|
||
}
|
||
|
||
function parsePort(value, label) {
|
||
const port = Number(value)
|
||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||
throw new Error(`${label} 必须是 1 到 65535 之间的整数`)
|
||
}
|
||
return port
|
||
}
|
||
|
||
function parseOptionalInt(value, label, options = {}) {
|
||
if (!String(value || '').trim()) return null
|
||
|
||
const number = Number(value)
|
||
const min = typeof options.min === 'number' ? options.min : Number.MIN_SAFE_INTEGER
|
||
const max = typeof options.max === 'number' ? options.max : Number.MAX_SAFE_INTEGER
|
||
if (!Number.isInteger(number) || number < min || number > max) {
|
||
throw new Error(`${label} 必须是 ${min} 到 ${max} 之间的整数`)
|
||
}
|
||
|
||
return number
|
||
}
|
||
|
||
function appendRawToml(lines, block, label) {
|
||
const trimmed = String(block || '').trim()
|
||
if (!trimmed) return
|
||
|
||
lines.push('', `# ${label}`)
|
||
for (const line of trimmed.split(/\r?\n/)) {
|
||
lines.push(line.trimEnd())
|
||
}
|
||
}
|
||
|
||
function collectFormValues() {
|
||
const values = { mode: 'frp' }
|
||
for (const id of fields) {
|
||
const element = $(id)
|
||
if (!element) continue
|
||
values[id] = element.type === 'checkbox' ? Boolean(element.checked) : element.value
|
||
}
|
||
return values
|
||
}
|
||
|
||
function applyFormValues(values) {
|
||
if (!values || typeof values !== 'object') return
|
||
|
||
for (const id of fields) {
|
||
if (!(id in values)) continue
|
||
const element = $(id)
|
||
if (!element) continue
|
||
|
||
if (element.type === 'checkbox') {
|
||
element.checked = Boolean(values[id])
|
||
} else {
|
||
element.value = values[id] == null ? '' : String(values[id])
|
||
}
|
||
}
|
||
}
|
||
|
||
function getSelectedInstance() {
|
||
const id = read('instanceSelect')
|
||
return state.instances.find(instance => instance.id === id) || null
|
||
}
|
||
|
||
function guessPort(instance) {
|
||
const text = `${instance.name || ''} ${instance.description || ''}`.toLowerCase()
|
||
if (text.includes('terraria') || text.includes('泰拉瑞亚')) return 7777
|
||
if (text.includes('minecraft') || text.includes('我的世界')) return 25565
|
||
if (text.includes('palworld') || text.includes('幻兽帕鲁')) return 8211
|
||
if (text.includes('valheim') || text.includes('英灵神殿')) return 2456
|
||
if (text.includes('factorio')) return 34197
|
||
return ''
|
||
}
|
||
|
||
function syncAutoDefaultField(id, nextValue) {
|
||
const field = state.autoDefaults[id]
|
||
if (!field) return
|
||
|
||
const element = $(id)
|
||
const currentValue = read(id)
|
||
const normalizedValue = String(nextValue || '')
|
||
const shouldUpdate = field.enabled || !currentValue || currentValue === field.value
|
||
|
||
field.value = normalizedValue
|
||
if (shouldUpdate) {
|
||
element.value = normalizedValue
|
||
field.enabled = true
|
||
}
|
||
}
|
||
|
||
function markAutoDefaultFieldInput(id) {
|
||
const field = state.autoDefaults[id]
|
||
if (!field) return
|
||
|
||
const currentValue = read(id)
|
||
field.enabled = !currentValue || currentValue === field.value
|
||
}
|
||
|
||
function markAutoDefaultFieldsManual(ids) {
|
||
for (const id of ids) {
|
||
const field = state.autoDefaults[id]
|
||
if (!field) continue
|
||
field.value = read(id)
|
||
field.enabled = false
|
||
}
|
||
}
|
||
|
||
function updateInstanceMeta() {
|
||
const instance = getSelectedInstance()
|
||
$('instancePath').textContent = instance ? instance.workingDirectory || '-' : '-'
|
||
$('instanceCommand').textContent = instance ? instance.startCommand || '-' : '-'
|
||
$('instanceStatus').textContent = instance ? instance.status || '-' : '-'
|
||
|
||
const profileName = instance ? sanitizeSlug(instance.name || 'game') : ''
|
||
const guessedPort = instance ? guessPort(instance) : ''
|
||
syncAutoDefaultField('profileName', profileName)
|
||
syncAutoDefaultField('localPort', guessedPort)
|
||
syncAutoDefaultField('remotePort', guessedPort)
|
||
}
|
||
|
||
async function loadSystemInfo() {
|
||
try {
|
||
const result = await window.gsm3.getSystemInfo()
|
||
state.systemInfo = result.data || result
|
||
} catch (error) {
|
||
state.systemInfo = null
|
||
}
|
||
}
|
||
|
||
async function loadInstances(options = {}) {
|
||
const { showLoadedMessage = true, selectedInstanceId = read('instanceSelect') } = options
|
||
if (showLoadedMessage) {
|
||
clearMessage()
|
||
}
|
||
$('instanceSelect').innerHTML = '<option value="">加载中...</option>'
|
||
try {
|
||
const result = await window.gsm3.getInstances()
|
||
state.instances = normalizeListResponse(result)
|
||
const gameInstances = state.instances.filter(instance => {
|
||
const description = String(instance.description || '')
|
||
return !description.includes('[tunnel-helper]')
|
||
})
|
||
|
||
$('instanceSelect').innerHTML = ''
|
||
if (gameInstances.length === 0) {
|
||
$('instanceSelect').innerHTML = '<option value="">暂无可选实例</option>'
|
||
updateInstanceMeta()
|
||
return
|
||
}
|
||
|
||
for (const instance of gameInstances) {
|
||
const option = document.createElement('option')
|
||
option.value = instance.id
|
||
option.textContent = `${instance.name} (${instance.status || 'unknown'})`
|
||
option.selected = instance.id === selectedInstanceId
|
||
$('instanceSelect').appendChild(option)
|
||
}
|
||
|
||
updateInstanceMeta()
|
||
if (showLoadedMessage) {
|
||
showMessage('success', `已加载 ${gameInstances.length} 个游戏实例`)
|
||
}
|
||
} catch (error) {
|
||
$('instanceSelect').innerHTML = '<option value="">加载失败</option>'
|
||
showMessage('error', `获取实例列表失败:${error.message}`)
|
||
}
|
||
}
|
||
|
||
function buildFrpPlan(common) {
|
||
const serverAddr = read('frpServerAddr')
|
||
const serverPort = parsePort(read('frpServerPort'), 'frps 端口')
|
||
const remotePort = parsePort(read('remotePort'), '远端端口')
|
||
const frpcPath = read('frpcPath')
|
||
|
||
if (!frpcPath) throw new Error('请填写 frpc 可执行文件')
|
||
if (!serverAddr) throw new Error('请填写 frps 地址')
|
||
|
||
const token = read('frpToken')
|
||
const proxyName = `${common.slug}-${common.protocol}-${common.localPort}`
|
||
const user = read('frpUser')
|
||
const transportProtocol = read('frpTransportProtocol')
|
||
const tlsEnable = read('frpTlsEnable')
|
||
const tcpMux = read('frpTcpMux')
|
||
const poolCount = parseOptionalInt(read('frpPoolCount'), '连接池数量', { min: 0, max: 200 })
|
||
const proxyUrl = read('frpProxyUrl')
|
||
const logLevel = read('frpLogLevel')
|
||
const logTo = read('frpLogTo')
|
||
const logMaxDays = parseOptionalInt(read('frpLogMaxDays'), '日志保留天数', { min: 1, max: 3650 })
|
||
const loginFailExit = read('frpLoginFailExit')
|
||
const udpPacketSize = parseOptionalInt(read('frpUdpPacketSize'), 'UDP 包大小', { min: 576, max: 65535 })
|
||
const bandwidthLimit = read('frpBandwidthLimit')
|
||
const bandwidthLimitMode = read('frpBandwidthLimitMode')
|
||
const proxyProtocolVersion = read('frpProxyProtocolVersion')
|
||
const loadBalancerGroup = read('frpLoadBalancerGroup')
|
||
const loadBalancerGroupKey = read('frpLoadBalancerGroupKey')
|
||
const healthCheckType = read('frpHealthCheckType')
|
||
const healthCheckPath = read('frpHealthCheckPath')
|
||
const healthCheckInterval = parseOptionalInt(read('frpHealthCheckInterval'), '健康检查间隔秒', { min: 1, max: 3600 })
|
||
const healthCheckTimeout = parseOptionalInt(read('frpHealthCheckTimeout'), '健康检查超时秒', { min: 1, max: 3600 })
|
||
const healthCheckMaxFailed = parseOptionalInt(read('frpHealthCheckMaxFailed'), '连续失败阈值', { min: 1, max: 100 })
|
||
const configLines = [
|
||
`serverAddr = ${tomlString(serverAddr)}`,
|
||
`serverPort = ${serverPort}`
|
||
]
|
||
|
||
if (user) {
|
||
configLines.push(`user = ${tomlString(user)}`)
|
||
}
|
||
|
||
if (logLevel) {
|
||
configLines.push(`log.level = ${tomlString(logLevel)}`)
|
||
}
|
||
|
||
if (logTo) {
|
||
configLines.push(`log.to = ${tomlString(logTo)}`)
|
||
}
|
||
|
||
if (logMaxDays !== null) {
|
||
configLines.push(`log.maxDays = ${logMaxDays}`)
|
||
}
|
||
|
||
if (readCheckbox('frpDisableLogColor')) {
|
||
configLines.push('log.disablePrintColor = true')
|
||
}
|
||
|
||
if (token) {
|
||
configLines.push(`auth.token = ${tomlString(token)}`)
|
||
}
|
||
|
||
if (loginFailExit) {
|
||
configLines.push(`loginFailExit = ${loginFailExit === 'true' ? 'true' : 'false'}`)
|
||
}
|
||
|
||
if (transportProtocol) {
|
||
configLines.push(`transport.protocol = ${tomlString(transportProtocol)}`)
|
||
}
|
||
|
||
if (tlsEnable) {
|
||
configLines.push(`transport.tls.enable = ${tlsEnable === 'true' ? 'true' : 'false'}`)
|
||
}
|
||
|
||
if (tcpMux) {
|
||
configLines.push(`transport.tcpMux = ${tcpMux === 'true' ? 'true' : 'false'}`)
|
||
}
|
||
|
||
if (poolCount !== null) {
|
||
configLines.push(`transport.poolCount = ${poolCount}`)
|
||
}
|
||
|
||
if (proxyUrl) {
|
||
configLines.push(`transport.proxyURL = ${tomlString(proxyUrl)}`)
|
||
}
|
||
|
||
if (udpPacketSize !== null) {
|
||
configLines.push(`udpPacketSize = ${udpPacketSize}`)
|
||
}
|
||
|
||
appendRawToml(configLines, read('frpExtraClientConfig'), '额外客户端配置')
|
||
|
||
configLines.push(
|
||
'',
|
||
'[[proxies]]',
|
||
`name = ${tomlString(proxyName)}`,
|
||
`type = ${tomlString(common.protocol)}`,
|
||
`localIP = ${tomlString(common.localAddress)}`,
|
||
`localPort = ${common.localPort}`,
|
||
`remotePort = ${remotePort}`,
|
||
''
|
||
)
|
||
|
||
if (readCheckbox('frpUseEncryption')) {
|
||
configLines.push('transport.useEncryption = true')
|
||
}
|
||
|
||
if (readCheckbox('frpUseCompression')) {
|
||
configLines.push('transport.useCompression = true')
|
||
}
|
||
|
||
if (bandwidthLimit) {
|
||
configLines.push(`transport.bandwidthLimit = ${tomlString(bandwidthLimit)}`)
|
||
}
|
||
|
||
if (bandwidthLimitMode) {
|
||
configLines.push(`transport.bandwidthLimitMode = ${tomlString(bandwidthLimitMode)}`)
|
||
}
|
||
|
||
if (proxyProtocolVersion) {
|
||
configLines.push(`transport.proxyProtocolVersion = ${tomlString(proxyProtocolVersion)}`)
|
||
}
|
||
|
||
if (loadBalancerGroup) {
|
||
configLines.push(`loadBalancer.group = ${tomlString(loadBalancerGroup)}`)
|
||
}
|
||
|
||
if (loadBalancerGroupKey) {
|
||
configLines.push(`loadBalancer.groupKey = ${tomlString(loadBalancerGroupKey)}`)
|
||
}
|
||
|
||
if (healthCheckType) {
|
||
configLines.push(`healthCheck.type = ${tomlString(healthCheckType)}`)
|
||
if (healthCheckPath) {
|
||
configLines.push(`healthCheck.path = ${tomlString(healthCheckPath)}`)
|
||
}
|
||
if (healthCheckInterval !== null) {
|
||
configLines.push(`healthCheck.intervalSeconds = ${healthCheckInterval}`)
|
||
}
|
||
if (healthCheckTimeout !== null) {
|
||
configLines.push(`healthCheck.timeoutSeconds = ${healthCheckTimeout}`)
|
||
}
|
||
if (healthCheckMaxFailed !== null) {
|
||
configLines.push(`healthCheck.maxFailed = ${healthCheckMaxFailed}`)
|
||
}
|
||
}
|
||
|
||
appendRawToml(configLines, read('frpExtraProxyConfig'), '额外代理配置')
|
||
|
||
const configPath = `${common.dataDir}/frpc.toml`
|
||
const startCommand = buildExecutableCommand(frpcPath, ['-c', 'frpc.toml'], 'frpc')
|
||
const publicAddress = `${serverAddr}:${remotePort}`
|
||
|
||
return {
|
||
...common,
|
||
tool: 'frp',
|
||
configPath,
|
||
configContent: configLines.join('\n'),
|
||
startCommand,
|
||
executablePath: frpcPath,
|
||
publicAddress,
|
||
formValues: collectFormValues(),
|
||
extraFiles: [],
|
||
instanceDescription: `[tunnel-helper] frp ${common.protocol.toUpperCase()} ${common.localAddress}:${common.localPort} -> ${publicAddress}`
|
||
}
|
||
}
|
||
|
||
function buildPlan(throwOnError = true) {
|
||
try {
|
||
const instance = getSelectedInstance()
|
||
if (!instance) throw new Error('请选择游戏实例')
|
||
|
||
const localPort = parsePort(read('localPort'), '本地端口')
|
||
const localAddress = read('localAddress') || '127.0.0.1'
|
||
const profile = read('profileName') || instance.name || 'game'
|
||
const slug = sanitizeSlug(profile)
|
||
const protocol = read('protocol')
|
||
const dataDir = `tunnels/${slug}`
|
||
const workingDirectory = `data/${dataDir}`
|
||
|
||
const common = {
|
||
instance,
|
||
profile,
|
||
slug,
|
||
protocol,
|
||
localAddress,
|
||
localPort,
|
||
dataDir,
|
||
workingDirectory
|
||
}
|
||
|
||
state.lastPlan = buildFrpPlan(common)
|
||
renderPlan()
|
||
clearMessage()
|
||
return state.lastPlan
|
||
} catch (error) {
|
||
state.lastPlan = null
|
||
renderPlan()
|
||
if (throwOnError) {
|
||
showMessage('error', error.message)
|
||
}
|
||
return null
|
||
}
|
||
}
|
||
|
||
function renderPlan() {
|
||
const plan = state.lastPlan
|
||
if (!plan) {
|
||
$('resultDir').textContent = '-'
|
||
$('resultCommand').textContent = '-'
|
||
$('resultAddress').textContent = '-'
|
||
$('preview').textContent = ''
|
||
return
|
||
}
|
||
|
||
$('resultDir').textContent = plan.workingDirectory
|
||
$('resultCommand').textContent = redactSensitiveText(plan.startCommand)
|
||
$('resultAddress').textContent = plan.publicAddress
|
||
$('preview').textContent = redactSensitiveText(plan.configContent)
|
||
}
|
||
|
||
function buildManifest(plan, existing = null) {
|
||
const now = new Date().toISOString()
|
||
|
||
return {
|
||
name: plan.profile,
|
||
tool: plan.tool,
|
||
linkedInstanceId: plan.instance.id,
|
||
linkedInstanceName: plan.instance.name,
|
||
localAddress: plan.localAddress,
|
||
localPort: plan.localPort,
|
||
protocol: plan.protocol,
|
||
publicAddress: plan.publicAddress,
|
||
workingDirectory: plan.workingDirectory,
|
||
configPath: plan.configPath,
|
||
formValues: plan.formValues || collectFormValues(),
|
||
createdAt: existing && existing.createdAt ? existing.createdAt : now,
|
||
updatedAt: now
|
||
}
|
||
}
|
||
|
||
async function writeTunnelFiles(plan) {
|
||
let existingManifest = null
|
||
try {
|
||
existingManifest = await readJsonFile(`${plan.dataDir}/manifest.json`)
|
||
} catch {
|
||
existingManifest = null
|
||
}
|
||
|
||
await window.gsm3.writeFile(plan.configPath, plan.configContent, 'utf-8')
|
||
await window.gsm3.writeFile(`${plan.dataDir}/manifest.json`, JSON.stringify(buildManifest(plan, existingManifest), null, 2), 'utf-8')
|
||
}
|
||
|
||
async function resolveTunnelWorkingDirectory(plan) {
|
||
const result = await window.gsm3.resolveDataPath(plan.dataDir)
|
||
return result && result.data && result.data.absolutePath
|
||
? result.data.absolutePath
|
||
: plan.workingDirectory
|
||
}
|
||
|
||
async function readJsonFile(filePath) {
|
||
const result = await window.gsm3.readFile(filePath, 'utf-8')
|
||
const content = result && result.data ? result.data.content : ''
|
||
return JSON.parse(content)
|
||
}
|
||
|
||
async function readTextFile(filePath) {
|
||
const result = await window.gsm3.readFile(filePath, 'utf-8')
|
||
return result && result.data ? result.data.content : ''
|
||
}
|
||
|
||
function setSavedPreview(config, content) {
|
||
$('savedPreviewSection').classList.toggle('hidden', !config)
|
||
$('savedName').textContent = config ? config.name || config.dirName || '-' : '-'
|
||
$('savedTool').textContent = config ? config.tool || '-' : '-'
|
||
$('savedInstance').textContent = config ? config.linkedInstanceName || '-' : '-'
|
||
$('savedAddress').textContent = config ? config.publicAddress || '-' : '-'
|
||
$('savedPreview').textContent = config
|
||
? redactSensitiveText(content || '')
|
||
: ''
|
||
}
|
||
|
||
function textNode(tagName, className, text) {
|
||
const node = document.createElement(tagName)
|
||
if (className) node.className = className
|
||
node.textContent = text
|
||
return node
|
||
}
|
||
|
||
async function loadSavedConfigs() {
|
||
const list = $('configList')
|
||
list.innerHTML = '<p>正在加载配置...</p>'
|
||
|
||
try {
|
||
const exists = await window.gsm3.exists('tunnels')
|
||
if (!dataExists(exists)) {
|
||
state.savedConfigs = []
|
||
list.innerHTML = '<p>暂无已保存配置</p>'
|
||
setSavedPreview(null, '')
|
||
return
|
||
}
|
||
|
||
const result = await window.gsm3.listDirectory('tunnels')
|
||
const dirs = normalizeListResponse(result).filter(item => item.type === 'directory')
|
||
const configs = []
|
||
|
||
for (const dir of dirs) {
|
||
const manifestPath = `${dir.path}/manifest.json`
|
||
try {
|
||
const manifest = await readJsonFile(manifestPath)
|
||
if (String(manifest.tool || '').toLowerCase() === 'easytier') {
|
||
continue
|
||
}
|
||
configs.push({
|
||
...manifest,
|
||
dirName: dir.name,
|
||
dirPath: dir.path,
|
||
manifestPath,
|
||
modified: dir.modified
|
||
})
|
||
} catch {
|
||
configs.push({
|
||
name: dir.name,
|
||
tool: 'unknown',
|
||
dirName: dir.name,
|
||
dirPath: dir.path,
|
||
manifestPath,
|
||
modified: dir.modified
|
||
})
|
||
}
|
||
}
|
||
|
||
state.savedConfigs = configs
|
||
renderSavedConfigs()
|
||
} catch (error) {
|
||
state.savedConfigs = []
|
||
list.innerHTML = ''
|
||
list.appendChild(textNode('p', '', `读取配置失败:${error.message}`))
|
||
setSavedPreview(null, '')
|
||
}
|
||
}
|
||
|
||
function renderSavedConfigs() {
|
||
const list = $('configList')
|
||
list.innerHTML = ''
|
||
|
||
if (state.savedConfigs.length === 0) {
|
||
list.innerHTML = '<p>暂无已保存配置</p>'
|
||
setSavedPreview(null, '')
|
||
return
|
||
}
|
||
|
||
for (const config of state.savedConfigs) {
|
||
const isPendingDelete = config.dirPath === state.pendingDeletePath
|
||
const card = document.createElement('div')
|
||
card.className = `config-card ${config.dirPath === state.selectedConfigPath ? 'active' : ''}`
|
||
|
||
const title = document.createElement('div')
|
||
title.className = 'config-head'
|
||
title.append(
|
||
textNode('div', 'config-title', config.name || config.dirName || '未命名配置'),
|
||
textNode('span', 'hint', config.tool || 'unknown')
|
||
)
|
||
|
||
const meta = document.createElement('div')
|
||
meta.className = 'hint'
|
||
meta.textContent = `${config.linkedInstanceName || '未关联实例'} · ${config.publicAddress || '未记录连接地址'}`
|
||
|
||
const actions = document.createElement('div')
|
||
actions.className = 'config-actions'
|
||
|
||
const previewButton = document.createElement('button')
|
||
previewButton.className = 'secondary'
|
||
previewButton.type = 'button'
|
||
previewButton.textContent = '预览'
|
||
previewButton.addEventListener('click', () => previewSavedConfig(config))
|
||
|
||
const loadButton = document.createElement('button')
|
||
loadButton.className = 'secondary'
|
||
loadButton.type = 'button'
|
||
loadButton.textContent = '载入表单'
|
||
loadButton.addEventListener('click', () => loadConfigIntoForm(config))
|
||
|
||
if (isPendingDelete) {
|
||
const confirmButton = document.createElement('button')
|
||
confirmButton.className = 'danger'
|
||
confirmButton.type = 'button'
|
||
confirmButton.textContent = '确认删除'
|
||
confirmButton.addEventListener('click', () => deleteSavedConfig(config))
|
||
|
||
const cancelButton = document.createElement('button')
|
||
cancelButton.className = 'secondary'
|
||
cancelButton.type = 'button'
|
||
cancelButton.textContent = '取消'
|
||
cancelButton.addEventListener('click', cancelDeleteConfig)
|
||
|
||
const confirmText = textNode('div', 'config-confirm', '只删除配置目录,不删除已创建的穿透实例。')
|
||
actions.append(confirmButton, cancelButton)
|
||
card.append(title, meta, confirmText, actions)
|
||
list.appendChild(card)
|
||
continue
|
||
}
|
||
|
||
const deleteButton = document.createElement('button')
|
||
deleteButton.className = 'secondary'
|
||
deleteButton.type = 'button'
|
||
deleteButton.textContent = '删除配置'
|
||
deleteButton.addEventListener('click', () => requestDeleteConfig(config))
|
||
|
||
actions.append(previewButton, loadButton, deleteButton)
|
||
card.append(title, meta, actions)
|
||
list.appendChild(card)
|
||
}
|
||
}
|
||
|
||
async function previewSavedConfig(config) {
|
||
try {
|
||
state.selectedConfigPath = config.dirPath
|
||
renderSavedConfigs()
|
||
|
||
const configPath = config.configPath || `${config.dirPath}/frpc.toml`
|
||
let content = ''
|
||
try {
|
||
content = await readTextFile(configPath)
|
||
} catch {
|
||
content = JSON.stringify(config, null, 2)
|
||
}
|
||
|
||
setSavedPreview(config, content)
|
||
} catch (error) {
|
||
showMessage('error', `预览配置失败:${error.message}`)
|
||
}
|
||
}
|
||
|
||
function loadConfigIntoForm(config) {
|
||
state.pendingDeletePath = ''
|
||
const formValues = config.formValues && typeof config.formValues === 'object' ? config.formValues : null
|
||
const mode = formValues && formValues.mode
|
||
? formValues.mode
|
||
: String(config.tool || '').toLowerCase()
|
||
|
||
if (mode !== 'frp') {
|
||
showMessage('warn', '此配置不属于 frp,请在对应的独立管理插件中处理。')
|
||
return
|
||
}
|
||
|
||
if (formValues) {
|
||
applyFormValues(formValues)
|
||
} else {
|
||
$('profileName').value = config.name || config.dirName || ''
|
||
$('localAddress').value = config.localAddress || '127.0.0.1'
|
||
$('localPort').value = config.localPort || ''
|
||
$('protocol').value = config.protocol || 'tcp'
|
||
}
|
||
|
||
if (config.linkedInstanceId && !$('instanceSelect').value) {
|
||
$('instanceSelect').value = config.linkedInstanceId
|
||
}
|
||
|
||
markAutoDefaultFieldsManual(['profileName', 'localPort', 'remotePort'])
|
||
buildPlan(false)
|
||
showMessage('success', `已载入配置:${config.name || config.dirName}`)
|
||
}
|
||
|
||
function requestDeleteConfig(config) {
|
||
state.pendingDeletePath = config.dirPath
|
||
renderSavedConfigs()
|
||
showMessage('warn', `确认删除配置:${config.name || config.dirName}`)
|
||
}
|
||
|
||
function cancelDeleteConfig() {
|
||
state.pendingDeletePath = ''
|
||
renderSavedConfigs()
|
||
clearMessage()
|
||
}
|
||
|
||
async function deleteSavedConfig(config) {
|
||
try {
|
||
await window.gsm3.deleteDirectory(config.dirPath, true)
|
||
if (state.selectedConfigPath === config.dirPath) {
|
||
state.selectedConfigPath = ''
|
||
setSavedPreview(null, '')
|
||
}
|
||
state.pendingDeletePath = ''
|
||
await loadSavedConfigs()
|
||
showMessage('success', `配置已删除:${config.name || config.dirName}`)
|
||
notify('success', `配置已删除:${config.name || config.dirName}`)
|
||
} catch (error) {
|
||
showMessage('error', `删除配置失败:${error.message}`)
|
||
notify('error', `删除配置失败:${error.message}`)
|
||
}
|
||
}
|
||
|
||
async function saveTunnelConfig() {
|
||
const plan = buildPlan(true)
|
||
if (!plan) return
|
||
|
||
$('saveConfigButton').disabled = true
|
||
const originalButtonText = $('saveConfigButton').textContent
|
||
$('saveConfigButton').textContent = '保存中...'
|
||
|
||
try {
|
||
state.pendingDeletePath = ''
|
||
await writeTunnelFiles(plan)
|
||
await loadSavedConfigs()
|
||
|
||
const saved = state.savedConfigs.find(config => config.dirPath === plan.dataDir)
|
||
if (saved) {
|
||
await previewSavedConfig(saved)
|
||
} else {
|
||
setSavedPreview(buildManifest(plan), plan.configContent)
|
||
}
|
||
|
||
showMessage('success', `配置已保存:${plan.profile}`)
|
||
notify('success', `配置已保存:${plan.profile}`)
|
||
} catch (error) {
|
||
showMessage('error', `保存配置失败:${error.message}`)
|
||
notify('error', `保存配置失败:${error.message}`)
|
||
} finally {
|
||
$('saveConfigButton').disabled = false
|
||
$('saveConfigButton').textContent = originalButtonText
|
||
}
|
||
}
|
||
|
||
async function createTunnelInstance() {
|
||
const plan = buildPlan(true)
|
||
if (!plan) return
|
||
|
||
const tunnelName = `穿透-${plan.profile}`
|
||
const existingTunnel = state.instances.find(instance => instance.name === tunnelName)
|
||
|
||
$('createButton').disabled = true
|
||
const originalButtonText = $('createButton').textContent
|
||
$('createButton').textContent = existingTunnel ? '更新中...' : '创建中...'
|
||
try {
|
||
await writeTunnelFiles(plan)
|
||
const workingDirectory = await resolveTunnelWorkingDirectory(plan)
|
||
|
||
const instancePayload = {
|
||
name: tunnelName,
|
||
description: plan.instanceDescription,
|
||
workingDirectory,
|
||
startCommand: plan.startCommand,
|
||
stopCommand: 'ctrl+c',
|
||
autoStart: false
|
||
}
|
||
|
||
const result = existingTunnel
|
||
? await window.gsm3.updateInstance(existingTunnel.id, instancePayload)
|
||
: await window.gsm3.createInstance(instancePayload)
|
||
|
||
const created = result.data || result
|
||
await loadInstances({ showLoadedMessage: false, selectedInstanceId: plan.instance.id })
|
||
const createdName = created.name || tunnelName
|
||
const actionText = existingTunnel ? '已更新' : '已创建'
|
||
showMessage('success', `穿透实例${actionText}:${createdName}。请到实例管理中启动该实例`)
|
||
notify('success', `穿透实例${actionText}:${createdName}`)
|
||
await loadSavedConfigs()
|
||
} catch (error) {
|
||
showMessage('error', `保存穿透实例失败:${error.message}`)
|
||
notify('error', `保存穿透实例失败:${error.message}`)
|
||
} finally {
|
||
$('createButton').disabled = false
|
||
$('createButton').textContent = originalButtonText
|
||
}
|
||
}
|
||
|
||
function attachEvents() {
|
||
$('refreshInstancesButton').addEventListener('click', loadInstances)
|
||
$('refreshConfigsButton').addEventListener('click', loadSavedConfigs)
|
||
$('previewButton').addEventListener('click', () => buildPlan(true))
|
||
$('saveConfigButton').addEventListener('click', saveTunnelConfig)
|
||
$('createButton').addEventListener('click', createTunnelInstance)
|
||
$('checkFrpcButton').addEventListener('click', () => checkToolAvailability('frp'))
|
||
$('installFrpcButton').addEventListener('click', () => installTool('frp'))
|
||
$('instanceSelect').addEventListener('change', () => {
|
||
updateInstanceMeta()
|
||
buildPlan(false)
|
||
})
|
||
|
||
for (const id of fields) {
|
||
const element = $(id)
|
||
if (!element) continue
|
||
element.addEventListener('input', () => {
|
||
markAutoDefaultFieldInput(id)
|
||
buildPlan(false)
|
||
})
|
||
element.addEventListener('change', () => {
|
||
markAutoDefaultFieldInput(id)
|
||
buildPlan(false)
|
||
})
|
||
}
|
||
}
|
||
|
||
async function boot() {
|
||
startThemeSync()
|
||
|
||
if (!window.gsm3) {
|
||
showMessage('error', '插件 API 未加载')
|
||
return
|
||
}
|
||
|
||
attachEvents()
|
||
await waitForPluginToken()
|
||
await loadSystemInfo()
|
||
await hydrateInstalledTools()
|
||
await loadInstances()
|
||
await loadSavedConfigs()
|
||
buildPlan(false)
|
||
checkToolAvailability('frp', false)
|
||
}
|
||
|
||
boot()
|
||
</script>
|
||
</body>
|
||
</html>
|