Files
GameServerManager/server/data/plugins/tunnel-helper/index.html
sakuradairong d641a84de2 feat(easytier): add managed networking plugin
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.
2026-08-01 11:46:34 +08:00

2028 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="例如:&#10;metadatas.env = &quot;prod&quot;"></textarea>
<span class="hint">追加到 frpc.toml 顶层,适合填写插件暂未暴露的客户端配置。</span>
</label>
<label>
额外代理 TOML
<textarea id="frpExtraProxyConfig" placeholder="例如:&#10;transport.bandwidthLimitMode = &quot;client&quot;"></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>