Bundle TAP-Windows installer and persist startup config

This commit is contained in:
2026-09-01 22:16:19 +08:00
parent 57e6ba8e1e
commit a8d803168f
12 changed files with 691 additions and 44 deletions
+12
View File
@@ -39,4 +39,16 @@ export const tapTraffic = (tapDevice) => nativeCall('tap_traffic', { request: {
export const resolveTapAdapter = (tapDevice) => nativeCall('resolve_tap_adapter', { request: { tapDevice } }, () => {
throw new Error('浏览器预览无法识别 TAP 网卡')
})
export const startupStatus = () => nativeCall('startup_status', {}, () => ({
configExists: false,
firstLaunch: false,
configPath: '',
tapDriverPresent: true,
tapInstallerPath: null,
}))
export const readConfig = () => nativeCall('read_config', {}, () => null)
export const writeConfig = (contents) => nativeCall('write_config', { contents }, () => null)
export const openTapInstaller = () => nativeCall('open_tap_installer', {}, () => {
throw new Error('浏览器预览无法打开 TAP-Windows 安装程序')
})
export const exportLog = (contents) => nativeCall('export_log', { contents }, () => null)
+96 -17
View File
@@ -4,7 +4,10 @@ import {
ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play,
Download, Router, ScrollText, Server, Settings2, Square, Wifi,
} from 'lucide-react'
import { exportLog, isTauri, resolveTapAdapter, serviceStatus, startService, stopService, tapTraffic } from './lib/tauri'
import {
exportLog, isTauri, openTapInstaller, readConfig, resolveTapAdapter, serviceStatus, startService,
startupStatus, stopService, tapTraffic, writeConfig,
} from './lib/tauri'
import './styles.css'
const initialEdge = {
@@ -20,22 +23,28 @@ const initialEdge = {
const edgeConfigStorageKey = 'super-n2n.edge-config.v1'
function loadEdgeConfig() {
function parseEdgeConfig(value) {
const fallback = { ...initialEdge }
if (typeof window === 'undefined') return fallback
try {
const saved = JSON.parse(window.localStorage.getItem(edgeConfigStorageKey) || 'null')
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return fallback
for (const [field, defaultValue] of Object.entries(initialEdge)) {
if (typeof saved[field] === typeof defaultValue) fallback[field] = saved[field]
}
} catch {
// A damaged or unavailable browser store must not block the connection form.
const saved = value && typeof value === 'object' && !Array.isArray(value) && value.edge
&& typeof value.edge === 'object' ? value.edge : value
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return fallback
for (const [field, defaultValue] of Object.entries(initialEdge)) {
if (typeof saved[field] === typeof defaultValue) fallback[field] = saved[field]
}
return fallback
}
function saveEdgeConfig(config) {
function loadBrowserEdgeConfig() {
if (typeof window === 'undefined') return { ...initialEdge }
try {
return parseEdgeConfig(JSON.parse(window.localStorage.getItem(edgeConfigStorageKey) || 'null'))
} catch {
// A damaged or unavailable browser store must not block the connection form.
return { ...initialEdge }
}
}
function saveBrowserEdgeConfig(config) {
try {
window.localStorage.setItem(edgeConfigStorageKey, JSON.stringify(config))
} catch {
@@ -43,9 +52,13 @@ function saveEdgeConfig(config) {
}
}
function serializeEdgeConfig(config) {
return JSON.stringify({ version: 1, edge: config }, null, 2)
}
function App() {
const [home, setHome] = useState('edge')
const [edge, setEdge] = useState(loadEdgeConfig)
const [edge, setEdge] = useState(loadBrowserEdgeConfig)
const [edgeRunning, setEdgeRunning] = useState(false)
const [activeTapDevice, setActiveTapDevice] = useState('')
const [advancedOpen, setAdvancedOpen] = useState(false)
@@ -53,7 +66,10 @@ function App() {
const [logs, setLogs] = useState([])
const [traffic, setTraffic] = useState(null)
const [message, setMessage] = useState(null)
const [startupInfo, setStartupInfo] = useState(null)
const [configReady, setConfigReady] = useState(!isTauri)
const lastTapSample = useRef(null)
const configWriteFailed = useRef(false)
const appendLog = (text, level = 'info') => {
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
@@ -61,8 +77,47 @@ function App() {
}
useEffect(() => {
saveEdgeConfig(edge)
}, [edge])
if (!configReady) return
if (!isTauri) {
saveBrowserEdgeConfig(edge)
return
}
writeConfig(serializeEdgeConfig(edge)).catch((error) => {
if (configWriteFailed.current) return
configWriteFailed.current = true
appendLog('配置保存失败:' + error.message, 'warning')
})
}, [edge, configReady])
useEffect(() => {
let active = true
const loadStartup = async () => {
try {
const status = await startupStatus()
if (!active) return
setStartupInfo(status)
if (isTauri && status.configExists) {
const contents = await readConfig()
if (active && contents) setEdge(parseEdgeConfig(JSON.parse(contents)))
}
if (active) {
setConfigReady(true)
if (status.firstLaunch) {
appendLog(
status.tapDriverPresent ? '首次启动:已检测到 TAP-Windows 网卡' : '首次启动:未检测到 TAP-Windows 网卡',
status.tapDriverPresent ? 'success' : 'warning',
)
}
}
} catch (error) {
if (!active) return
setConfigReady(true)
appendLog('读取启动配置失败:' + error.message, 'warning')
}
}
loadStartup()
return () => { active = false }
}, [])
useEffect(() => {
let active = true
@@ -163,6 +218,17 @@ function App() {
}
}
const installTapDriver = async () => {
try {
const path = await openTapInstaller()
appendLog('已打开 TAP-Windows 安装程序:' + path, 'info')
notify('请按安装向导完成驱动安装,然后重新打开应用', 'neutral')
} catch (error) {
appendLog('打开 TAP-Windows 安装程序失败:' + error.message, 'error')
notify('无法打开驱动安装程序:' + error.message)
}
}
return <main className="app-shell">
<header className="app-header">
<div className="brand"><span className="brand-icon"><Network size={19} /></span><span>Super N2N</span></div>
@@ -180,7 +246,7 @@ function App() {
</div>
{home === 'edge'
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} activeTapDevice={activeTapDevice} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onExportLog={() => exportLogs(logs, notify)} />
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} activeTapDevice={activeTapDevice} startupInfo={startupInfo} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onInstallTap={installTapDriver} onExportLog={() => exportLogs(logs, notify)} />
: <SupernodeHome />}
</section>
@@ -189,7 +255,7 @@ function App() {
</main>
}
function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, advancedOpen, setAdvancedOpen, logOpen, setLogOpen, logs, onToggle, onExportLog }) {
function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startupInfo, advancedOpen, setAdvancedOpen, setLogOpen, logOpen, logs, onToggle, onInstallTap, onExportLog }) {
const toggleAdvanced = () => {
setLogOpen(false)
setAdvancedOpen((current) => !current)
@@ -232,6 +298,8 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, advance
</div>
</div>
<TapSetupPrompt status={startupInfo} onInstall={onInstallTap} />
<button className={running ? 'connect-button stop' : 'connect-button'} onClick={onToggle}>
{running ? <Square size={19} fill="currentColor" /> : <Play size={20} fill="currentColor" />}
{running ? '断开连接' : '连接网络'}
@@ -257,6 +325,17 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, advance
</div>
}
function TapSetupPrompt({ status, onInstall }) {
if (!status?.firstLaunch || status.tapDriverPresent) return null
return <aside className="tap-setup" role="alert">
<div className="tap-setup-copy">
<CircleAlert size={16} />
<span><strong>需要安装 TAP-Windows</strong><small>未检测到虚拟网卡安装后重新打开应用</small></span>
</div>
{status.tapInstallerPath && <button className="tap-install" onClick={onInstall} title="打开随包 TAP-Windows 安装程序"><Download size={14} />安装</button>}
</aside>
}
function TrafficStrip({ traffic, tapDevice }) {
if (!traffic || traffic.error) {
return <div className="traffic-strip pending" role="status" title={traffic?.error || ''}>
+17
View File
@@ -64,6 +64,15 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
.ip-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.input-group small { color: var(--muted); font-size: 12px; line-height: 1.4; }
.tap-setup { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; margin: 0 22px 10px; padding: 8px 9px; color: #855f16; background: #fff8e8; border: 1px solid #ecd59c; border-radius: 5px; }
.tap-setup-copy { min-width: 0; display: flex; align-items: flex-start; gap: 7px; }
.tap-setup-copy > svg { flex: 0 0 auto; margin-top: 1px; }
.tap-setup-copy > span { min-width: 0; display: grid; gap: 2px; }
.tap-setup-copy strong { color: #76500d; font-size: 12px; line-height: 1.2; }
.tap-setup-copy small { overflow: hidden; color: #927642; font-size: 11px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
.tap-install { min-height: 28px; display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 0 8px; color: #76500d; background: #fff; border: 1px solid #dfc57d; border-radius: 4px; font-size: 11px; font-weight: 700; white-space: nowrap; }
.tap-install:hover { background: #fffdf5; border-color: #caa64d; }
.dhcp-toggle { display: inline-flex; align-items: center; gap: 7px; min-height: 28px; color: var(--muted); font-size: 13px; font-weight: 600; cursor: pointer; }
.dhcp-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.toggle-track { position: relative; width: 36px; height: 22px; flex: 0 0 auto; background: #a8b7ba; border-radius: 14px; transition: background 180ms ease; }
@@ -153,6 +162,7 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
.input-group { padding: 12px 0; }
.server-inputs { grid-template-columns: minmax(0, 1fr) auto 74px; gap: 5px; }
.input-group input { height: 38px; font-size: 14px; }
.tap-setup { margin: 0 16px 8px; }
.connect-button { width: calc(100% - 32px); margin: 10px 16px 14px; }
.connection-summary { grid-template-columns: 1fr; }
.connection-summary div { grid-template-columns: 80px 1fr; align-items: baseline; padding: 9px 14px; border-right: 0; border-bottom: 1px solid var(--line); }
@@ -194,6 +204,13 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
.field-title { gap: 0; font-size: 12px; }
.field-title svg { display: none; }
.input-group input { height: 30px; padding: 0 7px; border-radius: 4px; font-size: 12px; }
.tap-setup { gap: 5px; margin: 0 8px 3px; padding: 6px 7px; border-radius: 4px; }
.tap-setup-copy { gap: 5px; }
.tap-setup-copy > svg { width: 14px; height: 14px; }
.tap-setup-copy strong { font-size: 10px; }
.tap-setup-copy small { font-size: 9px; }
.tap-install { min-height: 24px; gap: 3px; padding: 0 6px; font-size: 10px; }
.tap-install svg { width: 12px; height: 12px; }
.server-inputs { grid-template-columns: minmax(0, 1fr) auto 58px; gap: 4px; }
.port-separator { font-size: 15px; }
.input-group small { display: none; }